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
This commit is contained in:
JARVIS 2026-01-27 22:03:26 +01:00
parent 284b54af42
commit 75023f3323
2 changed files with 99 additions and 5 deletions

View File

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text 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.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
private const val URL_TAG = "URL"
@Composable @Composable
fun ChatMarkdown(text: String, textColor: Color) { fun ChatMarkdown(text: String, textColor: Color) {
val blocks = remember(text) { splitMarkdown(text) } val blocks = remember(text) { splitMarkdown(text) }
val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow
val linkColor = MaterialTheme.colorScheme.primary
val uriHandler = LocalUriHandler.current
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
for (b in blocks) { for (b in blocks) {
@ -42,10 +49,20 @@ fun ChatMarkdown(text: String, textColor: Color) {
is ChatMarkdownBlock.Text -> { is ChatMarkdownBlock.Text -> {
val trimmed = b.text.trimEnd() val trimmed = b.text.trimEnd()
if (trimmed.isEmpty()) continue if (trimmed.isEmpty()) continue
Text( val annotated = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg, linkColor = linkColor)
text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg), ClickableText(
style = MaterialTheme.typography.bodyMedium, text = annotated,
color = textColor, 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 -> { is ChatMarkdownBlock.Code -> {
@ -126,12 +143,29 @@ private fun splitInlineImages(text: String): List<ChatMarkdownBlock> {
return out 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("") 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 { val out = buildAnnotatedString {
var i = 0 var i = 0
while (i < text.length) { 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)) { if (text.startsWith("**", startIndex = i)) {
val end = text.indexOf("**", startIndex = i + 2) val end = text.indexOf("**", startIndex = i + 2)
if (end > i + 2) { if (end > i + 2) {

View File

@ -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)
}
}