feat: Implement message reply functionality (#2147)

This commit is contained in:
James Rich
2025-06-18 01:15:07 +00:00
committed by GitHub
parent 7497540f80
commit 357efa9028
9 changed files with 238 additions and 71 deletions

View File

@@ -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> {

View File

@@ -49,6 +49,7 @@ data class PacketEntity(
routingError = routingError,
packetId = packetId,
emojis = reactions.toReaction(getNode),
replyId = data.replyId
)
}
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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 -> {

View File

@@ -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 }
)
}
}
)
}

View File

@@ -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,
)
}
}

View File

@@ -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,