feat: jump to oldest unread message upon opening a thread, display divider between read/unread (#3693)

This commit is contained in:
Mac DeCourcy 2025-11-14 11:03:46 -08:00 committed by GitHub
parent 427fb33e7e
commit 2a081f3c1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1322 additions and 209 deletions

View file

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

View file

@ -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')"
]
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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