mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
0357ac286b
commit
2f67727bf5
3 changed files with 84 additions and 67 deletions
|
|
@ -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)) },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue