diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt index e02b0b463..efdfa044e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt @@ -54,7 +54,7 @@ import org.meshtastic.core.database.model.Message import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.strings.R -import org.meshtastic.core.ui.component.MDText +import org.meshtastic.core.ui.component.AutoLinkText import org.meshtastic.core.ui.component.NodeChip import org.meshtastic.core.ui.component.Rssi import org.meshtastic.core.ui.component.Snr @@ -158,7 +158,7 @@ internal fun MessageItem( } Column(modifier = Modifier.padding(horizontal = 8.dp)) { - MDText( + AutoLinkText( modifier = Modifier.fillMaxWidth(), text = message.text, style = MaterialTheme.typography.bodyMedium, diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/AutoLinkText.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/AutoLinkText.kt new file mode 100644 index 000000000..e9f14b8b6 --- /dev/null +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/AutoLinkText.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.core.ui.component + +import android.text.Spannable +import android.text.Spannable.Factory +import android.text.style.URLSpan +import android.text.util.Linkify +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.tooling.preview.Preview +import androidx.core.text.util.LinkifyCompat +import org.meshtastic.core.ui.theme.HyperlinkBlue + +private val DefaultTextLinkStyles = + TextLinkStyles(style = SpanStyle(color = HyperlinkBlue, textDecoration = TextDecoration.Underline)) + +@Composable +fun AutoLinkText( + text: String, + modifier: Modifier = Modifier, + style: TextStyle = TextStyle.Default, + linkStyles: TextLinkStyles = DefaultTextLinkStyles, + color: Color = Color.Unspecified, +) { + val spannable = remember(text) { linkify(text) } + Text(text = spannable.toAnnotatedString(linkStyles), modifier = modifier, style = style.copy(color = color)) +} + +private fun linkify(text: String) = Factory.getInstance().newSpannable(text).also { + LinkifyCompat.addLinks(it, Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS) +} + +private fun Spannable.toAnnotatedString(linkStyles: TextLinkStyles): AnnotatedString = buildAnnotatedString { + val spannable = this@toAnnotatedString + var lastEnd = 0 + spannable.getSpans(0, spannable.length, Any::class.java).forEach { span -> + val start = spannable.getSpanStart(span) + val end = spannable.getSpanEnd(span) + append(spannable.subSequence(lastEnd, start)) + when (span) { + is URLSpan -> + withLink(LinkAnnotation.Url(url = span.url, styles = linkStyles)) { + append(spannable.subSequence(start, end)) + } + + else -> append(spannable.subSequence(start, end)) + } + lastEnd = end + } + append(spannable.subSequence(lastEnd, spannable.length)) +} + +@Preview(showBackground = true) +@Composable +private fun AutoLinkTextPreview() { + AutoLinkText("A text containing a link https://example.com") +} + +@Preview(showBackground = true) +@Composable +private fun AutoLinkTextPreview2() { + AutoLinkText("") +} diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MDText.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MDText.kt deleted file mode 100644 index f146400b5..000000000 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/MDText.kt +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.meshtastic.core.ui.component - -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.tooling.preview.Preview -import com.mikepenz.markdown.compose.components.markdownComponents -import com.mikepenz.markdown.m3.Markdown -import com.mikepenz.markdown.model.DefaultMarkdownColors -import com.mikepenz.markdown.model.DefaultMarkdownTypography -import org.meshtastic.core.ui.theme.HyperlinkBlue - -@Composable -fun MDText( - text: String, - modifier: Modifier = Modifier, - style: TextStyle = MaterialTheme.typography.bodyMedium, - color: Color = Color.Unspecified, -) { - val colors = - DefaultMarkdownColors( - text = color, - codeBackground = MaterialTheme.colorScheme.surfaceContainerHigh, - inlineCodeBackground = MaterialTheme.colorScheme.surfaceContainerHigh, - dividerColor = MaterialTheme.colorScheme.onSurface, - tableBackground = MaterialTheme.colorScheme.surfaceContainer, - ) - - val typography = - DefaultMarkdownTypography( - // Restrict max size of the text - h1 = MaterialTheme.typography.headlineMedium.copy(color = color), - h2 = MaterialTheme.typography.headlineMedium.copy(color = color), - h3 = MaterialTheme.typography.headlineSmall.copy(color = color), - h4 = MaterialTheme.typography.titleLarge.copy(color = color), - h5 = MaterialTheme.typography.titleMedium.copy(color = color), - h6 = MaterialTheme.typography.titleSmall.copy(color = color), - text = style, - code = - MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface, - ), - inlineCode = - MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface, - background = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - quote = MaterialTheme.typography.bodyLarge.copy(color = color), - paragraph = MaterialTheme.typography.bodyMedium.copy(color = color), - ordered = MaterialTheme.typography.bodyMedium.copy(color = color), - bullet = MaterialTheme.typography.bodyMedium.copy(color = color), - list = MaterialTheme.typography.bodyMedium.copy(color = color), - textLink = - TextLinkStyles(style = SpanStyle(color = HyperlinkBlue, textDecoration = TextDecoration.Underline)), - table = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurface), - ) - - // Custom Markdown components to disable image rendering - val customComponents = markdownComponents(image = { /* Empty composable to disable image rendering */ }) - - Markdown( - content = text, - modifier = modifier, - colors = colors, - typography = typography, - components = customComponents, // Use custom components - ) -} - -@Preview(showBackground = true) -@Composable -private fun AutoLinkTextPreview() { - MDText( - "A text containing a link https://example.com **bold** _Italics_" + - "\n # hello \n ## hello \n ### hello \n #### hello \n ##### hello \n ###### hello \n ```code```", - ) -}