feat(messaging): redesign message bubbles toward the M3 conversation pattern (#6368)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James RichandClaude Fable 5 authored and GitHub committed 2026-07-22 18:34:45 +00:00
1 parent 3c32ffa0d1
commit 49165fc412
15 files changed
+347 -85

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()
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 158 KiB