feat(#136): visible copy button overlay on long-press of message bubbles (#143)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57
2026-06-20 09:46:10 +03:00
committed by GitHub
co-authored by M57
parent 113467a99d
commit 7248f66abf
@@ -6,6 +6,9 @@ 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.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@@ -16,6 +19,7 @@ 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.widthIn import androidx.compose.foundation.layout.widthIn
@@ -25,15 +29,18 @@ 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
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -65,6 +72,7 @@ import com.m57.hermescontrol.theme.SystemMessageColor
import com.m57.hermescontrol.theme.ToolChipColor import com.m57.hermescontrol.theme.ToolChipColor
import com.m57.hermescontrol.theme.ToolChipColorLight import com.m57.hermescontrol.theme.ToolChipColorLight
import com.m57.hermescontrol.theme.UserBubble import com.m57.hermescontrol.theme.UserBubble
import kotlinx.coroutines.delay
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -124,6 +132,15 @@ private fun UserBubble(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
var showCopyButton by remember { mutableStateOf(false) }
// Auto-dismiss copy button after 4 seconds
LaunchedEffect(showCopyButton) {
if (showCopyButton) {
delay(4000)
showCopyButton = false
}
}
val highlightedText = val highlightedText =
remember(message.content, searchQuery, isCurrentMatch) { remember(message.content, searchQuery, isCurrentMatch) {
@@ -148,45 +165,79 @@ private fun UserBubble(
MaterialTheme.colorScheme.secondary, MaterialTheme.colorScheme.secondary,
), ),
) )
Surface( Box {
modifier = Surface(
Modifier modifier =
.widthIn(max = maxWidth) Modifier
.clip( .widthIn(max = maxWidth)
RoundedCornerShape( .clip(
topStart = 16.dp, RoundedCornerShape(
topEnd = 16.dp, topStart = 16.dp,
bottomStart = 16.dp, topEnd = 16.dp,
bottomEnd = 4.dp, bottomStart = 16.dp,
), bottomEnd = 4.dp,
).background(brush = gradientBrush) ),
.pointerInput(message.content) { ).background(brush = gradientBrush)
detectTapGestures( .pointerInput(message.content) {
onLongPress = { detectTapGestures(
clipboardManager.setText(AnnotatedString(message.content)) onLongPress = {
}, showCopyButton = true
},
)
},
color = Color.Transparent,
tonalElevation = 0.dp,
) {
Column(modifier = Modifier.padding(12.dp)) {
SelectionContainer {
Text(
text = highlightedText,
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
) )
}, }
color = Color.Transparent,
tonalElevation = 0.dp,
) {
Column(modifier = Modifier.padding(12.dp)) {
SelectionContainer {
Text( Text(
text = highlightedText, text = formatTimestamp(message.timestamp),
color = Color.White, color = Color.White.copy(alpha = 0.7f),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.labelSmall,
modifier =
Modifier
.align(Alignment.End)
.padding(top = 4.dp),
) )
} }
Text( }
text = formatTimestamp(message.timestamp),
color = Color.White.copy(alpha = 0.7f), // Copy button overlay — top-right of the bubble
style = MaterialTheme.typography.labelSmall, AnimatedVisibility(
modifier = visible = showCopyButton,
Modifier enter = fadeIn() + scaleIn(),
.align(Alignment.End) exit = fadeOut() + scaleOut(),
.padding(top = 4.dp), modifier =
) Modifier
.align(Alignment.TopEnd)
.offset(x = 8.dp, y = (-8).dp),
) {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
shadowElevation = 6.dp,
) {
IconButton(
onClick = {
clipboardManager.setText(AnnotatedString(message.content))
showCopyButton = false
},
modifier = Modifier.size(32.dp),
) {
Icon(
Icons.Filled.ContentCopy,
contentDescription = "Copy",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
} }
} }
} }
@@ -204,6 +255,15 @@ 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 val clipboardManager = LocalClipboardManager.current
var showCopyButton by remember { mutableStateOf(false) }
// Auto-dismiss copy button after 4 seconds
LaunchedEffect(showCopyButton) {
if (showCopyButton) {
delay(4000)
showCopyButton = false
}
}
Box( Box(
modifier = modifier =
@@ -212,52 +272,86 @@ private fun AssistantBubble(
.padding(horizontal = 12.dp, vertical = 3.dp), .padding(horizontal = 12.dp, vertical = 3.dp),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
Surface( Box {
modifier = Surface(
Modifier modifier =
.widthIn(max = maxWidth) Modifier
.animateContentSize() .widthIn(max = maxWidth)
.clip( .animateContentSize()
RoundedCornerShape( .clip(
topStart = 4.dp, RoundedCornerShape(
topEnd = 16.dp, topStart = 4.dp,
bottomStart = 16.dp, topEnd = 16.dp,
bottomEnd = 16.dp, bottomStart = 16.dp,
), bottomEnd = 16.dp,
).pointerInput(message.content) { ),
detectTapGestures( ).pointerInput(message.content) {
onLongPress = { detectTapGestures(
clipboardManager.setText(AnnotatedString(message.content)) onLongPress = {
}, showCopyButton = true
},
)
},
color = bubbleColor,
border =
BorderStroke(
width = 1.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.1f),
),
tonalElevation = 1.dp,
) {
Column(modifier = Modifier.padding(12.dp)) {
SelectionContainer {
RichText(
text = message.content,
textColor = textColor,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
) )
}, }
color = bubbleColor, if (!message.isStreaming) {
border = Text(
BorderStroke( text = formatTimestamp(message.timestamp),
width = 1.dp, color = textColor.copy(alpha = 0.5f),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), style = MaterialTheme.typography.labelSmall,
), modifier =
tonalElevation = 1.dp, Modifier
) { .align(Alignment.End)
Column(modifier = Modifier.padding(12.dp)) { .padding(top = 4.dp),
SelectionContainer { )
RichText( }
text = message.content,
textColor = textColor,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
)
} }
if (!message.isStreaming) { }
Text(
text = formatTimestamp(message.timestamp), // Copy button overlay — top-right of the bubble
color = textColor.copy(alpha = 0.5f), AnimatedVisibility(
style = MaterialTheme.typography.labelSmall, visible = showCopyButton,
modifier = enter = fadeIn() + scaleIn(),
Modifier exit = fadeOut() + scaleOut(),
.align(Alignment.End) modifier =
.padding(top = 4.dp), Modifier
) .align(Alignment.TopEnd)
.offset(x = (-8).dp, y = (-8).dp),
) {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
shadowElevation = 6.dp,
) {
IconButton(
onClick = {
clipboardManager.setText(AnnotatedString(message.content))
showCopyButton = false
},
modifier = Modifier.size(32.dp),
) {
Icon(
Icons.Filled.ContentCopy,
contentDescription = "Copy",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
} }
} }
} }