From f144454053bc4190fae005fdab5c1ece97859e3b Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 14 Jan 2026 20:40:05 -0600 Subject: [PATCH] refactor(messaging): Redesign message bubbles and reaction UI (#4217) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../messaging/component/MessageActions.kt | 4 +- .../messaging/component/MessageBubble.kt | 4 +- .../messaging/component/MessageItem.kt | 225 +++++++++--------- .../feature/messaging/component/Reaction.kt | 97 ++++++-- 4 files changed, 193 insertions(+), 137 deletions(-) diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActions.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActions.kt index 743f18dc5..d789d830c 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActions.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActions.kt @@ -22,7 +22,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Reply -import androidx.compose.material.icons.filled.EmojiEmotions +import androidx.compose.material.icons.filled.AddReaction import androidx.compose.material.icons.twotone.AddLink import androidx.compose.material.icons.twotone.Cloud import androidx.compose.material.icons.twotone.CloudDone @@ -61,7 +61,7 @@ internal fun ReactionButton(onSendReaction: (String) -> Unit = {}) { ) } IconButton(onClick = { showEmojiPickerDialog = true }) { - Icon(imageVector = Icons.Default.EmojiEmotions, contentDescription = stringResource(Res.string.react)) + Icon(imageVector = Icons.Default.AddReaction, contentDescription = stringResource(Res.string.react)) } } diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageBubble.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageBubble.kt index eaab35962..4796cc4d2 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageBubble.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageBubble.kt @@ -39,9 +39,9 @@ internal fun getMessageBubbleShape( ) } else { RoundedCornerShape( - topStart = if (hasSamePrev) square else round, + topStart = square, topEnd = if (hasSamePrev) square else round, - bottomStart = square, + bottomStart = if (hasSameNext) square else round, bottomEnd = if (hasSameNext) square else round, ) } diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt index a916a29b9..559ab0b6f 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt @@ -16,6 +16,7 @@ */ package org.meshtastic.feature.messaging.component +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -27,10 +28,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.FormatQuote @@ -113,7 +112,17 @@ internal fun MessageItem( onStatusClick: () -> Unit = {}, hasSamePrev: Boolean = false, hasSameNext: Boolean = false, -) = Column(modifier = modifier.padding(top = if (showUserName) 32.dp else 4.dp)) { +) = Column( + modifier = + modifier.padding( + top = + if (showUserName) { + 16.dp + } else { + 4.dp + }, + ), +) { var activeSheet by remember { mutableStateOf(null) } val clipboardManager = LocalClipboardManager.current val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -180,13 +189,6 @@ internal fun MessageItem( } else { Color(node.colors.second).copy(alpha = alpha) } - .apply { - if (inSelectionMode) { - copy(alpha = if (selected) 0.6f else 0.2f) - } else { - copy(alpha = 0.4f) - } - } val cardColors = CardDefaults.cardColors() .copy(containerColor = containerColor, contentColor = contentColorFor(containerColor)) @@ -206,117 +208,115 @@ internal fun MessageItem( Modifier }, ) - Box(modifier = Modifier.wrapContentSize()) { - Surface( - modifier = - Modifier.align(if (message.fromLocal) Alignment.TopEnd else Alignment.TopStart) - .padding( - start = if (!message.fromLocal) 0.dp else 16.dp, - end = if (message.fromLocal) 0.dp else 16.dp, - ) - .combinedClickable( - onClick = onClick, - onLongClick = { - onLongClick() - if (!inSelectionMode) { - activeSheet = ActiveSheet.Actions - } - }, - onDoubleClick = onDoubleClick, - ) - .then(messageModifier) - .semantics(mergeDescendants = true) { - val senderName = if (message.fromLocal) ourNode.user.longName else node.user.longName - contentDescription = "Message from $senderName: ${message.text}" - }, - color = containerColor, - contentColor = contentColorFor(containerColor), - shape = messageShape, + if (showUserName && !message.fromLocal) { + Row( + modifier = Modifier.padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Column(modifier = Modifier.fillMaxWidth()) { - OriginalMessageSnippet( - message = message, - ourNode = ourNode, - hasSamePrev = hasSamePrev, - onNavigateToOriginalMessage = onNavigateToOriginalMessage, + NodeChip(node = node, onClick = onClickChip) + Text( + text = node.user.longName, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.labelMedium, + ) + if (message.viaMqtt) { + Icon( + Icons.Default.Cloud, + contentDescription = stringResource(Res.string.via_mqtt), + modifier = Modifier.size(16.dp), ) - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (showUserName) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - val chipNode = if (message.fromLocal) ourNode else node - NodeChip(node = chipNode, onClick = onClickChip) - Text( - text = (if (message.fromLocal) ourNode.user else node.user).longName, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - style = MaterialTheme.typography.labelMedium, - ) - if (message.viaMqtt) { - Icon( - Icons.Default.Cloud, - contentDescription = stringResource(Res.string.via_mqtt), - modifier = Modifier.size(16.dp), - ) + } + } + } + Surface( + modifier = + Modifier.padding( + start = if (!message.fromLocal) 0.dp else 24.dp, + end = if (message.fromLocal) 0.dp else 24.dp, + ) + .combinedClickable( + onClick = onClick, + onLongClick = { + onLongClick() + if (!inSelectionMode) { + activeSheet = ActiveSheet.Actions + } + }, + onDoubleClick = onDoubleClick, + ) + .then(messageModifier) + .semantics(mergeDescendants = true) { + val senderName = if (message.fromLocal) ourNode.user.longName else node.user.longName + contentDescription = "Message from $senderName: ${message.text}" + }, + color = containerColor, + contentColor = contentColorFor(containerColor), + shape = messageShape, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + OriginalMessageSnippet( + modifier = Modifier.fillMaxWidth(), + message = message, + ourNode = ourNode, + hasSamePrev = hasSamePrev, + onNavigateToOriginalMessage = onNavigateToOriginalMessage, + ) + + Column(modifier = Modifier.padding(8.dp)) { + AutoLinkText( + text = message.text, + style = MaterialTheme.typography.bodyLarge, + color = cardColors.contentColor, + ) + + Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically) { + if (!message.fromLocal) { + if (message.hopsAway == 0) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Snr(message.snr) + Rssi(message.rssi) } + } else { + Text( + text = stringResource(Res.string.hops_away_template, message.hopsAway), + style = MaterialTheme.typography.labelSmall, + ) } } + if (containsBel) { + Text(text = "\uD83D\uDD14", modifier = Modifier.padding(end = 4.dp)) + } Spacer(modifier = Modifier.weight(1f)) - Text(text = message.time, style = MaterialTheme.typography.labelSmall) - if (message.fromLocal) { - Spacer(modifier = Modifier.size(4.dp)) - MessageStatusIcon(status = message.status ?: MessageStatus.UNKNOWN, onClick = onStatusClick) - } - } - - Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) { - AutoLinkText( - modifier = Modifier.fillMaxWidth(), - text = message.text, - style = MaterialTheme.typography.bodyMedium, - color = cardColors.contentColor, + Text( + modifier = Modifier.padding(8.dp), + text = message.time, + style = MaterialTheme.typography.labelSmall, ) - - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - if (!message.fromLocal) { - if (message.hopsAway == 0) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Snr(message.snr) - Rssi(message.rssi) - } - } else { - Text( - text = stringResource(Res.string.hops_away_template, message.hopsAway), - style = MaterialTheme.typography.labelSmall, - ) - } - } - if (containsBel) { - Text(text = "\uD83D\uDD14", modifier = Modifier.padding(end = 4.dp)) - } + if (message.fromLocal) { + MessageStatusIcon( + status = message.status ?: MessageStatus.UNKNOWN, + onClick = onStatusClick, + modifier = modifier.size(24.dp).padding(horizontal = 4.dp), + ) } } } } - - Box( + } + AnimatedVisibility(emojis.isNotEmpty()) { + ReactionRow( modifier = - Modifier.align(if (message.fromLocal) Alignment.BottomEnd else Alignment.BottomStart) - .padding(horizontal = 12.dp) - .offset(y = 20.dp), - ) { - ReactionRow( - reactions = if (message.fromLocal) emojis.reversed() else emojis, - myId = ourNode.user.id, - onSendReaction = sendReaction, - onShowReactions = onShowReactions, - ) - } + Modifier.padding( + start = if (!message.fromLocal) 0.dp else 24.dp, + end = if (message.fromLocal) 0.dp else 24.dp, + ), + reactions = if (message.fromLocal) emojis.reversed() else emojis, + myId = ourNode.user.id, + onSendReaction = sendReaction, + onShowReactions = onShowReactions, + ) } } @@ -330,7 +330,7 @@ private enum class ActiveSheet { } @Composable -private fun MessageStatusIcon(status: MessageStatus, onClick: () -> Unit) { +private fun MessageStatusIcon(status: MessageStatus, onClick: () -> Unit, modifier: Modifier = Modifier) { val icon = when (status) { MessageStatus.RECEIVED -> Icons.TwoTone.HowToReg @@ -345,7 +345,7 @@ private fun MessageStatusIcon(status: MessageStatus, onClick: () -> Unit) { Icon( imageVector = icon, contentDescription = stringResource(Res.string.message_delivery_status), - modifier = Modifier.size(24.dp).clickable(onClick = onClick), + modifier = modifier.clickable(onClick = onClick), ) } @@ -355,6 +355,7 @@ private fun OriginalMessageSnippet( ourNode: Node, hasSamePrev: Boolean, onNavigateToOriginalMessage: (Int) -> Unit, + modifier: Modifier = Modifier, ) { val originalMessage = message.originalMessage if (originalMessage != null && originalMessage.packetId != 0) { @@ -366,7 +367,7 @@ private fun OriginalMessageSnippet( contentColor = Color(originalMessageNode.colors.first), ) Surface( - modifier = Modifier.fillMaxWidth().clickable { onNavigateToOriginalMessage(originalMessage.packetId) }, + modifier = modifier.fillMaxWidth().clickable { onNavigateToOriginalMessage(originalMessage.packetId) }, contentColor = cardColors.contentColor, color = cardColors.containerColor, shape = diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt index 701fa34f8..5d7469d5a 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.messaging.component import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -28,13 +29,15 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Badge -import androidx.compose.material3.BadgedBox +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddReaction import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -52,6 +55,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.database.entity.Reaction @@ -67,10 +71,12 @@ import org.meshtastic.core.strings.hops_away_template import org.meshtastic.core.strings.message_delivery_status import org.meshtastic.core.strings.message_status_enroute import org.meshtastic.core.strings.message_status_queued +import org.meshtastic.core.strings.react import org.meshtastic.core.strings.you import org.meshtastic.core.ui.component.BottomSheetDialog import org.meshtastic.core.ui.component.Rssi import org.meshtastic.core.ui.component.Snr +import org.meshtastic.core.ui.emoji.EmojiPickerDialog import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.feature.messaging.DeliveryInfo import org.meshtastic.proto.MeshProtos @@ -87,26 +93,43 @@ private fun ReactionItem( val isSending = status == MessageStatus.QUEUED || status == MessageStatus.ENROUTE val isError = status == MessageStatus.ERROR - BadgedBox( - modifier = modifier, - badge = { - if (emojiCount > 1) { - Badge { Text(fontWeight = FontWeight.Bold, text = emojiCount.toString()) } - } + Surface( + modifier = + modifier + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .then(if (isSending) Modifier.graphicsLayer(alpha = 0.5f) else Modifier), + color = + when { + isError -> MaterialTheme.colorScheme.errorContainer + else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) }, - ) { - Surface( - modifier = - Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick) - .then(if (isSending) Modifier.graphicsLayer(alpha = 0.5f) else Modifier), + shape = if (emojiCount > 1) MaterialTheme.shapes.small else CircleShape, + border = + BorderStroke( + width = 1.dp, color = - when { - isError -> MaterialTheme.colorScheme.errorContainer - else -> MaterialTheme.colorScheme.primaryContainer + if (isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) }, - shape = CircleShape, + ), + ) { + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Text(text = emoji, modifier = Modifier.padding(4.dp).clip(CircleShape)) + Text(text = emoji, fontSize = 14.sp) + if (emojiCount > 1) { + Text( + text = emojiCount.toString(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } @@ -123,11 +146,14 @@ internal fun ReactionRow( val emojiGroups = reactions.groupBy { it.emoji } AnimatedVisibility(emojiGroups.isNotEmpty()) { - LazyRow(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + LazyRow( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { items(emojiGroups.entries.toList()) { (emoji, reactions) -> val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId } ReactionItem( - modifier = Modifier.padding(horizontal = 4.dp), emoji = emoji, emojiCount = reactions.size, status = localReaction?.status ?: MessageStatus.RECEIVED, @@ -135,10 +161,39 @@ internal fun ReactionRow( onLongClick = onShowReactions, ) } + item { AddReactionButton(onSendReaction = onSendReaction) } } } } +@Composable +private fun AddReactionButton(modifier: Modifier = Modifier, onSendReaction: (String) -> Unit = {}) { + var showEmojiPickerDialog by remember { mutableStateOf(false) } + if (showEmojiPickerDialog) { + EmojiPickerDialog( + onConfirm = { selectedEmoji -> + showEmojiPickerDialog = false + onSendReaction(selectedEmoji) + }, + onDismiss = { showEmojiPickerDialog = false }, + ) + } + Surface( + onClick = { showEmojiPickerDialog = true }, + modifier = modifier.size(28.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + shape = CircleShape, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)), + ) { + Icon( + imageVector = Icons.Default.AddReaction, + contentDescription = stringResource(Res.string.react), + modifier = Modifier.padding(6.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable internal fun ReactionDialog( @@ -266,7 +321,7 @@ private fun ReactionItemPreview() { Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { ReactionItem(emoji = "\uD83D\uDE42") ReactionItem(emoji = "\uD83D\uDE42", emojiCount = 2) - ReactionButton() + AddReactionButton() } } }