@@ -6,8 +6,6 @@ import android.text.Spannable
66import android.text.SpannableString
77import android.text.style.ForegroundColorSpan
88import android.text.style.StyleSpan
9- import android.util.Range
10- import network.loki.messenger.R
119import org.session.libsession.utilities.Address.Companion.toAddress
1210import org.session.libsession.utilities.ThemeUtil
1311import org.session.libsession.utilities.getColorFromAttr
@@ -17,6 +15,7 @@ import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel
1715import org.thoughtcrime.securesms.database.RecipientRepository
1816import org.thoughtcrime.securesms.util.RoundedBackgroundSpan
1917import org.thoughtcrime.securesms.util.getAccentColor
18+ import network.loki.messenger.R
2019import java.util.regex.Pattern
2120
2221object MentionUtilities {
@@ -27,152 +26,201 @@ object MentionUtilities {
2726 /* *
2827 * In-place replacement on the *live* MentionEditable that the
2928 * input-bar is already using.
30- *
31- * It swaps every "@<64-hex>" token for "@DisplayName" **and**
32- * attaches a MentionSpan so later normalisation still works.
3329 */
3430 fun substituteIdsInPlace (
3531 editable : MentionEditable ,
3632 membersById : Map <String , MentionViewModel .Member >
3733 ) {
3834 ACCOUNT_ID .findAll(editable)
39- .toList() // avoid index shifts
40- .asReversed() // back-to-front replacement
35+ .toList() // avoid index shifts
36+ .asReversed() // back-to-front replacement
4137 .forEach { m ->
42- val id = m.groupValues[1 ]
43- val member = membersById[id] ? : return @forEach
38+ val id = m.groupValues[1 ]
39+ val member = membersById[id] ? : return @forEach
4440
4541 val start = m.range.first
46- val end = m.range.last + 1 // inclusive ➜ exclusive
42+ val end = m.range.last + 1 // inclusive ➜ exclusive
4743
4844 editable.replace(start, end, " @${member.name} " )
49- editable.addMention(member, start .. start + member.name.length + 1 )
45+ editable.addMention(member, start.. start + member.name.length + 1 )
5046 }
5147 }
5248
49+ // ----------------------------
50+ // Shared parsing/substitution core
51+ // ----------------------------
52+
53+ data class MentionToken (
54+ val start : Int , // start in FINAL substituted text
55+ val endExclusive : Int , // end-exclusive in FINAL substituted text
56+ val publicKey : String ,
57+ val isSelf : Boolean
58+ )
59+
60+ data class ParsedMentions (
61+ val text : String ,
62+ val mentions : List <MentionToken >
63+ )
5364
5465 /* *
55- * Highlights mentions in a given text.
66+ * Shared core:
67+ * - replaces "@<66-hex>" with "@DisplayName"
68+ * - returns the final text + mention ranges (in that final text) + metadata
5669 *
57- * @param text The text to highlight mentions in.
58- * @param isOutgoingMessage Whether the message is outgoing.
59- * @param isQuote Whether the message is a quote.
60- * @param formatOnly Whether to only format the mentions. If true we only format the text itself,
61- * for example resolving an accountID to a username. If false we also apply styling, like colors and background.
62- * @param context The context to use.
63- * @return A SpannableString with highlighted mentions.
70+ * This is UI-agnostic and is used by BOTH:
71+ * - legacy XML span formatting
72+ * - Compose rich text formatting
6473 */
6574 @JvmStatic
66- fun highlightMentions (
75+ fun parseAndSubstituteMentions (
6776 recipientRepository : RecipientRepository ,
68- text : CharSequence ,
69- isOutgoingMessage : Boolean = false,
70- isQuote : Boolean = false,
71- formatOnly : Boolean = false,
77+ input : CharSequence ,
7278 context : Context
73- ): SpannableString {
74- @Suppress(" NAME_SHADOWING" ) var text = text
79+ ): ParsedMentions {
80+ @Suppress(" NAME_SHADOWING" )
81+ var text: CharSequence = input
7582
7683 var matcher = pattern.matcher(text)
77- val mentions = mutableListOf<Pair < Range < Int >, String > > ()
84+ val mentions = mutableListOf<MentionToken >()
7885 var startIndex = 0
7986
80- // Format the mention text
8187 if (matcher.find(startIndex)) {
8288 while (true ) {
83- val publicKey = text.subSequence(matcher.start() + 1 , matcher.end()).toString() // +1 to get rid of the @
84- val user = recipientRepository.getRecipientSync(publicKey.toAddress())
89+ val publicKey =
90+ text.subSequence(matcher.start() + 1 , matcher.end()).toString() // drop '@'
8591
86- val userDisplayName: String = if (user.isSelf) {
92+ val user = recipientRepository.getRecipientSync(publicKey.toAddress())
93+ val displayName = if (user.isSelf) {
8794 context.getString(R .string.you)
8895 } else {
8996 user.displayName(attachesBlindedId = true )
9097 }
9198
92- val mention = " @$userDisplayName "
93- text = text.subSequence(0 , matcher.start()).toString() + mention + text.subSequence(matcher.end(), text.length)
94- val endIndex = matcher.start() + 1 + userDisplayName.length
95- startIndex = endIndex
96- mentions.add(Pair (Range .create(matcher.start(), endIndex), publicKey))
99+ val replacement = " @$displayName "
100+
101+ val newText = buildString(
102+ text.length - (matcher.end() - matcher.start()) + replacement.length
103+ ) {
104+ append(text.subSequence(0 , matcher.start()))
105+ append(replacement)
106+ append(text.subSequence(matcher.end(), text.length))
107+ }
108+
109+ val start = matcher.start()
110+ val endExclusive = start + replacement.length
111+
112+ mentions + = MentionToken (
113+ start = start,
114+ endExclusive = endExclusive,
115+ publicKey = publicKey,
116+ isSelf = user.isSelf
117+ )
118+
119+ text = newText
120+ startIndex = endExclusive
97121
98122 matcher = pattern.matcher(text)
99- if (! matcher.find(startIndex)) { break }
123+ if (! matcher.find(startIndex)) break
100124 }
101125 }
102- val result = SpannableString (text)
103126
104- // apply styling if required
127+ return ParsedMentions (
128+ text = text.toString(),
129+ mentions = mentions
130+ )
131+ }
132+
133+ // ----------------------------
134+ // Legacy (XML/TextView) formatter
135+ // ----------------------------
136+
137+ /* *
138+ * Legacy (XML/TextView) formatter.
139+ *
140+ * Highlights mentions in a given text.
141+ *
142+ * @param formatOnly If true we only format the text itself,
143+ * for example resolving an accountID to a username. If false we also apply styling.
144+ */
145+ @JvmStatic
146+ fun highlightMentions (
147+ recipientRepository : RecipientRepository ,
148+ text : CharSequence ,
149+ isOutgoingMessage : Boolean = false,
150+ isQuote : Boolean = false,
151+ formatOnly : Boolean = false,
152+ context : Context
153+ ): SpannableString {
154+ val parsed = parseAndSubstituteMentions(recipientRepository, text, context)
155+ val result = SpannableString (parsed.text)
156+
157+ if (formatOnly) return result
158+
105159 // Normal text color: black in dark mode and primary text color for light mode
106160 val mainTextColor by lazy {
107161 if (ThemeUtil .isDarkTheme(context)) context.getColor(R .color.black)
108162 else context.getColorFromAttr(android.R .attr.textColorPrimary)
109163 }
110164
111- // Highlighted text color: primary/ accent in dark mode and primary text color for light mode
165+ // Highlighted text color: accent in dark theme and primary text for light
112166 val highlightedTextColor by lazy {
113167 if (ThemeUtil .isDarkTheme(context)) context.getAccentColor()
114168 else context.getColorFromAttr(android.R .attr.textColorPrimary)
115169 }
116170
117- if (! formatOnly) {
118- for (mention in mentions) {
119- val backgroundColor: Int?
120- val foregroundColor: Int?
171+ parsed.mentions.forEach { mention ->
172+ val backgroundColor: Int?
173+ val foregroundColor: Int?
121174
122- // quotes
123- if (isQuote) {
124- backgroundColor = null
125- // the text color has different rule depending if the message is incoming or outgoing
126- foregroundColor = if (isOutgoingMessage) null else highlightedTextColor
127- }
128- // incoming message mentioning you
129- else if (recipientRepository.getRecipientSync(mention.second.toAddress()).isSelf) {
130- backgroundColor = context.getAccentColor()
131- foregroundColor = mainTextColor
132- }
133- // outgoing message
134- else if (isOutgoingMessage) {
135- backgroundColor = null
136- foregroundColor = mainTextColor
137- }
138- // incoming messages mentioning someone else
139- else {
140- backgroundColor = null
141- // accent color for dark themes and primary text for light
142- foregroundColor = highlightedTextColor
143- }
175+ // quotes
176+ if (isQuote) {
177+ backgroundColor = null
178+ // incoming quote gets accent-ish foreground, outgoing quote keeps default
179+ foregroundColor = if (isOutgoingMessage) null else highlightedTextColor
180+ }
181+ // incoming message mentioning you
182+ else if (mention.isSelf && ! isOutgoingMessage) {
183+ backgroundColor = context.getAccentColor()
184+ foregroundColor = mainTextColor
185+ }
186+ // outgoing message
187+ else if (isOutgoingMessage) {
188+ backgroundColor = null
189+ foregroundColor = mainTextColor
190+ }
191+ // incoming messages mentioning someone else
192+ else {
193+ backgroundColor = null
194+ foregroundColor = highlightedTextColor
195+ }
144196
145- // apply the background, if any
146- backgroundColor?.let { background ->
147- result.setSpan(
148- RoundedBackgroundSpan (
149- context = context,
150- textColor = mainTextColor,
151- backgroundColor = background
152- ),
153- mention.first.lower, mention.first.upper, Spannable .SPAN_EXCLUSIVE_EXCLUSIVE
154- )
155- }
197+ val start = mention.start
198+ val end = mention.endExclusive
156199
157- // apply the foreground, if any
158- foregroundColor?.let {
159- result.setSpan(
160- ForegroundColorSpan (it),
161- mention.first.lower,
162- mention.first.upper,
163- Spannable .SPAN_EXCLUSIVE_EXCLUSIVE
164- )
165- }
200+ backgroundColor?.let { background ->
201+ result.setSpan(
202+ RoundedBackgroundSpan (
203+ context = context,
204+ textColor = mainTextColor,
205+ backgroundColor = background
206+ ),
207+ start, end, Spannable .SPAN_EXCLUSIVE_EXCLUSIVE
208+ )
209+ }
166210
167- // apply bold on the mention
211+ foregroundColor?. let { fg ->
168212 result.setSpan(
169- StyleSpan (Typeface .BOLD ),
170- mention.first.lower,
171- mention.first.upper,
172- Spannable .SPAN_EXCLUSIVE_EXCLUSIVE
213+ ForegroundColorSpan (fg),
214+ start, end, Spannable .SPAN_EXCLUSIVE_EXCLUSIVE
173215 )
174216 }
217+
218+ result.setSpan(
219+ StyleSpan (Typeface .BOLD ),
220+ start, end, Spannable .SPAN_EXCLUSIVE_EXCLUSIVE
221+ )
175222 }
223+
176224 return result
177225 }
178226}
0 commit comments