diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/MessageList.kt b/app/src/main/java/com/geeksville/mesh/ui/message/MessageList.kt index 2e6b3d083..1889f451f 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/MessageList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/MessageList.kt @@ -41,8 +41,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -118,7 +116,6 @@ internal fun MessageList( contactKey: String, onReply: (Message?) -> Unit, ) { - val haptics = LocalHapticFeedback.current val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } } AutoScrollToBottom(listState, messages) UpdateUnreadCount(listState, messages, onUnreadChanged) @@ -169,11 +166,6 @@ internal fun MessageList( ourNode = ourNode!!, message = msg, selected = selected, - onClick = { if (inSelectionMode) selectedIds.toggle(msg.uuid) }, - onLongClick = { - selectedIds.toggle(msg.uuid) - haptics.performHapticFeedback(HapticFeedbackType.LongPress) - }, onAction = onNodeMenuAction, onStatusClick = { showStatusDialog = msg }, onReply = { onReply(msg) }, diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageActions.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageActions.kt deleted file mode 100644 index 65350205b..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageActions.kt +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright (c) 2025 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 com.geeksville.mesh.ui.message.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Reply -import androidx.compose.material.icons.filled.EmojiEmotions -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.Warning -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -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.Modifier -import androidx.compose.ui.res.stringResource -import com.geeksville.mesh.MessageStatus -import com.geeksville.mesh.R -import com.geeksville.mesh.ui.common.components.EmojiPickerDialog - -@Composable -fun ReactionButton( - onSendReaction: (String) -> Unit = {}, -) { - var showEmojiPickerDialog by remember { mutableStateOf(false) } - if (showEmojiPickerDialog) { - EmojiPickerDialog( - onConfirm = { selectedEmoji -> - showEmojiPickerDialog = false - onSendReaction(selectedEmoji) - }, - onDismiss = { showEmojiPickerDialog = false } - ) - } - IconButton( - onClick = { showEmojiPickerDialog = true }, - ) { - Icon( - imageVector = Icons.Default.EmojiEmotions, - contentDescription = stringResource(R.string.react), - ) - } -} - -@Composable -fun ReplyButton( - onClick: () -> Unit = {}, -) = IconButton( - onClick = onClick, - content = { - Icon( - imageVector = Icons.AutoMirrored.Filled.Reply, - contentDescription = stringResource(R.string.reply), - ) - } -) - -@Composable -fun MessageStatusButton( - onStatusClick: () -> Unit = {}, - status: MessageStatus, - fromLocal: Boolean, -) = AnimatedVisibility(visible = fromLocal) { - IconButton( - onClick = onStatusClick - ) { - Icon( - imageVector = when (status) { - MessageStatus.RECEIVED -> Icons.TwoTone.HowToReg - MessageStatus.QUEUED -> Icons.TwoTone.CloudUpload - MessageStatus.DELIVERED -> Icons.TwoTone.CloudDone - MessageStatus.ENROUTE -> Icons.TwoTone.Cloud - MessageStatus.ERROR -> Icons.TwoTone.CloudOff - else -> Icons.TwoTone.Warning - }, - contentDescription = stringResource(R.string.message_delivery_status), - ) - } -} - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -fun MessageActions( - modifier: Modifier = Modifier, - isLocal: Boolean = false, - status: MessageStatus?, - onSendReaction: (String) -> Unit = {}, - onSendReply: () -> Unit = {}, - onStatusClick: () -> Unit = {}, -) { - Row( - modifier = modifier.wrapContentSize(), - ) { - - ReactionButton { onSendReaction(it) } - ReplyButton { onSendReply() } - MessageStatusButton( - onStatusClick = onStatusClick, - status = status ?: MessageStatus.UNKNOWN, - fromLocal = isLocal - ) - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageActionsDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageActionsDialog.kt new file mode 100644 index 000000000..e4b938fb3 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageActionsDialog.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 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 com.geeksville.mesh.ui.message.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Reply +import androidx.compose.material.icons.rounded.EmojiEmotions +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.Warning +import androidx.compose.material3.Card +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.window.Dialog +import com.geeksville.mesh.MessageStatus +import com.geeksville.mesh.R +import com.geeksville.mesh.ui.common.theme.AppTheme +import com.geeksville.mesh.ui.settings.components.SettingsItem + +@Composable +fun MessageActionsDialog( + status: MessageStatus?, + onDismiss: () -> Unit = {}, + onShowEmojiDialog: () -> Unit = {}, + onClickReply: () -> Unit, + onClickStatus: () -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + Card { + SettingsItem( + leadingIcon = Icons.Rounded.EmojiEmotions, + text = stringResource(R.string.react), + trailingIcon = null, + ) { + onShowEmojiDialog() + onDismiss() + } + + SettingsItem( + leadingIcon = Icons.AutoMirrored.Rounded.Reply, + text = stringResource(R.string.reply), + trailingIcon = null, + ) { + onClickReply() + onDismiss() + } + + status?.let { + SettingsItem( + leadingIcon = + when (it) { + MessageStatus.RECEIVED -> Icons.TwoTone.HowToReg + MessageStatus.QUEUED -> Icons.TwoTone.CloudUpload + MessageStatus.DELIVERED -> Icons.TwoTone.CloudDone + MessageStatus.ENROUTE -> Icons.TwoTone.Cloud + MessageStatus.ERROR -> Icons.TwoTone.CloudOff + else -> Icons.TwoTone.Warning + }, + text = stringResource(R.string.message_delivery_status), + trailingIcon = null, + ) { + onClickStatus() + onDismiss() + } + } + } + } +} + +@Preview +@Composable +private fun MessageActionsDialogPreview() { + AppTheme { + MessageActionsDialog( + status = null, + onDismiss = {}, + onShowEmojiDialog = {}, + onClickReply = {}, + onClickStatus = {}, + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt index f7ab4f738..ed5d49624 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt @@ -44,6 +44,10 @@ import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor 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 @@ -57,6 +61,7 @@ import com.geeksville.mesh.R import com.geeksville.mesh.database.entity.Reaction import com.geeksville.mesh.model.Message import com.geeksville.mesh.model.Node +import com.geeksville.mesh.ui.common.components.EmojiPickerDialog import com.geeksville.mesh.ui.common.components.MDText import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider import com.geeksville.mesh.ui.common.theme.AppTheme @@ -76,19 +81,35 @@ internal fun MessageItem( sendReaction: (String) -> Unit = {}, onShowReactions: () -> Unit = {}, emojis: List = emptyList(), - onClick: () -> Unit = {}, - onLongClick: () -> Unit = {}, onAction: (NodeMenuAction) -> Unit = {}, onStatusClick: () -> Unit = {}, isConnected: Boolean, onNavigateToOriginalMessage: (Int) -> Unit = {}, showNodeInfo: Boolean, -) = Column( - modifier = - modifier - .fillMaxWidth() - .background(color = if (selected) Color.Gray else MaterialTheme.colorScheme.background), -) { +) = Column(modifier = modifier.fillMaxWidth()) { + var showMessageActionsDialog by remember { mutableStateOf(false) } + var showEmojiPickerDialog by remember { mutableStateOf(false) } + + if (showMessageActionsDialog) { + MessageActionsDialog( + status = if (message.fromLocal) message.status else null, + onDismiss = { showMessageActionsDialog = false }, + onShowEmojiDialog = { showEmojiPickerDialog = true }, + onClickReply = { onReply() }, + onClickStatus = onStatusClick, + ) + } + + if (showEmojiPickerDialog) { + EmojiPickerDialog( + onConfirm = { selectedEmoji -> + showEmojiPickerDialog = false + sendReaction(selectedEmoji) + }, + onDismiss = { showEmojiPickerDialog = false }, + ) + } + val containsBel = message.text.contains('\u0007') val containerColor = Color( @@ -132,7 +153,12 @@ internal fun MessageItem( Box( modifier = Modifier.fillMaxWidth() - .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .combinedClickable( + interactionSource = null, + indication = null, + onLongClick = { showMessageActionsDialog = true }, + onClick = {}, + ) .padding(horizontal = 8.dp, vertical = 4.dp), ) { @Suppress("MagicNumber") @@ -183,13 +209,6 @@ internal fun MessageItem( modifier = Modifier.size(12.dp), ) } - /* MessageActions( - isLocal = message.fromLocal, - status = message.status, - onSendReaction = sendReaction, - onSendReply = onReply, - onStatusClick = onStatusClick, - )*/ } } } @@ -328,8 +347,6 @@ private fun MessageItemPreview() { message = sent, node = sent.node, selected = false, - onClick = {}, - onLongClick = {}, onStatusClick = {}, isConnected = true, ourNode = sent.node, @@ -340,8 +357,6 @@ private fun MessageItemPreview() { message = received, node = received.node, selected = false, - onClick = {}, - onLongClick = {}, onStatusClick = {}, isConnected = true, ourNode = sent.node, @@ -352,8 +367,6 @@ private fun MessageItemPreview() { message = receivedWithOriginalMessage, node = receivedWithOriginalMessage.node, selected = false, - onClick = {}, - onLongClick = {}, onStatusClick = {}, isConnected = true, ourNode = sent.node, diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/Reaction.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/Reaction.kt index 4742a4461..61bf5004e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/Reaction.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/Reaction.kt @@ -57,39 +57,20 @@ import com.geeksville.mesh.ui.common.components.BottomSheetDialog import com.geeksville.mesh.ui.common.theme.AppTheme @Composable -private fun ReactionItem( - emoji: String, - emojiCount: Int = 1, - onClick: () -> Unit = {}, - onLongClick: () -> Unit = {}, -) { +private fun ReactionItem(emoji: String, emojiCount: Int = 1, onClick: () -> Unit = {}, onLongClick: () -> Unit = {}) { BadgedBox( badge = { if (emojiCount > 1) { - Badge { - Text( - fontWeight = FontWeight.Bold, - text = emojiCount.toString() - ) - } + Badge { Text(fontWeight = FontWeight.Bold, text = emojiCount.toString()) } } - } + }, ) { Surface( - modifier = Modifier - .combinedClickable( - onClick = onClick, - onLongClick = onLongClick - ), + modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick), color = MaterialTheme.colorScheme.primaryContainer, shape = CircleShape, ) { - Text( - text = emoji, - modifier = Modifier - .padding(4.dp) - .clip(CircleShape), - ) + Text(text = emoji, modifier = Modifier.padding(4.dp).clip(CircleShape)) } } } @@ -102,10 +83,7 @@ fun ReactionRow( onSendReaction: (String) -> Unit = {}, onShowReactions: () -> Unit = {}, ) { - val emojiList = - reduceEmojis( - reactions.reversed().map { it.emoji } - ).entries + val emojiList = reduceEmojis(reactions.reversed().map { it.emoji }).entries AnimatedVisibility(emojiList.isNotEmpty()) { LazyRow( @@ -113,16 +91,12 @@ fun ReactionRow( horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, ) { - items( - emojiList.size - ) { index -> + items(emojiList.size) { index -> val entry = emojiList.elementAt(index) ReactionItem( emoji = entry.key, emojiCount = entry.value, - onClick = { - onSendReaction(entry.key) - }, + onClick = { onSendReaction(entry.key) }, onLongClick = onShowReactions, ) } @@ -133,71 +107,49 @@ fun ReactionRow( fun reduceEmojis(emojis: List): Map = emojis.groupingBy { it }.eachCount() @Composable -fun ReactionDialog( - reactions: List, - onDismiss: () -> Unit = {} -) = BottomSheetDialog( - onDismiss = onDismiss, - modifier = Modifier.fillMaxHeight(fraction = .3f), -) { - val groupedEmojis = reactions.groupBy { it.emoji } - var selectedEmoji by remember { mutableStateOf(null) } - val filteredReactions = selectedEmoji?.let { groupedEmojis[it] ?: emptyList() } ?: reactions +fun ReactionDialog(reactions: List, onDismiss: () -> Unit = {}) = + BottomSheetDialog(onDismiss = onDismiss, modifier = Modifier.fillMaxHeight(fraction = .3f)) { + val groupedEmojis = reactions.groupBy { it.emoji } + var selectedEmoji by remember { mutableStateOf(null) } + val filteredReactions = selectedEmoji?.let { groupedEmojis[it] ?: emptyList() } ?: reactions - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - items(groupedEmojis.entries.toList()) { (emoji, reactions) -> - Text( - text = "$emoji${reactions.size}", - modifier = Modifier - .clip(CircleShape) - .background(if (selectedEmoji == emoji) Color.Gray else Color.Transparent) - .padding(8.dp) - .clickable { - selectedEmoji = if (selectedEmoji == emoji) null else emoji - }, - style = MaterialTheme.typography.bodyMedium - ) - } - } - - HorizontalDivider(Modifier.padding(vertical = 8.dp)) - - LazyColumn( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(filteredReactions) { reaction -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + items(groupedEmojis.entries.toList()) { (emoji, reactions) -> Text( - text = reaction.user.longName, - style = MaterialTheme.typography.titleMedium - ) - Text( - text = reaction.emoji, - style = MaterialTheme.typography.titleLarge + text = "$emoji${reactions.size}", + modifier = + Modifier.clip(CircleShape) + .background(if (selectedEmoji == emoji) Color.Gray else Color.Transparent) + .padding(8.dp) + .clickable { selectedEmoji = if (selectedEmoji == emoji) null else emoji }, + style = MaterialTheme.typography.bodyMedium, ) } } + + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + + LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + items(filteredReactions) { reaction -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = reaction.user.longName, style = MaterialTheme.typography.titleMedium) + Text(text = reaction.emoji, style = MaterialTheme.typography.titleLarge) + } + } + } } -} @PreviewLightDark @Composable fun ReactionItemPreview() { AppTheme { - Column( - modifier = Modifier.background(MaterialTheme.colorScheme.background) - ) { + Column(modifier = Modifier.background(MaterialTheme.colorScheme.background)) { ReactionItem(emoji = "\uD83D\uDE42") ReactionItem(emoji = "\uD83D\uDE42", emojiCount = 2) - ReactionButton() } } } @@ -207,20 +159,21 @@ fun ReactionItemPreview() { fun ReactionRowPreview() { AppTheme { ReactionRow( - reactions = listOf( + reactions = + listOf( Reaction( replyId = 1, user = MeshProtos.User.getDefaultInstance(), emoji = "\uD83D\uDE42", - timestamp = 1L + timestamp = 1L, ), Reaction( replyId = 1, user = MeshProtos.User.getDefaultInstance(), emoji = "\uD83D\uDE42", - timestamp = 1L + timestamp = 1L, ), - ) + ), ) } }