From 1f4266f8b12cde9bada13146d48ab0a3439bf950 Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 7 Oct 2026 01:45:43 +0200 Subject: [PATCH] Move Android reactions inside bubbles beside the timestamp --- .../utun/chat/ui/components/MessageBubble.kt | 46 +++++++++---------- .../com/utun/chat/ui/components/PollUi.kt | 21 +++++---- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt index a7d67fa7..a4e35528 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt @@ -101,16 +101,31 @@ private fun detectEmojiOnly(text: String): Int { private fun deliveryLabel(message: Message): String = if (message.deliveryStatusRes == 0) "" else stringResource(message.deliveryStatusRes) -/* Footer получает ширину пузыря после измерения содержимого, включая медиа. */ +/* Одна нижняя строка внутри пузыря: реакции слева, время справа. Ширина учитывает оба блока. */ @Composable -internal fun MessageWithFooter(footer: @Composable () -> Unit, content: @Composable () -> Unit) { - Layout(content = { Box { content() }; Box { footer() } }) { children, constraints -> +internal fun MessageWithFooter(message: Message, footer: @Composable () -> Unit, + modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Layout(modifier = modifier, content = { + Box { content() } + Box { footer() } + Text(listOf(formatTime(message.ts), deliveryLabel(message)).filter { it.isNotEmpty() }.joinToString(" · "), + fontSize = 11.sp, color = ChatColors.Secondary, maxLines = 1, overflow = TextOverflow.Ellipsis) + }) { children, constraints -> val childConstraints = constraints.copy(minWidth = 0, minHeight = 0) val body = children[0].measure(childConstraints) - val bottom = children[1].measure(childConstraints.copy(minWidth = body.width, maxWidth = body.width)) - layout(body.width, body.height + bottom.height) { + val preferred = children[1].maxIntrinsicWidth(Int.MAX_VALUE).coerceAtMost(constraints.maxWidth) + val gap = if (preferred > 0) 6.dp.roundToPx() else 0 + val time = children[2].measure(childConstraints.copy( + maxWidth = (constraints.maxWidth - gap - if (preferred > 0) 32.dp.roundToPx() else 0).coerceAtLeast(0))) + val width = maxOf(constraints.minWidth, body.width, minOf(constraints.maxWidth, preferred + gap + time.width)) + val reactions = children[1].measure(childConstraints.copy(maxWidth = (width - time.width - gap).coerceAtLeast(0))) + val rowHeight = maxOf(reactions.height, time.height) + val lastLineHeight = maxOf(time.height, minOf(reactions.height, 32.dp.roundToPx())) + val top = body.height + 4.dp.roundToPx() + layout(width, top + rowHeight) { body.placeRelative(0, 0) - bottom.placeRelative(0, body.height) + reactions.placeRelative(0, top) + time.placeRelative(width - time.width, top + rowHeight - lastLineHeight + (lastLineHeight - time.height) / 2) } } } @@ -157,8 +172,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp), horizontalArrangement = alignment ) { - MessageWithFooter(footer) { - Column( + MessageWithFooter(message, footer, modifier = Modifier .widthIn(min = minBubbleWidth, max = if (isVoice) voiceMaxWidth else 300.dp) // Тень рисуем до clip, чтобы выступающие справа и снизу края оставались видны. @@ -172,6 +186,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = .background(bgColor) .padding(12.dp) ) { + Column { if (!isVoice) Text(if (message.isOutgoing) stringResource(R.string.you) else message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ChatColors.Secondary) Spacer(Modifier.height(2.dp)) @@ -203,15 +218,6 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = } } - if (!isVoice) { - Spacer(Modifier.height(2.dp)) - Text( - listOf(formatTime(message.ts), deliveryLabel(message)).filter { it.isNotEmpty() }.joinToString(" · "), - fontSize = 11.sp, - color = ChatColors.Secondary, - modifier = Modifier.align(Alignment.End) - ) - } } } } @@ -314,12 +320,6 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean, fontSize = 12.sp, color = ChatColors.Secondary ) - Text( - listOf(formatTime(message.ts), deliveryLabel(message)).filter { it.isNotEmpty() }.joinToString(" · "), - fontSize = 11.sp, - color = ChatColors.Secondary, - modifier = Modifier.align(Alignment.CenterVertically).padding(start = 8.dp) - ) } } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt index dafc9c36..27bc5c8c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt @@ -60,11 +60,11 @@ fun PollMessageBubble(message: Message, onVote: (Message, List, String) -> val poll = message.feedback?.poll Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp), horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start) { - MessageWithFooter(footer) { - Card(onClick = { if (poll != null) showVote = true }, modifier = Modifier.widthIn(max = 300.dp), + Card(onClick = { if (poll != null) showVote = true }, modifier = Modifier.widthIn(max = 300.dp), colors = CardDefaults.cardColors(containerColor = if (message.isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant)) { - Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + MessageWithFooter(message, footer, Modifier.padding(12.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { if (!message.isOutgoing) Text(message.author, style = MaterialTheme.typography.labelMedium) if (poll == null) Text(stringResource(R.string.poll_pending)) else { @@ -82,8 +82,8 @@ fun PollMessageBubble(message: Message, onVote: (Message, List, String) -> TextButton(onClick = { showVote = true }) { Text(stringResource(R.string.poll_vote_results)) } } } + } } - } } if (showVote && poll != null) PollVoteDialog(message, poll, { showVote = false }, onVote, authorName) } @@ -134,25 +134,28 @@ private fun PollVoteDialog(message: Message, poll: PollState, onDismiss: () -> U } }) } -@OptIn(ExperimentalLayoutApi::class) +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable fun MessageReactions(message: Message, catalog: List, onReact: (Message, Int?) -> Unit) { var picker by rememberSaveable(message.messageId) { mutableStateOf(false) } - FlowRow(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + FlowRow(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp)) { IconButton(onClick = { LogManager.addLog("DEBUG", "POLL", "reaction picker opened message=${message.messageId} type=${message.contentType}") picker = true - }) { + }, modifier = Modifier.size(32.dp)) { ReactionHeart(stringResource(R.string.poll_react)) } for (reaction in message.feedback?.reactions.orEmpty()) { - FilterChip(selected = reaction.mine, onClick = { + FilterChip(selected = reaction.mine, modifier = Modifier.height(32.dp), onClick = { LogManager.addLog("DEBUG", "POLL", "reaction toggled message=${message.messageId} emoji=${reaction.id} mine=${reaction.mine}") onReact(message, reaction.id) }, label = { Text("${reaction.emoji} ${reaction.count}") }) } } + } if (picker) AlertDialog(onDismissRequest = { picker = false }, title = { Text(stringResource(R.string.poll_react)) }, text = { FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { for (reaction in catalog) TextButton(onClick = { onReact(message, reaction.id); picker = false }) { Text(reaction.emoji) } @@ -176,7 +179,7 @@ private fun ReactionHeart(description: String) { close() } } - Canvas(Modifier.size(24.dp).semantics { contentDescription = description }) { + Canvas(Modifier.size(20.dp).semantics { contentDescription = description }) { val factor = size.width / 24f scale(factor, factor, pivot = Offset.Zero) { drawPath(path, Color.Black.copy(alpha = 0.6f), style = Stroke(width = 1.8f + 2f / factor, join = StrokeJoin.Round))