From 4b85e2d31ef2579e3876724b00dd579219b9f5e2 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:09:10 +0300 Subject: [PATCH 1/2] Turn a matrix: URI in a message into a pill A permalink written as a matrix: URI stayed plain text, while the same link in matrix.to form became a pill. The pillifier only scanned Patterns.WEB_URL, which cannot match a URI with no web scheme, even though the permalink parser already understands matrix: URIs. Those URIs are now scanned for as well, before the web pass, since Patterns.WEB_URL otherwise claims the bare domain inside the URI and whichever pass runs first wins the range. Each match also gets a URLSpan: the timeline only reports URLSpans as clicks, so without one the pill would look right and do nothing. The composer path is untouched, since it pillifies without permalinks. Relates to https://github.com/element-hq/element-x-android/issues/5319 --- .../impl/utils/TextPillificationHelper.kt | 31 ++++++++ .../DefaultTextPillificationHelperTest.kt | 73 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt index f74d3b5f30..dbdba43340 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt @@ -28,6 +28,11 @@ import io.element.android.libraries.textcomposer.mentions.getMentionSpans import io.element.android.wysiwyg.view.spans.CodeBlockSpan import io.element.android.wysiwyg.view.spans.InlineCodeSpan +/** + * Matches a `matrix:` URI as defined by MSC2312, stopping before any trailing sentence punctuation. + */ +private val MATRIX_URI_REGEX = Regex("""matrix:(?:u|user|r|room|roomid|e|event)/\S*[^\s.,;:!?)]""", RegexOption.IGNORE_CASE) + interface TextPillificationHelper { fun pillify(text: CharSequence, pillifyPermalinks: Boolean = true): CharSequence } @@ -87,6 +92,9 @@ class DefaultTextPillificationHelper( } private fun pillifyPermalinks(text: SpannableStringBuilder) { + // Before the web pass: Patterns.WEB_URL matches the bare domain inside a matrix: URI, and + // whichever pass runs first wins the range. + pillifyMatrixUris(text) for (match in Patterns.WEB_URL.toRegex().findAll(text)) { val start = match.range.first val end = match.range.last + 1 @@ -99,6 +107,29 @@ class DefaultTextPillificationHelper( } } + /** + * `Patterns.WEB_URL` cannot match a `matrix:` URI, so those permalinks were left as plain text + * even though the parser understands them. See + * https://github.com/element-hq/element-x-android/issues/5319 + */ + private fun pillifyMatrixUris(text: SpannableStringBuilder) { + for (match in MATRIX_URI_REGEX.findAll(text)) { + val start = match.range.first + val end = match.range.last + 1 + if (!text.canPillify(start, end)) continue + val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, match.value) + if (mentionSpan != null) { + text.setSpan(mentionSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + // A `matrix:` URI gets no URLSpan from linkification, and the timeline only reports + // URLSpans as clicks, so the pill would otherwise be inert. Adding it here also makes + // LinkifyHelper drop the bogus web link it would otherwise find inside the URI. + if (text.getSpans(start, end).isEmpty()) { + text.setSpan(URLSpan(match.value), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + } + } + private fun Spanned.canPillify(start: Int, end: Int): Boolean { if (getMentionSpans(start, end).isNotEmpty()) return false if (getSpans(start, end).isNotEmpty()) return false diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt index 6390c2ba48..ae9f5f5715 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt @@ -9,6 +9,9 @@ package io.element.android.features.messages.impl.utils import android.net.Uri +import android.text.Spanned +import android.text.style.URLSpan +import androidx.core.text.getSpans import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomAlias @@ -30,6 +33,76 @@ import io.element.android.tests.testutils.robolectric.RobolectricTest import org.junit.Test class DefaultTextPillificationHelperTest : RobolectricTest() { + @Test + fun `pillify - adds pills for matrix uri user links`() { + val text = "Hello matrix:u/someone:example.com" + val userId = UserId("@someone:example.com") + val helper = aTextPillificationHelper( + permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(userId) }), + permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { + Result.success("https://matrix.to/#/@someone:example.com") + }), + ) + val pillified = helper.pillify(text) + val mentionSpans = pillified.getMentionSpans() + assertThat(mentionSpans).hasSize(1) + assertThat(mentionSpans.first().type).isInstanceOf(MentionType.User::class.java) + val spanned = pillified as Spanned + assertThat(spanned.getSpanStart(mentionSpans.first())).isEqualTo(text.indexOf("matrix:")) + assertThat(spanned.getSpanEnd(mentionSpans.first())).isEqualTo(text.length) + // Without a URLSpan the pill would not be clickable in the timeline. + val urlSpans = spanned.getSpans(0, spanned.length) + assertThat(urlSpans).hasLength(1) + assertThat(urlSpans.first().url).isEqualTo("matrix:u/someone:example.com") + } + + @Test + fun `pillify - adds pills for matrix uri room links`() { + val text = "Join matrix:r/room:example.com now" + val roomAlias = RoomAlias("#room:example.com") + val helper = aTextPillificationHelper( + permalinkParser = FakePermalinkParser(result = { + PermalinkData.RoomLink(roomIdOrAlias = roomAlias.toRoomIdOrAlias()) + }), + permalinkBuilder = FakePermalinkBuilder(permalinkForRoomAliasLambda = { + Result.success("https://matrix.to/#/#room:example.com") + }), + ) + val mentionSpans = helper.pillify(text).getMentionSpans() + assertThat(mentionSpans).hasSize(1) + assertThat(mentionSpans.first().type).isInstanceOf(MentionType.Room::class.java) + } + + @Test + fun `pillify - a matrix uri does not swallow trailing punctuation`() { + val text = "See matrix:u/someone:example.com." + val userId = UserId("@someone:example.com") + val helper = aTextPillificationHelper( + permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(userId) }), + permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { + Result.success("https://matrix.to/#/@someone:example.com") + }), + ) + val pillified = helper.pillify(text) + val mentionSpans = pillified.getMentionSpans() + assertThat(mentionSpans).hasSize(1) + assertThat((pillified as Spanned).getSpanEnd(mentionSpans.first())).isEqualTo(text.length - 1) + } + + @Test + fun `pillify - does not pillify matrix uris when permalinks are disabled`() { + val text = "Hello matrix:u/someone:example.com" + val userId = UserId("@someone:example.com") + val helper = aTextPillificationHelper( + permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(userId) }), + permalinkBuilder = FakePermalinkBuilder(permalinkForUserLambda = { + Result.success("https://matrix.to/#/@someone:example.com") + }), + ) + val pillified = helper.pillify(text, pillifyPermalinks = false) + assertThat(pillified.getMentionSpans()).isEmpty() + } + @Test fun `pillify - adds pills for user ids`() { val text = "A @user:server.com" From f0e791001fa5b4120692bf8be36e27896bba4569 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:39:18 +0300 Subject: [PATCH 2/2] Drop the comments from the matrix URI pillification Review feedback: the regex name and the test names carry the meaning. --- .../messages/impl/utils/TextPillificationHelper.kt | 13 ------------- .../utils/DefaultTextPillificationHelperTest.kt | 1 - 2 files changed, 14 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt index dbdba43340..9faf42d3fd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/TextPillificationHelper.kt @@ -28,9 +28,6 @@ import io.element.android.libraries.textcomposer.mentions.getMentionSpans import io.element.android.wysiwyg.view.spans.CodeBlockSpan import io.element.android.wysiwyg.view.spans.InlineCodeSpan -/** - * Matches a `matrix:` URI as defined by MSC2312, stopping before any trailing sentence punctuation. - */ private val MATRIX_URI_REGEX = Regex("""matrix:(?:u|user|r|room|roomid|e|event)/\S*[^\s.,;:!?)]""", RegexOption.IGNORE_CASE) interface TextPillificationHelper { @@ -92,8 +89,6 @@ class DefaultTextPillificationHelper( } private fun pillifyPermalinks(text: SpannableStringBuilder) { - // Before the web pass: Patterns.WEB_URL matches the bare domain inside a matrix: URI, and - // whichever pass runs first wins the range. pillifyMatrixUris(text) for (match in Patterns.WEB_URL.toRegex().findAll(text)) { val start = match.range.first @@ -107,11 +102,6 @@ class DefaultTextPillificationHelper( } } - /** - * `Patterns.WEB_URL` cannot match a `matrix:` URI, so those permalinks were left as plain text - * even though the parser understands them. See - * https://github.com/element-hq/element-x-android/issues/5319 - */ private fun pillifyMatrixUris(text: SpannableStringBuilder) { for (match in MATRIX_URI_REGEX.findAll(text)) { val start = match.range.first @@ -120,9 +110,6 @@ class DefaultTextPillificationHelper( val mentionSpan = mentionSpanProvider.getMentionSpanFor(match.value, match.value) if (mentionSpan != null) { text.setSpan(mentionSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - // A `matrix:` URI gets no URLSpan from linkification, and the timeline only reports - // URLSpans as clicks, so the pill would otherwise be inert. Adding it here also makes - // LinkifyHelper drop the bogus web link it would otherwise find inside the URI. if (text.getSpans(start, end).isEmpty()) { text.setSpan(URLSpan(match.value), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt index ae9f5f5715..2590c9fc5c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/utils/DefaultTextPillificationHelperTest.kt @@ -50,7 +50,6 @@ class DefaultTextPillificationHelperTest : RobolectricTest() { val spanned = pillified as Spanned assertThat(spanned.getSpanStart(mentionSpans.first())).isEqualTo(text.indexOf("matrix:")) assertThat(spanned.getSpanEnd(mentionSpans.first())).isEqualTo(text.length) - // Without a URLSpan the pill would not be clickable in the timeline. val urlSpans = spanned.getSpans(0, spanned.length) assertThat(urlSpans).hasLength(1) assertThat(urlSpans.first().url).isEqualTo("matrix:u/someone:example.com")