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:
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) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user