Skip to content

Commit 323c811

Browse files
committed
Try avoiding trailing punctuation inside linkified URLs
1 parent 346e364 commit 323c811

File tree

5 files changed

+135
-29
lines changed

5 files changed

+135
-29
lines changed

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt

+27
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,16 @@ import androidx.compose.runtime.remember
2020
import androidx.compose.ui.Modifier
2121
import androidx.compose.ui.semantics.contentDescription
2222
import androidx.compose.ui.semantics.semantics
23+
import androidx.compose.ui.tooling.preview.Preview
2324
import androidx.compose.ui.tooling.preview.PreviewParameter
2425
import io.element.android.compound.theme.ElementTheme
2526
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
2627
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
2728
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
2829
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider
30+
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
2931
import io.element.android.features.messages.impl.utils.containsOnlyEmojis
32+
import io.element.android.libraries.androidutils.text.LinkifyHelper
3033
import io.element.android.libraries.designsystem.preview.ElementPreview
3134
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
3235
import io.element.android.libraries.matrix.api.core.UserId
@@ -114,3 +117,27 @@ internal fun TimelineItemTextViewPreview(
114117
onLinkClick = {},
115118
)
116119
}
120+
121+
@Preview
122+
@Composable
123+
internal fun TimelineItemTextViewWithLinkifiedUrlPreview() = ElementPreview {
124+
val content = aTimelineItemTextContent(
125+
pillifiedBody = LinkifyHelper.linkify("Does this work (url: github.com/element-hq/element-x-android/README?)?.")
126+
)
127+
TimelineItemTextView(
128+
content = content,
129+
onLinkClick = {},
130+
)
131+
}
132+
133+
@Preview
134+
@Composable
135+
internal fun TimelineItemTextViewWithLinkifiedUrlAndNestedParenthesisPreview() = ElementPreview {
136+
val content = aTimelineItemTextContent(
137+
pillifiedBody = LinkifyHelper.linkify("Does this work ((url: github.com/element-hq/element-x-android/READ(ME)))!")
138+
)
139+
TimelineItemTextView(
140+
content = content,
141+
onLinkClick = {},
142+
)
143+
}

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt

+7-29
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,11 @@
77

88
package io.element.android.features.messages.impl.timeline.factories.event
99

10-
import android.text.Spannable
1110
import android.text.style.URLSpan
1211
import android.text.util.Linkify
1312
import androidx.core.text.buildSpannedString
1413
import androidx.core.text.getSpans
1514
import androidx.core.text.toSpannable
16-
import androidx.core.text.util.LinkifyCompat
1715
import io.element.android.features.location.api.Location
1816
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
1917
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
@@ -29,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
2927
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
3028
import io.element.android.features.messages.impl.utils.TextPillificationHelper
3129
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
30+
import io.element.android.libraries.androidutils.text.LinkifyHelper
3231
import io.element.android.libraries.core.mimetype.MimeTypes
3332
import io.element.android.libraries.featureflag.api.FeatureFlagService
3433
import io.element.android.libraries.featureflag.api.FeatureFlags
@@ -232,7 +231,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
232231
val body = messageType.body.trimEnd()
233232
TimelineItemTextContent(
234233
body = body,
235-
pillifiedBody = textPillificationHelper.pillify(body),
234+
pillifiedBody = textPillificationHelper.pillify(body).withFixedURLSpans(),
236235
htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser),
237236
formattedBody = parseHtml(messageType.formatted) ?: body.withLinks(),
238237
isEdited = content.isEdited,
@@ -276,36 +275,15 @@ class TimelineItemContentMessageFactory @Inject constructor(
276275
result
277276
}
278277
}
278+
}
279279

280-
private fun CharSequence.withFixedURLSpans(): CharSequence {
281-
val spannable = this.toSpannable()
282-
// Get all URL spans, as they will be removed by LinkifyCompat.addLinks
283-
val oldURLSpans = spannable.getSpans<URLSpan>(0, length).associateWith {
284-
val start = spannable.getSpanStart(it)
285-
val end = spannable.getSpanEnd(it)
286-
Pair(start, end)
287-
}
288-
// Find and set as URLSpans any links present in the text
289-
LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES)
290-
// Restore old spans, remove new ones if there is a conflict
291-
for ((urlSpan, location) in oldURLSpans) {
292-
val (start, end) = location
293-
val addedSpans = spannable.getSpans<URLSpan>(start, end).orEmpty()
294-
if (addedSpans.isNotEmpty()) {
295-
for (span in addedSpans) {
296-
spannable.removeSpan(span)
297-
}
298-
}
299-
spannable.setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
300-
}
301-
return spannable
302-
}
280+
private fun CharSequence.withFixedURLSpans(): CharSequence {
281+
return LinkifyHelper.linkify(this, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES)
303282
}
304283

305284
@Suppress("USELESS_ELVIS")
306285
private fun String.withLinks(): CharSequence? {
307286
// Note: toSpannable() can return null when running unit tests
308-
val spannable = toSpannable() ?: return null
309-
val addedLinks = LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES)
310-
return spannable.takeIf { addedLinks }
287+
val spannable = withFixedURLSpans().toSpannable()
288+
return spannable.takeIf { spannable.getSpans<URLSpan>(0, length).isNotEmpty() }
311289
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.libraries.androidutils.text
9+
10+
import android.text.Spannable
11+
import android.text.style.URLSpan
12+
import android.text.util.Linkify
13+
import androidx.core.text.getSpans
14+
import androidx.core.text.toSpannable
15+
import androidx.core.text.util.LinkifyCompat
16+
import timber.log.Timber
17+
import kotlin.collections.component1
18+
import kotlin.collections.component2
19+
import kotlin.collections.isNotEmpty
20+
import kotlin.collections.iterator
21+
import kotlin.collections.orEmpty
22+
23+
object LinkifyHelper {
24+
fun linkify(
25+
text: CharSequence,
26+
@LinkifyCompat.LinkifyMask linkifyMask: Int = Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES,
27+
): CharSequence {
28+
val spannable = text.toSpannable()
29+
// Get all URL spans, as they will be removed by LinkifyCompat.addLinks
30+
val oldURLSpans = spannable.getSpans<URLSpan>(0, text.length).associateWith {
31+
val start = spannable.getSpanStart(it)
32+
val end = spannable.getSpanEnd(it)
33+
Pair(start, end)
34+
}
35+
// Find and set as URLSpans any links present in the text
36+
val addedNewLinks = LinkifyCompat.addLinks(spannable, linkifyMask)
37+
38+
// Process newly added URL spans
39+
if (addedNewLinks) {
40+
val newUrlSpans = spannable.getSpans<URLSpan>(0, spannable.length)
41+
for (urlSpan in newUrlSpans) {
42+
val start = spannable.getSpanStart(urlSpan)
43+
val end = spannable.getSpanEnd(urlSpan)
44+
45+
// Try to avoid including trailing punctuation in the link.
46+
// Since this might fail in some edge cases, we catch the exception and just use the original end index.
47+
val newEnd = runCatching {
48+
adjustLinkifiedUrlSpanEndIndex(spannable, start, end)
49+
}.onFailure {
50+
Timber.e(it, "Failed to adjust end index for link span")
51+
}.getOrNull() ?: end
52+
53+
// Adapt the url in the URL span to the new end index too if needed
54+
if (end != newEnd) {
55+
val url = spannable.subSequence(start, newEnd).toString()
56+
spannable.removeSpan(urlSpan)
57+
spannable.setSpan(URLSpan(url), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
58+
} else {
59+
spannable.setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
60+
}
61+
}
62+
}
63+
64+
// Restore old spans, remove new ones if there is a conflict
65+
for ((urlSpan, location) in oldURLSpans) {
66+
val (start, end) = location
67+
val addedConflictingSpans = spannable.getSpans<URLSpan>(start, end).orEmpty()
68+
if (addedConflictingSpans.isNotEmpty()) {
69+
for (span in addedConflictingSpans) {
70+
spannable.removeSpan(span)
71+
}
72+
}
73+
74+
spannable.setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
75+
}
76+
return spannable
77+
}
78+
79+
private fun adjustLinkifiedUrlSpanEndIndex(spannable: Spannable, start: Int, end: Int): Int {
80+
var end = end
81+
82+
// Trailing punctuation found, adjust the end index
83+
while (spannable[end - 1] in sequenceOf('.', ',', ';', ':', '!', '?', '') && end > start) {
84+
end--
85+
}
86+
87+
// If the last character is a closing parenthesis, check if it's part of a pair
88+
if (spannable[end - 1] == ')' && end > start) {
89+
val linkifiedTextLastPath = spannable.substring(start, end).substringAfterLast('/')
90+
val closingParenthesisCount = linkifiedTextLastPath.count { it == ')' }
91+
val openingParenthesisCount = linkifiedTextLastPath.count { it == '(' }
92+
// If it's not part of a pair, remove it from the link span by adjusting the end index
93+
end -= closingParenthesisCount - openingParenthesisCount
94+
}
95+
return end
96+
}
97+
}

libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt

+1
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString {
147147
if (original.getLinkAnnotations(start, end).isEmpty() && original.getStringAnnotations("URL", start, end).isEmpty()) {
148148
// Prevent linkifying domains in user or room handles (@user:domain.com, #room:domain.com)
149149
if (start > 0 && !spannable[start - 1].isWhitespace()) continue
150+
150151
addStyle(
151152
start = start,
152153
end = end,

0 commit comments

Comments
 (0)