feat(messaging): Improve message bubble UI and add delivery status action (#4330)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-01-26 18:26:27 -06:00 committed by GitHub
parent 0357ac286b
commit 2f67727bf5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 84 additions and 67 deletions

View file

@ -32,13 +32,11 @@ import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Reply
import androidx.compose.material.icons.rounded.SelectAll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -46,38 +44,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.copy
import org.meshtastic.core.strings.delete
import org.meshtastic.core.strings.message_delivery_status
import org.meshtastic.core.strings.reply
import org.meshtastic.core.strings.select
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageActionsBottomSheet(
quickEmojis: List<String>,
onDismiss: () -> Unit,
onReply: () -> Unit,
onReact: (String) -> Unit,
onMoreReactions: () -> Unit,
onCopy: () -> Unit,
onSelect: () -> Unit,
onDelete: () -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
MessageActionsContent(
quickEmojis = quickEmojis,
onReply = onReply,
onReact = onReact,
onMoreReactions = onMoreReactions,
onCopy = onCopy,
onSelect = onSelect,
onDelete = onDelete,
)
}
}
@Composable
fun MessageActionsContent(
quickEmojis: List<String>,
@ -87,12 +63,27 @@ fun MessageActionsContent(
onCopy: () -> Unit,
onSelect: () -> Unit,
onDelete: () -> Unit,
statusString: Pair<StringResource, StringResource>? = null,
status: MessageStatus? = null,
onStatus: (() -> Unit),
) {
Column {
QuickEmojiRow(quickEmojis = quickEmojis, onReact = onReact, onMoreReactions = onMoreReactions)
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
if (status != null) {
val title =
statusString?.first?.let { stringResource(it) } ?: stringResource(Res.string.message_delivery_status)
val statusText = statusString?.second?.let { stringResource(it) }
ListItem(
headlineContent = { Text("$title : $statusText") },
leadingContent = { MessageStatusIcon(status = status) },
modifier = Modifier.clickable(onClick = onStatus),
)
}
ListItem(
headlineContent = { Text(stringResource(Res.string.reply)) },
leadingContent = { Icon(Icons.Rounded.Reply, contentDescription = stringResource(Res.string.reply)) },

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2026 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -21,6 +21,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Returns a [CornerBasedShape] for a message bubble based on its position in a sequence.
*
* @param cornerRadius The base corner radius for the bubble.
* @param isSender Whether the message was sent by the local user.
* @param hasSamePrev Whether the previous message in the list is from the same sender.
* @param hasSameNext Whether the next message in the list is from the same sender.
*/
internal fun getMessageBubbleShape(
cornerRadius: Dp,
isSender: Boolean,
@ -31,18 +39,20 @@ internal fun getMessageBubbleShape(
val round = cornerRadius
return if (isSender) {
// Sent messages are on the right.
RoundedCornerShape(
topStart = if (hasSamePrev) square else round,
topStart = round,
topEnd = if (hasSamePrev) square else round,
bottomStart = if (hasSameNext) square else round,
bottomStart = round,
bottomEnd = square,
)
} else {
// Received messages are on the left.
RoundedCornerShape(
topStart = square,
topEnd = if (hasSamePrev) square else round,
topEnd = round,
bottomStart = if (hasSameNext) square else round,
bottomEnd = if (hasSameNext) square else round,
bottomEnd = round,
)
}
}

View file

@ -24,11 +24,14 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.FormatQuote
import androidx.compose.material3.CardDefaults
@ -114,20 +117,22 @@ internal fun MessageItem(
hasSameNext: Boolean = false,
) = Column(
modifier =
modifier.padding(
top =
if (showUserName) {
16.dp
} else {
4.dp
},
),
modifier
.fillMaxWidth()
.padding(
top =
if (showUserName) {
6.dp
} else {
1.dp
},
),
) {
var activeSheet by remember { mutableStateOf<ActiveSheet?>(null) }
val clipboardManager = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isLocal = node.num == ourNode.num
if (activeSheet != null) {
ModalBottomSheet(onDismissRequest = { activeSheet = null }, sheetState = sheetState) {
when (activeSheet) {
@ -159,6 +164,14 @@ internal fun MessageItem(
activeSheet = null
onDelete()
},
statusString = message.getStatusStringRes(),
status =
if (isLocal) {
message.status
} else {
null
},
onStatus = onStatusClick,
)
}
@ -199,7 +212,7 @@ internal fun MessageItem(
.copy(containerColor = containerColor, contentColor = contentColorFor(containerColor))
val messageShape =
getMessageBubbleShape(
cornerRadius = 16.dp,
cornerRadius = 8.dp,
isSender = message.fromLocal,
hasSamePrev = hasSamePrev,
hasSameNext = hasSameNext,
@ -219,7 +232,7 @@ internal fun MessageItem(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
NodeChip(node = node, onClick = onClickChip)
NodeChip(node = node, onClick = onClickChip, modifier = Modifier.height(28.dp))
Text(
text = node.user.longName,
overflow = TextOverflow.Ellipsis,
@ -237,10 +250,11 @@ internal fun MessageItem(
}
Surface(
modifier =
Modifier.padding(
start = if (!message.fromLocal) 0.dp else 24.dp,
end = if (message.fromLocal) 0.dp else 24.dp,
)
Modifier.align(if (message.fromLocal) Alignment.End else Alignment.Start)
.padding(
start = if (!message.fromLocal) 0.dp else 24.dp,
end = if (message.fromLocal) 0.dp else 24.dp,
)
.combinedClickable(
onClick = onClick,
onLongClick = {
@ -260,7 +274,7 @@ internal fun MessageItem(
contentColor = contentColorFor(containerColor),
shape = messageShape,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.width(IntrinsicSize.Max)) {
OriginalMessageSnippet(
modifier = Modifier.fillMaxWidth(),
message = message,
@ -269,10 +283,10 @@ internal fun MessageItem(
onNavigateToOriginalMessage = onNavigateToOriginalMessage,
)
Column(modifier = Modifier.padding(8.dp)) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) {
AutoLinkText(
text = message.text,
style = MaterialTheme.typography.bodyLarge,
style = MaterialTheme.typography.bodyMedium,
color = cardColors.contentColor,
)
@ -312,19 +326,18 @@ internal fun MessageItem(
modifier = Modifier.padding(start = 8.dp, end = 4.dp),
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
modifier = Modifier.padding(8.dp),
text = message.time,
style = MaterialTheme.typography.labelSmall,
)
if (message.fromLocal) {
MessageStatusIcon(
status = message.status ?: MessageStatus.UNKNOWN,
onClick = onStatusClick,
modifier = modifier.size(24.dp).padding(horizontal = 4.dp),
modifier = Modifier.size(18.dp),
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
modifier = Modifier.padding(start = 16.dp),
text = message.time,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@ -332,10 +345,11 @@ internal fun MessageItem(
AnimatedVisibility(emojis.isNotEmpty()) {
ReactionRow(
modifier =
Modifier.padding(
start = if (!message.fromLocal) 0.dp else 24.dp,
end = if (message.fromLocal) 0.dp else 24.dp,
),
Modifier.align(if (message.fromLocal) Alignment.End else Alignment.Start)
.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,
@ -355,7 +369,7 @@ private enum class ActiveSheet {
}
@Composable
private fun MessageStatusIcon(status: MessageStatus, onClick: () -> Unit, modifier: Modifier = Modifier) {
fun MessageStatusIcon(status: MessageStatus, modifier: Modifier = Modifier) {
val icon =
when (status) {
MessageStatus.RECEIVED -> MeshtasticIcons.CloudDone
@ -368,9 +382,9 @@ private fun MessageStatusIcon(status: MessageStatus, onClick: () -> Unit, modifi
else -> MeshtasticIcons.Warning
}
Icon(
modifier = modifier,
imageVector = icon,
contentDescription = stringResource(Res.string.message_delivery_status),
modifier = modifier.clickable(onClick = onClick),
)
}
@ -404,7 +418,7 @@ private fun OriginalMessageSnippet(
),
) {
Row(
modifier = Modifier.padding(8.dp),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
@ -425,7 +439,6 @@ private fun OriginalMessageSnippet(
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 20.dp),
)
}
}
@ -514,7 +527,10 @@ private fun MessageItemPreview() {
filtered = true,
)
AppTheme {
Column(modifier = Modifier.background(MaterialTheme.colorScheme.background).padding(vertical = 16.dp)) {
Column(
modifier =
Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.background).padding(vertical = 16.dp),
) {
MessageItem(
message = sent,
node = sent.node,