From 75023f3323bfa5e1ed1b4fbd78a15189353e4961 Mon Sep 17 00:00:00 2001 From: JARVIS Date: Tue, 27 Jan 2026 22:03:26 +0100 Subject: [PATCH] feat(android): add clickable URLs in chat messages URLs in chat messages (http:// and https://) are now rendered as tappable links that open in the browser. The links are styled with the primary color and underlined for visibility. - Detects URLs with a regex that handles paths, query params, and fragments - Strips trailing punctuation (.,!?:;) to avoid catching them in the URL - Uses ClickableText + LocalUriHandler for native Android URL handling - Adds unit tests for URL parsing logic --- .../bot/molt/android/ui/chat/ChatMarkdown.kt | 44 ++++++++++++-- .../android/ui/chat/ChatMarkdownUrlTest.kt | 60 +++++++++++++++++++ 2 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 apps/android/app/src/test/java/bot/molt/android/ui/chat/ChatMarkdownUrlTest.kt diff --git a/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatMarkdown.kt b/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatMarkdown.kt index 10cf25b81..fdf15584a 100644 --- a/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatMarkdown.kt +++ b/apps/android/app/src/main/java/bot/molt/android/ui/chat/ChatMarkdown.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -20,21 +21,27 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +private const val URL_TAG = "URL" + @Composable fun ChatMarkdown(text: String, textColor: Color) { val blocks = remember(text) { splitMarkdown(text) } val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow + val linkColor = MaterialTheme.colorScheme.primary + val uriHandler = LocalUriHandler.current Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { for (b in blocks) { @@ -42,10 +49,20 @@ fun ChatMarkdown(text: String, textColor: Color) { is ChatMarkdownBlock.Text -> { val trimmed = b.text.trimEnd() if (trimmed.isEmpty()) continue - Text( - text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg), - style = MaterialTheme.typography.bodyMedium, - color = textColor, + val annotated = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg, linkColor = linkColor) + ClickableText( + text = annotated, + style = MaterialTheme.typography.bodyMedium.copy(color = textColor), + onClick = { offset -> + annotated.getStringAnnotations(tag = URL_TAG, start = offset, end = offset) + .firstOrNull()?.let { annotation -> + try { + uriHandler.openUri(annotation.item) + } catch (_: Throwable) { + // Ignore invalid URLs + } + } + }, ) } is ChatMarkdownBlock.Code -> { @@ -126,12 +143,29 @@ private fun splitInlineImages(text: String): List { return out } -private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui.graphics.Color): AnnotatedString { +private fun parseInlineMarkdown(text: String, inlineCodeBg: androidx.compose.ui.graphics.Color, linkColor: androidx.compose.ui.graphics.Color): AnnotatedString { if (text.isEmpty()) return AnnotatedString("") + // Pre-parse URLs to detect their positions + val urlRegex = Regex("""https?://[^\s<>\[\](){}'"`,]+""") + val urlMatches = urlRegex.findAll(text).toList() + val out = buildAnnotatedString { var i = 0 while (i < text.length) { + // Check if we're at a URL position + val urlMatch = urlMatches.find { it.range.first == i } + if (urlMatch != null) { + val url = urlMatch.value.trimEnd('.', ',', '!', '?', ':', ';', ')') + pushStringAnnotation(tag = URL_TAG, annotation = url) + withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) { + append(url) + } + pop() + i += url.length + continue + } + if (text.startsWith("**", startIndex = i)) { val end = text.indexOf("**", startIndex = i + 2) if (end > i + 2) { diff --git a/apps/android/app/src/test/java/bot/molt/android/ui/chat/ChatMarkdownUrlTest.kt b/apps/android/app/src/test/java/bot/molt/android/ui/chat/ChatMarkdownUrlTest.kt new file mode 100644 index 000000000..b57b7a819 --- /dev/null +++ b/apps/android/app/src/test/java/bot/molt/android/ui/chat/ChatMarkdownUrlTest.kt @@ -0,0 +1,60 @@ +package bot.molt.android.ui.chat + +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatMarkdownUrlTest { + private val dummyCodeBg = Color.Gray + private val dummyLinkColor = Color.Blue + + @Test + fun `extracts simple https URL`() { + val text = "Check this: https://example.com/page" + val regex = Regex("""https?://[^\s<>\[\](){}'"`,]+""") + val match = regex.find(text) + assertEquals("https://example.com/page", match?.value) + } + + @Test + fun `extracts URL with trailing punctuation stripped`() { + val url = "https://example.com/page." + val trimmed = url.trimEnd('.', ',', '!', '?', ':', ';', ')') + assertEquals("https://example.com/page", trimmed) + } + + @Test + fun `extracts multiple URLs`() { + val text = "See https://a.com and https://b.com for more." + val regex = Regex("""https?://[^\s<>\[\](){}'"`,]+""") + val matches = regex.findAll(text).toList() + assertEquals(2, matches.size) + assertEquals("https://a.com", matches[0].value) + assertEquals("https://b.com", matches[1].value) + } + + @Test + fun `http URLs also work`() { + val text = "Old link: http://legacy.example.org/path" + val regex = Regex("""https?://[^\s<>\[\](){}'"`,]+""") + val match = regex.find(text) + assertTrue(match?.value?.startsWith("http://") == true) + } + + @Test + fun `URL with query params`() { + val text = "API: https://api.example.com/search?q=test&limit=10" + val regex = Regex("""https?://[^\s<>\[\](){}'"`,]+""") + val match = regex.find(text) + assertEquals("https://api.example.com/search?q=test&limit=10", match?.value) + } + + @Test + fun `URL with fragment`() { + val text = "Docs at https://docs.example.com/guide#section-3" + val regex = Regex("""https?://[^\s<>\[\](){}'"`,]+""") + val match = regex.find(text) + assertEquals("https://docs.example.com/guide#section-3", match?.value) + } +}