mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-02-06 05:42:40 -05:00
feat: Implement message reply functionality (#2147)
This commit is contained in:
@@ -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<DataPacket> {
|
||||
|
||||
@@ -49,6 +49,7 @@ data class PacketEntity(
|
||||
routingError = routingError,
|
||||
packetId = packetId,
|
||||
emojis = reactions.toReaction(getNode),
|
||||
replyId = data.replyId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ data class Message(
|
||||
val snr: Float,
|
||||
val rssi: Int,
|
||||
val hopsAway: Int,
|
||||
val replyId: Int?,
|
||||
) {
|
||||
fun getStatusStringRes(): Pair<Int, Int> {
|
||||
val title = if (routingError > 0) R.string.error else R.string.message_delivery_status
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Message?>(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 -> {
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Reaction> = emptyList(),
|
||||
ourNode: Node,
|
||||
message: Message,
|
||||
selected: Boolean,
|
||||
onReply: () -> Unit = {},
|
||||
sendReaction: (String) -> Unit = {},
|
||||
onShowReactions: () -> Unit = {},
|
||||
selected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
emojis: List<Reaction> = 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Reaction> = 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,
|
||||
|
||||
Reference in New Issue
Block a user