feat: Add Jetpack Paging 3 support for messages and threads/contacts (#3795)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Mac DeCourcy 2025-11-24 06:14:05 -08:00 committed by GitHub
parent 552097888f
commit 5e8c9794eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 551 additions and 250 deletions

View file

@ -230,6 +230,7 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.paging.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.coil.svg)
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)

View file

@ -17,6 +17,7 @@
package com.geeksville.mesh.ui.contact
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@ -36,6 +37,7 @@ import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.rounded.QrCode2
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButton
@ -63,6 +65,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import com.geeksville.mesh.model.Contact
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
@ -70,6 +76,7 @@ import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.model.util.formatMuteRemainingTime
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.close_selection
@ -116,7 +123,27 @@ fun ContactsScreen(
val isSelectionModeActive by remember { derivedStateOf { selectedContactKeys.isNotEmpty() } }
// State for contacts list
val contacts by viewModel.contactList.collectAsStateWithLifecycle()
val pagedContacts = viewModel.contactListPaged.collectAsLazyPagingItems()
// Create channel placeholders (always show broadcast contacts, even when empty)
val channels by viewModel.channels.collectAsStateWithLifecycle()
val channelPlaceholders =
remember(channels.settingsList.size) {
(0 until channels.settingsList.size).map { ch ->
Contact(
contactKey = "$ch^all",
shortName = "$ch",
longName = channels.getChannel(ch)?.name ?: "Channel $ch",
lastMessageTime = "",
lastMessageText = "",
unreadCount = 0,
messageCount = 0,
isMuted = false,
isUnmessageable = false,
nodeColors = null,
)
}
}
val contactsListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
@ -131,7 +158,11 @@ fun ContactsScreen(
// Derived state for selected contacts and count
val selectedContacts =
remember(contacts, selectedContactKeys) { contacts.filter { it.contactKey in selectedContactKeys } }
remember(pagedContacts.itemCount, selectedContactKeys) {
(0 until pagedContacts.itemCount)
.mapNotNull { pagedContacts[it] }
.filter { it.contactKey in selectedContactKeys }
}
val selectedCount = remember(selectedContacts) { selectedContacts.sumOf { it.messageCount } }
val isAllMuted = remember(selectedContacts) { selectedContacts.all { it.isMuted } }
@ -209,15 +240,17 @@ fun ContactsScreen(
onDeleteSelected = { showDeleteDialog = true },
onSelectAll = {
selectedContactKeys.clear()
selectedContactKeys.addAll(contacts.map { it.contactKey })
selectedContactKeys.addAll(
(0 until pagedContacts.itemCount).mapNotNull { pagedContacts[it]?.contactKey },
)
},
isAllMuted = isAllMuted, // Pass the derived state
)
}
val channels by viewModel.channels.collectAsStateWithLifecycle()
ContactListView(
contacts = contacts,
ContactListViewPaged(
contacts = pagedContacts,
channelPlaceholders = channelPlaceholders,
selectedList = selectedContactKeys,
onClick = onContactClick,
onLongClick = onContactLongClick,
@ -435,6 +468,14 @@ fun SelectionToolbar(
)
}
/**
* Non-paginated contact list view.
*
* NOTE: This is kept for ShareScreen which displays a simple contact picker. The main ContactsScreen uses
* [ContactListViewPaged] for better performance.
*
* @see ContactListViewPaged for the paginated version
*/
@Composable
fun ContactListView(
contacts: List<Contact>,
@ -464,3 +505,96 @@ fun ContactListView(
}
}
}
@Composable
fun ContactListViewPaged(
contacts: LazyPagingItems<Contact>,
channelPlaceholders: List<Contact>,
selectedList: List<String>,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
listState: LazyListState,
modifier: Modifier = Modifier,
channels: AppOnlyProtos.ChannelSet? = null,
) {
val haptics = LocalHapticFeedback.current
// Handle initial loading state
if (contacts.loadState.refresh is LoadState.Loading && contacts.itemCount == 0) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
return
}
val visiblePlaceholders = rememberVisiblePlaceholders(contacts, channelPlaceholders)
LazyColumn(modifier = modifier.fillMaxSize(), state = listState) {
// Show channel placeholders first
items(
count = visiblePlaceholders.size,
key = { index -> "placeholder_${visiblePlaceholders[index].contactKey}" },
) { index ->
val placeholder = visiblePlaceholders[index]
val selected by remember { derivedStateOf { selectedList.contains(placeholder.contactKey) } }
ContactItem(
contact = placeholder,
selected = selected,
onClick = { onClick(placeholder) },
onLongClick = {
onLongClick(placeholder)
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
},
channels = channels,
onNodeChipClick = { onNodeChipClick(placeholder) },
)
}
// Show paged contacts
items(count = contacts.itemCount, key = contacts.itemKey { it.contactKey }) { index ->
val contact = contacts[index]
if (contact != null) {
val selected by remember { derivedStateOf { selectedList.contains(contact.contactKey) } }
ContactItem(
contact = contact,
selected = selected,
onClick = { onClick(contact) },
onLongClick = {
onLongClick(contact)
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
},
channels = channels,
onNodeChipClick = { onNodeChipClick(contact) },
)
}
}
// Loading indicator when loading more contacts
contacts.apply {
when {
loadState.append is LoadState.Loading -> {
item(key = "append_loading") {
Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
}
}
}
}
@Composable
private fun rememberVisiblePlaceholders(
contacts: LazyPagingItems<Contact>,
channelPlaceholders: List<Contact>,
): List<Contact> {
val contactKeys by
remember(contacts.itemCount) {
derivedStateOf { (0 until contacts.itemCount).mapNotNull { contacts[it]?.contactKey }.toSet() }
}
return remember(channelPlaceholders, contactKeys) {
channelPlaceholders.filter { placeholder -> !contactKeys.contains(placeholder.contactKey) }
}
}

View file

@ -19,11 +19,18 @@ package com.geeksville.mesh.ui.contact
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import com.geeksville.mesh.model.Contact
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
@ -38,7 +45,7 @@ import org.meshtastic.core.strings.channel_name
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.channelSet
import javax.inject.Inject
import kotlin.collections.map
import kotlin.collections.map as collectionsMap
@HiltViewModel
class ContactsViewModel
@ -55,6 +62,14 @@ constructor(
val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = channelSet {})
/**
* Non-paginated contact list.
*
* NOTE: This is kept for ShareScreen which needs a simple, non-paginated list of contacts. The main ContactsScreen
* uses [contactListPaged] instead for better performance with large contact lists.
*
* @see contactListPaged for the paginated version used in ContactsScreen
*/
val contactList =
combine(
nodeRepository.myNodeInfo,
@ -71,7 +86,7 @@ constructor(
contactKey to Packet(0L, myNodeNum, 1, contactKey, 0L, true, data)
}
(contacts + (placeholder - contacts.keys)).values.map { packet ->
(contacts + (placeholder - contacts.keys)).values.collectionsMap { packet ->
val data = packet.data
val contactKey = packet.contact_key
@ -112,6 +127,58 @@ constructor(
}
.stateInWhileSubscribed(initialValue = emptyList())
val contactListPaged: Flow<PagingData<Contact>> =
combine(nodeRepository.myNodeInfo, channels, packetRepository.getContactSettings()) {
myNodeInfo,
channelSet,
settings,
->
Triple(myNodeInfo?.myNodeNum, channelSet, settings)
}
.flatMapLatest { (myNodeNum, channelSet, settings) ->
packetRepository.getContactsPaged().map { pagingData ->
pagingData.map { packet ->
val data = packet.data
val contactKey = packet.contact_key
// Determine if this is my message (originated on this device)
val fromLocal = data.from == DataPacket.ID_LOCAL
val toBroadcast = data.to == DataPacket.ID_BROADCAST
// grab usernames from NodeInfo
val user = getUser(if (fromLocal) data.to else data.from)
val node = getNode(if (fromLocal) data.to else data.from)
val shortName = user.shortName
val longName =
if (toBroadcast) {
channelSet.getChannel(data.channel)?.name ?: getString(Res.string.channel_name)
} else {
user.longName
}
Contact(
contactKey = contactKey,
shortName = if (toBroadcast) "${data.channel}" else shortName,
longName = longName,
lastMessageTime = getShortDate(data.time),
lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}",
unreadCount = runBlocking(Dispatchers.IO) { packetRepository.getUnreadCount(contactKey) },
messageCount = runBlocking(Dispatchers.IO) { packetRepository.getMessageCount(contactKey) },
isMuted = settings[contactKey]?.isMuted == true,
isUnmessageable = user.isUnmessagable,
nodeColors =
if (!toBroadcast) {
node.colors
} else {
null
},
)
}
}
}
.cachedIn(viewModelScope)
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
fun deleteContacts(contacts: List<String>) =

View file

@ -36,8 +36,10 @@ dependencies {
// Needed because core:data references MeshtasticDatabase (supertype RoomDatabase)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.paging)
implementation(libs.androidx.core.location.altitude)
implementation(libs.androidx.paging.common)
implementation(libs.kotlinx.serialization.json)
implementation(libs.timber)
}

View file

@ -17,14 +17,20 @@
package org.meshtastic.core.data.repository
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.map
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
@ -45,12 +51,24 @@ constructor(
fun getContacts(): Flow<Map<String, Packet>> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getContactKeys() }
fun getContactsPaged(): Flow<PagingData<Packet>> = Pager(
config = PagingConfig(pageSize = 30, enablePlaceholders = false, initialLoadSize = 30),
pagingSourceFactory = { dbManager.currentDb.value.packetDao().getContactKeysPaged() },
)
.flow
suspend fun getMessageCount(contact: String): Int =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getMessageCount(contact) }
suspend fun getUnreadCount(contact: String): Int =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getUnreadCount(contact) }
fun getFirstUnreadMessageUuid(contact: String): Flow<Long?> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFirstUnreadMessageUuid(contact) }
fun hasUnreadMessages(contact: String): Flow<Boolean> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().hasUnreadMessages(contact) }
fun getUnreadCountTotal(): Flow<Int> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountTotal() }
@ -92,6 +110,22 @@ constructor(
}
}
fun getMessagesFromPaged(contact: String, getNode: suspend (String?) -> Node): Flow<PagingData<Message>> = Pager(
config = PagingConfig(pageSize = 50, enablePlaceholders = false, initialLoadSize = 50),
pagingSourceFactory = { dbManager.currentDb.value.packetDao().getMessagesFromPaged(contact) },
)
.flow
.map { pagingData ->
pagingData.map { packet ->
val message = packet.toMessage(getNode)
message.replyId
.takeIf { it != null && it != 0 }
?.let { getPacketByPacketId(it) }
?.toMessage(getNode)
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
}
}
suspend fun updateMessageStatus(d: DataPacket, m: MessageStatus) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateMessageStatus(d, m) }

View file

@ -35,6 +35,7 @@ dependencies {
implementation(projects.core.proto)
implementation(projects.core.strings)
implementation(libs.androidx.room.paging)
implementation(libs.kotlinx.serialization.json)
implementation(libs.timber)

View file

@ -17,6 +17,7 @@
package org.meshtastic.core.database.dao
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.MapColumn
import androidx.room.Query
@ -62,6 +63,23 @@ interface PacketDao {
>,
>
@Query(
"""
SELECT p.* FROM packet p
INNER JOIN (
SELECT contact_key, MAX(received_time) as max_time
FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = 1
GROUP BY contact_key
) latest ON p.contact_key = latest.contact_key AND p.received_time = latest.max_time
WHERE (p.myNodeNum = 0 OR p.myNodeNum = (SELECT myNodeNum FROM my_node))
AND p.port_num = 1
ORDER BY p.received_time DESC
""",
)
fun getContactKeysPaged(): PagingSource<Int, Packet>
@Query(
"""
SELECT COUNT(*) FROM packet
@ -80,6 +98,26 @@ interface PacketDao {
)
suspend fun getUnreadCount(contact: String): Int
@Query(
"""
SELECT uuid FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = 1 AND contact_key = :contact AND read = 0
ORDER BY received_time ASC
LIMIT 1
""",
)
fun getFirstUnreadMessageUuid(contact: String): Flow<Long?>
@Query(
"""
SELECT COUNT(*) > 0 FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = 1 AND contact_key = :contact AND read = 0
""",
)
fun hasUnreadMessages(contact: String): Flow<Boolean>
@Query(
"""
SELECT COUNT(*) FROM packet
@ -112,6 +150,17 @@ interface PacketDao {
)
fun getMessagesFrom(contact: String): Flow<List<PacketEntity>>
@Transaction
@Query(
"""
SELECT * FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = 1 AND contact_key = :contact
ORDER BY received_time DESC
""",
)
fun getMessagesFromPaged(contact: String): PagingSource<Int, PacketEntity>
@Query(
"""
SELECT * FROM packet

View file

@ -39,6 +39,7 @@ dependencies {
implementation(libs.androidx.compose.ui.text)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
implementation(libs.androidx.paging.compose)
implementation(libs.timber)
debugImplementation(libs.androidx.compose.ui.test.manifest)

View file

@ -93,6 +93,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.collectAsLazyPagingItems
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.pluralStringResource
@ -166,7 +167,7 @@ fun MessageScreen(
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val channels by viewModel.channels.collectAsStateWithLifecycle()
val quickChatActions by viewModel.quickChatActions.collectAsStateWithLifecycle(initialValue = emptyList())
val messages by viewModel.getMessagesFrom(contactKey).collectAsStateWithLifecycle(initialValue = emptyList())
val pagedMessages = viewModel.getMessagesFromPaged(contactKey).collectAsLazyPagingItems()
val contactSettings by viewModel.contactSettings.collectAsStateWithLifecycle(initialValue = emptyMap())
// UI State managed within this Composable
@ -211,55 +212,35 @@ fun MessageScreen(
derivedStateOf { contactSettings[contactKey]?.lastReadMessageTimestamp }
}
// Track unread messages using lightweight metadata queries
val hasUnreadMessages by viewModel.hasUnreadMessages(contactKey).collectAsStateWithLifecycle(initialValue = false)
val firstUnreadMessageUuid by
viewModel.getFirstUnreadMessageUuid(contactKey).collectAsStateWithLifecycle(initialValue = null)
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) {
// Find the index of the first unread message in the paged list
val firstUnreadIndex by
remember(pagedMessages.itemCount, firstUnreadMessageUuid) {
derivedStateOf {
initialUnreadMessageUuid?.let { uuid -> messages.indexOfFirst { it.uuid == uuid } }?.takeIf { it >= 0 }
firstUnreadMessageUuid?.let { uuid ->
(0 until pagedMessages.itemCount).firstOrNull { index -> pagedMessages[index]?.uuid == uuid }
}
}
}
LaunchedEffect(messages, initialUnreadIndex, earliestUnreadIndex) {
if (!hasPerformedInitialScroll && messages.isNotEmpty()) {
val unreadStart = initialUnreadIndex ?: earliestUnreadIndex
val targetIndex =
when {
unreadStart == null -> 0
unreadStart <= 0 -> 0
else -> (unreadStart - (UnreadUiDefaults.VISIBLE_CONTEXT_COUNT - 1)).coerceAtLeast(0)
}
if (listState.firstVisibleItemIndex != targetIndex) {
listState.smartScrollToIndex(coroutineScope = coroutineScope, targetIndex = targetIndex)
}
// Scroll to first unread message on initial load
LaunchedEffect(hasPerformedInitialScroll, firstUnreadIndex, pagedMessages.itemCount) {
if (hasPerformedInitialScroll || pagedMessages.itemCount == 0) return@LaunchedEffect
val shouldScrollToUnread = hasUnreadMessages && firstUnreadIndex != null
if (shouldScrollToUnread) {
val targetIndex = (firstUnreadIndex!! - (UnreadUiDefaults.VISIBLE_CONTEXT_COUNT - 1)).coerceAtLeast(0)
listState.smartScrollToIndex(coroutineScope = coroutineScope, targetIndex = targetIndex)
hasPerformedInitialScroll = true
} else if (!hasUnreadMessages) {
// If no unread messages, just scroll to bottom (most recent)
listState.scrollToItem(0)
hasPerformedInitialScroll = true
}
}
@ -322,7 +303,8 @@ fun MessageScreen(
when (action) {
MessageMenuAction.ClipboardCopy -> {
val copiedText =
messages
(0 until pagedMessages.itemCount)
.mapNotNull { pagedMessages[it] }
.filter { it.uuid in selectedMessageIds.value }
.joinToString("\n") { it.text }
onEvent(MessageScreenEvent.CopyToClipboard(copiedText))
@ -331,11 +313,14 @@ fun MessageScreen(
MessageMenuAction.Delete -> showDeleteDialog = true
MessageMenuAction.Dismiss -> selectedMessageIds.value = emptySet()
MessageMenuAction.SelectAll -> {
// Note: Select All is disabled with pagination since we don't have
// access to the full message list. This would need to be reworked
// to select all currently loaded items instead.
selectedMessageIds.value =
if (selectedMessageIds.value.size == messages.size) {
if (selectedMessageIds.value.size == pagedMessages.itemCount) {
emptySet()
} else {
messages.map { it.uuid }.toSet()
(0 until pagedMessages.itemCount).mapNotNull { pagedMessages[it]?.uuid }.toSet()
}
}
}
@ -358,19 +343,18 @@ fun MessageScreen(
) { paddingValues ->
Column(Modifier.padding(paddingValues)) {
Box(modifier = Modifier.weight(1f)) {
MessageList(
MessageListPaged(
modifier = Modifier.fillMaxSize(),
listState = listState,
state =
MessageListState(
MessageListPagedState(
nodes = nodes,
ourNode = ourNode,
messages = messages,
messages = pagedMessages,
selectedIds = selectedMessageIds,
hasUnreadMessages = hasUnreadMessages,
initialUnreadMessageUuid = initialUnreadMessageUuid,
fallbackUnreadIndex = earliestUnreadIndex,
contactKey = contactKey,
firstUnreadMessageUuid = firstUnreadMessageUuid,
hasUnreadMessages = hasUnreadMessages,
),
handlers =
MessageListHandlers(
@ -403,9 +387,13 @@ fun MessageScreen(
)
}
val originalMessage by
remember(replyingToPacketId, messages) {
remember(replyingToPacketId, pagedMessages.itemCount) {
derivedStateOf {
replyingToPacketId?.let { messages.firstOrNull { it.packetId == replyingToPacketId } }
replyingToPacketId?.let { id ->
(0 until pagedMessages.itemCount).firstNotNullOfOrNull { index ->
pagedMessages[index]?.takeIf { it.packetId == id }
}
}
}
}
ReplySnippet(

View file

@ -18,6 +18,7 @@
package org.meshtastic.feature.messaging
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -25,8 +26,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -38,6 +39,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
@ -46,6 +48,9 @@ import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
@ -61,19 +66,6 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.new_messages_below
import org.meshtastic.feature.messaging.component.MessageItem
import org.meshtastic.feature.messaging.component.ReactionDialog
import timber.log.Timber
import kotlin.collections.buildList
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,
@ -84,6 +76,16 @@ internal data class MessageListHandlers(
val onReply: (Message?) -> Unit,
)
internal data class MessageListPagedState(
val nodes: List<Node>,
val ourNode: Node?,
val messages: LazyPagingItems<Message>,
val selectedIds: MutableState<Set<Long>>,
val contactKey: String,
val firstUnreadMessageUuid: Long? = null,
val hasUnreadMessages: Boolean = false,
)
private fun MutableState<Set<Long>>.toggle(uuid: Long) {
value =
if (value.contains(uuid)) {
@ -94,25 +96,14 @@ private fun MutableState<Set<Long>>.toggle(uuid: Long) {
}
@Composable
internal fun MessageList(
internal fun MessageListPaged(
state: MessageListPagedState,
handlers: MessageListHandlers,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
state: MessageListState,
handlers: MessageListHandlers,
) {
val haptics = LocalHapticFeedback.current
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) }
showStatusDialog?.let { message ->
@ -133,17 +124,19 @@ internal fun MessageList(
showReactionDialog?.let { reactions -> ReactionDialog(reactions) { showReactionDialog = null } }
val coroutineScope = rememberCoroutineScope()
val messageRows =
rememberMessageRows(
messages = state.messages,
showUnreadDivider = showUnreadDivider,
unreadDividerIndex = unreadDividerIndex,
initialUnreadMessageUuid = state.initialUnreadMessageUuid,
)
MessageListContent(
// Track unread count based on scroll position
UpdateUnreadCountPaged(listState = listState, messages = state.messages, onUnreadChange = handlers.onUnreadChanged)
// Auto-scroll to bottom when new messages arrive
AutoScrollToBottomPaged(
listState = listState,
messages = state.messages,
hasUnreadMessages = state.hasUnreadMessages,
)
MessageListPagedContent(
listState = listState,
messageRows = messageRows,
state = state,
handlers = handlers,
inSelectionMode = inSelectionMode,
@ -155,17 +148,10 @@ internal fun MessageList(
)
}
private sealed interface MessageListRow {
data class ChatMessage(val index: Int, val message: Message) : MessageListRow
data class UnreadDivider(val key: String) : MessageListRow
}
@Composable
private fun MessageListContent(
private fun MessageListPagedContent(
listState: LazyListState,
messageRows: List<MessageListRow>,
state: MessageListState,
state: MessageListPagedState,
handlers: MessageListHandlers,
inSelectionMode: Boolean,
coroutineScope: CoroutineScope,
@ -174,21 +160,23 @@ private fun MessageListContent(
onShowReactions: (List<Reaction>) -> Unit,
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
// Calculate unread divider position
val unreadDividerIndex by
remember(state.messages.itemCount, state.firstUnreadMessageUuid) {
derivedStateOf {
state.firstUnreadMessageUuid?.let { uuid ->
(0 until state.messages.itemCount).firstOrNull { index -> state.messages[index]?.uuid == uuid }
}
},
) { row ->
when (row) {
is MessageListRow.UnreadDivider -> UnreadMessagesDivider(modifier = Modifier.animateItem())
is MessageListRow.ChatMessage ->
renderChatMessageRow(
row = row,
}
}
Box(modifier = modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.fillMaxSize(), state = listState, reverseLayout = true) {
items(count = state.messages.itemCount, key = state.messages.itemKey { it.uuid }) { index ->
val message = state.messages[index]
if (message != null) {
renderPagedChatMessageRow(
message = message,
state = state,
handlers = handlers,
inSelectionMode = inSelectionMode,
@ -198,15 +186,37 @@ private fun MessageListContent(
onShowStatusDialog = onShowStatusDialog,
onShowReactions = onShowReactions,
)
// Show unread divider after the first unread message
if (state.hasUnreadMessages && unreadDividerIndex == index) {
UnreadMessagesDivider(modifier = Modifier.animateItem())
}
}
}
// Loading indicator at the end (top when reversed) when loading more items
state.messages.apply {
when {
loadState.append is LoadState.Loading -> {
item(key = "append_loading") {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
}
}
}
}
}
@Composable
private fun LazyItemScope.renderChatMessageRow(
row: MessageListRow.ChatMessage,
state: MessageListState,
private fun LazyItemScope.renderPagedChatMessageRow(
message: Message,
state: MessageListPagedState,
handlers: MessageListHandlers,
inSelectionMode: Boolean,
coroutineScope: CoroutineScope,
@ -216,7 +226,6 @@ private fun LazyItemScope.renderChatMessageRow(
onShowReactions: (List<Reaction>) -> Unit,
) {
val ourNode = state.ourNode ?: return
val message = row.message
val selected by
remember(message.uuid, state.selectedIds.value) {
derivedStateOf { state.selectedIds.value.contains(message.uuid) }
@ -245,8 +254,14 @@ private fun LazyItemScope.renderChatMessageRow(
onShowReactions = { onShowReactions(message.emojis) },
onNavigateToOriginalMessage = {
coroutineScope.launch {
val targetIndex = state.messages.indexOfFirst { it.packetId == message.replyId }
if (targetIndex != -1) {
// Note: With pagination, we can't guarantee the original message is loaded
// This is a limitation of pagination - we would need to implement
// a search/jump feature to load and scroll to specific messages
val targetIndex =
(0 until state.messages.itemCount).firstOrNull { index ->
state.messages[index]?.packetId == message.replyId
}
if (targetIndex != null) {
listState.animateScrollToItem(index = targetIndex)
}
}
@ -255,7 +270,115 @@ private fun LazyItemScope.renderChatMessageRow(
}
@Composable
private fun MessageStatusDialog(
private fun AutoScrollToBottomPaged(
listState: LazyListState,
messages: LazyPagingItems<Message>,
hasUnreadMessages: Boolean,
itemThreshold: Int = 3,
) = with(listState) {
val shouldAutoScroll by
remember(hasUnreadMessages) {
derivedStateOf {
val isAtBottom =
firstVisibleItemIndex == 0 &&
firstVisibleItemScrollOffset <= UnreadUiDefaults.AUTO_SCROLL_BOTTOM_OFFSET_TOLERANCE
val isNearBottom = firstVisibleItemIndex <= itemThreshold
isAtBottom || (!hasUnreadMessages && isNearBottom)
}
}
if (shouldAutoScroll) {
LaunchedEffect(messages.itemCount) {
if (!isScrollInProgress && messages.itemCount > 0) {
scrollToItem(0)
}
}
}
}
@OptIn(FlowPreview::class)
@Composable
private fun UpdateUnreadCountPaged(
listState: LazyListState,
messages: LazyPagingItems<Message>,
onUnreadChange: (Long, Long) -> Unit,
) {
val currentOnUnreadChange by rememberUpdatedState(onUnreadChange)
// Track remote message count to restart effect when remote messages change
// This fixes race condition when sending/receiving messages during debounce period
val remoteMessageCount by
remember(messages.itemCount) {
derivedStateOf {
(0 until messages.itemCount).count { i ->
val msg = messages[i]
msg != null && !msg.fromLocal
}
}
}
// Mark messages as read after debounce period
// Handles both scrolling cases and when all unread messages are visible without scrolling
LaunchedEffect(remoteMessageCount, listState) {
snapshotFlow {
// Emit when scroll stops OR when at initial position (covers no-scroll case)
if (listState.isScrollInProgress) {
null // Scrolling in progress, don't emit
} else {
listState.firstVisibleItemIndex // Emit current position when not scrolling
}
}
.debounce(timeoutMillis = UnreadUiDefaults.SCROLL_DEBOUNCE_MILLIS)
.collectLatest { index ->
if (index != null) {
// Find the last (oldest in timeline, highest index) unread message in loaded items
val lastUnreadIndex =
(0 until messages.itemCount).lastOrNull { i ->
val msg = messages[i]
msg != null && !msg.read && !msg.fromLocal
}
// If we're at/past the oldest unread, mark the first visible unread message
// Since newer messages have HIGHER timestamps, marking a newer message's timestamp
// will batch-mark all older messages via SQL: WHERE received_time <= timestamp
if (lastUnreadIndex != null && index <= lastUnreadIndex) {
// Find the first (newest in timeline, lowest index) visible unread message
val firstVisibleUnreadIndex =
(index until messages.itemCount).firstOrNull { i ->
val msg = messages[i]
msg != null && !msg.read && !msg.fromLocal
}
if (firstVisibleUnreadIndex != null) {
val firstVisibleUnread = messages[firstVisibleUnreadIndex]
if (firstVisibleUnread != null) {
currentOnUnreadChange(firstVisibleUnread.uuid, firstVisibleUnread.receivedTime)
}
}
}
}
}
}
}
@Composable
internal 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
internal fun MessageStatusDialog(
message: Message,
nodes: List<Node>,
resendOption: Boolean,
@ -278,115 +401,3 @@ private fun MessageStatusDialog(
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 {
val isAtBottom =
firstVisibleItemIndex == 0 &&
firstVisibleItemScrollOffset <= UnreadUiDefaults.AUTO_SCROLL_BOTTOM_OFFSET_TOLERANCE
val isNearBottom = firstVisibleItemIndex <= itemThreshold
isAtBottom || (!hasUnreadMessages && isNearBottom)
}
}
if (shouldAutoScroll) {
LaunchedEffect(list) {
if (!isScrollInProgress) {
scrollToItem(0)
}
}
}
}
@OptIn(FlowPreview::class)
@Composable
private fun UpdateUnreadCount(
listState: LazyListState,
messages: List<Message>,
onUnreadChanged: (Long, Long) -> Unit,
) {
val remoteMessageCount = remember(messages) { messages.count { !it.fromLocal } }
LaunchedEffect(remoteMessageCount, listState) {
Timber.d("UpdateUnreadCount LaunchedEffect started/restarted, remoteMessageCount=$remoteMessageCount")
snapshotFlow { listState.firstVisibleItemIndex }
.debounce(timeoutMillis = UnreadUiDefaults.SCROLL_DEBOUNCE_MILLIS)
.collectLatest { index ->
Timber.d("Debounce triggered, index=$index, messages.size=${messages.size}")
val lastUnreadIndex = messages.indexOfLast { !it.read && !it.fromLocal }
Timber.d("lastUnreadIndex=$lastUnreadIndex")
// If user has scrolled past all unread messages, mark the last unread message as read
if (lastUnreadIndex != -1 && index <= lastUnreadIndex) {
val lastUnreadMessage = messages[lastUnreadIndex]
Timber.d("Marking last unread message as read: ${lastUnreadMessage.uuid}")
onUnreadChanged(lastUnreadMessage.uuid, lastUnreadMessage.receivedTime)
} else {
Timber.d("Not marking as read - no unread messages or user hasn't scrolled past them")
}
}
}
}

View file

@ -20,8 +20,11 @@ package org.meshtastic.feature.messaging
import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -82,22 +85,27 @@ constructor(
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
private val contactKeyForPagedMessages: MutableStateFlow<String?> = MutableStateFlow(null)
private val pagedMessagesForContactKey: Flow<PagingData<Message>> =
contactKeyForPagedMessages
.filterNotNull()
.flatMapLatest { contactKey -> packetRepository.getMessagesFrom(contactKey, ::getNode) }
.stateInWhileSubscribed(initialValue = emptyList())
.flatMapLatest { contactKey -> packetRepository.getMessagesFromPaged(contactKey, ::getNode) }
.cachedIn(viewModelScope)
fun setTitle(title: String) {
viewModelScope.launch { _title.value = title }
}
fun getMessagesFrom(contactKey: String): StateFlow<List<Message>> {
contactKeyForMessages.value = contactKey
return messagesForContactKey
fun getMessagesFromPaged(contactKey: String): Flow<PagingData<Message>> {
contactKeyForPagedMessages.value = contactKey
return pagedMessagesForContactKey
}
fun getFirstUnreadMessageUuid(contactKey: String): Flow<Long?> =
packetRepository.getFirstUnreadMessageUuid(contactKey)
fun hasUnreadMessages(contactKey: String): Flow<Boolean> = packetRepository.hasUnreadMessages(contactKey)
fun toggleShowQuickChat() = toggle(_showQuickChat) { uiPrefs.showQuickChat = it }
private fun toggle(state: MutableStateFlow<Boolean>, onChanged: (newValue: Boolean) -> Unit) {

View file

@ -11,6 +11,7 @@ datastore = "1.2.0"
lifecycle = "2.10.0"
navigation = "2.9.6"
navigation3 = "1.0.0"
paging = "3.3.5"
room = "2.8.4"
# Kotlin
@ -59,7 +60,11 @@ androidx-navigation-compose = { module = "androidx.navigation:navigation-compose
androidx-navigation-runtime = { module = "androidx.navigation:navigation-runtime", version.ref = "navigation" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "room" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.0" }