From 9b3830bada13bf0cf6f274254c1e9453af9002af Mon Sep 17 00:00:00 2001 From: Igor Furgala Date: Tue, 25 Nov 2025 15:09:42 +0100 Subject: [PATCH 1/3] feat: mentions with space - android --- .../enriched/styles/ParametrizedStyles.kt | 78 ++++++++++++++----- 1 file changed, 57 insertions(+), 21 deletions(-) diff --git a/android/src/main/java/com/swmansion/enriched/styles/ParametrizedStyles.kt b/android/src/main/java/com/swmansion/enriched/styles/ParametrizedStyles.kt index 9b8665606..2ad6a3a8e 100644 --- a/android/src/main/java/com/swmansion/enriched/styles/ParametrizedStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/styles/ParametrizedStyles.kt @@ -58,10 +58,13 @@ class ParametrizedStyles(private val view: EnrichedTextInputView) { } fun afterTextChanged(s: Editable, endCursorPosition: Int) { - val result = getWordAtIndex(s, endCursorPosition) ?: return + val currentWord = getWordAtIndex(s, endCursorPosition) ?: return + afterTextChangedLinks(currentWord) - afterTextChangedLinks(result) - afterTextChangedMentions(result) + val mentionText = getMentionTextAtIndex(s, endCursorPosition) + if (mentionText != null) { + afterTextChangedMentions(currentWord, mentionText) + } } fun detectAllLinks() { @@ -85,7 +88,34 @@ class ParametrizedStyles(private val view: EnrichedTextInputView) { } } - private fun getWordAtIndex(s: Editable, index: Int): Triple? { + // Returns the mention string (max two words) along with its start and end indices + private fun getMentionTextAtIndex(s: Editable, index: Int): TextRange? { + if (index < 0 ) return null + + var start = index + var end = index + var whitespaceReached = false + + while (start > 0) { + if (Character.isWhitespace(s[start - 1])) { + // One whitespace is allowed for mentions (to cover two-word mentions) + if (whitespaceReached) break + whitespaceReached = true + } + + start-- + } + + while (end < s.length && !Character.isWhitespace(s[end])) { + end++ + } + + val result = s.subSequence(start, end).toString() + + return TextRange(result, start, end) + } + + private fun getWordAtIndex(s: Editable, index: Int): TextRange? { if (index < 0 ) return null var start = index @@ -101,7 +131,7 @@ class ParametrizedStyles(private val view: EnrichedTextInputView) { val result = s.subSequence(start, end).toString() - return Triple(result, start, end) + return TextRange(result, start, end) } private fun canLinkBeApplied(): Boolean { @@ -120,7 +150,7 @@ class ParametrizedStyles(private val view: EnrichedTextInputView) { return true } - private fun afterTextChangedLinks(result: Triple) { + private fun afterTextChangedLinks(result: TextRange) { // Do not detect link if it's applied manually if (isSettingLinkSpan || !canLinkBeApplied()) return @@ -141,33 +171,35 @@ class ParametrizedStyles(private val view: EnrichedTextInputView) { } } - private fun afterTextChangedMentions(result: Triple) { + private fun afterTextChangedMentions(currentWord: TextRange, mentionText: TextRange) { val mentionHandler = view.mentionHandler ?: return val spannable = view.text as Spannable - val (word, start, end) = result val indicatorsPattern = mentionIndicators.joinToString("|") { Regex.escape(it) } - val mentionIndicatorRegex = Regex("^($indicatorsPattern)") - val mentionRegex= Regex("^($indicatorsPattern)\\w*") + val mentionIndicatorRegex = Regex("($indicatorsPattern)") - val spans = spannable.getSpans(start, end, EnrichedMentionSpan::class.java) + val spans = spannable.getSpans(currentWord.start, currentWord.end, EnrichedMentionSpan::class.java) for (span in spans) { spannable.removeSpan(span) } - if (mentionRegex.matches(word)) { - val indicator = mentionIndicatorRegex.find(word)?.value ?: "" - val text = word.replaceFirst(indicator, "") + val match = mentionIndicatorRegex.find(mentionText.text) + if (match == null) { + mentionHandler.endMention() + return + } - // Means we are starting mention - if (text.isEmpty()) { - mentionStart = start - } + val indicator = match.value + val indicatorIndexInWord = match.range.first + val textStart = indicatorIndexInWord + indicator.length + val text = if (textStart <= mentionText.text.length) mentionText.text.substring(textStart) else "" - mentionHandler.onMention(indicator, text) - } else { - mentionHandler.endMention() + // Means we are starting mention (indicator present but no text after it) + if (text.isEmpty()) { + mentionStart = mentionText.start + indicatorIndexInWord } + + mentionHandler.onMention(indicator, text) } fun setImageSpan(src: String) { @@ -245,4 +277,8 @@ class ParametrizedStyles(private val view: EnrichedTextInputView) { val spannable = view.text as Spannable return removeSpansForRange(spannable, start, end, config.clazz) } + + companion object { + data class TextRange(val text: String, val start: Int, val end: Int) + } } From 42ca7f76c325dc5f997c3f4c71d03cee696ec936 Mon Sep 17 00:00:00 2001 From: Igor Furgala Date: Thu, 27 Nov 2025 12:50:14 +0100 Subject: [PATCH 2/3] chore: do not use new logic --- .../enriched/styles/ParametrizedStyles.kt | 68 +++++-------------- 1 file changed, 18 insertions(+), 50 deletions(-) diff --git a/android/src/main/java/com/swmansion/enriched/styles/ParametrizedStyles.kt b/android/src/main/java/com/swmansion/enriched/styles/ParametrizedStyles.kt index 2ad6a3a8e..dabf576f8 100644 --- a/android/src/main/java/com/swmansion/enriched/styles/ParametrizedStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/styles/ParametrizedStyles.kt @@ -58,13 +58,10 @@ class ParametrizedStyles(private val view: EnrichedTextInputView) { } fun afterTextChanged(s: Editable, endCursorPosition: Int) { - val currentWord = getWordAtIndex(s, endCursorPosition) ?: return - afterTextChangedLinks(currentWord) + val result = getWordAtIndex(s, endCursorPosition) ?: return - val mentionText = getMentionTextAtIndex(s, endCursorPosition) - if (mentionText != null) { - afterTextChangedMentions(currentWord, mentionText) - } + afterTextChangedLinks(result) + afterTextChangedMentions(result) } fun detectAllLinks() { @@ -88,33 +85,6 @@ class ParametrizedStyles(private val view: EnrichedTextInputView) { } } - // Returns the mention string (max two words) along with its start and end indices - private fun getMentionTextAtIndex(s: Editable, index: Int): TextRange? { - if (index < 0 ) return null - - var start = index - var end = index - var whitespaceReached = false - - while (start > 0) { - if (Character.isWhitespace(s[start - 1])) { - // One whitespace is allowed for mentions (to cover two-word mentions) - if (whitespaceReached) break - whitespaceReached = true - } - - start-- - } - - while (end < s.length && !Character.isWhitespace(s[end])) { - end++ - } - - val result = s.subSequence(start, end).toString() - - return TextRange(result, start, end) - } - private fun getWordAtIndex(s: Editable, index: Int): TextRange? { if (index < 0 ) return null @@ -171,35 +141,33 @@ class ParametrizedStyles(private val view: EnrichedTextInputView) { } } - private fun afterTextChangedMentions(currentWord: TextRange, mentionText: TextRange) { + private fun afterTextChangedMentions(result: TextRange) { val mentionHandler = view.mentionHandler ?: return val spannable = view.text as Spannable + val (word, start, end) = result val indicatorsPattern = mentionIndicators.joinToString("|") { Regex.escape(it) } - val mentionIndicatorRegex = Regex("($indicatorsPattern)") + val mentionIndicatorRegex = Regex("^($indicatorsPattern)") + val mentionRegex= Regex("^($indicatorsPattern)\\w*") - val spans = spannable.getSpans(currentWord.start, currentWord.end, EnrichedMentionSpan::class.java) + val spans = spannable.getSpans(start, end, EnrichedMentionSpan::class.java) for (span in spans) { spannable.removeSpan(span) } - val match = mentionIndicatorRegex.find(mentionText.text) - if (match == null) { - mentionHandler.endMention() - return - } + if (mentionRegex.matches(word)) { + val indicator = mentionIndicatorRegex.find(word)?.value ?: "" + val text = word.replaceFirst(indicator, "") - val indicator = match.value - val indicatorIndexInWord = match.range.first - val textStart = indicatorIndexInWord + indicator.length - val text = if (textStart <= mentionText.text.length) mentionText.text.substring(textStart) else "" + // Means we are starting mention + if (text.isEmpty()) { + mentionStart = start + } - // Means we are starting mention (indicator present but no text after it) - if (text.isEmpty()) { - mentionStart = mentionText.start + indicatorIndexInWord + mentionHandler.onMention(indicator, text) + } else { + mentionHandler.endMention() } - - mentionHandler.onMention(indicator, text) } fun setImageSpan(src: String) { From 7f45ca4491524895a58424b89ce0cb242e8f2f73 Mon Sep 17 00:00:00 2001 From: Igor Furgala Date: Thu, 27 Nov 2025 13:21:39 +0100 Subject: [PATCH 3/3] feat: rework android mentions with space --- .../enriched/styles/ParametrizedStyles.kt | 48 ++++++++++++++----- example/src/useChannelMention.ts | 4 ++ 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/android/src/main/java/com/swmansion/enriched/styles/ParametrizedStyles.kt b/android/src/main/java/com/swmansion/enriched/styles/ParametrizedStyles.kt index dabf576f8..f5054fc25 100644 --- a/android/src/main/java/com/swmansion/enriched/styles/ParametrizedStyles.kt +++ b/android/src/main/java/com/swmansion/enriched/styles/ParametrizedStyles.kt @@ -85,7 +85,7 @@ class ParametrizedStyles(private val view: EnrichedTextInputView) { } } - private fun getWordAtIndex(s: Editable, index: Int): TextRange? { + private fun getWordAtIndex(s: CharSequence, index: Int): TextRange? { if (index < 0 ) return null var start = index @@ -141,33 +141,57 @@ class ParametrizedStyles(private val view: EnrichedTextInputView) { } } - private fun afterTextChangedMentions(result: TextRange) { + private fun afterTextChangedMentions(currentWord: TextRange) { val mentionHandler = view.mentionHandler ?: return val spannable = view.text as Spannable - val (word, start, end) = result val indicatorsPattern = mentionIndicators.joinToString("|") { Regex.escape(it) } val mentionIndicatorRegex = Regex("^($indicatorsPattern)") val mentionRegex= Regex("^($indicatorsPattern)\\w*") - val spans = spannable.getSpans(start, end, EnrichedMentionSpan::class.java) + val spans = spannable.getSpans(currentWord.start, currentWord.end, EnrichedMentionSpan::class.java) for (span in spans) { spannable.removeSpan(span) } - if (mentionRegex.matches(word)) { - val indicator = mentionIndicatorRegex.find(word)?.value ?: "" - val text = word.replaceFirst(indicator, "") + var indicator: String + var finalStart: Int + val finalEnd = currentWord.end + + // No mention in the current word, check previous one + if (!mentionRegex.matches(currentWord.text)) { + val previousWord = getWordAtIndex(spannable, currentWord.start - 1) + + // No previous word -> no mention to be detected + if (previousWord == null) { + mentionHandler.endMention() + return + } - // Means we are starting mention - if (text.isEmpty()) { - mentionStart = start + // Previous word is not a mention -> end mention + if (!mentionRegex.matches(previousWord.text)) { + mentionHandler.endMention() + return } - mentionHandler.onMention(indicator, text) + // Previous word is a mention -> use it + finalStart = previousWord.start + indicator = mentionIndicatorRegex.find(previousWord.text)?.value ?: "" } else { - mentionHandler.endMention() + // Current word is a mention -> use it + finalStart = currentWord.start + indicator = mentionIndicatorRegex.find(currentWord.text)?.value ?: "" } + + // Extract text without indicator + val text = spannable.subSequence(finalStart, finalEnd).toString().replaceFirst(indicator, "") + + // Means we are starting mention + if (text.isEmpty()) { + mentionStart = finalStart + } + + mentionHandler.onMention(indicator, text) } fun setImageSpan(src: String) { diff --git a/example/src/useChannelMention.ts b/example/src/useChannelMention.ts index 1a555aded..de08982b3 100644 --- a/example/src/useChannelMention.ts +++ b/example/src/useChannelMention.ts @@ -13,6 +13,10 @@ const MOCKED_DATA = [ id: '3', name: 'Engineering', }, + { + id: '4', + name: 'Private channel', + }, ]; export const useChannelMention = () => {