From 2a081f3c1f4c8786585d91e7a461b8d1f4f4060e Mon Sep 17 00:00:00 2001 From: Mac DeCourcy <49794076+mdecourcy@users.noreply.github.com> Date: Fri, 14 Nov 2025 11:03:46 -0800 Subject: [PATCH] feat: jump to oldest unread message upon opening a thread, display divider between read/unread (#3693) --- .../core/data/repository/PacketRepository.kt | 16 + .../23.json | 745 ++++++++++++++++++ .../core/database/MeshtasticDatabase.kt | 3 +- .../meshtastic/core/database/entity/Packet.kt | 7 +- .../composeResources/values/strings.xml | 1 + .../core/ui/component/ScrollExtensions.kt | 68 ++ .../ui/component/ScrollToTopExtensions.kt | 46 -- .../feature/messaging/DeliveryInfoDialog.kt | 90 +++ .../meshtastic/feature/messaging/Message.kt | 99 ++- .../feature/messaging/MessageList.kt | 432 ++++++---- .../feature/messaging/MessageScreenEvent.kt | 4 +- .../feature/messaging/MessageViewModel.kt | 20 +- 12 files changed, 1322 insertions(+), 209 deletions(-) create mode 100644 core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/23.json create mode 100644 core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollExtensions.kt delete mode 100644 core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollToTopExtensions.kt create mode 100644 feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/DeliveryInfoDialog.kt 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 54a88d0e3..0e56004bb 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 @@ -57,6 +57,22 @@ constructor( suspend fun clearUnreadCount(contact: String, timestamp: Long) = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearUnreadCount(contact, timestamp) } + suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) = + withContext(dispatchers.io) { + val dao = dbManager.currentDb.value.packetDao() + val current = dao.getContactSettings(contact) + val existingTimestamp = current?.lastReadMessageTimestamp ?: Long.MIN_VALUE + if (lastReadTimestamp <= existingTimestamp) { + return@withContext + } + val updated = + (current ?: ContactSettings(contact_key = contact)).copy( + lastReadMessageUuid = messageUuid, + lastReadMessageTimestamp = lastReadTimestamp, + ) + dao.upsertContactSettings(listOf(updated)) + } + suspend fun getQueuedPackets(): List? = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() } diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/23.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/23.json new file mode 100644 index 000000000..3307d14b0 --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/23.json @@ -0,0 +1,745 @@ +{ + "formatVersion": 1, + "database": { + "version": 23, + "identityHash": "51f5f6ebc6ef9d279deb9944746fad68", + "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" + ] + } + }, + { + "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`)" + } + ] + }, + { + "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, 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 + } + ], + "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`)" + } + ] + }, + { + "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" + ] + } + } + ], + "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, '51f5f6ebc6ef9d279deb9944746fad68')" + ] + } +} \ 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 6060d577d..3cd57ad33 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 @@ -77,8 +77,9 @@ import org.meshtastic.core.database.entity.ReactionEntity AutoMigration(from = 19, to = 20), AutoMigration(from = 20, to = 21), AutoMigration(from = 21, to = 22), + AutoMigration(from = 22, to = 23), ], - version = 22, + version = 23, exportSchema = true, ) @TypeConverters(Converters::class) 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 bc719fdc1..833a0b6a0 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 @@ -97,7 +97,12 @@ data class Packet( @Suppress("ConstructorParameterNaming") @Entity(tableName = "contact_settings") -data class ContactSettings(@PrimaryKey val contact_key: String, val muteUntil: Long = 0L) { +data class ContactSettings( + @PrimaryKey val contact_key: String, + val muteUntil: Long = 0L, + @ColumnInfo(name = "last_read_message_uuid") val lastReadMessageUuid: Long? = null, + @ColumnInfo(name = "last_read_message_timestamp") val lastReadMessageTimestamp: Long? = null, +) { val isMuted get() = System.currentTimeMillis() <= muteUntil } diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index d95c2a689..268b5affc 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -239,6 +239,7 @@ This will remove all log packets and database entries from your device - It is a full reset, and is permanent. Clear Message delivery status + New messages below Direct message notifications Broadcast message notifications Alert notifications diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollExtensions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollExtensions.kt new file mode 100644 index 000000000..03996b0c8 --- /dev/null +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollExtensions.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.core.ui.component + +import androidx.compose.foundation.lazy.LazyListState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +private const val SCROLL_TO_TOP_INDEX = 0 +private const val FAST_SCROLL_THRESHOLD = 10 + +/** + * Executes the smart scroll-to-top policy. + * + * Policy: + * - If the first visible item is already at index 0, do nothing. + * - Otherwise, smoothly animate the list back to the first item. + */ +fun LazyListState.smartScrollToTop(coroutineScope: CoroutineScope) { + smartScrollToIndex(coroutineScope = coroutineScope, targetIndex = SCROLL_TO_TOP_INDEX) +} + +/** + * Scrolls to the [targetIndex] while applying the same fast-scroll optimisation used by [smartScrollToTop]. + * + * If the destination is far away, the list first jumps closer to the goal (within [FAST_SCROLL_THRESHOLD] items) to + * avoid long smooth animations and then animates the final segment. + * + * @param coroutineScope Scope used to perform the scroll operations. + * @param targetIndex Absolute index that should end up at the top of the viewport. + */ +fun LazyListState.smartScrollToIndex(coroutineScope: CoroutineScope, targetIndex: Int) { + if (targetIndex < 0 || firstVisibleItemIndex == targetIndex) { + return + } + coroutineScope.launch { + val totalItems = layoutInfo.totalItemsCount + if (totalItems == 0) { + return@launch + } + val clampedTarget = targetIndex.coerceIn(0, totalItems - 1) + val difference = firstVisibleItemIndex - clampedTarget + val jumpIndex = + when { + difference > FAST_SCROLL_THRESHOLD -> + (clampedTarget + FAST_SCROLL_THRESHOLD).coerceAtMost(totalItems - 1) + difference < -FAST_SCROLL_THRESHOLD -> (clampedTarget - FAST_SCROLL_THRESHOLD).coerceAtLeast(0) + else -> null + } + jumpIndex?.let { scrollToItem(it) } + animateScrollToItem(index = clampedTarget) + } +} diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollToTopExtensions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollToTopExtensions.kt deleted file mode 100644 index 6eb4ac1f1..000000000 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ScrollToTopExtensions.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.meshtastic.core.ui.component - -import androidx.compose.foundation.lazy.LazyListState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlin.math.max - -private const val SCROLL_TO_TOP_INDEX = 0 -private const val FAST_SCROLL_THRESHOLD = 10 - -/** - * Executes the smart scroll-to-top policy. - * - * Policy: - * - If the first visible item is already at index 0, do nothing. - * - Otherwise, smoothly animate the list back to the first item. - */ -fun LazyListState.smartScrollToTop(coroutineScope: CoroutineScope) { - if (firstVisibleItemIndex == SCROLL_TO_TOP_INDEX) { - return - } - coroutineScope.launch { - if (firstVisibleItemIndex > FAST_SCROLL_THRESHOLD) { - val jumpIndex = max(SCROLL_TO_TOP_INDEX, firstVisibleItemIndex - FAST_SCROLL_THRESHOLD) - scrollToItem(jumpIndex) - } - animateScrollToItem(index = SCROLL_TO_TOP_INDEX) - } -} diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/DeliveryInfoDialog.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/DeliveryInfoDialog.kt new file mode 100644 index 000000000..6e7f703a4 --- /dev/null +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/DeliveryInfoDialog.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.feature.messaging + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.close +import org.meshtastic.core.strings.relayed_by +import org.meshtastic.core.strings.resend + +@Composable +fun DeliveryInfo( + title: StringResource, + resendOption: Boolean, + text: StringResource? = null, + relayNodeName: String? = null, + onConfirm: (() -> Unit) = {}, + onDismiss: () -> Unit = {}, +) = AlertDialog( + onDismissRequest = onDismiss, + dismissButton = { + FilledTonalButton(onClick = onDismiss, modifier = Modifier.padding(horizontal = 16.dp)) { + Text(text = stringResource(Res.string.close)) + } + }, + confirmButton = { + if (resendOption) { + FilledTonalButton(onClick = onConfirm, modifier = Modifier.padding(horizontal = 16.dp)) { + Text(text = stringResource(Res.string.resend)) + } + } + }, + title = { + Text( + text = stringResource(title), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineSmall, + ) + }, + text = { + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + text?.let { + Text( + text = stringResource(it), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + } + relayNodeName?.let { + Text( + text = stringResource(Res.string.relayed_by, it), + modifier = Modifier.padding(top = 8.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + }, + shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp), + containerColor = MaterialTheme.colorScheme.surface, +) diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt index 95e69d344..5af3c7a1c 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt @@ -73,6 +73,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -127,6 +128,7 @@ import org.meshtastic.core.strings.unknown_channel import org.meshtastic.core.ui.component.NodeKeyStatusIcon import org.meshtastic.core.ui.component.SecurityIcon import org.meshtastic.core.ui.component.SharedContactDialog +import org.meshtastic.core.ui.component.smartScrollToIndex import org.meshtastic.core.ui.theme.AppTheme import org.meshtastic.proto.AppOnlyProtos import java.nio.charset.StandardCharsets @@ -165,6 +167,7 @@ fun MessageScreen( val channels by viewModel.channels.collectAsStateWithLifecycle() val quickChatActions by viewModel.quickChatActions.collectAsStateWithLifecycle(initialValue = emptyList()) val messages by viewModel.getMessagesFrom(contactKey).collectAsStateWithLifecycle(initialValue = emptyList()) + val contactSettings by viewModel.contactSettings.collectAsStateWithLifecycle(initialValue = emptyMap()) // UI State managed within this Composable var replyingToPacketId by rememberSaveable { mutableStateOf(null) } @@ -201,14 +204,63 @@ fun MessageScreen( val inSelectionMode by remember { derivedStateOf { selectedMessageIds.value.isNotEmpty() } } - val listState = - rememberLazyListState( - initialFirstVisibleItemIndex = remember(messages) { messages.indexOfLast { !it.read }.coerceAtLeast(0) }, - ) + val listState = rememberLazyListState() + + val lastReadMessageTimestamp by + remember(contactKey, contactSettings) { + derivedStateOf { contactSettings[contactKey]?.lastReadMessageTimestamp } + } + + var hasPerformedInitialScroll by rememberSaveable(contactKey) { mutableStateOf(false) } + + val hasUnreadMessages = messages.any { !it.read && !it.fromLocal } + + val earliestUnreadIndex by + remember(messages, lastReadMessageTimestamp) { + derivedStateOf { findEarliestUnreadIndex(messages, lastReadMessageTimestamp) } + } + + val initialUnreadUuidState = rememberSaveable(contactKey) { mutableStateOf(null) } + + LaunchedEffect(messages, earliestUnreadIndex, hasUnreadMessages) { + if (!hasUnreadMessages) { + initialUnreadUuidState.value = null + return@LaunchedEffect + } + val currentUuid = initialUnreadUuidState.value + val fallbackUuid = earliestUnreadIndex?.let { idx -> messages.getOrNull(idx)?.uuid } + if (currentUuid != null) { + val uuidStillPresent = messages.any { it.uuid == currentUuid } + if (!uuidStillPresent) { + initialUnreadUuidState.value = fallbackUuid + } + } else { + initialUnreadUuidState.value = fallbackUuid + } + } + + val initialUnreadMessageUuid = initialUnreadUuidState.value + + val initialUnreadIndex by + remember(messages, initialUnreadMessageUuid) { + derivedStateOf { + initialUnreadMessageUuid?.let { uuid -> messages.indexOfFirst { it.uuid == uuid } }?.takeIf { it >= 0 } + } + } + + LaunchedEffect(messages, initialUnreadIndex, earliestUnreadIndex) { + if (!hasPerformedInitialScroll && messages.isNotEmpty()) { + val targetIndex = (initialUnreadIndex ?: earliestUnreadIndex ?: 0).coerceIn(0, messages.lastIndex) + if (listState.firstVisibleItemIndex != targetIndex) { + listState.smartScrollToIndex(coroutineScope = coroutineScope, targetIndex = targetIndex) + } + hasPerformedInitialScroll = true + } + } val onEvent: (MessageScreenEvent) -> Unit = remember(viewModel, contactKey, messageInputState, ourNode) { - { event -> + fun handle(event: MessageScreenEvent) { when (event) { is MessageScreenEvent.SendMessage -> { viewModel.sendMessage(event.text, contactKey, event.replyingToPacketId) @@ -226,7 +278,7 @@ fun MessageScreen( } is MessageScreenEvent.ClearUnreadCount -> - viewModel.clearUnreadCount(contactKey, event.lastReadMessageId) + viewModel.clearUnreadCount(contactKey, event.messageUuid, event.lastReadTimestamp) is MessageScreenEvent.NodeDetails -> navigateToNodeDetails(event.node.num) @@ -240,6 +292,8 @@ fun MessageScreen( } } } + + ::handle } if (showDeleteDialog) { @@ -299,19 +353,30 @@ fun MessageScreen( Column(Modifier.padding(paddingValues)) { Box(modifier = Modifier.weight(1f)) { MessageList( - nodes = nodes, - ourNode = ourNode, modifier = Modifier.fillMaxSize(), listState = listState, - messages = messages, - selectedIds = selectedMessageIds, - onUnreadChanged = { messageId -> onEvent(MessageScreenEvent.ClearUnreadCount(messageId)) }, - onSendReaction = { emoji, id -> onEvent(MessageScreenEvent.SendReaction(emoji, id)) }, - onDeleteMessages = { viewModel.deleteMessages(it) }, - onSendMessage = { text, contactKey -> viewModel.sendMessage(text, contactKey) }, - contactKey = contactKey, - onReply = { message -> replyingToPacketId = message?.packetId }, - onClickChip = { onEvent(MessageScreenEvent.NodeDetails(it)) }, + state = + MessageListState( + nodes = nodes, + ourNode = ourNode, + messages = messages, + selectedIds = selectedMessageIds, + hasUnreadMessages = hasUnreadMessages, + initialUnreadMessageUuid = initialUnreadMessageUuid, + fallbackUnreadIndex = earliestUnreadIndex, + contactKey = contactKey, + ), + handlers = + MessageListHandlers( + onUnreadChanged = { messageUuid, timestamp -> + onEvent(MessageScreenEvent.ClearUnreadCount(messageUuid, timestamp)) + }, + onSendReaction = { emoji, id -> onEvent(MessageScreenEvent.SendReaction(emoji, id)) }, + onClickChip = { onEvent(MessageScreenEvent.NodeDetails(it)) }, + onDeleteMessages = { viewModel.deleteMessages(it) }, + onSendMessage = { text, key -> viewModel.sendMessage(text, key) }, + onReply = { message -> replyingToPacketId = message?.packetId }, + ), ) // Show FAB if we can scroll towards the newest messages (index 0). if (listState.canScrollBackward) { diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt index ae2e5d3c3..e9de5c43e 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt @@ -17,7 +17,8 @@ package org.meshtastic.feature.messaging -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -25,9 +26,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -42,15 +41,15 @@ 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.HapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.database.entity.Reaction @@ -58,165 +57,318 @@ import org.meshtastic.core.database.model.Message import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.strings.Res -import org.meshtastic.core.strings.close -import org.meshtastic.core.strings.relayed_by -import org.meshtastic.core.strings.resend +import org.meshtastic.core.strings.new_messages_below import org.meshtastic.feature.messaging.component.MessageItem import org.meshtastic.feature.messaging.component.ReactionDialog +import kotlin.collections.buildList -@Composable -fun DeliveryInfo( - title: StringResource, - text: StringResource? = null, - relayNodeName: String? = null, - onConfirm: (() -> Unit) = {}, - onDismiss: () -> Unit = {}, - resendOption: Boolean, -) = AlertDialog( - onDismissRequest = onDismiss, - dismissButton = { - FilledTonalButton(onClick = onDismiss, modifier = Modifier.padding(horizontal = 16.dp)) { - Text(text = stringResource(Res.string.close)) - } - }, - confirmButton = { - if (resendOption) { - FilledTonalButton(onClick = onConfirm, modifier = Modifier.padding(horizontal = 16.dp)) { - Text(text = stringResource(Res.string.resend)) - } - } - }, - title = { - Text( - text = stringResource(title), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.headlineSmall, - ) - }, - text = { - Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - text?.let { - Text( - text = stringResource(it), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - ) - } - relayNodeName?.let { - Text( - text = stringResource(Res.string.relayed_by, it), - modifier = Modifier.padding(top = 8.dp), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - }, - shape = RoundedCornerShape(16.dp), - containerColor = MaterialTheme.colorScheme.surface, +internal data class MessageListState( + val nodes: List, + val ourNode: Node?, + val messages: List, + val selectedIds: MutableState>, + val hasUnreadMessages: Boolean, + val initialUnreadMessageUuid: Long?, + val fallbackUnreadIndex: Int?, + val contactKey: String, +) + +internal data class MessageListHandlers( + val onUnreadChanged: (Long, Long) -> Unit, + val onSendReaction: (String, Int) -> Unit, + val onClickChip: (Node) -> Unit, + val onDeleteMessages: (List) -> Unit, + val onSendMessage: (String, String) -> Unit, + val onReply: (Message?) -> Unit, ) -@Suppress("LongMethod") @Composable internal fun MessageList( - nodes: List, - ourNode: Node?, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), - messages: List, - selectedIds: MutableState>, - onUnreadChanged: (Long) -> Unit, - onSendReaction: (String, Int) -> Unit, - onClickChip: (Node) -> Unit, - onDeleteMessages: (List) -> Unit, - onSendMessage: (messageText: String, contactKey: String) -> Unit, - contactKey: String, - onReply: (Message?) -> Unit, + state: MessageListState, + handlers: MessageListHandlers, ) { val haptics = LocalHapticFeedback.current - val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } } - AutoScrollToBottom(listState, messages) - UpdateUnreadCount(listState, messages, onUnreadChanged) + val inSelectionMode by remember { derivedStateOf { state.selectedIds.value.isNotEmpty() } } + val unreadDividerIndex by + remember(state.messages, state.initialUnreadMessageUuid, state.fallbackUnreadIndex) { + derivedStateOf { + state.initialUnreadMessageUuid?.let { uuid -> + state.messages.indexOfFirst { it.uuid == uuid }.takeIf { it >= 0 } + } ?: state.fallbackUnreadIndex + } + } + val showUnreadDivider = state.hasUnreadMessages && unreadDividerIndex != null + AutoScrollToBottom(listState, state.messages, state.hasUnreadMessages) + UpdateUnreadCount(listState, state.messages, handlers.onUnreadChanged) var showStatusDialog by remember { mutableStateOf(null) } - if (showStatusDialog != null) { - val msg = showStatusDialog ?: return - val (title, text) = msg.getStatusStringRes() - val relayNodeName by - remember(msg.relayNode, nodes) { - derivedStateOf { - msg.relayNode?.let { relayNodeId -> Packet.getRelayNode(relayNodeId, nodes)?.user?.longName } - } - } - DeliveryInfo( - title = title, - text = text, - relayNodeName = relayNodeName, - onConfirm = { - val deleteList: List = listOf(msg.uuid) - onDeleteMessages(deleteList) + showStatusDialog?.let { message -> + MessageStatusDialog( + message = message, + nodes = state.nodes, + resendOption = message.status?.equals(MessageStatus.ERROR) ?: false, + onResend = { + handlers.onDeleteMessages(listOf(message.uuid)) + handlers.onSendMessage(message.text, state.contactKey) showStatusDialog = null - onSendMessage(msg.text, contactKey) }, onDismiss = { showStatusDialog = null }, - resendOption = msg.status?.equals(MessageStatus.ERROR) ?: false, ) } var showReactionDialog by remember { mutableStateOf?>(null) } - if (showReactionDialog != null) { - val reactions = showReactionDialog ?: return - ReactionDialog(reactions) { showReactionDialog = null } - } - - fun MutableState>.toggle(uuid: Long) = if (value.contains(uuid)) { - value -= uuid - } else { - value += uuid - } + showReactionDialog?.let { reactions -> ReactionDialog(reactions) { showReactionDialog = null } } val coroutineScope = rememberCoroutineScope() - LazyColumn(modifier = modifier.fillMaxSize(), state = listState, reverseLayout = true) { - items(messages, key = { it.uuid }) { msg -> - if (ourNode != null) { - val selected by remember { derivedStateOf { selectedIds.value.contains(msg.uuid) } } - val node by remember { derivedStateOf { nodes.find { it.num == msg.node.num } ?: msg.node } } + val messageRows = + rememberMessageRows( + messages = state.messages, + showUnreadDivider = showUnreadDivider, + unreadDividerIndex = unreadDividerIndex, + initialUnreadMessageUuid = state.initialUnreadMessageUuid, + ) - MessageItem( - modifier = Modifier.animateItem(), - node = node, + MessageListContent( + listState = listState, + messageRows = messageRows, + state = state, + handlers = handlers, + inSelectionMode = inSelectionMode, + onShowStatusDialog = { showStatusDialog = it }, + onShowReactions = { showReactionDialog = it }, + coroutineScope = coroutineScope, + haptics = haptics, + modifier = modifier, + ) +} + +private sealed interface MessageListRow { + data class ChatMessage(val index: Int, val message: Message) : MessageListRow + + data class UnreadDivider(val key: String) : MessageListRow +} + +@Composable +private fun MessageRowContent( + row: MessageListRow, + state: MessageListState, + handlers: MessageListHandlers, + inSelectionMode: Boolean, + listState: LazyListState, + coroutineScope: CoroutineScope, + haptics: HapticFeedback, + onShowStatusDialog: (Message) -> Unit, + onShowReactions: (List) -> Unit, +) { + when (row) { + is MessageListRow.UnreadDivider -> UnreadMessagesDivider() + is MessageListRow.ChatMessage -> + state.ourNode?.let { ourNode -> + ChatMessageRow( + row = row, + state = state, + handlers = handlers, + inSelectionMode = inSelectionMode, + listState = listState, + coroutineScope = coroutineScope, + haptics = haptics, + onShowStatusDialog = onShowStatusDialog, + onShowReactions = onShowReactions, ourNode = ourNode, - message = msg, - selected = selected, - onClick = { if (inSelectionMode) selectedIds.toggle(msg.uuid) }, - onLongClick = { - selectedIds.toggle(msg.uuid) - haptics.performHapticFeedback(HapticFeedbackType.LongPress) - }, - onClickChip = onClickChip, - onStatusClick = { showStatusDialog = msg }, - onReply = { onReply(msg) }, - emojis = msg.emojis, - sendReaction = { onSendReaction(it, msg.packetId) }, - onShowReactions = { showReactionDialog = msg.emojis }, - onNavigateToOriginalMessage = { - coroutineScope.launch { - val targetIndex = messages.indexOfFirst { it.packetId == msg.replyId } - if (targetIndex != -1) { - listState.animateScrollToItem(index = targetIndex) - } - } - }, ) } + } +} + +@Composable +private fun ChatMessageRow( + row: MessageListRow.ChatMessage, + state: MessageListState, + handlers: MessageListHandlers, + inSelectionMode: Boolean, + listState: LazyListState, + coroutineScope: CoroutineScope, + haptics: HapticFeedback, + onShowStatusDialog: (Message) -> Unit, + onShowReactions: (List) -> Unit, + ourNode: Node, +) { + val message = row.message + val selected by + remember(message.uuid, state.selectedIds.value) { + derivedStateOf { state.selectedIds.value.contains(message.uuid) } + } + val node by + remember(message.node.num, state.nodes) { + derivedStateOf { state.nodes.find { it.num == message.node.num } ?: message.node } + } + + MessageItem( + node = node, + ourNode = ourNode, + message = message, + selected = selected, + onClick = { + if (inSelectionMode) { + state.selectedIds.value = + if (selected) state.selectedIds.value - message.uuid else state.selectedIds.value + message.uuid + } + }, + onLongClick = { + state.selectedIds.value = + if (selected) state.selectedIds.value - message.uuid else state.selectedIds.value + message.uuid + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onClickChip = handlers.onClickChip, + onStatusClick = { onShowStatusDialog(message) }, + onReply = { handlers.onReply(message) }, + emojis = message.emojis, + sendReaction = { handlers.onSendReaction(it, message.packetId) }, + onShowReactions = { onShowReactions(message.emojis) }, + onNavigateToOriginalMessage = { + coroutineScope.launch { + val targetIndex = state.messages.indexOfFirst { it.packetId == message.replyId } + if (targetIndex != -1) { + listState.animateScrollToItem(index = targetIndex) + } + } + }, + ) +} + +@Composable +private fun MessageListContent( + listState: LazyListState, + messageRows: List, + state: MessageListState, + handlers: MessageListHandlers, + inSelectionMode: Boolean, + onShowStatusDialog: (Message) -> Unit, + onShowReactions: (List) -> Unit, + coroutineScope: CoroutineScope, + haptics: HapticFeedback, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier.fillMaxSize(), state = listState, reverseLayout = true) { + items( + items = messageRows, + key = { row -> + when (row) { + is MessageListRow.ChatMessage -> row.message.uuid + is MessageListRow.UnreadDivider -> row.key + } + }, + ) { row -> + MessageRowContent( + row = row, + state = state, + handlers = handlers, + inSelectionMode = inSelectionMode, + listState = listState, + coroutineScope = coroutineScope, + haptics = haptics, + onShowStatusDialog = onShowStatusDialog, + onShowReactions = onShowReactions, + ) } } } @Composable -private fun AutoScrollToBottom(listState: LazyListState, list: List, itemThreshold: Int = 3) = with(listState) { - val shouldAutoScroll by remember { derivedStateOf { firstVisibleItemIndex < itemThreshold } } +private fun MessageStatusDialog( + message: Message, + nodes: List, + resendOption: Boolean, + onResend: () -> Unit, + onDismiss: () -> Unit, +) { + val (title, text) = message.getStatusStringRes() + val relayNodeName by + remember(message.relayNode, nodes) { + derivedStateOf { + message.relayNode?.let { relayNodeId -> Packet.getRelayNode(relayNodeId, nodes)?.user?.longName } + } + } + DeliveryInfo( + title = title, + resendOption = resendOption, + text = text, + relayNodeName = relayNodeName, + onConfirm = onResend, + onDismiss = onDismiss, + ) +} + +@Composable +private fun rememberMessageRows( + messages: List, + showUnreadDivider: Boolean, + unreadDividerIndex: Int?, + initialUnreadMessageUuid: Long?, +) = remember(messages, showUnreadDivider, unreadDividerIndex, initialUnreadMessageUuid) { + buildList { + messages.forEachIndexed { index, message -> + add(MessageListRow.ChatMessage(index = index, message = message)) + if (showUnreadDivider && unreadDividerIndex == index) { + val key = initialUnreadMessageUuid?.let { "unread-divider-$it" } ?: "unread-divider-index-$index" + add(MessageListRow.UnreadDivider(key = key)) + } + } + } +} + +/** + * Calculates the index of the first unread remote message. + * + * We track unread state with two sources: the persisted timestamp of the last read message and the in-memory + * `Message.read` flag. The timestamp helps when the local flag state is stale (e.g. after app restarts), while the flag + * catches messages that are already marked read locally. We take the maximum of the two indices to target the oldest + * unread entry that still needs attention. The message list is newest-first, so we deliberately use `lastOrNull` for + * the timestamp branch to land on the oldest unread item after the stored mark. + */ +internal fun findEarliestUnreadIndex(messages: List, lastReadMessageTimestamp: Long?): Int? { + val remoteMessages = messages.withIndex().filter { !it.value.fromLocal } + if (remoteMessages.isEmpty()) { + return null + } + val timestampIndex = + lastReadMessageTimestamp?.let { timestamp -> + remoteMessages.lastOrNull { it.value.receivedTime > timestamp }?.index + } + val readFlagIndex = messages.indexOfLast { !it.read && !it.fromLocal }.takeIf { it != -1 } + return listOfNotNull(timestampIndex, readFlagIndex).maxOrNull() +} + +@Composable +private fun UnreadMessagesDivider(modifier: Modifier = Modifier) { + Row( + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text( + text = stringResource(Res.string.new_messages_below), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun AutoScrollToBottom( + listState: LazyListState, + list: List, + hasUnreadMessages: Boolean, + itemThreshold: Int = 3, +) = with(listState) { + val shouldAutoScroll by + remember(hasUnreadMessages) { + derivedStateOf { !hasUnreadMessages && firstVisibleItemIndex < itemThreshold } + } if (shouldAutoScroll) { LaunchedEffect(list) { if (!isScrollInProgress) { @@ -228,15 +380,21 @@ private fun AutoScrollToBottom(listState: LazyListState, list: List, item @OptIn(FlowPreview::class) @Composable -private fun UpdateUnreadCount(listState: LazyListState, messages: List, onUnreadChanged: (Long) -> Unit) { +private fun UpdateUnreadCount( + listState: LazyListState, + messages: List, + onUnreadChanged: (Long, Long) -> Unit, +) { LaunchedEffect(messages) { snapshotFlow { listState.firstVisibleItemIndex } .debounce(timeoutMillis = 500L) .collectLatest { index -> - val lastUnreadIndex = messages.indexOfLast { !it.read } + val lastUnreadIndex = messages.indexOfLast { !it.read && !it.fromLocal } if (lastUnreadIndex != -1 && index <= lastUnreadIndex && index < messages.size) { val visibleMessage = messages[index] - onUnreadChanged(visibleMessage.receivedTime) + if (!visibleMessage.read && !visibleMessage.fromLocal) { + onUnreadChanged(visibleMessage.uuid, visibleMessage.receivedTime) + } } } } diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageScreenEvent.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageScreenEvent.kt index 62bf93aa8..14e3d886c 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageScreenEvent.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageScreenEvent.kt @@ -30,8 +30,8 @@ internal sealed interface MessageScreenEvent { /** Delete one or more selected messages. */ data class DeleteMessages(val ids: List) : MessageScreenEvent - /** Mark messages up to a certain ID as read. */ - data class ClearUnreadCount(val lastReadMessageId: Long) : MessageScreenEvent + /** Mark messages up to a certain message as read. */ + data class ClearUnreadCount(val messageUuid: Long, val lastReadTimestamp: Long) : MessageScreenEvent /** Handle an action from a node's context menu. */ data class NodeDetails(val node: Node) : MessageScreenEvent diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index 0864b9dcf..07c0e8597 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -33,6 +33,7 @@ import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.data.repository.PacketRepository import org.meshtastic.core.data.repository.QuickChatActionRepository import org.meshtastic.core.data.repository.RadioConfigRepository +import org.meshtastic.core.database.entity.ContactSettings import org.meshtastic.core.database.model.Message import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DataPacket @@ -78,6 +79,9 @@ constructor( val quickChatActions = quickChatActionRepository.getAllActions().stateInWhileSubscribed(initialValue = emptyList()) + val contactSettings: StateFlow> = + packetRepository.getContactSettings().stateInWhileSubscribed(initialValue = emptyMap()) + private val contactKeyForMessages: MutableStateFlow = MutableStateFlow(null) private val messagesForContactKey: StateFlow> = contactKeyForMessages @@ -158,11 +162,17 @@ constructor( fun deleteMessages(uuidList: List) = viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteMessages(uuidList) } - fun clearUnreadCount(contact: String, timestamp: Long) = viewModelScope.launch(Dispatchers.IO) { - packetRepository.clearUnreadCount(contact, timestamp) - val unreadCount = packetRepository.getUnreadCount(contact) - if (unreadCount == 0) meshServiceNotifications.cancelMessageNotification(contact) - } + fun clearUnreadCount(contact: String, messageUuid: Long, lastReadTimestamp: Long) = + viewModelScope.launch(Dispatchers.IO) { + val existingTimestamp = contactSettings.value[contact]?.lastReadMessageTimestamp ?: Long.MIN_VALUE + if (lastReadTimestamp <= existingTimestamp) { + return@launch + } + packetRepository.clearUnreadCount(contact, lastReadTimestamp) + packetRepository.updateLastReadMessage(contact, messageUuid, lastReadTimestamp) + val unreadCount = packetRepository.getUnreadCount(contact) + if (unreadCount == 0) meshServiceNotifications.cancelMessageNotification(contact) + } private fun favoriteNode(node: Node) = viewModelScope.launch { try {