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
This commit is contained in:
hayaksi1
2026-08-09 04:09:10 +03:00
parent 044737a3a7
commit 4b85e2d31e
2 changed files with 104 additions and 0 deletions
@@ -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<URLSpan>(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<CodeBlockSpan>(start, end).isNotEmpty()) return false
@@ -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<URLSpan>(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"