Browse Source

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.
pull/1107/head
Jorge Martin Espinosa 1 year ago committed by GitHub
parent
commit
bfd938a970
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      changelog.d/1079.bugfix
  2. 42
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
  3. 7
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt
  4. 81
      libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt

1
changelog.d/1079.bugfix

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

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

@ -16,10 +16,6 @@ @@ -16,10 +16,6 @@
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.layout.Box
import androidx.compose.foundation.layout.Column
@ -28,20 +24,16 @@ import androidx.compose.foundation.layout.height @@ -28,20 +24,16 @@ import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.PreviewParameter
import androidx.compose.ui.unit.dp
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.model.event.TimelineItemTextBasedContent
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.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.theme.LinkColor
import io.element.android.libraries.designsystem.text.toAnnotatedString
@Composable
@ -69,14 +61,11 @@ fun TimelineItemTextView( @@ -69,14 +61,11 @@ fun TimelineItemTextView(
}
} else {
Box(modifier) {
val linkStyle = SpanStyle(
color = LinkColor,
)
val styledText = remember(content.body) {
content.body.linkify(linkStyle) + extraPadding.getStr(16.sp).toAnnotatedString()
val textWithPadding = remember(content.body) {
content.body + extraPadding.getStr(16.sp).toAnnotatedString()
}
ClickableLinkText(
text = styledText,
text = textWithPadding,
linkAnnotationTag = "URL",
onClick = onTextClicked,
onLongClick = onTextLongClicked,
@ -86,31 +75,6 @@ fun TimelineItemTextView( @@ -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
@Composable
internal fun TimelineItemTextViewLightPreview(@PreviewParameter(TimelineItemTextBasedContentProvider::class) content: TimelineItemTextBasedContent) =

7
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt

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

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

@ -16,6 +16,9 @@ @@ -16,6 +16,9 @@
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.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
@ -32,27 +35,64 @@ import androidx.compose.ui.platform.LocalUriHandler @@ -32,27 +35,64 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
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.PreviewGroup
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.persistentMapOf
@Composable
fun ClickableLinkText(
text: String,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
linkify: Boolean = true,
linkAnnotationTag: String = "",
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
style: TextStyle = LocalTextStyle.current,
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(
text: AnnotatedString,
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 layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator = Modifier.pointerInput(onClick) {
@ -73,10 +113,10 @@ fun ClickableLinkText( @@ -73,10 +113,10 @@ fun ClickableLinkText(
) { offset ->
layoutResult.value?.let { layoutResult ->
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) }
val linkStringAnnotations = linkUrlAnnotations +
text.getStringAnnotations(linkAnnotationTag, position, position)
annotatedString.getStringAnnotations(linkAnnotationTag, position, position)
if (linkStringAnnotations.isEmpty()) {
onClick()
} else {
@ -86,7 +126,7 @@ fun ClickableLinkText( @@ -86,7 +126,7 @@ fun ClickableLinkText(
}
}
Text(
text = text,
text = processedText,
modifier = modifier.then(pressIndicator),
style = style,
onTextLayout = {
@ -97,6 +137,37 @@ fun ClickableLinkText( @@ -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)
@Composable
internal fun ClickableLinkTextPreview() =
@ -105,7 +176,7 @@ internal fun ClickableLinkTextPreview() = @@ -105,7 +176,7 @@ internal fun ClickableLinkTextPreview() =
@Composable
private fun ContentToPreview() {
ClickableLinkText(
text = AnnotatedString("Hello", ParagraphStyle()),
annotatedString = AnnotatedString("Hello", ParagraphStyle()),
linkAnnotationTag = "",
onClick = {},
onLongClick = {},

Loading…
Cancel
Save