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:
committed by
GitHub
parent
059f93cac7
commit
1092e0bed9
1
changelog.d/1079.bugfix
Normal file
1
changelog.d/1079.bugfix
Normal file
@@ -0,0 +1 @@
|
||||
Linkify links in HTML contents.
|
||||
@@ -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
|
||||
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(
|
||||
}
|
||||
} 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(
|
||||
}
|
||||
}
|
||||
|
||||
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) =
|
||||
|
||||
@@ -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(
|
||||
) {
|
||||
val inlineContentMap = persistentMapOf<String, InlineTextContent>()
|
||||
ClickableLinkText(
|
||||
text = text,
|
||||
annotatedString = text,
|
||||
linkAnnotationTag = "URL",
|
||||
style = style,
|
||||
modifier = modifier,
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalTextApi::class)
|
||||
@Composable
|
||||
fun ClickableLinkText(
|
||||
text: AnnotatedString,
|
||||
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(
|
||||
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(
|
||||
) { 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(
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
text = processedText,
|
||||
modifier = modifier.then(pressIndicator),
|
||||
style = style,
|
||||
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)
|
||||
@Composable
|
||||
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 = {},
|
||||
|
||||
Reference in New Issue
Block a user