diff --git a/changelog.d/1079.bugfix b/changelog.d/1079.bugfix new file mode 100644 index 0000000000..6fcaa759c3 --- /dev/null +++ b/changelog.d/1079.bugfix @@ -0,0 +1 @@ +Linkify links in HTML contents. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt index 9066c88182..bc06afcd61 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -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) = diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt index bb3e7b7f10..dd1a090075 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt @@ -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() ClickableLinkText( - text = text, + annotatedString = text, linkAnnotationTag = "URL", style = style, modifier = modifier, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt index 21bddd67a1..bd5f393cd2 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ClickableLinkText.kt @@ -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 +@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 = 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 = persistentMapOf(), ) { + val processedText = remember(annotatedString) { + if (linkify) { + annotatedString.linkify(SpanStyle(color = LinkColor)) + } else { + annotatedString + } + } val uriHandler = LocalUriHandler.current val layoutResult = remember { mutableStateOf(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 = {},