mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: jump to oldest unread message upon opening a thread, display divider between read/unread (#3693)
This commit is contained in:
parent
427fb33e7e
commit
2a081f3c1f
12 changed files with 1322 additions and 209 deletions
|
|
@ -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<DataPacket>? =
|
||||
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() }
|
||||
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@
|
|||
<string name="debug_clear_logs_confirm">This will remove all log packets and database entries from your device - It is a full reset, and is permanent.</string>
|
||||
<string name="clear">Clear</string>
|
||||
<string name="message_delivery_status">Message delivery status</string>
|
||||
<string name="new_messages_below">New messages below</string>
|
||||
<string name="meshtastic_messages_notifications">Direct message notifications</string>
|
||||
<string name="meshtastic_broadcast_notifications">Broadcast message notifications</string>
|
||||
<string name="meshtastic_alerts_notifications">Alert notifications</string>
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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,
|
||||
)
|
||||
|
|
@ -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<Int?>(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<Long?>(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) {
|
||||
|
|
|
|||
|
|
@ -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<Node>,
|
||||
val ourNode: Node?,
|
||||
val messages: List<Message>,
|
||||
val selectedIds: MutableState<Set<Long>>,
|
||||
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<Long>) -> Unit,
|
||||
val onSendMessage: (String, String) -> Unit,
|
||||
val onReply: (Message?) -> Unit,
|
||||
)
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
internal fun MessageList(
|
||||
nodes: List<Node>,
|
||||
ourNode: Node?,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
messages: List<Message>,
|
||||
selectedIds: MutableState<Set<Long>>,
|
||||
onUnreadChanged: (Long) -> Unit,
|
||||
onSendReaction: (String, Int) -> Unit,
|
||||
onClickChip: (Node) -> Unit,
|
||||
onDeleteMessages: (List<Long>) -> 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<Message?>(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<Long> = 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<List<Reaction>?>(null) }
|
||||
if (showReactionDialog != null) {
|
||||
val reactions = showReactionDialog ?: return
|
||||
ReactionDialog(reactions) { showReactionDialog = null }
|
||||
}
|
||||
|
||||
fun MutableState<Set<Long>>.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<Reaction>) -> 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<Reaction>) -> 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<MessageListRow>,
|
||||
state: MessageListState,
|
||||
handlers: MessageListHandlers,
|
||||
inSelectionMode: Boolean,
|
||||
onShowStatusDialog: (Message) -> Unit,
|
||||
onShowReactions: (List<Reaction>) -> 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 <T> AutoScrollToBottom(listState: LazyListState, list: List<T>, itemThreshold: Int = 3) = with(listState) {
|
||||
val shouldAutoScroll by remember { derivedStateOf { firstVisibleItemIndex < itemThreshold } }
|
||||
private fun MessageStatusDialog(
|
||||
message: Message,
|
||||
nodes: List<Node>,
|
||||
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<Message>,
|
||||
showUnreadDivider: Boolean,
|
||||
unreadDividerIndex: Int?,
|
||||
initialUnreadMessageUuid: Long?,
|
||||
) = remember(messages, showUnreadDivider, unreadDividerIndex, initialUnreadMessageUuid) {
|
||||
buildList<MessageListRow> {
|
||||
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<Message>, 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 <T> AutoScrollToBottom(
|
||||
listState: LazyListState,
|
||||
list: List<T>,
|
||||
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 <T> AutoScrollToBottom(listState: LazyListState, list: List<T>, item
|
|||
|
||||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
private fun UpdateUnreadCount(listState: LazyListState, messages: List<Message>, onUnreadChanged: (Long) -> Unit) {
|
||||
private fun UpdateUnreadCount(
|
||||
listState: LazyListState,
|
||||
messages: List<Message>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ internal sealed interface MessageScreenEvent {
|
|||
/** Delete one or more selected messages. */
|
||||
data class DeleteMessages(val ids: List<Long>) : 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
|
||||
|
|
|
|||
|
|
@ -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<Map<String, ContactSettings>> =
|
||||
packetRepository.getContactSettings().stateInWhileSubscribed(initialValue = emptyMap())
|
||||
|
||||
private val contactKeyForMessages: MutableStateFlow<String?> = MutableStateFlow(null)
|
||||
private val messagesForContactKey: StateFlow<List<Message>> =
|
||||
contactKeyForMessages
|
||||
|
|
@ -158,11 +162,17 @@ constructor(
|
|||
fun deleteMessages(uuidList: List<Long>) =
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue