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.animation.fadeIn
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.ClickableText
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Build
|
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.draw.clip
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
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.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
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 androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -92,6 +99,7 @@ private fun UserBubble(
|
|||||||
maxWidth: androidx.compose.ui.unit.Dp,
|
maxWidth: androidx.compose.ui.unit.Dp,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
@@ -118,7 +126,14 @@ private fun UserBubble(
|
|||||||
bottomStart = 16.dp,
|
bottomStart = 16.dp,
|
||||||
bottomEnd = 4.dp,
|
bottomEnd = 4.dp,
|
||||||
),
|
),
|
||||||
).background(brush = gradientBrush),
|
).background(brush = gradientBrush)
|
||||||
|
.pointerInput(message.content) {
|
||||||
|
detectTapGestures(
|
||||||
|
onLongPress = {
|
||||||
|
clipboardManager.setText(AnnotatedString(message.content))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
color = Color.Transparent,
|
color = Color.Transparent,
|
||||||
tonalElevation = 0.dp,
|
tonalElevation = 0.dp,
|
||||||
) {
|
) {
|
||||||
@@ -153,6 +168,7 @@ private fun AssistantBubble(
|
|||||||
) {
|
) {
|
||||||
val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight
|
val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight
|
||||||
val textColor = MaterialTheme.colorScheme.onSurface
|
val textColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -173,7 +189,14 @@ private fun AssistantBubble(
|
|||||||
bottomStart = 16.dp,
|
bottomStart = 16.dp,
|
||||||
bottomEnd = 16.dp,
|
bottomEnd = 16.dp,
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
|
.pointerInput(message.content) {
|
||||||
|
detectTapGestures(
|
||||||
|
onLongPress = {
|
||||||
|
clipboardManager.setText(AnnotatedString(message.content))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
color = bubbleColor,
|
color = bubbleColor,
|
||||||
border =
|
border =
|
||||||
BorderStroke(
|
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
|
@Composable
|
||||||
private fun RichText(
|
private fun RichText(
|
||||||
text: String,
|
text: String,
|
||||||
textColor: Color,
|
textColor: Color,
|
||||||
) {
|
) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val urlPattern = remember { Regex("""https?://[^\s)>\]\u0022\u0027]+""") }
|
||||||
|
val linkColor = MaterialTheme.colorScheme.primary
|
||||||
val annotated =
|
val annotated =
|
||||||
remember(text) {
|
remember(text) {
|
||||||
buildAnnotatedString {
|
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 -> {
|
else -> {
|
||||||
append(src[i])
|
append(src[i])
|
||||||
i++
|
i++
|
||||||
@@ -389,9 +431,13 @@ private fun RichText(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
ClickableText(
|
||||||
text = annotated,
|
text = annotated,
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
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