From 5d0d52517db8af96f9778a7c56bc40883aea4d5f Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 13 Jan 2026 20:43:06 -0600
Subject: [PATCH] feat(messaging): Overhaul message bubbles and add actions
(#4206)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.../composeResources/values/strings.xml | 1 +
.../messaging/component/MessageItemTest.kt | 44 ++-
.../meshtastic/feature/messaging/Message.kt | 6 +-
.../feature/messaging/MessageListPaged.kt | 51 ++-
.../feature/messaging/MessageViewModel.kt | 14 +
.../component/MessageActionsBottomSheet.kt | 156 ++++++++
.../messaging/component/MessageBubble.kt | 48 +++
.../messaging/component/MessageItem.kt | 344 +++++++++++++-----
.../feature/messaging/component/Reaction.kt | 9 +-
9 files changed, 569 insertions(+), 104 deletions(-)
create mode 100644 feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt
create mode 100644 feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageBubble.kt
diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml
index 0e2eb436d..2cb2f8e04 100644
--- a/core/strings/src/commonMain/composeResources/values/strings.xml
+++ b/core/strings/src/commonMain/composeResources/values/strings.xml
@@ -292,6 +292,7 @@
Delete
Delete for everyone
Delete for me
+ Select
Select all
Close selection
Delete selected
diff --git a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt
index 6de58c9dd..c0473a8e2 100644
--- a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt
+++ b/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.feature.messaging.component
import androidx.compose.ui.test.assertIsDisplayed
@@ -110,4 +109,45 @@ class MessageItemTest {
// Check that the MQTT icon is not displayed
composeTestRule.onNodeWithContentDescription("via MQTT").assertDoesNotExist()
}
+
+ @Test
+ fun messageItem_hasCorrectSemanticContentDescription() {
+ val testNode = NodePreviewParameterProvider().minnieMouse
+ val message =
+ Message(
+ text = "Hello World",
+ time = "10:00",
+ fromLocal = false,
+ status = MessageStatus.RECEIVED,
+ snr = 2.5f,
+ rssi = 90,
+ hopsAway = 0,
+ uuid = 1L,
+ receivedTime = System.currentTimeMillis(),
+ node = testNode,
+ read = false,
+ routingError = 0,
+ packetId = 1234,
+ emojis = listOf(),
+ replyId = null,
+ viaMqtt = false,
+ )
+
+ composeTestRule.setContent {
+ MessageItem(
+ message = message,
+ node = testNode,
+ selected = false,
+ onClick = {},
+ onLongClick = {},
+ onStatusClick = {},
+ ourNode = testNode,
+ )
+ }
+
+ // Verify that the node containing the message text exists and matches the text
+ composeTestRule
+ .onNodeWithContentDescription("Message from ${testNode.user?.longName}: Hello World")
+ .assertIsDisplayed()
+ }
}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt
index 8b4360354..da3d85bf9 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * Copyright (c) 2025-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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
@file:Suppress("TooManyFunctions")
package org.meshtastic.feature.messaging
@@ -369,6 +368,7 @@ fun MessageScreen(
onSendMessage = { text, key -> viewModel.sendMessage(text, key) },
onReply = { message -> replyingToPacketId = message?.packetId },
),
+ quickEmojis = viewModel.frequentEmojis,
)
// Show FAB if we can scroll towards the newest messages (index 0).
if (listState.canScrollBackward) {
@@ -779,7 +779,7 @@ private fun QuickChatRow(
// Memoize if content is static
QuickChatAction(
name = "🔔",
- message = "🔔 $alertActionMessage ", // Bell character added to message
+ message = "🔔 $alertActionMessage ", // Bell character added to message
mode = QuickChatAction.Mode.Append,
position = -1, // Assuming -1 means it's a special prepended action
)
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
index 43227f6b6..8345181c0 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
@@ -18,6 +18,7 @@ package org.meshtastic.feature.messaging
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -103,6 +104,7 @@ internal fun MessageListPaged(
handlers: MessageListHandlers,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
+ quickEmojis: List = emptyList(),
) {
val haptics = LocalHapticFeedback.current
val inSelectionMode by remember { derivedStateOf { state.selectedIds.value.isNotEmpty() } }
@@ -170,9 +172,11 @@ internal fun MessageListPaged(
onShowStatusDialog = { showStatusDialog = it },
onShowReactions = { showReactionDialog = it },
modifier = modifier,
+ quickEmojis = quickEmojis,
)
}
+@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
private fun MessageListPagedContent(
listState: LazyListState,
@@ -185,6 +189,7 @@ private fun MessageListPagedContent(
onShowStatusDialog: (Message) -> Unit,
onShowReactions: (List) -> Unit,
modifier: Modifier = Modifier,
+ quickEmojis: List,
) {
// Calculate unread divider position
val unreadDividerIndex by
@@ -200,9 +205,33 @@ private fun MessageListPagedContent(
val enableAnimations by remember { derivedStateOf { !listState.isScrollInProgress } }
Box(modifier = modifier.fillMaxSize()) {
- LazyColumn(modifier = Modifier.fillMaxSize(), state = listState, reverseLayout = true) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ state = listState,
+ reverseLayout = true,
+ contentPadding = PaddingValues(bottom = 24.dp),
+ ) {
items(count = state.messages.itemCount, key = state.messages.itemKey { it.uuid }) { index ->
val message = state.messages[index]
+ val visuallyPrevMessage = if (index < state.messages.itemCount - 1) state.messages[index + 1] else null
+ 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
+ }
+
+ val hasSameNext =
+ if (message != null && visuallyNextMessage != null) {
+ visuallyNextMessage.fromLocal == message.fromLocal &&
+ (message.fromLocal || visuallyNextMessage.node.num == message.node.num)
+ } else {
+ false
+ }
+
if (message != null) {
renderPagedChatMessageRow(
message = message,
@@ -216,6 +245,10 @@ private fun MessageListPagedContent(
onShowStatusDialog = onShowStatusDialog,
onShowReactions = onShowReactions,
enableAnimations = enableAnimations,
+ showUserName = !hasSamePrev,
+ hasSamePrev = hasSamePrev,
+ hasSameNext = hasSameNext,
+ quickEmojis = quickEmojis,
)
// Show unread divider after the first unread message
@@ -244,6 +277,7 @@ private fun MessageListPagedContent(
}
}
+@Suppress("LongParameterList")
@Composable
private fun LazyItemScope.renderPagedChatMessageRow(
message: Message,
@@ -257,6 +291,10 @@ private fun LazyItemScope.renderPagedChatMessageRow(
onShowStatusDialog: (Message) -> Unit,
onShowReactions: (List) -> Unit,
enableAnimations: Boolean,
+ showUserName: Boolean,
+ hasSamePrev: Boolean,
+ hasSameNext: Boolean,
+ quickEmojis: List,
) {
val ourNode = state.ourNode ?: return
val selected by
@@ -271,15 +309,21 @@ private fun LazyItemScope.renderPagedChatMessageRow(
ourNode = ourNode,
message = message,
selected = selected,
+ inSelectionMode = inSelectionMode,
onClick = { if (inSelectionMode) state.selectedIds.toggle(message.uuid) },
onLongClick = {
- state.selectedIds.toggle(message.uuid)
+ if (inSelectionMode) {
+ state.selectedIds.toggle(message.uuid)
+ }
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
},
+ onSelect = { state.selectedIds.toggle(message.uuid) },
+ onDelete = { handlers.onDeleteMessages(listOf(message.uuid)) },
onClickChip = handlers.onClickChip,
onStatusClick = { onShowStatusDialog(message) },
onReply = { handlers.onReply(message) },
emojis = message.emojis,
+ showUserName = showUserName,
sendReaction = { emoji ->
val hasReacted =
message.emojis.any { reaction ->
@@ -307,6 +351,9 @@ private fun LazyItemScope.renderPagedChatMessageRow(
}
}
},
+ hasSamePrev = hasSamePrev,
+ hasSameNext = hasSameNext,
+ quickEmojis = quickEmojis,
)
}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
index ed92476fa..4c962ab61 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
@@ -42,6 +42,7 @@ import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.prefs.emoji.CustomEmojiPrefs
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceAction
@@ -64,6 +65,7 @@ constructor(
private val serviceRepository: ServiceRepository,
private val packetRepository: PacketRepository,
private val uiPrefs: UiPrefs,
+ private val customEmojiPrefs: CustomEmojiPrefs,
private val meshServiceNotifications: MeshServiceNotifications,
) : ViewModel() {
private val _title = MutableStateFlow("")
@@ -92,6 +94,18 @@ constructor(
.flatMapLatest { contactKey -> packetRepository.getMessagesFromPaged(contactKey, ::getNode) }
.cachedIn(viewModelScope)
+ val frequentEmojis: List
+ get() =
+ customEmojiPrefs.customEmojiFrequency
+ ?.split(",")
+ ?.associate { entry ->
+ entry.split("=", limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1].toInt() } ?: ("" to 0)
+ }
+ ?.toList()
+ ?.sortedByDescending { it.second }
+ ?.map { it.first }
+ ?.take(6) ?: listOf("👍", "👎", "😂", "🔥", "❤️", "😮")
+
init {
val contactKey = savedStateHandle.get("contactKey")
if (contactKey != null) {
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt
new file mode 100644
index 000000000..02d06a936
--- /dev/null
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt
@@ -0,0 +1,156 @@
+/*
+ * Copyright (c) 2025-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.component
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AddReaction
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Reply
+import androidx.compose.material.icons.filled.SelectAll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.copy
+import org.meshtastic.core.strings.delete
+import org.meshtastic.core.strings.reply
+import org.meshtastic.core.strings.select
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MessageActionsBottomSheet(
+ quickEmojis: List,
+ onDismiss: () -> Unit,
+ onReply: () -> Unit,
+ onReact: (String) -> Unit,
+ onMoreReactions: () -> Unit,
+ onCopy: () -> Unit,
+ onSelect: () -> Unit,
+ onDelete: () -> Unit,
+) {
+ ModalBottomSheet(onDismissRequest = onDismiss) {
+ MessageActionsContent(
+ quickEmojis = quickEmojis,
+ onReply = onReply,
+ onReact = onReact,
+ onMoreReactions = onMoreReactions,
+ onCopy = onCopy,
+ onSelect = onSelect,
+ onDelete = onDelete,
+ )
+ }
+}
+
+@Composable
+fun MessageActionsContent(
+ quickEmojis: List,
+ onReply: () -> Unit,
+ onReact: (String) -> Unit,
+ onMoreReactions: () -> Unit,
+ onCopy: () -> Unit,
+ onSelect: () -> Unit,
+ onDelete: () -> Unit,
+) {
+ Column {
+ QuickEmojiRow(quickEmojis = quickEmojis, onReact = onReact, onMoreReactions = onMoreReactions)
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+
+ ListItem(
+ headlineContent = { Text(stringResource(Res.string.reply)) },
+ leadingContent = { Icon(Icons.Default.Reply, contentDescription = stringResource(Res.string.reply)) },
+ modifier = Modifier.clickable(onClick = onReply),
+ )
+
+ ListItem(
+ headlineContent = { Text(stringResource(Res.string.copy)) },
+ leadingContent = { Icon(Icons.Default.ContentCopy, contentDescription = stringResource(Res.string.copy)) },
+ modifier = Modifier.clickable(onClick = onCopy),
+ )
+
+ ListItem(
+ headlineContent = { Text(stringResource(Res.string.select)) },
+ leadingContent = { Icon(Icons.Default.SelectAll, contentDescription = stringResource(Res.string.select)) },
+ modifier = Modifier.clickable(onClick = onSelect),
+ )
+
+ ListItem(
+ headlineContent = { Text(stringResource(Res.string.delete)) },
+ leadingContent = { Icon(Icons.Default.Delete, contentDescription = stringResource(Res.string.delete)) },
+ modifier = Modifier.clickable(onClick = onDelete),
+ )
+ }
+}
+
+private const val MAX_EMOJI_ROW_SIZE = 6
+
+@Composable
+private fun QuickEmojiRow(quickEmojis: List, onReact: (String) -> Unit, onMoreReactions: () -> Unit) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ quickEmojis.take(MAX_EMOJI_ROW_SIZE).forEach { emoji ->
+ Box(
+ modifier =
+ Modifier.size(40.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .clickable { onReact(emoji) },
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(text = emoji, fontSize = 20.sp)
+ }
+ }
+
+ IconButton(
+ onClick = onMoreReactions,
+ modifier = Modifier.size(40.dp).background(MaterialTheme.colorScheme.surfaceVariant, CircleShape),
+ ) {
+ Icon(
+ Icons.Default.AddReaction,
+ contentDescription = "More reactions",
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageBubble.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageBubble.kt
new file mode 100644
index 000000000..eaab35962
--- /dev/null
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageBubble.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.component
+
+import androidx.compose.foundation.shape.CornerBasedShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+internal fun getMessageBubbleShape(
+ cornerRadius: Dp,
+ isSender: Boolean,
+ hasSamePrev: Boolean = false,
+ hasSameNext: Boolean = false,
+): CornerBasedShape {
+ val square = 0.dp
+ val round = cornerRadius
+
+ return if (isSender) {
+ RoundedCornerShape(
+ topStart = if (hasSamePrev) square else round,
+ topEnd = if (hasSamePrev) square else round,
+ bottomStart = if (hasSameNext) square else round,
+ bottomEnd = square,
+ )
+ } else {
+ RoundedCornerShape(
+ topStart = if (hasSamePrev) square else round,
+ topEnd = if (hasSamePrev) square else round,
+ bottomStart = square,
+ bottomEnd = if (hasSameNext) square else round,
+ )
+ }
+}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
index 58554b6c0..f48407dc4 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
@@ -26,23 +26,44 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.FormatQuote
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardColors
+import androidx.compose.material.icons.twotone.AddLink
+import androidx.compose.material.icons.twotone.Cloud
+import androidx.compose.material.icons.twotone.CloudDone
+import androidx.compose.material.icons.twotone.CloudOff
+import androidx.compose.material.icons.twotone.CloudUpload
+import androidx.compose.material.icons.twotone.HowToReg
+import androidx.compose.material.icons.twotone.Link
+import androidx.compose.material.icons.twotone.Warning
import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
+import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
@@ -54,6 +75,7 @@ import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.hops_away_template
+import org.meshtastic.core.strings.message_delivery_status
import org.meshtastic.core.strings.reply
import org.meshtastic.core.strings.sample_message
import org.meshtastic.core.strings.via_mqtt
@@ -62,9 +84,11 @@ import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.component.Rssi
import org.meshtastic.core.ui.component.Snr
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
+import org.meshtastic.core.ui.emoji.EmojiPicker
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.MessageItemColors
+@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
internal fun MessageItem(
@@ -73,95 +97,190 @@ internal fun MessageItem(
ourNode: Node,
message: Message,
selected: Boolean,
+ inSelectionMode: Boolean = false,
onReply: () -> Unit = {},
sendReaction: (String) -> Unit = {},
onShowReactions: () -> Unit = {},
+ showUserName: Boolean = true,
emojis: List = emptyList(),
+ quickEmojis: List = listOf("👍", "👎", "😂", "🔥", "❤️", "😮"),
onClick: () -> Unit = {},
onLongClick: () -> Unit = {},
onDoubleClick: () -> Unit = {},
+ onSelect: () -> Unit = {},
+ onDelete: () -> Unit = {},
onClickChip: (Node) -> Unit = {},
- onStatusClick: () -> Unit = {},
onNavigateToOriginalMessage: (Int) -> Unit = {},
-) = Column(
- modifier =
- modifier
- .fillMaxWidth()
- .background(color = if (selected) Color.Gray else MaterialTheme.colorScheme.background),
-) {
+ onStatusClick: () -> Unit = {},
+ hasSamePrev: Boolean = false,
+ hasSameNext: Boolean = false,
+) = Column(modifier = modifier.padding(top = if (showUserName) 32.dp else 4.dp)) {
+ var activeSheet by remember { mutableStateOf(null) }
+ val clipboardManager = LocalClipboardManager.current
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ if (activeSheet != null) {
+ ModalBottomSheet(onDismissRequest = { activeSheet = null }, sheetState = sheetState) {
+ when (activeSheet) {
+ ActiveSheet.Actions -> {
+ MessageActionsContent(
+ quickEmojis = quickEmojis,
+ onReply = {
+ activeSheet = null
+ onReply()
+ },
+ onReact = { emoji ->
+ activeSheet = null
+ sendReaction(emoji)
+ },
+ onMoreReactions = { activeSheet = ActiveSheet.Emoji },
+ onCopy = {
+ activeSheet = null
+ clipboardManager.setText(AnnotatedString(message.text))
+ },
+ onSelect = {
+ activeSheet = null
+ onSelect()
+ },
+ onDelete = {
+ activeSheet = null
+ onDelete()
+ },
+ )
+ }
+
+ ActiveSheet.Emoji -> {
+ // Limit height of emoji picker so it doesn't look weird full screen
+ Box(modifier = Modifier.heightIn(max = 400.dp)) {
+ EmojiPicker(
+ onDismiss = { activeSheet = null },
+ onConfirm = { emoji ->
+ activeSheet = null
+ sendReaction(emoji)
+ },
+ )
+ }
+ }
+
+ null -> {}
+ }
+ }
+ }
+
val containsBel = message.text.contains('\u0007')
+
+ val alpha =
+ if (inSelectionMode) {
+ if (selected) SELECTED_ALPHA else UNSELECTED_ALPHA
+ } else {
+ NORMAL_ALPHA
+ }
val containerColor =
- Color(
- if (message.fromLocal) {
- ourNode.colors.second
- } else {
- node.colors.second
- },
- )
- .copy(alpha = 0.2f)
+ if (message.fromLocal) {
+ Color(ourNode.colors.second).copy(alpha = alpha)
+ } else {
+ Color(node.colors.second).copy(alpha = alpha)
+ }
+ .apply {
+ if (inSelectionMode) {
+ copy(alpha = if (selected) 0.6f else 0.2f)
+ } else {
+ copy(alpha = 0.4f)
+ }
+ }
val cardColors =
CardDefaults.cardColors()
.copy(containerColor = containerColor, contentColor = contentColorFor(containerColor))
+ val messageShape =
+ getMessageBubbleShape(
+ cornerRadius = 16.dp,
+ isSender = message.fromLocal,
+ hasSamePrev = hasSamePrev,
+ hasSameNext = hasSameNext,
+ )
val messageModifier =
- Modifier.padding(start = 8.dp, top = 8.dp, end = 8.dp)
+ Modifier.padding(horizontal = 8.dp)
.then(
if (containsBel) {
- Modifier.border(2.dp, MessageItemColors.Red, shape = MaterialTheme.shapes.medium)
+ Modifier.border(2.dp, color = MessageItemColors.Red, shape = messageShape)
} else {
Modifier
},
)
- Box {
- Card(
+ Box(modifier = Modifier.wrapContentSize()) {
+ Surface(
modifier =
- Modifier.align(if (message.fromLocal) Alignment.BottomEnd else Alignment.BottomStart)
+ Modifier.align(if (message.fromLocal) Alignment.TopEnd else Alignment.TopStart)
.padding(
- top = 4.dp,
start = if (!message.fromLocal) 0.dp else 16.dp,
end = if (message.fromLocal) 0.dp else 16.dp,
)
- .combinedClickable(onClick = onClick, onLongClick = onLongClick, onDoubleClick = onDoubleClick)
- .then(messageModifier),
- colors = cardColors,
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = {
+ onLongClick()
+ if (!inSelectionMode) {
+ activeSheet = ActiveSheet.Actions
+ }
+ },
+ onDoubleClick = onDoubleClick,
+ )
+ .then(messageModifier)
+ .semantics(mergeDescendants = true) {
+ val senderName = if (message.fromLocal) ourNode.user.longName else node.user.longName
+ contentDescription = "Message from $senderName: ${message.text}"
+ },
+ color = containerColor,
+ contentColor = contentColorFor(containerColor),
+ shape = messageShape,
) {
- Column(modifier = Modifier.fillMaxWidth()) {
+ val hasPrevPadding =
+ if (hasSamePrev) {
+ 12.dp
+ } else {
+ 0.dp
+ }
+ Column(modifier = Modifier.fillMaxWidth().padding(top = hasPrevPadding)) {
OriginalMessageSnippet(
message = message,
ourNode = ourNode,
- cardColors = cardColors,
onNavigateToOriginalMessage = onNavigateToOriginalMessage,
)
Row(
- modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
- val chipNode = if (message.fromLocal) ourNode else node
- NodeChip(node = chipNode, onClick = onClickChip)
- Text(
- text = with(if (message.fromLocal) ourNode.user else node.user) { "$longName ($id)" },
- overflow = TextOverflow.Ellipsis,
- maxLines = 1,
- style = MaterialTheme.typography.labelMedium,
- modifier = Modifier.weight(1f, fill = true),
- )
- if (message.viaMqtt) {
- Icon(
- Icons.Default.Cloud,
- contentDescription = stringResource(Res.string.via_mqtt),
- modifier = Modifier.size(16.dp),
- )
+ if (showUserName) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ val chipNode = if (message.fromLocal) ourNode else node
+ NodeChip(node = chipNode, onClick = onClickChip)
+ Text(
+ text = (if (message.fromLocal) ourNode.user else node.user).longName,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ style = MaterialTheme.typography.labelMedium,
+ )
+ if (message.viaMqtt) {
+ Icon(
+ Icons.Default.Cloud,
+ contentDescription = stringResource(Res.string.via_mqtt),
+ modifier = Modifier.size(16.dp),
+ )
+ }
+ }
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ Text(text = message.time, style = MaterialTheme.typography.labelSmall)
+ if (message.fromLocal) {
+ Spacer(modifier = Modifier.size(4.dp))
+ MessageStatusIcon(status = message.status ?: MessageStatus.UNKNOWN, onClick = onStatusClick)
}
- MessageActions(
- isLocal = message.fromLocal,
- status = message.status,
- onSendReaction = sendReaction,
- onSendReply = onReply,
- onStatusClick = onStatusClick,
- )
}
- Column(modifier = Modifier.padding(horizontal = 8.dp)) {
+ Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) {
AutoLinkText(
modifier = Modifier.fillMaxWidth(),
text = message.text,
@@ -169,12 +288,7 @@ internal fun MessageItem(
color = cardColors.contentColor,
)
- val topPadding = if (!message.fromLocal) 2.dp else 0.dp
- Row(
- modifier = Modifier.fillMaxWidth().padding(top = topPadding, bottom = 4.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
if (!message.fromLocal) {
if (message.hopsAway == 0) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -188,52 +302,83 @@ internal fun MessageItem(
)
}
}
- Spacer(modifier = Modifier.weight(1f))
- Row(verticalAlignment = Alignment.CenterVertically) {
- if (containsBel) {
- Text(text = "\uD83D\uDD14", modifier = Modifier.padding(end = 4.dp))
- }
- Text(text = message.time, style = MaterialTheme.typography.labelSmall)
+ if (containsBel) {
+ Text(text = "\uD83D\uDD14", modifier = Modifier.padding(end = 4.dp))
}
}
}
}
}
+
+ Box(
+ modifier =
+ Modifier.align(if (message.fromLocal) Alignment.BottomEnd else Alignment.BottomStart)
+ .padding(horizontal = 24.dp)
+ .offset(y = 24.dp),
+ ) {
+ ReactionRow(
+ reactions = emojis,
+ myId = ourNode.user.id,
+ onSendReaction = sendReaction,
+ onShowReactions = onShowReactions,
+ )
+ }
}
- ReactionRow(
- modifier = Modifier.fillMaxWidth(),
- reactions = emojis,
- myId = ourNode.user.id,
- onSendReaction = sendReaction,
- onShowReactions = onShowReactions,
+}
+
+private const val SELECTED_ALPHA = 0.6f
+private const val UNSELECTED_ALPHA = 0.2f
+private const val NORMAL_ALPHA = 0.4f
+
+private enum class ActiveSheet {
+ Actions,
+ Emoji,
+}
+
+@Composable
+private fun MessageStatusIcon(status: MessageStatus, onClick: () -> Unit) {
+ val icon =
+ when (status) {
+ MessageStatus.RECEIVED -> Icons.TwoTone.HowToReg
+ MessageStatus.QUEUED -> Icons.TwoTone.CloudUpload
+ MessageStatus.DELIVERED -> Icons.TwoTone.CloudDone
+ MessageStatus.SFPP_ROUTING -> Icons.TwoTone.AddLink
+ MessageStatus.SFPP_CONFIRMED -> Icons.TwoTone.Link
+ MessageStatus.ENROUTE -> Icons.TwoTone.Cloud
+ MessageStatus.ERROR -> Icons.TwoTone.CloudOff
+ else -> Icons.TwoTone.Warning
+ }
+ Icon(
+ imageVector = icon,
+ contentDescription = stringResource(Res.string.message_delivery_status),
+ modifier = Modifier.size(24.dp).clickable(onClick = onClick),
)
}
@Composable
-private fun OriginalMessageSnippet(
- message: Message,
- ourNode: Node,
- cardColors: CardColors = CardDefaults.cardColors(),
- onNavigateToOriginalMessage: (Int) -> Unit,
-) {
+private fun OriginalMessageSnippet(message: Message, ourNode: Node, onNavigateToOriginalMessage: (Int) -> Unit) {
val originalMessage = message.originalMessage
if (originalMessage != null && originalMessage.packetId != 0) {
val originalMessageNode = if (originalMessage.fromLocal) ourNode else originalMessage.node
+ val cardColors =
+ CardDefaults.cardColors()
+ .copy(
+ containerColor = Color(originalMessageNode.colors.second).copy(alpha = 0.8f),
+ contentColor = Color(originalMessageNode.colors.first),
+ )
OutlinedCard(
- modifier =
- Modifier.fillMaxWidth().padding(4.dp).clickable {
- onNavigateToOriginalMessage(originalMessage.packetId)
- },
+ modifier = Modifier.fillMaxWidth().clickable { onNavigateToOriginalMessage(originalMessage.packetId) },
colors = cardColors,
) {
Row(
- modifier = Modifier.padding(horizontal = 4.dp),
+ modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
Icons.Default.FormatQuote,
- contentDescription = stringResource(Res.string.reply), // Add to strings.xml
+ contentDescription = stringResource(Res.string.reply),
+ modifier = Modifier.size(16.dp),
)
Text(
text = originalMessageNode.user.shortName,
@@ -243,11 +388,11 @@ private fun OriginalMessageSnippet(
overflow = TextOverflow.Ellipsis,
)
Text(
- modifier = Modifier.weight(1f, fill = true),
- text = originalMessage.text, // Should not be null if isAReply is true
+ text = originalMessage.text,
style = MaterialTheme.typography.bodySmall,
- maxLines = 1, // Keep snippet brief
+ maxLines = 1,
overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(start = 20.dp),
)
}
}
@@ -321,30 +466,45 @@ private fun MessageItemPreview() {
message = sent,
node = sent.node,
selected = false,
+ ourNode = sent.node,
+ onReply = {},
+ sendReaction = {},
+ onShowReactions = {},
onClick = {},
onLongClick = {},
- onStatusClick = {},
- ourNode = sent.node,
+ onDoubleClick = {},
+ onClickChip = {},
+ onNavigateToOriginalMessage = {},
)
MessageItem(
message = received,
node = received.node,
selected = false,
+ ourNode = sent.node,
+ onReply = {},
+ sendReaction = {},
+ onShowReactions = {},
onClick = {},
onLongClick = {},
- onStatusClick = {},
- ourNode = sent.node,
+ onDoubleClick = {},
+ onClickChip = {},
+ onNavigateToOriginalMessage = {},
)
MessageItem(
message = receivedWithOriginalMessage,
node = receivedWithOriginalMessage.node,
selected = false,
+ ourNode = sent.node,
+ onReply = {},
+ sendReaction = {},
+ onShowReactions = {},
onClick = {},
onLongClick = {},
- onStatusClick = {},
- ourNode = sent.node,
+ onDoubleClick = {},
+ onClickChip = {},
+ onNavigateToOriginalMessage = {},
)
}
}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt
index 3bc2bdbcc..701fa34f8 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt
@@ -77,6 +77,7 @@ import org.meshtastic.proto.MeshProtos
@Composable
private fun ReactionItem(
+ modifier: Modifier = Modifier,
emoji: String,
emojiCount: Int = 1,
status: MessageStatus = MessageStatus.UNKNOWN,
@@ -87,6 +88,7 @@ private fun ReactionItem(
val isError = status == MessageStatus.ERROR
BadgedBox(
+ modifier = modifier,
badge = {
if (emojiCount > 1) {
Badge { Text(fontWeight = FontWeight.Bold, text = emojiCount.toString()) }
@@ -121,14 +123,11 @@ internal fun ReactionRow(
val emojiGroups = reactions.groupBy { it.emoji }
AnimatedVisibility(emojiGroups.isNotEmpty()) {
- LazyRow(
- modifier = modifier.padding(horizontal = 4.dp),
- horizontalArrangement = Arrangement.Start,
- verticalAlignment = Alignment.CenterVertically,
- ) {
+ LazyRow(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
items(emojiGroups.entries.toList()) { (emoji, reactions) ->
val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId }
ReactionItem(
+ modifier = Modifier.padding(horizontal = 4.dp),
emoji = emoji,
emojiCount = reactions.size,
status = localReaction?.status ?: MessageStatus.RECEIVED,