From 2526728859271801fa753d202eacdc50b4a872dc Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 6 Jan 2026 11:43:36 -0600 Subject: [PATCH] feat: Add acknowledgement status and retry for emoji reactions (#4142) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../mesh/service/MeshActionHandler.kt | 9 +- .../mesh/service/MeshDataHandler.kt | 68 +- .../mesh/service/ReactionReceiver.kt | 18 + .../core/data/repository/PacketRepository.kt | 6 + .../28.json | 965 ++++++++++++++++++ .../core/database/MeshtasticDatabase.kt | 6 +- .../meshtastic/core/database/dao/PacketDao.kt | 5 + .../meshtastic/core/database/entity/Packet.kt | 27 +- .../feature/messaging/MessageListPaged.kt | 25 +- .../messaging/component/MessageActions.kt | 5 +- .../feature/messaging/component/Reaction.kt | 206 ++-- 11 files changed, 1257 insertions(+), 83 deletions(-) create mode 100644 core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/28.json diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt index b488c237d..9b374de48 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt @@ -28,6 +28,7 @@ import org.meshtastic.core.data.repository.PacketRepository import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.database.entity.ReactionEntity import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.Position import org.meshtastic.core.prefs.mesh.MeshPrefs import org.meshtastic.core.service.MeshServiceNotifications @@ -112,11 +113,11 @@ constructor( bytes = action.emoji.encodeToByteArray(), channel = channel, replyId = action.replyId, - wantAck = false, + wantAck = true, emoji = action.emoji.codePointAt(0), ) commandSender.sendData(dataPacket) - rememberReaction(action) + rememberReaction(action, dataPacket.id) } private fun handleImportContact(action: ServiceAction.ImportContact, myNodeNum: Int) { @@ -125,7 +126,7 @@ constructor( nodeManager.handleReceivedUser(verifiedContact.nodeNum, verifiedContact.user, manuallyVerified = true) } - private fun rememberReaction(action: ServiceAction.Reaction) { + private fun rememberReaction(action: ServiceAction.Reaction, packetId: Int) { scope.handledLaunch { val reaction = ReactionEntity( @@ -136,6 +137,8 @@ constructor( snr = 0f, rssi = 0, hopsAway = 0, + packetId = packetId, + status = MessageStatus.QUEUED, ) packetRepository.get().insertReaction(reaction) } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt index d609ebe50..fe6a5d369 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt @@ -333,10 +333,13 @@ constructor( packetHandler.removeResponse(packet.decoded.requestId, complete = true) } + @Suppress("CyclomaticComplexMethod", "LongMethod") private fun handleAckNak(requestId: Int, fromId: String, routingError: Int, relayNode: Int?) { scope.handledLaunch { val isAck = routingError == MeshProtos.Routing.Error.NONE_VALUE val p = packetRepository.get().getPacketById(requestId) + val reaction = packetRepository.get().getReactionByPacketId(requestId) + val isMaxRetransmit = routingError == MeshProtos.Routing.Error.MAX_RETRANSMIT_VALUE val shouldRetry = isMaxRetransmit && @@ -345,14 +348,22 @@ constructor( p.data.from == DataPacket.ID_LOCAL && p.data.retryCount < MAX_RETRY_ATTEMPTS + val shouldRetryReaction = + isMaxRetransmit && + reaction != null && + reaction.userId == DataPacket.ID_LOCAL && + reaction.retryCount < MAX_RETRY_ATTEMPTS && + reaction.to != null + @Suppress("MaxLineLength") Logger.d { - val retryInfo = "packetId=${p?.packetId} dataId=${p?.data?.id} retry=${p?.data?.retryCount}" - val statusInfo = "status=${p?.data?.status}" + val retryInfo = + "packetId=${p?.packetId ?: reaction?.packetId} dataId=${p?.data?.id} retry=${p?.data?.retryCount ?: reaction?.retryCount}" + val statusInfo = "status=${p?.data?.status ?: reaction?.status}" "[ackNak] req=$requestId routeErr=$routingError isAck=$isAck " + - "maxRetransmit=$isMaxRetransmit shouldRetry=$shouldRetry $retryInfo $statusInfo" + "maxRetransmit=$isMaxRetransmit shouldRetry=$shouldRetry reaction=$shouldRetryReaction $retryInfo $statusInfo" } - if (shouldRetry && p != null) { + if (shouldRetry) { val newRetryCount = p.data.retryCount + 1 val newId = commandSender.generatePacketId() val updatedData = @@ -368,21 +379,66 @@ constructor( return@handledLaunch } + if (shouldRetryReaction && reaction != null) { + val newRetryCount = reaction.retryCount + 1 + val newId = commandSender.generatePacketId() + + val reactionPacket = + DataPacket( + to = reaction.to, + channel = reaction.channel, + bytes = reaction.emoji.toByteArray(Charsets.UTF_8), + dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + replyId = reaction.replyId, + wantAck = true, + emoji = reaction.emoji.codePointAt(0), + id = newId, + retryCount = newRetryCount, + ) + + val updatedReaction = + reaction.copy( + packetId = newId, + status = MessageStatus.QUEUED, + retryCount = newRetryCount, + relayNode = null, + routingError = MeshProtos.Routing.Error.NONE_VALUE, + ) + packetRepository.get().updateReaction(updatedReaction) + + Logger.w { "[ackNak] retrying reaction req=$requestId newId=$newId retry=$newRetryCount" } + + delay(RETRY_DELAY_MS) + commandSender.sendData(reactionPacket) + return@handledLaunch + } + val m = when { - isAck && fromId == p?.data?.to -> MessageStatus.RECEIVED + isAck && (fromId == p?.data?.to || fromId == reaction?.to) -> MessageStatus.RECEIVED isAck -> MessageStatus.DELIVERED else -> MessageStatus.ERROR } if (p != null && p.data.status != MessageStatus.RECEIVED) { p.data.status = m p.routingError = routingError - p.data.relayNode = relayNode if (isAck) { p.data.relays += 1 } + p.data.relayNode = relayNode packetRepository.get().update(p) } + + reaction?.let { r -> + if (r.status != MessageStatus.RECEIVED) { + var updated = r.copy(status = m, routingError = routingError, relayNode = relayNode) + if (isAck) { + updated = updated.copy(relays = updated.relays + 1) + } + packetRepository.get().updateReaction(updated) + } + } + serviceBroadcasts.broadcastMessageStatus(requestId, m) } } diff --git a/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt index 6482659cc..184c3ad06 100644 --- a/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt +++ b/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt @@ -24,6 +24,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import org.meshtastic.core.data.repository.PacketRepository +import org.meshtastic.core.database.entity.ReactionEntity import org.meshtastic.core.model.DataPacket import org.meshtastic.core.service.MeshServiceNotifications import org.meshtastic.proto.Portnums @@ -35,6 +37,8 @@ class ReactionReceiver : BroadcastReceiver() { @Inject lateinit var meshServiceNotifications: MeshServiceNotifications + @Inject lateinit var packetRepository: PacketRepository + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) companion object { @@ -71,10 +75,24 @@ class ReactionReceiver : BroadcastReceiver() { bytes = emoji.toByteArray(Charsets.UTF_8), dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, replyId = packetId, + wantAck = true, emoji = emoji.codePointAt(0), ) commandSender.sendData(reactionPacket) + val reaction = + ReactionEntity( + replyId = packetId, + userId = DataPacket.ID_LOCAL, + emoji = emoji, + timestamp = System.currentTimeMillis(), + packetId = reactionPacket.id, + status = org.meshtastic.core.model.MessageStatus.QUEUED, + to = toId, + channel = channelIndex, + ) + packetRepository.insertReaction(reaction) + // Dismiss the notification after reacting meshServiceNotifications.cancelMessageNotification(contactKey) } finally { diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt index d69914f11..6f8064a2d 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt @@ -172,6 +172,12 @@ constructor( suspend fun insertReaction(reaction: ReactionEntity) = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(reaction) } + suspend fun updateReaction(reaction: ReactionEntity) = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().update(reaction) } + + suspend fun getReactionByPacketId(packetId: Int) = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getReactionByPacketId(packetId) } + suspend fun clearPacketDB() = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteAll() } suspend fun migrateChannelsByPSK(oldSettings: List, newSettings: List) = diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/28.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/28.json new file mode 100644 index 000000000..1e07ed71f --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/28.json @@ -0,0 +1,965 @@ +{ + "formatVersion": 1, + "database": { + "version": 28, + "identityHash": "36622b8fdfb35d71c5d4385b2f833f97", + "entities": [ + { + "tableName": "my_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "couldUpdate", + "columnName": "couldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldUpdate", + "columnName": "shouldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPacketId", + "columnName": "currentPacketId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageTimeoutMsec", + "columnName": "messageTimeoutMsec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAppVersion", + "columnName": "minAppVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChannels", + "columnName": "maxChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasWifi", + "columnName": "hasWifi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + } + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "last_heard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceTelemetry", + "columnName": "device_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaMqtt", + "columnName": "via_mqtt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hops_away", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "environmentTelemetry", + "columnName": "environment_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "powerTelemetry", + "columnName": "power_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "paxcounter", + "columnName": "paxcounter", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "manuallyVerified", + "columnName": "manually_verified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_nodes_last_heard", + "unique": false, + "columnNames": [ + "last_heard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)" + }, + { + "name": "index_nodes_short_name", + "unique": false, + "columnNames": [ + "short_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)" + }, + { + "name": "index_nodes_long_name", + "unique": false, + "columnNames": [ + "long_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)" + }, + { + "name": "index_nodes_hops_away", + "unique": false, + "columnNames": [ + "hops_away" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)" + }, + { + "name": "index_nodes_is_favorite", + "unique": false, + "columnNames": [ + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)" + }, + { + "name": "index_nodes_last_heard_is_favorite", + "unique": false, + "columnNames": [ + "last_heard", + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)" + } + ] + }, + { + "tableName": "packet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `reply_id` INTEGER NOT NULL DEFAULT 0, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "port_num", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_time", + "columnName": "received_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_packet_myNodeNum", + "unique": false, + "columnNames": [ + "myNodeNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)" + }, + { + "name": "index_packet_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)" + }, + { + "name": "index_packet_contact_key", + "unique": false, + "columnNames": [ + "contact_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)" + }, + { + "name": "index_packet_contact_key_port_num_received_time", + "unique": false, + "columnNames": [ + "contact_key", + "port_num", + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)" + } + ] + }, + { + "tableName": "contact_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, PRIMARY KEY(`contact_key`))", + "fields": [ + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteUntil", + "columnName": "muteUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessageUuid", + "columnName": "last_read_message_uuid", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastReadMessageTimestamp", + "columnName": "last_read_message_timestamp", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contact_key" + ] + } + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message_type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_date", + "columnName": "received_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw_message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromNum", + "columnName": "from_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "portNum", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fromRadio", + "columnName": "from_radio", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_log_from_num", + "unique": false, + "columnNames": [ + "from_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)" + }, + { + "name": "index_log_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)" + } + ] + }, + { + "tableName": "quick_chat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "reactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` TEXT NOT NULL DEFAULT '0', `routing_error` INTEGER NOT NULL DEFAULT 0, `retry_count` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`reply_id`, `user_id`, `emoji`))", + "fields": [ + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "retryCount", + "columnName": "retry_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relays", + "columnName": "relays", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relayNode", + "columnName": "relay_node", + "affinity": "INTEGER" + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT" + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "reply_id", + "user_id", + "emoji" + ] + }, + "indices": [ + { + "name": "index_reactions_reply_id", + "unique": false, + "columnNames": [ + "reply_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)" + }, + { + "name": "index_reactions_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + } + ] + }, + { + "tableName": "metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proto", + "columnName": "proto", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_metadata_num", + "unique": false, + "columnNames": [ + "num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)" + } + ] + }, + { + "tableName": "device_hardware", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`hwModel`))", + "fields": [ + { + "fieldPath": "activelySupported", + "columnName": "actively_supported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "architecture", + "columnName": "architecture", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasInkHud", + "columnName": "has_ink_hud", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasMui", + "columnName": "has_mui", + "affinity": "INTEGER" + }, + { + "fieldPath": "hwModel", + "columnName": "hwModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hwModelSlug", + "columnName": "hw_model_slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "images", + "columnName": "images", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partitionScheme", + "columnName": "partition_scheme", + "affinity": "TEXT" + }, + { + "fieldPath": "platformioTarget", + "columnName": "platformio_target", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiresDfu", + "columnName": "requires_dfu", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportLevel", + "columnName": "support_level", + "affinity": "INTEGER" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "hwModel" + ] + } + }, + { + "tableName": "firmware_release", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageUrl", + "columnName": "page_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseNotes", + "columnName": "release_notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zipUrl", + "columnName": "zip_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "traceroute_node_position", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "logUuid", + "columnName": "log_uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "request_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "log_uuid", + "node_num" + ] + }, + "indices": [ + { + "name": "index_traceroute_node_position_log_uuid", + "unique": false, + "columnNames": [ + "log_uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)" + }, + { + "name": "index_traceroute_node_position_request_id", + "unique": false, + "columnNames": [ + "request_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)" + } + ], + "foreignKeys": [ + { + "table": "log", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "log_uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '36622b8fdfb35d71c5d4385b2f833f97')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index d7a0a6917..75c602275 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.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.core.database import android.content.Context @@ -85,8 +84,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity AutoMigration(from = 24, to = 25), AutoMigration(from = 25, to = 26), AutoMigration(from = 26, to = 27), + AutoMigration(from = 27, to = 28), ], - version = 27, + version = 28, exportSchema = true, ) @TypeConverters(Converters::class) diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt index 73b29f9fe..12c35c4ae 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt @@ -309,6 +309,11 @@ interface PacketDao { @Upsert suspend fun insert(reaction: ReactionEntity) + @Update suspend fun update(reaction: ReactionEntity) + + @Query("SELECT * FROM reactions WHERE packet_id = :packetId LIMIT 1") + suspend fun getReactionByPacketId(packetId: Int): ReactionEntity? + @Transaction suspend fun deleteAll() { deleteAllPackets() diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt index 3c4bdfa51..ebfba43b8 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt @@ -25,6 +25,7 @@ import androidx.room.Relation import org.meshtastic.core.database.model.Message import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.util.getShortDateTime import org.meshtastic.proto.MeshProtos.User @@ -130,12 +131,20 @@ data class Reaction( val snr: Float, val rssi: Int, val hopsAway: Int, + val packetId: Int = 0, + val status: MessageStatus = MessageStatus.UNKNOWN, + val routingError: Int = 0, + val retryCount: Int = 0, + val relays: Int = 0, + val relayNode: Int? = null, + val to: String? = null, + val channel: Int = 0, ) @Entity( tableName = "reactions", primaryKeys = ["reply_id", "user_id", "emoji"], - indices = [Index(value = ["reply_id"])], + indices = [Index(value = ["reply_id"]), Index(value = ["packet_id"])], ) data class ReactionEntity( @ColumnInfo(name = "reply_id") val replyId: Int, @@ -145,6 +154,14 @@ data class ReactionEntity( @ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f, @ColumnInfo(name = "rssi", defaultValue = "0") val rssi: Int = 0, @ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1, + @ColumnInfo(name = "packet_id", defaultValue = "0") val packetId: Int = 0, + @ColumnInfo(name = "status", defaultValue = "0") val status: MessageStatus = MessageStatus.UNKNOWN, + @ColumnInfo(name = "routing_error", defaultValue = "0") val routingError: Int = 0, + @ColumnInfo(name = "retry_count", defaultValue = "0") val retryCount: Int = 0, + @ColumnInfo(name = "relays", defaultValue = "0") val relays: Int = 0, + @ColumnInfo(name = "relay_node") val relayNode: Int? = null, + @ColumnInfo(name = "to") val to: String? = null, + @ColumnInfo(name = "channel", defaultValue = "0") val channel: Int = 0, ) private suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?) -> Node) = Reaction( @@ -155,6 +172,14 @@ private suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?) snr = snr, rssi = rssi, hopsAway = hopsAway, + packetId = packetId, + status = status, + routingError = routingError, + retryCount = retryCount, + relays = relays, + relayNode = relayNode, + to = to, + channel = channel, ) private suspend fun List.toReaction(getNode: suspend (userId: String?) -> Node) = 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 acfcdc074..43227f6b6 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 @@ -130,7 +130,17 @@ internal fun MessageListPaged( var showReactionDialog by remember { mutableStateOf?>(null) } showReactionDialog?.let { reactions -> - ReactionDialog(reactions = reactions, myId = state.ourNode?.user?.id, onDismiss = { showReactionDialog = null }) + ReactionDialog( + reactions = reactions, + myId = state.ourNode?.user?.id, + onDismiss = { showReactionDialog = null }, + onResend = { reaction -> + handlers.onSendReaction(reaction.emoji, reaction.replyId) + showReactionDialog = null + }, + nodes = state.nodes, + ourNode = state.ourNode, + ) } val coroutineScope = rememberCoroutineScope() @@ -270,7 +280,18 @@ private fun LazyItemScope.renderPagedChatMessageRow( onStatusClick = { onShowStatusDialog(message) }, onReply = { handlers.onReply(message) }, emojis = message.emojis, - sendReaction = { handlers.onSendReaction(it, message.packetId) }, + sendReaction = { emoji -> + val hasReacted = + message.emojis.any { reaction -> + ( + reaction.user.id == ourNode.user.id || + reaction.user.id == org.meshtastic.core.model.DataPacket.ID_LOCAL + ) && reaction.emoji == emoji + } + if (!hasReacted) { + handlers.onSendReaction(emoji, message.packetId) + } + }, onShowReactions = { onShowReactions(message.emojis) }, onNavigateToOriginalMessage = { coroutineScope.launch { diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActions.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActions.kt index 2ec6a43a0..a6d3edb3a 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActions.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActions.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.animation.AnimatedVisibility @@ -73,7 +72,7 @@ private fun ReplyButton(onClick: () -> Unit = {}) = IconButton( ) @Composable -private fun MessageStatusButton(onStatusClick: () -> Unit = {}, status: MessageStatus, fromLocal: Boolean) = +internal fun MessageStatusButton(onStatusClick: () -> Unit = {}, status: MessageStatus, fromLocal: Boolean) = AnimatedVisibility(visible = fromLocal) { IconButton(onClick = onStatusClick) { Crossfade(targetState = status, label = "MessageStatusIcon") { currentStatus -> 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 2e871289f..7c76ce0f7 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 @@ -47,25 +47,45 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.database.entity.Reaction +import org.meshtastic.core.database.model.Node +import org.meshtastic.core.database.model.getStringResFrom import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.util.getShortDateTime import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.delivery_confirmed +import org.meshtastic.core.strings.error import org.meshtastic.core.strings.hops_away_template +import org.meshtastic.core.strings.message_delivery_status +import org.meshtastic.core.strings.message_status_enroute +import org.meshtastic.core.strings.message_status_queued import org.meshtastic.core.strings.you import org.meshtastic.core.ui.component.BottomSheetDialog import org.meshtastic.core.ui.component.Rssi import org.meshtastic.core.ui.component.Snr import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.feature.messaging.DeliveryInfo import org.meshtastic.proto.MeshProtos @Composable -private fun ReactionItem(emoji: String, emojiCount: Int = 1, onClick: () -> Unit = {}, onLongClick: () -> Unit = {}) { +private fun ReactionItem( + emoji: String, + emojiCount: Int = 1, + status: MessageStatus = MessageStatus.UNKNOWN, + onClick: () -> Unit = {}, + onLongClick: () -> Unit = {}, +) { + val isSending = status == MessageStatus.QUEUED || status == MessageStatus.ENROUTE + val isError = status == MessageStatus.ERROR + BadgedBox( badge = { if (emojiCount > 1) { @@ -74,8 +94,14 @@ private fun ReactionItem(emoji: String, emojiCount: Int = 1, onClick: () -> Unit }, ) { Surface( - modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick), - color = MaterialTheme.colorScheme.primaryContainer, + modifier = + Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick) + .then(if (isSending) Modifier.graphicsLayer(alpha = 0.5f) else Modifier), + color = + when { + isError -> MaterialTheme.colorScheme.errorContainer + else -> MaterialTheme.colorScheme.primaryContainer + }, shape = CircleShape, ) { Text(text = emoji, modifier = Modifier.padding(4.dp).clip(CircleShape)) @@ -91,20 +117,21 @@ internal fun ReactionRow( onSendReaction: (String) -> Unit = {}, onShowReactions: () -> Unit = {}, ) { - val emojiList = reduceEmojis(reactions.reversed().map { it.emoji }).entries + val emojiGroups = reactions.groupBy { it.emoji } - AnimatedVisibility(emojiList.isNotEmpty()) { + AnimatedVisibility(emojiGroups.isNotEmpty()) { LazyRow( modifier = modifier.padding(horizontal = 4.dp), horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, ) { - items(emojiList.size) { index -> - val entry = emojiList.elementAt(index) + items(emojiGroups.entries.toList()) { (emoji, reactions) -> + val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL } ReactionItem( - emoji = entry.key, - emojiCount = entry.value, - onClick = { onSendReaction(entry.key) }, + emoji = emoji, + emojiCount = reactions.size, + status = localReaction?.status ?: MessageStatus.RECEIVED, + onClick = { onSendReaction(emoji) }, onLongClick = onShowReactions, ) } @@ -112,76 +139,125 @@ internal fun ReactionRow( } } -private fun reduceEmojis(emojis: List): Map = emojis.groupingBy { it }.eachCount() - -@Suppress("LongMethod") +@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable -internal fun ReactionDialog(reactions: List, myId: String? = null, 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 +internal fun ReactionDialog( + reactions: List, + onDismiss: () -> Unit = {}, + myId: String? = null, + onResend: (Reaction) -> Unit = {}, + nodes: List = emptyList(), + ourNode: Node? = null, +) = 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, - ) + var showStatusDialog by remember { mutableStateOf(null) } + showStatusDialog?.let { reaction -> + val title = if (reaction.routingError > 0) Res.string.error else Res.string.message_delivery_status + val text = + when (reaction.status) { + MessageStatus.RECEIVED -> Res.string.delivery_confirmed + MessageStatus.QUEUED -> Res.string.message_status_queued + MessageStatus.ENROUTE -> Res.string.message_status_enroute + else -> getStringResFrom(reaction.routingError) } + + val relayNodeName = + reaction.relayNode?.let { relayNodeId -> + Packet.getRelayNode(relayNodeId, nodes, ourNode?.num)?.user?.longName + } + + DeliveryInfo( + title = title, + text = text, + resendOption = reaction.status == MessageStatus.ERROR, + onConfirm = { + onResend(reaction) + showStatusDialog = null + }, + onDismiss = { showStatusDialog = null }, + relayNodeName = relayNodeName, + relays = reaction.relays, + retryCount = reaction.retryCount, + maxRetries = 5, + ) + } + + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + items(groupedEmojis.entries.toList()) { (emoji, reactions) -> + val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL } + val isSending = + localReaction?.status == MessageStatus.QUEUED || localReaction?.status == MessageStatus.ENROUTE + Text( + text = "$emoji${reactions.size}", + modifier = + Modifier.clip(CircleShape) + .background(if (selectedEmoji == emoji) Color.Gray else Color.Transparent) + .then(if (isSending) Modifier.graphicsLayer(alpha = 0.5f) else Modifier) + .padding(8.dp) + .clickable { selectedEmoji = if (selectedEmoji == emoji) null else emoji }, + style = MaterialTheme.typography.bodyMedium, + ) } + } - HorizontalDivider(Modifier.padding(vertical = 8.dp)) + HorizontalDivider(Modifier.padding(vertical = 8.dp)) - LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { - items(filteredReactions) { reaction -> - Column(modifier = Modifier.padding(horizontal = 8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - val isLocal = reaction.user.id == myId || reaction.user.id == DataPacket.ID_LOCAL - val displayName = - if (isLocal) { - "${reaction.user.longName} (${stringResource(Res.string.you)})" - } else { - reaction.user.longName - } - Text(text = displayName, style = MaterialTheme.typography.titleMedium) + LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + items(filteredReactions) { reaction -> + Column(modifier = Modifier.padding(horizontal = 8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val isLocal = reaction.user.id == myId || reaction.user.id == DataPacket.ID_LOCAL + val displayName = + if (isLocal) { + "${reaction.user.longName} (${stringResource(Res.string.you)})" + } else { + reaction.user.longName + } + Text(text = displayName, style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + if (isLocal) { + MessageStatusButton( + status = reaction.status, + fromLocal = true, + onStatusClick = { showStatusDialog = reaction }, + ) + } Text(text = reaction.emoji, style = MaterialTheme.typography.titleLarge) } - Row( - modifier = Modifier.fillMaxWidth().padding(top = 0.dp, bottom = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - val isLocalOrPreDbUpdateReaction = (reaction.rssi == 0) - if (!isLocalOrPreDbUpdateReaction) { - if (reaction.hopsAway == 0) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Snr(reaction.snr) - Rssi(reaction.rssi) - } - } else { - Text( - text = stringResource(Res.string.hops_away_template, reaction.hopsAway), - style = MaterialTheme.typography.labelSmall, - ) + } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 0.dp, bottom = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val isLocalOrPreDbUpdateReaction = (reaction.rssi == 0) + if (!isLocalOrPreDbUpdateReaction) { + if (reaction.hopsAway == 0) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Snr(reaction.snr) + Rssi(reaction.rssi) } + } else { + Text( + text = stringResource(Res.string.hops_away_template, reaction.hopsAway), + style = MaterialTheme.typography.labelSmall, + ) } - Spacer(modifier = Modifier.weight(1f)) - Text(text = getShortDateTime(reaction.timestamp), style = MaterialTheme.typography.labelSmall) } + Spacer(modifier = Modifier.weight(1f)) + Text(text = getShortDateTime(reaction.timestamp), style = MaterialTheme.typography.labelSmall) } } } } +} @PreviewLightDark @Composable