feat: clickable URLs and long-press copy in chat bubbles (Issues #29, #30) (#36)

Add clickable URL detection with primary color + underline styling and browser launch via UriHandler. Add long-press to copy raw message text to clipboard for both user and assistant chat bubbles.

Fixes #29, Fixes #30
This commit is contained in:
M57 authored and GitHub committed 2026-06-19 14:16:56 +03:00
1 parent 2cfe1f71fc
commit fb605d1bc0
1 file changed
+50 -4
@@ -8,6 +8,7 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
@@ -39,12 +41,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalConfiguration
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 androidx.compose.ui.unit.sp
@@ -92,6 +99,7 @@ private fun UserBubble(
maxWidth: androidx.compose.ui.unit.Dp,
modifier: Modifier = Modifier,
) {
val clipboardManager = LocalClipboardManager.current
Box(
modifier =
modifier
@@ -118,7 +126,14 @@ private fun UserBubble(
bottomStart = 16.dp,
bottomEnd = 4.dp,
),
).background(brush = gradientBrush),
).background(brush = gradientBrush)
.pointerInput(message.content) {
detectTapGestures(
onLongPress = {
clipboardManager.setText(AnnotatedString(message.content))
},
)
},
color = Color.Transparent,
tonalElevation = 0.dp,
) {
@@ -153,6 +168,7 @@ private fun AssistantBubble(
) {
val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight
val textColor = MaterialTheme.colorScheme.onSurface
val clipboardManager = LocalClipboardManager.current
Box(
modifier =
@@ -173,7 +189,14 @@ private fun AssistantBubble(
bottomStart = 16.dp,
bottomEnd = 16.dp,
),
),
)
.pointerInput(message.content) {
detectTapGestures(
onLongPress = {
clipboardManager.setText(AnnotatedString(message.content))
},
)
},
color = bubbleColor,
border =
BorderStroke(
@@ -311,13 +334,16 @@ private fun ToolBubble(
}
/**
* Simple rich text renderer supporting **bold**, `inline code`, and ```code blocks```.
* Simple rich text renderer supporting **bold**, `inline code`, ```code blocks```, and clickable URLs.
*/
@Composable
private fun RichText(
text: String,
textColor: Color,
) {
val uriHandler = LocalUriHandler.current
val urlPattern = remember { Regex("""https?://[^\s)>\]\u0022\u0027]+""") }
val linkColor = MaterialTheme.colorScheme.primary
val annotated =
remember(text) {
buildAnnotatedString {
@@ -380,6 +406,22 @@ private fun RichText(
}
}
// URL: https://...
urlPattern.matchAt(src, i) != null -> {
val match = urlPattern.matchAt(src, i)!!
pushStringAnnotation(tag = "URL", annotation = match.value)
withStyle(
SpanStyle(
color = linkColor,
textDecoration = TextDecoration.Underline,
),
) {
append(match.value)
}
pop()
i = match.range.last + 1
}
else -> {
append(src[i])
i++
@@ -389,9 +431,13 @@ private fun RichText(
}
}
Text(
ClickableText(
text = annotated,
style = MaterialTheme.typography.bodyMedium.copy(color = textColor),
onClick = { offset: Int ->
annotated.getStringAnnotations("URL", offset, offset)
.firstOrNull()?.let { uriHandler.openUri(it.item) }
},
)
}