diff --git a/app/src/main/java/com/geeksville/mesh/DataPacket.kt b/app/src/main/java/com/geeksville/mesh/DataPacket.kt index 58329eaa2..398db58da 100644 --- a/app/src/main/java/com/geeksville/mesh/DataPacket.kt +++ b/app/src/main/java/com/geeksville/mesh/DataPacket.kt @@ -62,6 +62,7 @@ data class DataPacket( var hopStart: Int = 0, var snr: Float = 0f, var rssi: Int = 0, + var replyId: Int? = null // If this is a reply to a previous message, this is the ID of that message ) : Parcelable { /** @@ -72,11 +73,12 @@ data class DataPacket( /** * Syntactic sugar to make it easy to create text messages */ - constructor(to: String?, channel: Int, text: String) : this( + constructor(to: String?, channel: Int, text: String, replyId: Int? = null) : this( to = to, bytes = text.encodeToByteArray(), dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, - channel = channel + channel = channel, + replyId = replyId ?: 0 ) /** @@ -100,7 +102,7 @@ data class DataPacket( to = to, bytes = waypoint.toByteArray(), dataType = Portnums.PortNum.WAYPOINT_APP_VALUE, - channel = channel + channel = channel, ) val waypoint: MeshProtos.Waypoint? @@ -129,6 +131,7 @@ data class DataPacket( parcel.readInt(), parcel.readFloat(), parcel.readInt(), + if (parcel.readInt() == 0) null else parcel.readInt() ) @Suppress("CyclomaticComplexMethod") @@ -151,6 +154,7 @@ data class DataPacket( if (hopStart != other.hopStart) return false if (snr != other.snr) return false if (rssi != other.rssi) return false + if (replyId != other.replyId) return false return true } @@ -169,6 +173,7 @@ data class DataPacket( result = 31 * result + hopStart result = 31 * result + snr.hashCode() result = 31 * result + rssi + result = 31 * result + replyId.hashCode() return result } @@ -186,6 +191,7 @@ data class DataPacket( parcel.writeInt(hopStart) parcel.writeFloat(snr) parcel.writeInt(rssi) + parcel.writeInt(replyId ?: 0) } override fun describeContents(): Int { @@ -207,6 +213,7 @@ data class DataPacket( hopStart = parcel.readInt() snr = parcel.readFloat() rssi = parcel.readInt() + replyId = parcel.readInt().let { if (it == 0) null else it } } companion object CREATOR : Parcelable.Creator { diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt b/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt index d6acdd113..c5f141166 100644 --- a/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt +++ b/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt @@ -49,6 +49,7 @@ data class PacketEntity( routingError = routingError, packetId = packetId, emojis = reactions.toReaction(getNode), + replyId = data.replyId ) } } diff --git a/app/src/main/java/com/geeksville/mesh/model/Message.kt b/app/src/main/java/com/geeksville/mesh/model/Message.kt index 6bf699eb6..aa2a3dddb 100644 --- a/app/src/main/java/com/geeksville/mesh/model/Message.kt +++ b/app/src/main/java/com/geeksville/mesh/model/Message.kt @@ -59,6 +59,7 @@ data class Message( val snr: Float, val rssi: Int, val hopsAway: Int, + val replyId: Int?, ) { fun getStatusStringRes(): Pair { val title = if (routingError > 0) R.string.error else R.string.message_delivery_status diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 6b3c86bba..9f7729796 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -534,7 +534,7 @@ class UIViewModel @Inject constructor( } } - fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}") { + fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) { // contactKey: unique contact key filter (channel)+(nodeId) val channel = contactKey[0].digitToIntOrNull() val dest = if (channel != null) contactKey.substring(1) else contactKey @@ -547,7 +547,7 @@ class UIViewModel @Inject constructor( favoriteNode(nodeDB.getNode(dest)) } } - val p = DataPacket(dest, channel ?: 0, str) + val p = DataPacket(dest, channel ?: 0, str, replyId) sendDataPacket(p) } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index befa22567..cd60cee36 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -677,7 +677,8 @@ class MeshService : Service(), Logging { wantAck = packet.wantAck, hopStart = packet.hopStart, snr = packet.rxSnr, - rssi = packet.rxRssi + rssi = packet.rxRssi, + replyId = data.replyId, ) } } @@ -733,7 +734,8 @@ class MeshService : Service(), Logging { data = dataPacket, snr = dataPacket.snr, rssi = dataPacket.rssi, - hopsAway = dataPacket.hopsAway + hopsAway = dataPacket.hopsAway, + replyId = dataPacket.replyId ?: 0 ) serviceScope.handledLaunch { packetRepository.get().apply { @@ -772,7 +774,10 @@ class MeshService : Service(), Logging { when (data.portnumValue) { Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> { - if (data.emoji != 0) { + if (data.replyId != 0 && data.emoji == 0) { + debug("Received REPLY from $fromId") + rememberDataPacket(dataPacket) + } else if (data.replyId != 0 && data.emoji != 0) { debug("Received EMOJI from $fromId") rememberReaction(packet) } else { diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt index 9f942d284..0b3d4341e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt @@ -18,11 +18,15 @@ package com.geeksville.mesh.ui.message import android.content.ClipData +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background 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.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -30,7 +34,9 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.SelectAll @@ -57,6 +63,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType @@ -75,6 +82,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -82,6 +90,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.DataPacket import com.geeksville.mesh.R import com.geeksville.mesh.database.entity.QuickChatAction +import com.geeksville.mesh.model.Message import com.geeksville.mesh.model.Node import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.model.getChannel @@ -92,6 +101,7 @@ import com.geeksville.mesh.ui.sharing.SharedContactDialog import kotlinx.coroutines.launch private const val MESSAGE_CHARACTER_LIMIT = 200 +private const val SNIPPET_CHARACTER_LIMIT = 50 @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable @@ -137,6 +147,7 @@ internal fun MessageScreen( val messageInput = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(message)) } + var replyingTo by remember { mutableStateOf(null) } var showDeleteDialog by remember { mutableStateOf(false) } if (showDeleteDialog) { @@ -207,7 +218,53 @@ internal fun MessageScreen( viewModel.sendMessage(action.message, contactKey) } } - TextInput(isConnected, messageInput) { viewModel.sendMessage(it, contactKey) } + + AnimatedVisibility(visible = replyingTo != null) { + val fromLocal = replyingTo?.node?.user?.id == DataPacket.ID_LOCAL + + val replyingToNode = if (fromLocal) { + viewModel.ourNodeInfo.value + } else { + replyingTo?.node + } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.AutoMirrored.Default.Reply, + contentDescription = stringResource(R.string.reply) + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + "Replying to ${replyingToNode?.user?.shortName ?: stringResource(R.string.unknown)}", + style = MaterialTheme.typography.labelMedium + ) + Text( + replyingTo?.text?.take(SNIPPET_CHARACTER_LIMIT) + ?.let { if (it.length == SNIPPET_CHARACTER_LIMIT) "$it…" else it } ?: "", // Snippet + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + IconButton(onClick = { + replyingTo = null + }) { // ViewModel function to set replyingToMessageState to null + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.cancel)) + } + } + } + TextInput(isConnected, messageInput) { message -> + replyingTo?.let { + viewModel.sendMessage(message, contactKey, it.packetId) + replyingTo = null + } ?: viewModel.sendMessage(message, contactKey) + } } } ) { padding -> @@ -228,6 +285,7 @@ internal fun MessageScreen( onSendReaction = { emoji, id -> viewModel.sendReaction(emoji, id, contactKey) }, viewModel = viewModel, contactKey = contactKey, + onReply = { replyingTo = it }, onNodeMenuAction = { action -> when (action) { is NodeMenuAction.DirectMessage -> { 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 dc44c03da..60af1fbb3 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 @@ -18,11 +18,9 @@ package com.geeksville.mesh.ui.message import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items @@ -39,9 +37,9 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback @@ -60,6 +58,7 @@ import com.geeksville.mesh.ui.node.components.NodeMenuAction import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch @Composable fun DeliveryInfo( @@ -117,6 +116,7 @@ internal fun MessageList( onNodeMenuAction: (NodeMenuAction) -> Unit, viewModel: UIViewModel, contactKey: String, + onReply: (Message?) -> Unit, ) { val haptics = LocalHapticFeedback.current val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } } @@ -157,27 +157,28 @@ internal fun MessageList( } val nodes by viewModel.nodeList.collectAsStateWithLifecycle() + val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() val isConnected by viewModel.isConnected.collectAsStateWithLifecycle(false) - + val coroutineScope = rememberCoroutineScope() LazyColumn( modifier = modifier.fillMaxSize(), state = listState, reverseLayout = true, ) { items(messages, key = { it.uuid }) { msg -> - val selected by remember { derivedStateOf { selectedIds.value.contains(msg.uuid) } } - var node by remember { - mutableStateOf(nodes.find { it.num == msg.node.num } ?: msg.node) - } - LaunchedEffect(nodes) { - node = nodes.find { it.num == msg.node.num } ?: msg.node - } - Box(Modifier.wrapContentSize(Alignment.TopStart)) { + if (ourNode != null) { + val selected by remember { derivedStateOf { selectedIds.value.contains(msg.uuid) } } + var node by remember { + mutableStateOf(nodes.find { it.num == msg.node.num } ?: msg.node) + } + val originalMessage = messages.find { it.packetId == msg.replyId } + LaunchedEffect(nodes, messages) { + node = nodes.find { it.num == msg.node.num } ?: msg.node + } MessageItem( node = node, - messageText = msg.text, - messageTime = msg.time, - messageStatus = msg.status, + ourNode = ourNode!!, + message = msg, selected = selected, onClick = { if (inSelectionMode) selectedIds.toggle(msg.uuid) }, onLongClick = { @@ -186,19 +187,18 @@ internal fun MessageList( }, onAction = onNodeMenuAction, onStatusClick = { showStatusDialog = msg }, + onReply = { onReply(msg) }, emojis = msg.emojis, sendReaction = { onSendReaction(it, msg.packetId) }, onShowReactions = { showReactionDialog = msg.emojis }, isConnected = isConnected, - snr = msg.snr, - rssi = msg.rssi, - hopsAway = if (msg.hopsAway > 0) { - "%s: %d".format( - stringResource(id = R.string.hops_away), - msg.hopsAway - ) - } else { - null + originalMessage = originalMessage, + onNavigateToOriginalMessage = { + coroutineScope.launch { + listState.animateScrollToItem( + index = messages.indexOfFirst { it.packetId == msg.replyId } + ) + } } ) } 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 7b0a3ad66..07f05986c 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 @@ -27,9 +27,12 @@ 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.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FormatQuote import androidx.compose.material.icons.twotone.Cloud import androidx.compose.material.icons.twotone.CloudDone import androidx.compose.material.icons.twotone.CloudOff @@ -41,6 +44,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable @@ -48,6 +52,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp @@ -55,6 +60,7 @@ import com.geeksville.mesh.DataPacket import com.geeksville.mesh.MessageStatus 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.AutoLinkText import com.geeksville.mesh.ui.common.components.Rssi @@ -63,28 +69,28 @@ import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider import com.geeksville.mesh.ui.common.theme.AppTheme import com.geeksville.mesh.ui.node.components.NodeChip import com.geeksville.mesh.ui.node.components.NodeMenuAction +import kotlin.uuid.ExperimentalUuidApi @Suppress("LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable internal fun MessageItem( + modifier: Modifier = Modifier, node: Node, - messageText: String?, - messageTime: String, - messageStatus: MessageStatus?, - emojis: List = emptyList(), + ourNode: Node, + message: Message, + selected: Boolean, + onReply: () -> Unit = {}, sendReaction: (String) -> Unit = {}, onShowReactions: () -> Unit = {}, - selected: Boolean, - modifier: Modifier = Modifier, + emojis: List = emptyList(), onClick: () -> Unit = {}, onLongClick: () -> Unit = {}, onAction: (NodeMenuAction) -> Unit = {}, onStatusClick: () -> Unit = {}, isConnected: Boolean, - snr: Float, - rssi: Int, - hopsAway: String?, + originalMessage: Message? = null, + onNavigateToOriginalMessage: (Int) -> Unit = {}, ) = Column( modifier = modifier .fillMaxWidth() @@ -92,7 +98,7 @@ internal fun MessageItem( ) { val fromLocal = node.user.id == DataPacket.ID_LOCAL val messageColor = if (fromLocal) { - MaterialTheme.colorScheme.secondaryContainer + Color(ourNode.colors.second).copy(alpha = 0.25f) } else { Color(node.colors.second).copy(alpha = 0.25f) } @@ -100,6 +106,10 @@ internal fun MessageItem( Card( modifier = Modifier + .padding( + start = if (fromLocal) 0.dp else 8.dp, + end = if (!fromLocal) 0.dp else 8.dp, + ) .combinedClickable( onClick = onClick, onLongClick = onLongClick, @@ -112,12 +122,57 @@ internal fun MessageItem( ) { Column( modifier = Modifier - .fillMaxWidth() - .padding(8.dp), + .fillMaxWidth(), ) { + if (originalMessage != null) { + val originalMessageIsFromLocal = originalMessage.node.user.id == DataPacket.ID_LOCAL + val originalMessageNode = + if (originalMessageIsFromLocal) ourNode else originalMessage.node + OutlinedCard( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp) + .clickable { onNavigateToOriginalMessage(originalMessage.packetId) }, + colors = CardDefaults.cardColors( + containerColor = Color(originalMessageNode.colors.second).copy(alpha = 0.8f), + contentColor = Color(originalMessageNode.colors.first), + ), + ) { + Row( + modifier = Modifier.padding(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.FormatQuote, + contentDescription = stringResource(R.string.reply), // Add to strings.xml + modifier = Modifier.size(14.dp), // Smaller icon + ) + Spacer(Modifier.width(6.dp)) + Column { + Text( + text = "${originalMessageNode.user.shortName} ${originalMessageNode.user.longName + ?: stringResource(R.string.unknown_username)}", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(1.dp)) + Text( + text = originalMessage.text, // Should not be null if isAReply is true + style = MaterialTheme.typography.bodySmall, + maxLines = 2, // Keep snippet brief + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + Row( modifier = Modifier - .fillMaxWidth(), + .fillMaxWidth() + .padding(4.dp), verticalAlignment = Alignment.CenterVertically, ) { if (!fromLocal) { @@ -141,7 +196,7 @@ internal fun MessageItem( modifier = Modifier .fillMaxWidth() .padding(4.dp), - text = messageText.orEmpty(), + text = message.text, style = MaterialTheme.typography.bodyMedium, ) Row( @@ -152,27 +207,33 @@ internal fun MessageItem( verticalAlignment = Alignment.CenterVertically, ) { if (!fromLocal) { - if (hopsAway == null) { + if (message.hopsAway == 0) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Snr(snr, fontSize = MaterialTheme.typography.labelSmall.fontSize) - Rssi(rssi, fontSize = MaterialTheme.typography.labelSmall.fontSize) + Snr( + message.snr, + fontSize = MaterialTheme.typography.labelSmall.fontSize + ) + Rssi( + message.rssi, + fontSize = MaterialTheme.typography.labelSmall.fontSize + ) } } else { Text( - text = hopsAway, + text = "${message.hopsAway}", style = MaterialTheme.typography.labelSmall, ) } } Text( - text = messageTime, + text = message.time, style = MaterialTheme.typography.labelSmall, ) AnimatedVisibility(visible = fromLocal) { Icon( - imageVector = when (messageStatus) { + imageVector = when (message.status) { MessageStatus.RECEIVED -> Icons.TwoTone.HowToReg MessageStatus.QUEUED -> Icons.TwoTone.CloudUpload MessageStatus.DELIVERED -> Icons.TwoTone.CloudDone @@ -194,32 +255,41 @@ internal fun MessageItem( .fillMaxWidth(), reactions = emojis, onSendReaction = sendReaction, - onShowReactions = onShowReactions + onShowReactions = onShowReactions, + onSendReply = onReply ) } +@OptIn(ExperimentalUuidApi::class) @PreviewLightDark @Composable private fun MessageItemPreview() { + val message = Message( + text = stringResource(R.string.sample_message), + time = "10:00", + status = MessageStatus.DELIVERED, + snr = 20.5f, + rssi = 90, + hopsAway = 0, + uuid = 1L, + receivedTime = System.currentTimeMillis(), + node = NodePreviewParameterProvider().values.first(), + read = false, + routingError = 0, + packetId = 4545, + emojis = listOf(), + replyId = null, + ) AppTheme { MessageItem( - node = NodePreviewParameterProvider().values.first(), - messageText = stringResource(R.string.sample_message), - messageTime = "10:00", - messageStatus = MessageStatus.DELIVERED, + message = message, + node = message.node, selected = false, + onClick = {}, + onLongClick = {}, + onStatusClick = {}, isConnected = true, - snr = 20.5f, - rssi = 90, - hopsAway = null, - emojis = listOf( - Reaction( - emoji = "\uD83D\uDE42", - user = NodePreviewParameterProvider().values.first().user, - replyId = 0, - timestamp = 0L - ), - ) + ourNode = message.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 6945278aa..27a2e8c56 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 @@ -35,6 +35,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.EmojiEmotions +import androidx.compose.material.icons.filled.Reply import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.HorizontalDivider @@ -89,6 +90,22 @@ fun ReactionButton( } } +@Composable +fun ReplyButton( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, +) = IconButton( + modifier = modifier + .size(48.dp), + onClick = onClick, + content = { + Icon( + imageVector = Icons.Default.Reply, + contentDescription = "reply", + ) + } +) + @Composable private fun ReactionItem( emoji: String, @@ -133,7 +150,8 @@ fun ReactionRow( modifier: Modifier = Modifier, reactions: List = emptyList(), onSendReaction: (String) -> Unit = {}, - onShowReactions: () -> Unit = {} + onShowReactions: () -> Unit = {}, + onSendReply: () -> Unit = {}, ) { val emojiList = reduceEmojis( @@ -141,11 +159,18 @@ fun ReactionRow( ).entries LazyRow( - modifier = modifier.height(48.dp).padding(bottom = 8.dp), + modifier = modifier + .height(48.dp) + .padding(bottom = 8.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, reverseLayout = true ) { + item { + ReplyButton { + onSendReply() + } + } item { ReactionButton( onSendReaction = onSendReaction,