mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Add Jetpack Paging 3 support for messages and threads/contacts (#3795)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
552097888f
commit
5e8c9794eb
12 changed files with 551 additions and 250 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>) =
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue