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,