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 f45b089069..6a24b45150 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 @@ -62,9 +62,20 @@ import org.meshtastic.feature.messaging.component.MessageItem import org.meshtastic.feature.messaging.component.MessageStatusDialog import org.meshtastic.feature.messaging.component.ReactionDialog import org.meshtastic.feature.messaging.component.UnreadMessagesDivider +import kotlin.math.abs private const val HEX_RADIX = 16 +/** + * Messages merge into one visual group only when they are from the same sender AND close in time. The group header + * carries the run's only timestamp, so a run must never span messages minutes apart from what the header shows. + */ +internal const val GROUPING_WINDOW_MILLIS = 10 * 60 * 1000L + +internal fun isSameGroup(older: Message, newer: Message): Boolean = older.fromLocal == newer.fromLocal && + (newer.fromLocal || older.node.num == newer.node.num) && + abs(newer.receivedTime - older.receivedTime) <= GROUPING_WINDOW_MILLIS + internal data class MessageListHandlers( val onUnreadChanged: (Long, Long) -> Unit, val onSendReaction: (String, Int) -> Unit, @@ -222,20 +233,10 @@ private fun MessageListPagedContent( val visuallyNextMessage = if (index > 0) state.messages[index - 1] else null val hasSamePrev = - if (message != null && visuallyPrevMessage != null) { - visuallyPrevMessage.fromLocal == message.fromLocal && - (message.fromLocal || visuallyPrevMessage.node.num == message.node.num) - } else { - false - } + message != null && visuallyPrevMessage != null && isSameGroup(visuallyPrevMessage, message) val hasSameNext = - if (message != null && visuallyNextMessage != null) { - visuallyNextMessage.fromLocal == message.fromLocal && - (message.fromLocal || visuallyNextMessage.node.num == message.node.num) - } else { - false - } + message != null && visuallyNextMessage != null && isSameGroup(message, visuallyNextMessage) if (message != null) { val isFirstUnread = state.hasUnreadMessages && unreadDividerIndex == index diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt index 3230f2b5b0..14481dfb30 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt @@ -61,10 +61,12 @@ import org.meshtastic.core.resources.security_signed_verified import org.meshtastic.core.resources.select import org.meshtastic.core.resources.show_original import org.meshtastic.core.resources.show_translation +import org.meshtastic.core.resources.timestamp import org.meshtastic.core.resources.translate import org.meshtastic.core.ui.icon.AddReaction import org.meshtastic.core.ui.icon.Copy import org.meshtastic.core.ui.icon.Delete +import org.meshtastic.core.ui.icon.History import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Reply import org.meshtastic.core.ui.icon.SelectAll @@ -84,6 +86,7 @@ fun MessageActionsContent( onStatus: () -> Unit, statusString: Pair? = null, status: MessageStatus? = null, + timestamp: String? = null, xeddsaSigned: Boolean = false, translationRowState: TranslationRowState? = null, onTranslate: () -> Unit = {}, @@ -108,6 +111,22 @@ fun MessageActionsContent( ) } + // Bubbles only show a short header time per run, so the sheet is where the full date-time lives. + if (timestamp != null) { + ListItem( + headlineContent = { + Text( + stringResource( + Res.string.device_metrics_label_value, + stringResource(Res.string.timestamp), + timestamp, + ), + ) + }, + leadingContent = { Icon(MeshtasticIcons.History, contentDescription = null) }, + ) + } + if (status != null) { val title = statusString?.first?.let { stringResource(it) } ?: stringResource(Res.string.message_delivery_status) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageBubble.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageBubble.kt index 3b0ffa4325..f96bf598ea 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageBubble.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageBubble.kt @@ -24,34 +24,39 @@ import androidx.compose.ui.unit.dp /** * Returns a [CornerBasedShape] for a message bubble based on its position in a sequence. * + * Standalone bubbles are fully rounded; consecutive bubbles from the same sender flatten only the corners facing each + * other (to [groupedCornerRadius]) so a run reads as one visual group, matching the M3 conversation pattern. + * * @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. + * @param groupedCornerRadius The reduced radius used on corners adjacent to a same-sender neighbor. */ fun getMessageBubbleShape( cornerRadius: Dp, isSender: Boolean, hasSamePrev: Boolean = false, hasSameNext: Boolean = false, + groupedCornerRadius: Dp = 4.dp, ): CornerBasedShape { - val square = 0.dp val round = cornerRadius + val grouped = groupedCornerRadius return if (isSender) { - // Sent messages are on the right. + // Sent messages are on the right; grouped corners flatten along the end edge. RoundedCornerShape( topStart = round, - topEnd = if (hasSamePrev) square else round, + topEnd = if (hasSamePrev) grouped else round, bottomStart = round, - bottomEnd = square, + bottomEnd = if (hasSameNext) grouped else round, ) } else { - // Received messages are on the left. + // Received messages are on the left; grouped corners flatten along the start edge. RoundedCornerShape( - topStart = square, + topStart = if (hasSamePrev) grouped else round, topEnd = round, - bottomStart = if (hasSameNext) square else round, + bottomStart = if (hasSameNext) grouped else round, bottomEnd = round, ) } 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 0917e3344c..ee30693df9 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,16 +20,16 @@ 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.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.RowScope 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.foundation.layout.widthIn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -63,6 +63,7 @@ import org.meshtastic.core.model.Message import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.Node import org.meshtastic.core.model.Reaction +import org.meshtastic.core.model.util.getShortDateTime import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.a11y_message_from import org.meshtastic.core.resources.action_show_message_status @@ -128,12 +129,13 @@ fun MessageItem( modifier = modifier .fillMaxWidth() + // Wider gap between sender groups, tight spacing within a group, so runs read as one unit. .padding( top = - if (showUserName) { - 6.dp + if (hasSamePrev) { + 2.dp } else { - 1.dp + 10.dp }, ), ) { @@ -183,6 +185,7 @@ fun MessageItem( } else { null }, + timestamp = getShortDateTime(message.receivedTime), xeddsaSigned = message.xeddsaSigned, onStatus = onStatusClick, translationRowState = translationRowStateFor(message, translationAvailable), @@ -229,13 +232,13 @@ fun MessageItem( val metadataStyle = MaterialTheme.typography.labelSmall val messageShape = getMessageBubbleShape( - cornerRadius = 8.dp, + cornerRadius = 18.dp, isSender = message.fromLocal, hasSamePrev = hasSamePrev, hasSameNext = hasSameNext, ) val messageModifier = - Modifier.padding(horizontal = 8.dp) + Modifier.padding(horizontal = 12.dp) .then( if (containsBel) { Modifier.border(2.dp, color = MessageItemColors.Red, shape = messageShape) @@ -245,28 +248,45 @@ fun MessageItem( ) val senderName = if (message.fromLocal) ourNode.user.long_name else node.user.long_name val messageA11yText = stringResource(Res.string.a11y_message_from, senderName, bodyText) - if (showUserName && !message.fromLocal) { - Row( - modifier = Modifier.padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - NodeChip(node = node, onClick = onClickChip, modifier = Modifier.height(28.dp)) + // Timestamp lives in the group header (Google Chat pattern) rather than inside every bubble; grouping is + // time-windowed upstream, so the header time is always close to every message in the run. + if (showUserName) { + if (message.fromLocal) { Text( - text = node.user.long_name, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - style = MaterialTheme.typography.labelMedium, + text = message.time, + modifier = Modifier.align(Alignment.End).padding(end = 12.dp, bottom = 2.dp), + style = metadataStyle, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + } else { + Row( + modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + NodeChip(node = node, onClick = onClickChip, modifier = Modifier.height(28.dp)) + Text( + text = node.user.long_name, + modifier = Modifier.weight(1f, fill = false), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(text = message.time, style = metadataStyle, color = MaterialTheme.colorScheme.onSurfaceVariant) + } } } Surface( modifier = Modifier.align(if (message.fromLocal) Alignment.End else Alignment.Start) + // Reserve space on the opposite side and cap the width so bubbles never span the + // whole screen (keeps sender sides scannable on phones and wide layouts alike). .padding( - start = if (!message.fromLocal) 0.dp else 24.dp, - end = if (message.fromLocal) 0.dp else 24.dp, + start = if (!message.fromLocal) 0.dp else 36.dp, + end = if (message.fromLocal) 0.dp else 36.dp, ) + .widthIn(max = 480.dp) .combinedClickable( onClick = onClick, onLongClick = { @@ -295,7 +315,7 @@ fun MessageItem( onNavigateToOriginalMessage = onNavigateToOriginalMessage, ) - Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { if (searchQuery.isNotEmpty()) { HighlightedText( text = message.text, @@ -320,57 +340,54 @@ fun MessageItem( } Row( - modifier = Modifier, + modifier = Modifier.padding(top = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { if (!message.fromLocal) { - // XEdDSA is only set on verified broadcasts, never DMs — so this never shows on a DM. - if (message.xeddsaSigned) { - Icon( - imageVector = MeshtasticIcons.ShieldCheck, - contentDescription = stringResource(Res.string.security_signed_verified), - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.StatusGreen, + // All mesh diagnostics (signature, signal or hops, transport) grouped in one compact run. + DiagnosticsRow { + // XEdDSA is only set on verified broadcasts, never DMs — so this never shows on a DM. + if (message.xeddsaSigned) { + Icon( + imageVector = MeshtasticIcons.ShieldCheck, + contentDescription = stringResource(Res.string.security_signed_verified), + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.StatusGreen, + ) + } + TransportIcon( + transport = message.transportMechanism, + viaMqtt = message.viaMqtt, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) - } - if (message.hopsAway == 0 && !message.viaMqtt) { - Snr(message.snr) - Rssi(message.rssi) - } else { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp), - ) { + if (message.hopsAway == 0 && !message.viaMqtt) { + Snr(message.snr) + Rssi(message.rssi) + } else { Icon( imageVector = MeshtasticIcons.HopCount, contentDescription = null, modifier = Modifier.size(14.dp), - tint = Color.White, + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = if (message.hopsAway >= 0) message.hopsAway.toString() else "?", style = metadataStyle, - color = Color.White, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - TransportIcon( - transport = message.transportMechanism, - viaMqtt = message.viaMqtt, - modifier = Modifier.size(16.dp), - tint = Color.White, - ) } if (containsBel) { - Text(text = "\uD83D\uDD14", modifier = Modifier.padding(start = 4.dp)) + Text(text = "\uD83D\uDD14") } if (message.filtered) { Text( text = stringResource(Res.string.filter_message_label), style = metadataStyle, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 8.dp, end = 4.dp), ) } if (showsTranslation) { @@ -378,24 +395,17 @@ fun MessageItem( text = stringResource(Res.string.message_translated_label), style = metadataStyle, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 8.dp, end = 4.dp), ) } if (message.fromLocal) { - 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)) + MessageStatusLabel( + status = message.status ?: MessageStatus.UNKNOWN, + text = stringResource(statusString.second), + metadataStyle = metadataStyle, + isWarning = isDirectImplicitAck, + onStatusClick = onStatusClick, + ) } - Text(modifier = Modifier.padding(start = 16.dp), text = message.time, style = metadataStyle) } } } @@ -405,8 +415,9 @@ fun MessageItem( modifier = 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, + top = 2.dp, + start = if (!message.fromLocal) 12.dp else 48.dp, + end = if (message.fromLocal) 12.dp else 48.dp, ), reactions = if (message.fromLocal) emojis.reversed() else emojis, myId = ourNode.user.id, @@ -420,6 +431,17 @@ private enum class ActiveSheet { Emoji, } +/** Row grouping a received message's mesh diagnostics (signature, signal or hops, transport). */ +@Composable +private fun DiagnosticsRow(modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + content = content, + ) +} + @Composable private fun MessageStatusLabel( status: MessageStatus, @@ -496,7 +518,7 @@ private fun OriginalMessageSnippet( shape = RectangleShape, ) { Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { 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 117c527cf3..9df6ed23fb 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 @@ -34,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.feature.messaging.isSameGroup import org.meshtastic.proto.Routing @Suppress("PreviewPublic") @@ -258,6 +259,107 @@ fun MessageItemStatusStatesPreview() { } } +private const val PREVIEW_MINUTE_MILLIS = 60_000L + +/** + * A full conversation: a received same-sender run, the SAME sender returning after a >10-minute gap (which must start a + * new block with its own header), then a sent run. Grouping is computed with the real [isSameGroup] rule, so this pins + * the grouped treatment end to end — header only at the start of each block, 4dp flattened facing corners within a + * block, full rounding on the outer edges. + */ +@Suppress("PreviewPublic") +@PreviewLightDark +@Composable +fun MessageItemGroupedRunPreview() { + val ourNode = NodePreviewParameterProvider().mickeyMouse + val minnie = NodePreviewParameterProvider().minnieMouse + val t0 = nowMillis + val base = + Message( + text = "Trailhead in 10", + time = "09:12", + fromLocal = false, + status = MessageStatus.RECEIVED, + snr = 5.5f, + rssi = 88, + hopsAway = 0, + uuid = 30L, + receivedTime = t0, + node = minnie, + read = true, + routingError = 0, + packetId = 7001, + emojis = listOf(), + replyId = null, + viaMqtt = false, + ) + val run = + listOf( + base, + base.copy( + text = "Parking lot is full, use the overflow", + time = "09:13", + receivedTime = t0 + 1 * PREVIEW_MINUTE_MILLIS, + uuid = 31L, + packetId = 7002, + ), + base.copy( + text = "Bring water", + time = "09:14", + receivedTime = t0 + 2 * PREVIEW_MINUTE_MILLIS, + uuid = 32L, + packetId = 7003, + ), + // Same sender, but 33 minutes later — past the grouping window, so this starts a new block. + base.copy( + text = "Still waiting at the junction", + time = "09:47", + receivedTime = t0 + 35 * PREVIEW_MINUTE_MILLIS, + uuid = 33L, + packetId = 7004, + ), + base.copy( + text = "Copy that", + time = "09:48", + fromLocal = true, + node = ourNode, + receivedTime = t0 + 36 * PREVIEW_MINUTE_MILLIS, + uuid = 34L, + packetId = 7005, + ), + base.copy( + text = "Omw", + time = "09:49", + fromLocal = true, + status = MessageStatus.ENROUTE, + node = ourNode, + receivedTime = t0 + 37 * PREVIEW_MINUTE_MILLIS, + uuid = 35L, + packetId = 7006, + ), + ) + AppTheme { + Column( + modifier = + Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.background).padding(vertical = 16.dp), + ) { + run.forEachIndexed { index, msg -> + val prevSame = index > 0 && isSameGroup(run[index - 1], msg) + val nextSame = index < run.lastIndex && isSameGroup(msg, run[index + 1]) + MessageItem( + message = msg, + node = msg.node, + ourNode = ourNode, + selected = false, + showUserName = !prevSame, + hasSamePrev = prevSame, + hasSameNext = nextSame, + ) + } + } + } +} + @PreviewLightDark @Composable private fun MessageItemPreview() { diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageGroupingTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageGroupingTest.kt new file mode 100644 index 0000000000..d771818a28 --- /dev/null +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageGroupingTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.messaging + +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 kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Pins the conversation-block rules: messages merge into one visual group only when they are from the same sender AND + * within [GROUPING_WINDOW_MILLIS] of each other. The group header carries the run's only timestamp, so a significant + * time gap must start a new block even for the same sender. + */ +class MessageGroupingTest { + + private val minnie = NodePreviewParameterProvider().minnieMouse + private val mickey = NodePreviewParameterProvider().mickeyMouse + + private fun message(fromLocal: Boolean, node: Node, receivedTime: Long) = Message( + text = "test", + time = "10:00", + fromLocal = fromLocal, + status = MessageStatus.RECEIVED, + snr = 2.5f, + rssi = 90, + hopsAway = 0, + uuid = receivedTime, + receivedTime = receivedTime, + node = node, + read = true, + routingError = 0, + packetId = 1, + emojis = listOf(), + replyId = null, + viaMqtt = false, + ) + + @Test + fun sameSenderWithinWindow_groups() { + val older = message(fromLocal = false, node = minnie, receivedTime = 0L) + val newer = message(fromLocal = false, node = minnie, receivedTime = GROUPING_WINDOW_MILLIS) + assertTrue(isSameGroup(older, newer)) + } + + @Test + fun sameSenderBeyondWindow_startsNewBlock() { + val older = message(fromLocal = false, node = minnie, receivedTime = 0L) + val newer = message(fromLocal = false, node = minnie, receivedTime = GROUPING_WINDOW_MILLIS + 1) + assertFalse(isSameGroup(older, newer)) + } + + @Test + fun differentSendersWithinWindow_doNotGroup() { + val older = message(fromLocal = false, node = minnie, receivedTime = 0L) + val newer = message(fromLocal = false, node = mickey, receivedTime = 1_000L) + assertFalse(isSameGroup(older, newer)) + } + + @Test + fun localAndRemoteWithinWindow_doNotGroup() { + val older = message(fromLocal = true, node = mickey, receivedTime = 0L) + val newer = message(fromLocal = false, node = mickey, receivedTime = 1_000L) + assertFalse(isSameGroup(older, newer)) + } + + @Test + fun localRunWithinWindow_groups() { + val older = message(fromLocal = true, node = mickey, receivedTime = 0L) + val newer = message(fromLocal = true, node = mickey, receivedTime = 1_000L) + assertTrue(isSameGroup(older, newer)) + } + + @Test + fun localRunBeyondWindow_startsNewBlock() { + val older = message(fromLocal = true, node = mickey, receivedTime = 0L) + val newer = message(fromLocal = true, node = mickey, receivedTime = GROUPING_WINDOW_MILLIS + 1) + assertFalse(isSameGroup(older, newer)) + } + + @Test + fun outOfOrderTimestampsWithinWindow_stillGroup() { + // Mesh delivery can reorder; grouping uses the absolute gap. + val older = message(fromLocal = false, node = minnie, receivedTime = 5_000L) + val newer = message(fromLocal = false, node = minnie, receivedTime = 1_000L) + assertTrue(isSameGroup(older, newer)) + } +} 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 655fa506d6..f80889d213 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 @@ -22,6 +22,7 @@ import com.android.tools.screenshot.PreviewTest import org.meshtastic.feature.messaging.EditQuickChatDialogPreview import org.meshtastic.feature.messaging.MessageInputPreview import org.meshtastic.feature.messaging.QuickChatItemPreview +import org.meshtastic.feature.messaging.component.MessageItemGroupedRunPreview import org.meshtastic.feature.messaging.component.MessageItemMarkdownPreview import org.meshtastic.feature.messaging.component.MessageItemSignedPreview import org.meshtastic.feature.messaging.component.MessageItemStatusStatesPreview @@ -83,3 +84,10 @@ fun ScreenshotMessageItemStatusStates() { fun ScreenshotMessageItemMarkdown() { MessageItemMarkdownPreview() } + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotMessageItemGroupedRun() { + MessageItemGroupedRunPreview() +} diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemGroupedRun_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemGroupedRun_Dark_d19fbf1f_0.png new file mode 100644 index 0000000000..3ad2dfe5fc Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemGroupedRun_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemGroupedRun_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemGroupedRun_Light_b29dc7a7_0.png new file mode 100644 index 0000000000..091a25f156 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemGroupedRun_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemMarkdown_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemMarkdown_Dark_d19fbf1f_0.png index 3d709092f3..876c963776 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemMarkdown_Dark_d19fbf1f_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemMarkdown_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemMarkdown_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemMarkdown_Light_b29dc7a7_0.png index 97d47eb962..5e8f18422c 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemMarkdown_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemMarkdown_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemSigned_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemSigned_Dark_d19fbf1f_0.png index e7d4a6a3ec..1631763665 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemSigned_Dark_d19fbf1f_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemSigned_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemSigned_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemSigned_Light_b29dc7a7_0.png index b87659de2d..4cad253f9b 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemSigned_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemSigned_Light_b29dc7a7_0.png differ 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 index c6eafebb93..b35bd89e7e 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemStatusStates_Dark_d19fbf1f_0.png 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 index 0948a1b1b5..2b651d9009 100644 Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemStatusStates_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MessagingScreenshotTestsKt/ScreenshotMessageItemStatusStates_Light_b29dc7a7_0.png differ