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:
parent
284b54af42
commit
75023f3323
@ -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) {
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user