Skip to content

Commit 7cf8df5

Browse files
committed
Support rendering a subset of CommonMark in the chat
1 parent 9eb125e commit 7cf8df5

File tree

2 files changed

+78
-0
lines changed

2 files changed

+78
-0
lines changed

atox/src/main/kotlin/ui/chat/ChatAdapter.kt

+32
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
package ltd.evilcorp.atox.ui.chat
22

33
import android.content.res.Resources
4+
import android.graphics.Typeface
5+
import android.text.Spannable
46
import android.text.format.Formatter
7+
import android.text.style.RelativeSizeSpan
8+
import android.text.style.StyleSpan
9+
import android.text.style.TypefaceSpan
510
import android.util.Log
611
import android.view.Gravity
712
import android.view.LayoutInflater
@@ -15,6 +20,7 @@ import android.widget.ListView
1520
import android.widget.ProgressBar
1621
import android.widget.RelativeLayout
1722
import android.widget.TextView
23+
import androidx.core.text.toSpannable
1824
import com.squareup.picasso.Picasso
1925
import java.net.URLConnection
2026
import java.text.DateFormat
@@ -31,6 +37,27 @@ import ltd.evilcorp.core.vo.isStarted
3137

3238
private const val TAG = "ChatAdapter"
3339

40+
private fun applyStyle(
41+
view: TextView,
42+
regex: Regex,
43+
typefaceStyle: Int,
44+
fontFamily: String = "",
45+
size: Float = 1f,
46+
) {
47+
val spannable = view.text.toSpannable()
48+
for (match in regex.findAll(spannable)) {
49+
val start = match.range.first
50+
val end = match.range.last + 1
51+
spannable.setSpan(RelativeSizeSpan(size), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
52+
spannable.setSpan(StyleSpan(typefaceStyle), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
53+
if (fontFamily.isNotEmpty()) {
54+
spannable.setSpan(TypefaceSpan(fontFamily), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
55+
}
56+
}
57+
58+
view.text = spannable
59+
}
60+
3461
private fun FileTransfer.isImage() = try {
3562
URLConnection.guessContentTypeFromName(fileName).startsWith("image/")
3663
} catch (e: Exception) {
@@ -151,6 +178,11 @@ class ChatAdapter(
151178
}
152179
}
153180

181+
applyStyle(vh.message, Regex("(?<!`)`[^`\n]+?`(?!`)"), Typeface.BOLD, "monospace", 0.8f)
182+
applyStyle(vh.message, Regex("(?<!\\*)\\*\\*\\*[^*\n]+?\\*\\*\\*(?!\\*)"), Typeface.BOLD_ITALIC)
183+
applyStyle(vh.message, Regex("(?<!\\*)\\*\\*[^*\n]+?\\*\\*(?!\\*)"), Typeface.BOLD)
184+
applyStyle(vh.message, Regex("(?<!\\*)\\*[^*\n]+?\\*(?!\\*)"), Typeface.ITALIC)
185+
154186
view
155187
}
156188
ChatItemType.ReceivedFileTransfer, ChatItemType.SentFileTransfer -> {

atox/src/main/kotlin/ui/chat/ChatFragment.kt

+46
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,27 @@ import android.content.ActivityNotFoundException
55
import android.content.ClipData
66
import android.content.ClipboardManager
77
import android.content.Intent
8+
import android.graphics.Typeface
89
import android.net.Uri
910
import android.os.Bundle
11+
import android.text.Spannable
12+
import android.text.style.ForegroundColorSpan
13+
import android.text.style.RelativeSizeSpan
14+
import android.text.style.StyleSpan
15+
import android.text.style.TypefaceSpan
1016
import android.view.ContextMenu
1117
import android.view.MenuItem
1218
import android.view.MotionEvent
1319
import android.view.View
1420
import android.widget.AdapterView
21+
import android.widget.EditText
1522
import android.widget.Toast
1623
import androidx.activity.result.contract.ActivityResultContracts
1724
import androidx.core.content.FileProvider
1825
import androidx.core.content.getSystemService
1926
import androidx.core.content.res.ResourcesCompat
2027
import androidx.core.os.bundleOf
28+
import androidx.core.text.getSpans
2129
import androidx.core.view.ViewCompat
2230
import androidx.core.view.WindowInsetsAnimationCompat
2331
import androidx.core.view.WindowInsetsCompat
@@ -50,6 +58,39 @@ import ltd.evilcorp.domain.tox.PublicKey
5058
const val CONTACT_PUBLIC_KEY = "publicKey"
5159
private const val MAX_CONFIRM_DELETE_STRING_LENGTH = 20
5260

61+
private inline fun <reified T : Any> clearStyle(e: Spannable) {
62+
for (span in e.getSpans<T>()) {
63+
e.removeSpan(span)
64+
}
65+
}
66+
67+
private fun clearStyles(view: EditText) {
68+
val spannable = view.text
69+
clearStyle<ForegroundColorSpan>(spannable)
70+
clearStyle<RelativeSizeSpan>(spannable)
71+
clearStyle<StyleSpan>(spannable)
72+
clearStyle<TypefaceSpan>(spannable)
73+
}
74+
75+
private fun applyStyle(
76+
view: EditText,
77+
regex: Regex,
78+
typefaceStyle: Int,
79+
fontFamily: String = "",
80+
size: Float = 1f,
81+
) {
82+
val spannable = view.text
83+
for (match in regex.findAll(spannable)) {
84+
val start = match.range.first
85+
val end = match.range.last + 1
86+
spannable.setSpan(RelativeSizeSpan(size), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
87+
spannable.setSpan(StyleSpan(typefaceStyle), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
88+
if (fontFamily.isNotEmpty()) {
89+
spannable.setSpan(TypefaceSpan(fontFamily), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
90+
}
91+
}
92+
}
93+
5394
class ChatFragment : BaseFragment<FragmentChatBinding>(FragmentChatBinding::inflate) {
5495
private val viewModel: ChatViewModel by viewModels { vmFactory }
5596

@@ -265,6 +306,11 @@ class ChatFragment : BaseFragment<FragmentChatBinding>(FragmentChatBinding::infl
265306
outgoingMessage.doAfterTextChanged {
266307
viewModel.setTyping(outgoingMessage.text.isNotEmpty())
267308
updateActions()
309+
clearStyles(outgoingMessage)
310+
applyStyle(outgoingMessage, Regex("(?<!`)`[^`\n]+?`(?!`)"), Typeface.BOLD, "monospace", 0.8f)
311+
applyStyle(outgoingMessage, Regex("(?<!\\*)\\*\\*\\*[^*\n]+?\\*\\*\\*(?!\\*)"), Typeface.BOLD_ITALIC)
312+
applyStyle(outgoingMessage, Regex("(?<!\\*)\\*\\*[^*\n]+?\\*\\*(?!\\*)"), Typeface.BOLD)
313+
applyStyle(outgoingMessage, Regex("(?<!\\*)\\*[^*\n]+?\\*(?!\\*)"), Typeface.ITALIC)
268314
}
269315

270316
updateActions()

0 commit comments

Comments
 (0)