diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt index 1ed6ad74..1f41b0e2 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationMessages.kt @@ -87,10 +87,14 @@ fun ConversationMessages(target: ConversationTarget, messages: List, st modifier = Modifier.padding(start = 8.dp, bottom = 4.dp), size = 32.dp) Box(Modifier.weight(1f)) { Column { - if (target is ConversationTarget.Channel && message.contentType == "poll") PollMessageBubble(message, onVote, authorName) - else MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile, onOpenFile, onOpenImage, onPlayVideo, onLink) - if (target is ConversationTarget.Channel && message.messageId.isNotEmpty()) - MessageReactions(message, reactionCatalog, onReact) + val footer: @Composable () -> Unit = { + if (target is ConversationTarget.Channel && message.messageId.isNotEmpty()) + MessageReactions(message, reactionCatalog, onReact) + } + if (target is ConversationTarget.Channel && message.contentType == "poll") + PollMessageBubble(message, onVote, authorName, footer) + else MessageBubble(message, playbackState, onPlayVoice, onSeekVoice, onDownloadFile, + onOpenFile, onOpenImage, onPlayVideo, onLink, footer) } } if (message.isOutgoing) UserAvatar(group, message.authorNodeId, message.author, 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 8cd0c160..6ed7eead 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 @@ -42,6 +42,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext @@ -100,6 +101,20 @@ 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 -> + 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) { + body.placeRelative(0, 0) + bottom.placeRelative(0, body.height) + } + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(), @@ -109,7 +124,8 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = onOpenFile: (Message) -> Unit = {}, onOpenImage: (Message) -> Unit = {}, onPlayVideo: (Message) -> Unit = {}, - onLink: (String) -> Unit = {}) { + onLink: (String) -> Unit = {}, + footer: @Composable () -> Unit = {}) { val bgColor = if (message.isOutgoing) ChatColors.OutgoingBubble else ChatColors.IncomingBubble val textColor = ChatColors.Text val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start @@ -141,6 +157,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp), horizontalArrangement = alignment ) { + MessageWithFooter(footer) { Column( modifier = Modifier .widthIn(min = minBubbleWidth, max = if (isVoice) voiceMaxWidth else 300.dp) @@ -196,6 +213,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ) } } + } } } @Composable 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 25a69fca..30833dca 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 @@ -1,5 +1,6 @@ package com.utun.chat.ui.components +import androidx.compose.foundation.Canvas import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -8,7 +9,15 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.utun.chat.R import com.utun.chat.data.* @@ -45,10 +54,14 @@ fun CreatePollDialog(onDismiss: () -> Unit, onCreate: (String, List, Boo } @Composable -fun PollMessageBubble(message: Message, onVote: (Message, List, String) -> Unit, authorName: (Long) -> String) { +fun PollMessageBubble(message: Message, onVote: (Message, List, String) -> Unit, authorName: (Long) -> String, + footer: @Composable () -> Unit = {}) { var showVote by rememberSaveable(message.messageId) { mutableStateOf(false) } val poll = message.feedback?.poll - Card(onClick = { if (poll != null) showVote = true }, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + 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), colors = CardDefaults.cardColors(containerColor = if (message.isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { @@ -70,6 +83,8 @@ fun PollMessageBubble(message: Message, onVote: (Message, List, String) -> } } } + } + } if (showVote && poll != null) PollVoteDialog(message, poll, { showVote = false }, onVote, authorName) } @@ -123,12 +138,20 @@ private fun PollVoteDialog(message: Message, poll: PollState, onDismiss: () -> U @Composable fun MessageReactions(message: Message, catalog: List, onReact: (Message, Int?) -> Unit) { var picker by rememberSaveable(message.messageId) { mutableStateOf(false) } - FlowRow(Modifier.padding(horizontal = 8.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FlowRow(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton(onClick = { + LogManager.addLog("DEBUG", "POLL", "reaction picker opened message=${message.messageId} type=${message.contentType}") + picker = true + }) { + ReactionHeart(stringResource(R.string.poll_react)) + } for (reaction in message.feedback?.reactions.orEmpty()) { - FilterChip(selected = reaction.mine, onClick = { onReact(message, reaction.id) }, + FilterChip(selected = reaction.mine, 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}") }) } - TextButton(onClick = { picker = true }) { Text(stringResource(R.string.poll_react)) } } if (picker) AlertDialog(onDismissRequest = { picker = false }, title = { Text(stringResource(R.string.poll_react)) }, text = { FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) { @@ -139,3 +162,25 @@ fun MessageReactions(message: Message, catalog: List, onReact: (M } }, dismissButton = { TextButton(onClick = { picker = false }) { Text(stringResource(R.string.close)) } }) } + +/* Два контура без заливки: более широкий тёмный даёт тень ровно в один физический пиксель. */ +@Composable +private fun ReactionHeart(description: String) { + val path = remember { + Path().apply { + moveTo(12f, 21f) + cubicTo(10.5f, 19.5f, 3f, 14.2f, 3f, 8.5f) + cubicTo(3f, 3f, 9f, 2f, 12f, 6f) + cubicTo(15f, 2f, 21f, 3f, 21f, 8.5f) + cubicTo(21f, 14.2f, 13.5f, 19.5f, 12f, 21f) + close() + } + } + Canvas(Modifier.size(24.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)) + drawPath(path, Color.White, style = Stroke(width = 1.8f, join = StrokeJoin.Round)) + } + } +}