diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt index e60e773cc..ad313b59b 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt @@ -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, - 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, @@ -87,12 +63,27 @@ fun MessageActionsContent( onCopy: () -> Unit, onSelect: () -> Unit, onDelete: () -> Unit, + statusString: Pair? = 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)) }, 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 4796cc4d2..01466613b 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 @@ -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, ) } } 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 450595705..955da9850 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 @@ -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(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,