Show outgoing message status text (#6121)

Co-authored-by: James Rich <james.a.rich@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Benjamin Faershtein
2026-07-06 16:47:49 -07:00
committed by GitHub
parent e6bd6a9012
commit cd3b3c2b70
14 changed files with 641 additions and 59 deletions

View File

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

View File

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

View File

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

View File

@@ -990,9 +990,17 @@
<string name="message_delivery_status">Message delivery status</string>
<string name="message_device_managed">This radio is managed and can only be changed by a remote admin.</string>
<string name="message_input_label">Message</string>
<string name="message_routing_error_max_retransmit">Failed to deliver to mesh</string>
<string name="message_routing_error_no_channel">No channel selected</string>
<string name="message_routing_error_pki_failed">Could not send encrypted message</string>
<string name="message_routing_error_pki_send_fail_public_key">Recipient key unavailable</string>
<string name="message_routing_error_pki_unknown_pubkey">Recipient needs your key</string>
<string name="message_routing_error_too_large">Message is too large to send</string>
<string name="message_status_delivered">Delivered to mesh</string>
<string name="message_status_enroute">Waiting to be acknowledged</string>
<string name="message_status_enroute">Sending...</string>
<string name="message_status_queued">Queued for sending</string>
<string name="message_status_recipient_delivered">Delivered to recipient</string>
<string name="message_status_relayed_not_confirmed">Relayed, not confirmed by recipient</string>
<string name="message_status_sfpp_confirmed">Confirmed on SF++ chain</string>
<string name="message_status_sfpp_routing">Routing via SF++ chain…</string>
<string name="message_status_unknown">Unknown</string>

View File

@@ -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<Message?>(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<Reaction>) -> 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<Reaction>) -> 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) },
)

View File

@@ -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.

View File

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

View File

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

View File

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

View File

@@ -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<Reaction?>(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

View File

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

View File

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