feat(messaging): redesign message bubbles toward the M3 conversation pattern (#6368)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<StringResource, StringResource>? = 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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 158 KiB |