Linkify raw links in HTML message contents (#1102)

* Linkify links in HTML too:

- Creates a `ClickableLinkText` for `String`.
- Adds a `linkify` parameter to the original function, which is `true` by default.
- Does the linkify logic inside that component, if `linkify` is true.

* Add changelog

* Make sure we don't linkify user mentions or room aliases.

* Use remember to avoid re-processing the text for no reason.
This commit is contained in:
Jorge Martin Espinosa 2023-08-21 16:31:01 +02:00 committed by GitHub
parent 059f93cac7
commit 1092e0bed9
4 changed files with 83 additions and 50 deletions

1
changelog.d/1079.bugfix Normal file
View file

@ -0,0 +1 @@
Linkify links in HTML contents.

View file

@ -16,10 +16,6 @@
package io.element.android.features.messages.impl.timeline.components.event package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannableString
import android.text.style.URLSpan
import android.text.util.Linkify.PHONE_NUMBERS
import android.text.util.Linkify.WEB_URLS
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -28,20 +24,16 @@ import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.text.util.LinkifyCompat
import io.element.android.features.messages.impl.timeline.components.html.HtmlDocument import io.element.android.features.messages.impl.timeline.components.html.HtmlDocument
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider
import io.element.android.libraries.designsystem.components.ClickableLinkText import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.theme.LinkColor
import io.element.android.libraries.designsystem.text.toAnnotatedString import io.element.android.libraries.designsystem.text.toAnnotatedString
@Composable @Composable
@ -69,14 +61,11 @@ fun TimelineItemTextView(
} }
} else { } else {
Box(modifier) { Box(modifier) {
val linkStyle = SpanStyle( val textWithPadding = remember(content.body) {
color = LinkColor, content.body + extraPadding.getStr(16.sp).toAnnotatedString()
)
val styledText = remember(content.body) {
content.body.linkify(linkStyle) + extraPadding.getStr(16.sp).toAnnotatedString()
} }
ClickableLinkText( ClickableLinkText(
text = styledText, text = textWithPadding,
linkAnnotationTag = "URL", linkAnnotationTag = "URL",
onClick = onTextClicked, onClick = onTextClicked,
onLongClick = onTextLongClicked, onLongClick = onTextLongClicked,
@ -86,31 +75,6 @@ fun TimelineItemTextView(
} }
} }
private fun String.linkify(
linkStyle: SpanStyle,
) = buildAnnotatedString {
append(this@linkify)
val spannable = SpannableString(this@linkify)
LinkifyCompat.addLinks(spannable, WEB_URLS or PHONE_NUMBERS)
val spans = spannable.getSpans(0, spannable.length, URLSpan::class.java)
for (span in spans) {
val start = spannable.getSpanStart(span)
val end = spannable.getSpanEnd(span)
addStyle(
start = start,
end = end,
style = linkStyle,
)
addStringAnnotation(
tag = "URL",
annotation = span.url,
start = start,
end = end
)
}
}
@Preview @Preview
@Composable @Composable
internal fun TimelineItemTextViewLightPreview(@PreviewParameter(TimelineItemTextBasedContentProvider::class) content: TimelineItemTextBasedContent) = internal fun TimelineItemTextViewLightPreview(@PreviewParameter(TimelineItemTextBasedContentProvider::class) content: TimelineItemTextBasedContent) =

View file

@ -104,10 +104,7 @@ private fun HtmlBody(
when (val node = nodes.next()) { when (val node = nodes.next()) {
is TextNode -> { is TextNode -> {
if (!node.isBlank) { if (!node.isBlank) {
Text( ClickableLinkText(text = node.text(), interactionSource = interactionSource)
text = node.text(),
color = MaterialTheme.colorScheme.primary,
)
} }
} }
is Element -> { is Element -> {
@ -579,7 +576,7 @@ private fun HtmlText(
) { ) {
val inlineContentMap = persistentMapOf<String, InlineTextContent>() val inlineContentMap = persistentMapOf<String, InlineTextContent>()
ClickableLinkText( ClickableLinkText(
text = text, annotatedString = text,
linkAnnotationTag = "URL", linkAnnotationTag = "URL",
style = style, style = style,
modifier = modifier, modifier = modifier,

View file

@ -16,6 +16,9 @@
package io.element.android.libraries.designsystem.components package io.element.android.libraries.designsystem.components
import android.text.SpannableString
import android.text.style.URLSpan
import android.text.util.Linkify
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.interaction.PressInteraction
@ -32,27 +35,64 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.core.text.util.LinkifyCompat
import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.LinkColor
import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
@OptIn(ExperimentalTextApi::class)
@Composable @Composable
fun ClickableLinkText( fun ClickableLinkText(
text: AnnotatedString, text: String,
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
linkify: Boolean = true,
linkAnnotationTag: String = "", linkAnnotationTag: String = "",
onClick: () -> Unit = {}, onClick: () -> Unit = {},
onLongClick: () -> Unit = {}, onLongClick: () -> Unit = {},
style: TextStyle = LocalTextStyle.current, style: TextStyle = LocalTextStyle.current,
inlineContent: ImmutableMap<String, InlineTextContent> = persistentMapOf(), inlineContent: ImmutableMap<String, InlineTextContent> = persistentMapOf(),
) { ) {
ClickableLinkText(
annotatedString = AnnotatedString(text),
interactionSource = interactionSource,
modifier = modifier,
linkify = linkify,
linkAnnotationTag = linkAnnotationTag,
onClick = onClick,
onLongClick = onLongClick,
style = style,
inlineContent = inlineContent,
)
}
@OptIn(ExperimentalTextApi::class)
@Composable
fun ClickableLinkText(
annotatedString: AnnotatedString,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
linkify: Boolean = true,
linkAnnotationTag: String = "",
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
style: TextStyle = LocalTextStyle.current,
inlineContent: ImmutableMap<String, InlineTextContent> = persistentMapOf(),
) {
val processedText = remember(annotatedString) {
if (linkify) {
annotatedString.linkify(SpanStyle(color = LinkColor))
} else {
annotatedString
}
}
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) } val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator = Modifier.pointerInput(onClick) { val pressIndicator = Modifier.pointerInput(onClick) {
@ -73,10 +113,10 @@ fun ClickableLinkText(
) { offset -> ) { offset ->
layoutResult.value?.let { layoutResult -> layoutResult.value?.let { layoutResult ->
val position = layoutResult.getOffsetForPosition(offset) val position = layoutResult.getOffsetForPosition(offset)
val linkUrlAnnotations = text.getUrlAnnotations(position, position) val linkUrlAnnotations = annotatedString.getUrlAnnotations(position, position)
.map { AnnotatedString.Range(it.item.url, it.start, it.end, linkAnnotationTag) } .map { AnnotatedString.Range(it.item.url, it.start, it.end, linkAnnotationTag) }
val linkStringAnnotations = linkUrlAnnotations + val linkStringAnnotations = linkUrlAnnotations +
text.getStringAnnotations(linkAnnotationTag, position, position) annotatedString.getStringAnnotations(linkAnnotationTag, position, position)
if (linkStringAnnotations.isEmpty()) { if (linkStringAnnotations.isEmpty()) {
onClick() onClick()
} else { } else {
@ -86,7 +126,7 @@ fun ClickableLinkText(
} }
} }
Text( Text(
text = text, text = processedText,
modifier = modifier.then(pressIndicator), modifier = modifier.then(pressIndicator),
style = style, style = style,
onTextLayout = { onTextLayout = {
@ -97,6 +137,37 @@ fun ClickableLinkText(
) )
} }
@OptIn(ExperimentalTextApi::class)
fun AnnotatedString.linkify(linkStyle: SpanStyle): AnnotatedString {
val original = this
val spannable = SpannableString(this.text)
LinkifyCompat.addLinks(spannable, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS)
val spans = spannable.getSpans(0, spannable.length, URLSpan::class.java)
return buildAnnotatedString {
append(original)
for (span in spans) {
val start = spannable.getSpanStart(span)
val end = spannable.getSpanEnd(span)
if (original.getUrlAnnotations(start, end).isEmpty() && original.getStringAnnotations("URL", start, end).isEmpty()) {
// Prevent linkifying domains in user or room handles (@user:domain.com, #room:domain.com)
if (start > 0 && !spannable[start - 1].isWhitespace()) continue
addStyle(
start = start,
end = end,
style = linkStyle,
)
addStringAnnotation(
tag = "URL",
annotation = span.url,
start = start,
end = end
)
}
}
}
}
@Preview(group = PreviewGroup.Text) @Preview(group = PreviewGroup.Text)
@Composable @Composable
internal fun ClickableLinkTextPreview() = internal fun ClickableLinkTextPreview() =
@ -105,7 +176,7 @@ internal fun ClickableLinkTextPreview() =
@Composable @Composable
private fun ContentToPreview() { private fun ContentToPreview() {
ClickableLinkText( ClickableLinkText(
text = AnnotatedString("Hello", ParagraphStyle()), annotatedString = AnnotatedString("Hello", ParagraphStyle()),
linkAnnotationTag = "", linkAnnotationTag = "",
onClick = {}, onClick = {},
onLongClick = {}, onLongClick = {},