fix: move bubble copy button into footer next to timestamp (#850)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-08 11:23:36 +03:00
1 parent 2980a1e884
commit 5a84f9f44e
1 file changed
+72 -107
@@ -8,9 +8,6 @@ import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -21,7 +18,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -63,7 +59,6 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
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
@@ -174,13 +169,13 @@ private fun UserBubble(
) { ) {
val clipboard = LocalClipboard.current val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showCopyButton by remember { mutableStateOf(false) } var copied by remember { mutableStateOf(false) }
// Auto-dismiss copy button after 4 seconds // Copy feedback: briefly show ✓ then revert
LaunchedEffect(showCopyButton) { LaunchedEffect(copied) {
if (showCopyButton) { if (copied) {
delay(4000) delay(1500)
showCopyButton = false copied = false
} }
} }
@@ -244,13 +239,7 @@ private fun UserBubble(
bottomEnd = 4.dp, bottomEnd = 4.dp,
), ),
).background(brush = gradientBrush) ).background(brush = gradientBrush)
.testTag("chat_bubble_user") .testTag("chat_bubble_user"),
.clickable(
role = Role.Button,
onClick = {
showCopyButton = true
},
),
color = Color.Transparent, color = Color.Transparent,
tonalElevation = 0.dp, tonalElevation = 0.dp,
) { ) {
@@ -279,49 +268,40 @@ private fun UserBubble(
} }
} }
if (!message.isStreaming) { if (!message.isStreaming) {
Text( Row(
text = formatTimestamp(message.timestamp, DateFormat.is24HourFormat(LocalContext.current)),
color = userBubbleTextColor.copy(alpha = 0.6f),
style = MaterialTheme.typography.labelSmall,
modifier = modifier =
Modifier Modifier
.align(Alignment.End) .align(Alignment.End)
.padding(top = 4.dp), .padding(top = 4.dp),
) verticalAlignment = Alignment.CenterVertically,
} ) {
} IconButton(
} onClick = {
scope.launch {
// Copy button overlay — top-right of the bubble clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
AnimatedVisibility( }
visible = showCopyButton, copied = true
enter = fadeIn() + scaleIn(), },
exit = fadeOut() + scaleOut(), modifier = Modifier.size(20.dp),
modifier = ) {
Modifier Icon(
.align(Alignment.TopEnd) imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
.offset(x = 8.dp, y = (-8).dp), contentDescription = stringResource(R.string.content_desc_copy),
) { modifier = Modifier.size(12.dp),
Surface( tint = userBubbleTextColor.copy(alpha = 0.7f),
shape = RoundedCornerShape(50), )
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
shadowElevation = 6.dp,
) {
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
} }
showCopyButton = false Spacer(modifier = Modifier.width(2.dp))
}, Text(
modifier = Modifier.size(32.dp), text =
) { formatTimestamp(
Icon( message.timestamp,
Icons.Filled.ContentCopy, DateFormat.is24HourFormat(LocalContext.current),
contentDescription = stringResource(R.string.content_desc_copy), ),
modifier = Modifier.size(16.dp), color = userBubbleTextColor.copy(alpha = 0.6f),
tint = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelSmall,
) )
}
} }
} }
} }
@@ -347,13 +327,13 @@ private fun AssistantBubble(
val textColor = MaterialTheme.colorScheme.onSurfaceVariant val textColor = MaterialTheme.colorScheme.onSurfaceVariant
val clipboard = LocalClipboard.current val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showCopyButton by remember { mutableStateOf(false) } var copied by remember { mutableStateOf(false) }
// Auto-dismiss copy button after 4 seconds // Copy feedback: briefly show ✓ then revert
LaunchedEffect(showCopyButton) { LaunchedEffect(copied) {
if (showCopyButton) { if (copied) {
delay(4000) delay(1500)
showCopyButton = false copied = false
} }
} }
@@ -377,13 +357,7 @@ private fun AssistantBubble(
bottomStart = 16.dp, bottomStart = 16.dp,
bottomEnd = 16.dp, bottomEnd = 16.dp,
), ),
).testTag("chat_bubble_assistant") ).testTag("chat_bubble_assistant"),
.clickable(
role = Role.Button,
onClick = {
showCopyButton = true
},
),
color = bubbleColor, color = bubbleColor,
border = border =
BorderStroke( BorderStroke(
@@ -428,49 +402,40 @@ private fun AssistantBubble(
} }
} }
if (!message.isStreaming) { if (!message.isStreaming) {
Text( Row(
text = formatTimestamp(message.timestamp, DateFormat.is24HourFormat(LocalContext.current)),
color = textColor.copy(alpha = 0.5f),
style = MaterialTheme.typography.labelSmall,
modifier = modifier =
Modifier Modifier
.align(Alignment.End) .align(Alignment.End)
.padding(top = 4.dp), .padding(top = 4.dp),
) verticalAlignment = Alignment.CenterVertically,
} ) {
} IconButton(
} onClick = {
scope.launch {
// Copy button overlay — top-right of the bubble clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
AnimatedVisibility( }
visible = showCopyButton, copied = true
enter = fadeIn() + scaleIn(), },
exit = fadeOut() + scaleOut(), modifier = Modifier.size(20.dp),
modifier = ) {
Modifier Icon(
.align(Alignment.TopEnd) imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
.offset(x = (-8).dp, y = (-8).dp), contentDescription = stringResource(R.string.content_desc_copy),
) { modifier = Modifier.size(12.dp),
Surface( tint = textColor.copy(alpha = 0.6f),
shape = RoundedCornerShape(50), )
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
shadowElevation = 6.dp,
) {
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, message.content)))
} }
showCopyButton = false Spacer(modifier = Modifier.width(2.dp))
}, Text(
modifier = Modifier.size(32.dp), text =
) { formatTimestamp(
Icon( message.timestamp,
Icons.Filled.ContentCopy, DateFormat.is24HourFormat(LocalContext.current),
contentDescription = stringResource(R.string.content_desc_copy), ),
modifier = Modifier.size(16.dp), color = textColor.copy(alpha = 0.5f),
tint = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelSmall,
) )
}
} }
} }
} }