refactor(messaging): Redesign message bubbles and reaction UI (#4217)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-01-14 20:40:05 -06:00
committed by GitHub
parent b84dcb3971
commit f144454053
4 changed files with 193 additions and 137 deletions

View File

@@ -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))
}
}

View File

@@ -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,
)
}

View File

@@ -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<ActiveSheet?>(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 =

View File

@@ -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()
}
}
}