diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 95889f93f..b494598ca 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -960,9 +960,17 @@ message message_delivery_status message_device_managed message_input_label +message_routing_error_max_retransmit +message_routing_error_no_channel +message_routing_error_pki_failed +message_routing_error_pki_send_fail_public_key +message_routing_error_pki_unknown_pubkey +message_routing_error_too_large message_status_delivered message_status_enroute message_status_queued +message_status_recipient_delivered +message_status_relayed_not_confirmed message_status_sfpp_confirmed message_status_sfpp_routing message_status_unknown diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt index 1d30065dd..efb213a3c 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt @@ -18,12 +18,18 @@ package org.meshtastic.core.model import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.delivery_confirmed import org.meshtastic.core.resources.error import org.meshtastic.core.resources.message_delivery_status +import org.meshtastic.core.resources.message_routing_error_max_retransmit +import org.meshtastic.core.resources.message_routing_error_no_channel +import org.meshtastic.core.resources.message_routing_error_pki_failed +import org.meshtastic.core.resources.message_routing_error_pki_send_fail_public_key +import org.meshtastic.core.resources.message_routing_error_pki_unknown_pubkey +import org.meshtastic.core.resources.message_routing_error_too_large import org.meshtastic.core.resources.message_status_delivered import org.meshtastic.core.resources.message_status_enroute -import org.meshtastic.core.resources.message_status_queued +import org.meshtastic.core.resources.message_status_recipient_delivered +import org.meshtastic.core.resources.message_status_relayed_not_confirmed import org.meshtastic.core.resources.message_status_sfpp_confirmed import org.meshtastic.core.resources.message_status_sfpp_routing import org.meshtastic.core.resources.message_status_unknown @@ -72,6 +78,70 @@ fun getStringResFrom(routingError: Int): StringResource = when (routingError) { else -> Res.string.unrecognized } +fun getMessageRoutingErrorStringResFrom(routingError: Int): StringResource = when (routingError) { + Routing.Error.GOT_NAK.value, + Routing.Error.TIMEOUT.value, + Routing.Error.MAX_RETRANSMIT.value, + Routing.Error.NO_RESPONSE.value, + -> Res.string.message_routing_error_max_retransmit + + Routing.Error.NO_CHANNEL.value -> Res.string.message_routing_error_no_channel + + Routing.Error.TOO_LARGE.value -> Res.string.message_routing_error_too_large + + Routing.Error.PKI_FAILED.value -> Res.string.message_routing_error_pki_failed + + Routing.Error.PKI_UNKNOWN_PUBKEY.value -> Res.string.message_routing_error_pki_unknown_pubkey + + Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY.value -> Res.string.message_routing_error_pki_send_fail_public_key + + else -> getStringResFrom(routingError) +} + +fun getMessageStatusStringRes( + status: MessageStatus?, + routingError: Int, + isDirectMessage: Boolean = false, +): Pair { + val title = if (routingError > 0) Res.string.error else Res.string.message_delivery_status + val text = + when (status) { + MessageStatus.RECEIVED -> Res.string.message_status_recipient_delivered + + MessageStatus.QUEUED -> Res.string.message_status_enroute + + MessageStatus.ENROUTE -> Res.string.message_status_enroute + + MessageStatus.SFPP_ROUTING -> Res.string.message_status_sfpp_routing + + MessageStatus.SFPP_CONFIRMED -> Res.string.message_status_sfpp_confirmed + + MessageStatus.DELIVERED -> + if (isDirectMessage) { + Res.string.message_status_relayed_not_confirmed + } else { + Res.string.message_status_delivered + } + + MessageStatus.ERROR -> getMessageRoutingErrorStringResFrom(routingError) + + MessageStatus.UNKNOWN, + null, + -> Res.string.message_status_unknown + } + return title to text +} + +fun isMessageStatusRetryable(status: MessageStatus?, routingError: Int, isDirectMessage: Boolean = false): Boolean = + when { + status == MessageStatus.DELIVERED && isDirectMessage -> true + status != MessageStatus.ERROR -> false + routingError in nonRetryableMessageRoutingErrors -> false + else -> true + } + +private val nonRetryableMessageRoutingErrors = setOf(Routing.Error.NO_CHANNEL.value, Routing.Error.TOO_LARGE.value) + data class Message( val uuid: Long, val receivedTime: Long, @@ -109,28 +179,9 @@ data class Message( fun displayedText(searching: Boolean = false): String = if (showTranslated && translatedText != null && !searching) translatedText else text - fun getStatusStringRes(): Pair { - val title = if (routingError > 0) Res.string.error else Res.string.message_delivery_status - val text = - when (status) { - MessageStatus.RECEIVED -> Res.string.delivery_confirmed + fun getStatusStringRes(isDirectMessage: Boolean = false): Pair = + getMessageStatusStringRes(status, routingError, isDirectMessage) - MessageStatus.QUEUED -> Res.string.message_status_queued - - MessageStatus.ENROUTE -> Res.string.message_status_enroute - - MessageStatus.SFPP_ROUTING -> Res.string.message_status_sfpp_routing - - MessageStatus.SFPP_CONFIRMED -> Res.string.message_status_sfpp_confirmed - - MessageStatus.DELIVERED -> Res.string.message_status_delivered - - MessageStatus.ERROR -> getStringResFrom(routingError) - - MessageStatus.UNKNOWN, - null, - -> Res.string.message_status_unknown - } - return title to text - } + fun isStatusRetryable(isDirectMessage: Boolean = false): Boolean = + isMessageStatusRetryable(status, routingError, isDirectMessage) } diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt index f16930929..ab5d93c94 100644 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt @@ -20,10 +20,25 @@ import okio.ByteString.Companion.encodeUtf8 import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.error import org.meshtastic.core.resources.message_delivery_status +import org.meshtastic.core.resources.message_routing_error_max_retransmit +import org.meshtastic.core.resources.message_routing_error_no_channel +import org.meshtastic.core.resources.message_routing_error_pki_failed +import org.meshtastic.core.resources.message_routing_error_pki_send_fail_public_key +import org.meshtastic.core.resources.message_routing_error_pki_unknown_pubkey +import org.meshtastic.core.resources.message_routing_error_too_large import org.meshtastic.core.resources.message_status_delivered +import org.meshtastic.core.resources.message_status_enroute +import org.meshtastic.core.resources.message_status_recipient_delivered +import org.meshtastic.core.resources.message_status_relayed_not_confirmed import org.meshtastic.core.resources.message_status_unknown +import org.meshtastic.core.resources.routing_error_max_retransmit +import org.meshtastic.core.resources.routing_error_no_channel import org.meshtastic.core.resources.routing_error_no_route import org.meshtastic.core.resources.routing_error_none +import org.meshtastic.core.resources.routing_error_pki_failed +import org.meshtastic.core.resources.routing_error_pki_send_fail_public_key +import org.meshtastic.core.resources.routing_error_pki_unknown_pubkey +import org.meshtastic.core.resources.routing_error_too_large import org.meshtastic.core.resources.unrecognized import org.meshtastic.proto.PortNum import org.meshtastic.proto.Routing @@ -215,6 +230,51 @@ class MessageTest { assertEquals(Res.string.message_status_delivered, text) } + @Test + fun getStatusStringRes_returnsDirectImplicitAckResourceForDirectMessages() { + val (title, text) = messageWith(status = MessageStatus.DELIVERED).getStatusStringRes(isDirectMessage = true) + + assertEquals(Res.string.message_delivery_status, title) + assertEquals(Res.string.message_status_relayed_not_confirmed, text) + } + + @Test + fun getStatusStringRes_returnsRecipientDeliveryResource() { + val message = + Message( + uuid = 1L, + receivedTime = 0L, + node = Node.createFallback(1, "Node"), + text = "hello", + fromLocal = true, + time = "now", + read = false, + status = MessageStatus.RECEIVED, + routingError = 0, + packetId = 1, + emojis = emptyList(), + snr = 0f, + rssi = 0, + hopsAway = 0, + replyId = null, + ) + + val (title, text) = message.getStatusStringRes() + + assertEquals(Res.string.message_delivery_status, title) + assertEquals(Res.string.message_status_recipient_delivered, text) + } + + @Test + fun getStatusStringRes_returnsSendingResourceForPendingMessages() { + for (status in listOf(MessageStatus.QUEUED, MessageStatus.ENROUTE)) { + val (title, text) = messageWith(status = status).getStatusStringRes() + + assertEquals(Res.string.message_delivery_status, title) + assertEquals(Res.string.message_status_enroute, text) + } + } + @Test fun getStatusStringRes_returnsErrorResourcesForRoutingFailures() { val message = @@ -242,6 +302,31 @@ class MessageTest { assertEquals(Res.string.routing_error_no_route, text) } + @Test + fun getStatusStringRes_returnsMessageSpecificRoutingResources() { + val mappings = + listOf( + Routing.Error.MAX_RETRANSMIT.value to Res.string.message_routing_error_max_retransmit, + Routing.Error.GOT_NAK.value to Res.string.message_routing_error_max_retransmit, + Routing.Error.TIMEOUT.value to Res.string.message_routing_error_max_retransmit, + Routing.Error.NO_RESPONSE.value to Res.string.message_routing_error_max_retransmit, + Routing.Error.NO_CHANNEL.value to Res.string.message_routing_error_no_channel, + Routing.Error.PKI_FAILED.value to Res.string.message_routing_error_pki_failed, + Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY.value to + Res.string.message_routing_error_pki_send_fail_public_key, + Routing.Error.PKI_UNKNOWN_PUBKEY.value to Res.string.message_routing_error_pki_unknown_pubkey, + Routing.Error.TOO_LARGE.value to Res.string.message_routing_error_too_large, + ) + + for ((routingError, expectedText) in mappings) { + val (title, text) = + messageWith(status = MessageStatus.ERROR, routingError = routingError).getStatusStringRes() + + assertEquals(Res.string.error, title) + assertEquals(expectedText, text) + } + } + @Test fun getStatusStringRes_returnsUnknownForMissingStatus() { val message = @@ -274,4 +359,66 @@ class MessageTest { assertEquals(Res.string.routing_error_none, getStringResFrom(Routing.Error.NONE.value)) assertEquals(Res.string.unrecognized, getStringResFrom(Int.MAX_VALUE)) } + + @Test + fun getStringResFrom_keepsSharedRoutingResourcesForGenericContexts() { + val mappings = + listOf( + Routing.Error.MAX_RETRANSMIT.value to Res.string.routing_error_max_retransmit, + Routing.Error.NO_CHANNEL.value to Res.string.routing_error_no_channel, + Routing.Error.PKI_FAILED.value to Res.string.routing_error_pki_failed, + Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY.value to Res.string.routing_error_pki_send_fail_public_key, + Routing.Error.PKI_UNKNOWN_PUBKEY.value to Res.string.routing_error_pki_unknown_pubkey, + Routing.Error.TOO_LARGE.value to Res.string.routing_error_too_large, + ) + + for ((routingError, expectedText) in mappings) { + assertEquals(expectedText, getStringResFrom(routingError)) + } + } + + @Test + fun isMessageStatusRetryable_allowsRetryForRetryableMessageStatusesOnly() { + assertTrue( + messageWith(status = MessageStatus.ERROR, routingError = Routing.Error.MAX_RETRANSMIT.value) + .isStatusRetryable(), + ) + val retryableRoutingErrors = + listOf( + Routing.Error.PKI_FAILED.value, + Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY.value, + Routing.Error.PKI_UNKNOWN_PUBKEY.value, + ) + + for (routingError in retryableRoutingErrors) { + assertTrue(messageWith(status = MessageStatus.ERROR, routingError = routingError).isStatusRetryable()) + } + assertTrue(messageWith(status = MessageStatus.DELIVERED).isStatusRetryable(isDirectMessage = true)) + + val nonRetryableRoutingErrors = listOf(Routing.Error.NO_CHANNEL.value, Routing.Error.TOO_LARGE.value) + + for (routingError in nonRetryableRoutingErrors) { + assertFalse(messageWith(status = MessageStatus.ERROR, routingError = routingError).isStatusRetryable()) + } + assertFalse(messageWith(status = MessageStatus.DELIVERED).isStatusRetryable()) + assertFalse(messageWith(status = MessageStatus.ENROUTE).isStatusRetryable()) + } + + private fun messageWith(status: MessageStatus?, routingError: Int = 0) = Message( + uuid = 1L, + receivedTime = 0L, + node = Node.createFallback(1, "Node"), + text = "hello", + fromLocal = true, + time = "now", + read = false, + status = status, + routingError = routingError, + packetId = 1, + emojis = emptyList(), + snr = 0f, + rssi = 0, + hopsAway = 0, + replyId = null, + ) } diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index dc1abcf61..2579faeb8 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -990,9 +990,17 @@ Message delivery status This radio is managed and can only be changed by a remote admin. Message + Failed to deliver to mesh + No channel selected + Could not send encrypted message + Recipient key unavailable + Recipient needs your key + Message is too large to send Delivered to mesh - Waiting to be acknowledged + Sending... Queued for sending + Delivered to recipient + Relayed, not confirmed by recipient Confirmed on SF++ chain Routing via SF++ chain… Unknown diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt index d50e09963..f45b08906 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt @@ -53,8 +53,8 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch +import org.meshtastic.core.model.ContactKey import org.meshtastic.core.model.Message -import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.Reaction @@ -113,12 +113,15 @@ internal fun MessageListPaged( // Optimization: Pre-calculate map for O(1) lookup in list items to avoid O(N) linear search during scrolling. val nodeMap = remember(state.nodes) { state.nodes.associateBy { it.num } } + val isDirectMessageConversation = + remember(state.contactKey) { ContactKey(state.contactKey).addressString != NodeAddress.ID_BROADCAST } var showStatusDialog by remember { mutableStateOf(null) } showStatusDialog?.let { message -> MessageStatusDialog( message = message, - resendOption = message.status?.equals(MessageStatus.ERROR) ?: false, + isDirectMessage = isDirectMessageConversation, + resendOption = message.isStatusRetryable(isDirectMessageConversation), onResend = { handlers.onDeleteMessages(listOf(message.uuid)) handlers.onSendMessage(message.text, state.contactKey) @@ -165,6 +168,7 @@ internal fun MessageListPaged( inSelectionMode = inSelectionMode, coroutineScope = coroutineScope, haptics = haptics, + isDirectMessageConversation = isDirectMessageConversation, onShowStatusDialog = { showStatusDialog = it }, onShowReactions = { showReactionDialog = it }, modifier = modifier, @@ -182,6 +186,7 @@ private fun MessageListPagedContent( inSelectionMode: Boolean, coroutineScope: CoroutineScope, haptics: HapticFeedback, + isDirectMessageConversation: Boolean, onShowStatusDialog: (Message) -> Unit, onShowReactions: (List) -> Unit, modifier: Modifier = Modifier, @@ -249,6 +254,7 @@ private fun MessageListPagedContent( inSelectionMode = inSelectionMode, coroutineScope = coroutineScope, haptics = haptics, + isDirectMessageConversation = isDirectMessageConversation, listState = listState, onShowStatusDialog = onShowStatusDialog, onShowReactions = onShowReactions, @@ -267,6 +273,7 @@ private fun MessageListPagedContent( inSelectionMode = inSelectionMode, coroutineScope = coroutineScope, haptics = haptics, + isDirectMessageConversation = isDirectMessageConversation, listState = listState, onShowStatusDialog = onShowStatusDialog, onShowReactions = onShowReactions, @@ -309,6 +316,7 @@ private fun RenderPagedChatMessageRow( inSelectionMode: Boolean, coroutineScope: CoroutineScope, haptics: HapticFeedback, + isDirectMessageConversation: Boolean, listState: LazyListState, onShowStatusDialog: (Message) -> Unit, onShowReactions: (List) -> Unit, @@ -381,6 +389,7 @@ private fun RenderPagedChatMessageRow( quickEmojis = quickEmojis, searchQuery = state.searchQuery, translationAvailable = state.translationAvailable, + isDirectMessage = isDirectMessageConversation, onTranslate = { handlers.onTranslate(message) }, onToggleTranslation = { handlers.onToggleTranslation(message) }, ) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt index c85ecd3ca..1723722d3 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues @@ -48,10 +49,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -63,6 +66,7 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.model.Reaction import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.a11y_message_from +import org.meshtastic.core.resources.action_show_message_status import org.meshtastic.core.resources.filter_message_label import org.meshtastic.core.resources.message_translated_label import org.meshtastic.core.resources.reply @@ -88,6 +92,8 @@ import org.meshtastic.core.ui.theme.MessageItemColors import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.util.createClipEntry +internal const val MESSAGE_STATUS_LABEL_TEST_TAG = "message_status_label" + @OptIn(ExperimentalMaterial3Api::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable @@ -117,6 +123,7 @@ fun MessageItem( hasSameNext: Boolean = false, searchQuery: String = "", translationAvailable: Boolean = false, + isDirectMessage: Boolean = false, onTranslate: () -> Unit = {}, onToggleTranslation: () -> Unit = {}, ) = Column( @@ -137,6 +144,8 @@ fun MessageItem( val coroutineScope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val isLocal = node.num == ourNode.num + val statusString = message.getStatusStringRes(isDirectMessage) + val isDirectImplicitAck = message.status == MessageStatus.DELIVERED && isDirectMessage // While searching, always show the original text — FTS matches and highlights apply to it, not the translation. val showsTranslation = message.showTranslated && message.translatedText != null && searchQuery.isEmpty() val bodyText = message.displayedText(searching = searchQuery.isNotEmpty()) @@ -169,7 +178,7 @@ fun MessageItem( activeSheet = null onDelete() }, - statusString = message.getStatusStringRes(), + statusString = statusString, status = if (isLocal) { message.status @@ -380,12 +389,19 @@ fun MessageItem( ) } if (message.fromLocal) { - MessageStatusIcon( - status = message.status ?: MessageStatus.UNKNOWN, - modifier = Modifier.size(18.dp), - ) + val status = message.status ?: MessageStatus.UNKNOWN + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + MessageStatusLabel( + status = status, + text = stringResource(statusString.second), + metadataStyle = metadataStyle, + isWarning = isDirectImplicitAck, + onStatusClick = onStatusClick, + ) + } + } else { + Spacer(modifier = Modifier.weight(1f)) } - Spacer(modifier = Modifier.weight(1f)) Text(modifier = Modifier.padding(start = 16.dp), text = message.time, style = metadataStyle) } } @@ -411,6 +427,46 @@ private enum class ActiveSheet { Emoji, } +@Composable +private fun MessageStatusLabel( + status: MessageStatus, + text: String, + metadataStyle: TextStyle, + isWarning: Boolean, + onStatusClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val statusColor = messageStatusColor(status, isWarning = isWarning) + Row( + modifier = + modifier + .fillMaxWidth() + .testTag(MESSAGE_STATUS_LABEL_TEST_TAG) + .clickable( + onClickLabel = stringResource(Res.string.action_show_message_status), + role = Role.Button, + onClick = onStatusClick, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + MessageStatusIcon( + status = status, + modifier = Modifier.size(14.dp), + tint = statusColor, + includeContentDescription = false, + ) + Text( + text = text, + modifier = Modifier.weight(1f, fill = false), + style = metadataStyle, + color = statusColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + private fun translationRowStateFor(message: Message, translationAvailable: Boolean): TranslationRowState? = when { // Toggling a persisted translation is just a DB flag flip — offer it even when // the translation engine is no longer available for the current locale. diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt index 415ed7a10..cd67d7818 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.messaging.component import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -33,6 +34,7 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.sample_message import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.proto.Routing @Suppress("PreviewPublic") @PreviewLightDark @@ -84,6 +86,130 @@ fun MessageItemSignedPreview() { } } +@Suppress("PreviewPublic") +@PreviewLightDark +@Composable +fun MessageItemStatusStatesPreview() { + val ourNode = NodePreviewParameterProvider().mickeyMouse + val messages = + listOf( + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "Explicit recipient ACK", + time = "10:00", + status = MessageStatus.RECEIVED, + node = ourNode, + ), + ), + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "Channel implicit ACK", + time = "10:01", + status = MessageStatus.DELIVERED, + node = ourNode, + ), + ), + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "Direct implicit ACK", + time = "10:02", + status = MessageStatus.DELIVERED, + node = ourNode, + ), + isDirectMessage = true, + ), + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "Queued for send", + time = "10:03", + status = MessageStatus.QUEUED, + node = ourNode, + ), + ), + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "No ACK received", + time = "10:04", + status = MessageStatus.ERROR, + routingError = Routing.Error.MAX_RETRANSMIT.value, + node = ourNode, + ), + ), + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "No channel selected", + time = "10:05", + status = MessageStatus.ERROR, + routingError = Routing.Error.NO_CHANNEL.value, + node = ourNode, + ), + ), + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "Encrypted send failed", + time = "10:06", + status = MessageStatus.ERROR, + routingError = Routing.Error.PKI_FAILED.value, + node = ourNode, + ), + ), + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "Recipient key unavailable", + time = "10:07", + status = MessageStatus.ERROR, + routingError = Routing.Error.PKI_SEND_FAIL_PUBLIC_KEY.value, + node = ourNode, + ), + ), + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "Recipient needs your key", + time = "10:08", + status = MessageStatus.ERROR, + routingError = Routing.Error.PKI_UNKNOWN_PUBKEY.value, + node = ourNode, + ), + ), + StatusPreviewMessage( + outgoingStatusPreviewMessage( + text = "Too large to send", + time = "10:09", + status = MessageStatus.ERROR, + routingError = Routing.Error.TOO_LARGE.value, + node = ourNode, + ), + ), + ) + + AppTheme { + Column( + modifier = + Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.background).padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + messages.forEach { preview -> + MessageItem( + message = preview.message, + node = preview.message.node, + selected = false, + ourNode = ourNode, + onReply = {}, + sendReaction = {}, + onShowReactions = {}, + onClick = {}, + onLongClick = {}, + onDoubleClick = {}, + onClickChip = {}, + onNavigateToOriginalMessage = {}, + onStatusClick = {}, + isDirectMessage = preview.isDirectMessage, + ) + } + } + } +} + @PreviewLightDark @Composable private fun MessageItemPreview() { @@ -269,3 +395,30 @@ private fun MessageItemPreview() { } } } + +private data class StatusPreviewMessage(val message: Message, val isDirectMessage: Boolean = false) + +private fun outgoingStatusPreviewMessage( + text: String, + time: String, + status: MessageStatus, + node: org.meshtastic.core.model.Node, + routingError: Int = 0, +) = Message( + text = text, + time = time, + fromLocal = true, + status = status, + snr = 0f, + rssi = 0, + hopsAway = 0, + uuid = time.filter(Char::isDigit).toLong(), + receivedTime = nowMillis, + node = node, + read = false, + routingError = routingError, + packetId = time.filter(Char::isDigit).toInt(), + emojis = listOf(), + replyId = null, + viaMqtt = false, +) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt index 8273cc569..8a202f279 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt @@ -663,8 +663,14 @@ fun UnreadMessagesDivider(modifier: Modifier = Modifier) { // region ── MessageStatusDialog ── @Composable -fun MessageStatusDialog(message: Message, resendOption: Boolean, onResend: () -> Unit, onDismiss: () -> Unit) { - val (title, text) = message.getStatusStringRes() +fun MessageStatusDialog( + message: Message, + resendOption: Boolean, + onResend: () -> Unit, + onDismiss: () -> Unit, + isDirectMessage: Boolean = false, +) { + val (title, text) = message.getStatusStringRes(isDirectMessage) DeliveryInfo( title = title, resendOption = resendOption, diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt index 4e6010ea9..d3b3aedef 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageStatusIcon.kt @@ -17,8 +17,12 @@ package org.meshtastic.feature.messaging.component import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.takeOrElse import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.resources.Res @@ -32,9 +36,18 @@ import org.meshtastic.core.ui.icon.MessageEnroute import org.meshtastic.core.ui.icon.MessageError import org.meshtastic.core.ui.icon.MqttDelivered import org.meshtastic.core.ui.icon.Warning +import org.meshtastic.core.ui.theme.StatusColors.StatusBlue +import org.meshtastic.core.ui.theme.StatusColors.StatusGreen +import org.meshtastic.core.ui.theme.StatusColors.StatusRed +import org.meshtastic.core.ui.theme.StatusColors.StatusYellow @Composable -fun MessageStatusIcon(status: MessageStatus, modifier: Modifier = Modifier) { +fun MessageStatusIcon( + status: MessageStatus, + modifier: Modifier = Modifier, + tint: Color = Color.Unspecified, + includeContentDescription: Boolean = true, +) { val icon = when (status) { MessageStatus.RECEIVED -> MeshtasticIcons.Acknowledged @@ -46,9 +59,40 @@ fun MessageStatusIcon(status: MessageStatus, modifier: Modifier = Modifier) { MessageStatus.ERROR -> MeshtasticIcons.MessageError else -> MeshtasticIcons.Warning } + val contentDescription = + if (includeContentDescription) { + stringResource(Res.string.message_delivery_status) + } else { + null + } Icon( modifier = modifier, imageVector = icon, - contentDescription = stringResource(Res.string.message_delivery_status), + contentDescription = contentDescription, + tint = tint.takeOrElse { LocalContentColor.current }, ) } + +@Composable +internal fun messageStatusColor(status: MessageStatus, isWarning: Boolean = false): Color { + val colorScheme = MaterialTheme.colorScheme + if (isWarning) { + return colorScheme.StatusYellow + } + return when (status) { + MessageStatus.RECEIVED, + MessageStatus.DELIVERED, + MessageStatus.SFPP_CONFIRMED, + -> colorScheme.StatusGreen + + MessageStatus.QUEUED, + MessageStatus.UNKNOWN, + -> colorScheme.StatusYellow + + MessageStatus.ENROUTE, + MessageStatus.SFPP_ROUTING, + -> colorScheme.StatusBlue + + MessageStatus.ERROR -> colorScheme.StatusRed + } +} diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt index e1adf4ea1..21732f7d0 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt @@ -58,16 +58,10 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.Reaction -import org.meshtastic.core.model.getStringResFrom +import org.meshtastic.core.model.getMessageStatusStringRes +import org.meshtastic.core.model.isMessageStatusRetryable import org.meshtastic.core.model.util.getShortDateTime import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.delivery_confirmed -import org.meshtastic.core.resources.error -import org.meshtastic.core.resources.message_delivery_status -import org.meshtastic.core.resources.message_status_delivered -import org.meshtastic.core.resources.message_status_enroute -import org.meshtastic.core.resources.message_status_queued -import org.meshtastic.core.resources.message_status_unknown import org.meshtastic.core.resources.react import org.meshtastic.core.resources.you import org.meshtastic.core.ui.component.Rssi @@ -206,23 +200,13 @@ internal fun ReactionDialog( var showStatusDialog by remember { mutableStateOf(null) } showStatusDialog?.let { reaction -> - val title = if (reaction.routingError > 0) Res.string.error else Res.string.message_delivery_status - val text = - when (reaction.status) { - MessageStatus.RECEIVED -> Res.string.delivery_confirmed - MessageStatus.QUEUED -> Res.string.message_status_queued - MessageStatus.ENROUTE -> Res.string.message_status_enroute - MessageStatus.DELIVERED -> Res.string.message_status_delivered - MessageStatus.SFPP_ROUTING -> Res.string.message_status_enroute - MessageStatus.SFPP_CONFIRMED -> Res.string.delivery_confirmed - MessageStatus.ERROR -> getStringResFrom(reaction.routingError) - MessageStatus.UNKNOWN -> Res.string.message_status_unknown - } + val isDirectMessage = NodeAddress.fromString(reaction.to) !is NodeAddress.Broadcast + val (title, text) = getMessageStatusStringRes(reaction.status, reaction.routingError, isDirectMessage) DeliveryInfo( title = title, text = text, - resendOption = reaction.status == MessageStatus.ERROR, + resendOption = isMessageStatusRetryable(reaction.status, reaction.routingError, isDirectMessage), onConfirm = { onResend(reaction) showStatusDialog = null diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt index fcf15d1ec..1d8e41fea 100644 --- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt @@ -19,12 +19,18 @@ package org.meshtastic.feature.messaging.component import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.compose.ui.test.v2.runComposeUiTest import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.Message import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.Node import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider +import org.meshtastic.proto.Routing import kotlin.test.Test +import kotlin.test.assertEquals @OptIn(ExperimentalTestApi::class) class MessageItemTest { @@ -145,4 +151,106 @@ class MessageItemTest { // Verify that the node containing the message text exists and matches the text onNodeWithContentDescription("Message from ${testNode.user.long_name}: Hello World").assertIsDisplayed() } + + @Test + fun localMessage_displaysDeliveryStatusText() = runComposeUiTest { + val testNode = NodePreviewParameterProvider().mickeyMouse + val message = localMessage(node = testNode, status = MessageStatus.RECEIVED) + + setContent { + MessageItem(message = message, node = testNode, selected = false, onStatusClick = {}, ourNode = testNode) + } + + onNodeWithText("Delivered to recipient", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun localDirectMessage_displaysImplicitAckWarningText() = runComposeUiTest { + val testNode = NodePreviewParameterProvider().mickeyMouse + val message = localMessage(node = testNode, status = MessageStatus.DELIVERED) + + setContent { + MessageItem( + message = message, + node = testNode, + selected = false, + onStatusClick = {}, + ourNode = testNode, + isDirectMessage = true, + ) + } + + onNodeWithText("Relayed, not confirmed by recipient", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun localMessage_displaysRoutingErrorStatusText() = runComposeUiTest { + val testNode = NodePreviewParameterProvider().mickeyMouse + val message = + localMessage( + node = testNode, + status = MessageStatus.ERROR, + routingError = Routing.Error.MAX_RETRANSMIT.value, + ) + + setContent { + MessageItem(message = message, node = testNode, selected = false, onStatusClick = {}, ourNode = testNode) + } + + onNodeWithText("Failed to deliver to mesh", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun localMessageStatus_invokesStatusClick() = runComposeUiTest { + val testNode = NodePreviewParameterProvider().mickeyMouse + val message = localMessage(node = testNode, status = MessageStatus.QUEUED) + var statusClicks = 0 + + setContent { + MessageItem( + message = message, + node = testNode, + selected = false, + onStatusClick = { statusClicks += 1 }, + ourNode = testNode, + ) + } + + onNodeWithText("Sending...", useUnmergedTree = true).assertIsDisplayed() + onNodeWithTag(MESSAGE_STATUS_LABEL_TEST_TAG, useUnmergedTree = true).performClick() + + assertEquals(1, statusClicks) + } + + @Test + fun localMessageStatus_doesNotExposeGenericIconDescription() = runComposeUiTest { + val testNode = NodePreviewParameterProvider().mickeyMouse + val message = localMessage(node = testNode, status = MessageStatus.ENROUTE) + + setContent { + MessageItem(message = message, node = testNode, selected = false, onStatusClick = {}, ourNode = testNode) + } + + onNodeWithText("Sending...", useUnmergedTree = true).assertIsDisplayed() + onNodeWithContentDescription("Message delivery status", useUnmergedTree = true).assertDoesNotExist() + } + + private fun localMessage(node: Node, status: MessageStatus, routingError: Int = 0) = Message( + text = "Local message", + time = "10:00", + fromLocal = true, + status = status, + snr = 2.5f, + rssi = 90, + hopsAway = 0, + uuid = 1L, + receivedTime = nowMillis, + node = node, + read = false, + routingError = routingError, + packetId = 1234, + emojis = listOf(), + replyId = null, + viaMqtt = false, + ) } diff --git a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/MessagingScreenshotTests.kt b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/MessagingScreenshotTests.kt index 875db307d..8fdd6d592 100644 --- a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/MessagingScreenshotTests.kt +++ b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/MessagingScreenshotTests.kt @@ -23,6 +23,7 @@ import org.meshtastic.feature.messaging.EditQuickChatDialogPreview import org.meshtastic.feature.messaging.MessageInputPreview import org.meshtastic.feature.messaging.QuickChatItemPreview import org.meshtastic.feature.messaging.component.MessageItemSignedPreview +import org.meshtastic.feature.messaging.component.MessageItemStatusStatesPreview import org.meshtastic.feature.messaging.component.MessageSearchBarPreview import org.meshtastic.feature.messaging.component.ReactionItemPreview @@ -67,3 +68,10 @@ fun ScreenshotMessageSearchBar() { fun ScreenshotMessageItemSigned() { MessageItemSignedPreview() } + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotMessageItemStatusStates() { + MessageItemStatusStatesPreview() +} diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemStatusStates_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemStatusStates_Dark_d19fbf1f_0.png new file mode 100644 index 000000000..c6eafebb9 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemStatusStates_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemStatusStates_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemStatusStates_Light_b29dc7a7_0.png new file mode 100644 index 000000000..0948a1b1b Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemStatusStates_Light_b29dc7a7_0.png differ