From b0258d0cf14f985bf480c961fa0fc557526e29da Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:18:34 -0600 Subject: [PATCH] feat: Add "Mark all as read" and unread message count indicators (#4720) --- .../data/repository/PacketRepositoryImpl.kt | 6 ++ .../core/database/dao/PacketDaoTest.kt | 20 +++++++ .../meshtastic/core/database/dao/PacketDao.kt | 29 +++++++-- .../core/repository/PacketRepository.kt | 6 ++ .../org/meshtastic/core/ui/icon/Actions.kt | 4 ++ .../meshtastic/feature/messaging/Message.kt | 59 ++++++++++++++----- .../feature/messaging/MessageViewModel.kt | 10 +++- .../feature/messaging/UnreadUiDefaults.kt | 5 +- .../feature/messaging/ui/contact/Contacts.kt | 14 ++++- .../messaging/ui/contact/ContactsViewModel.kt | 4 ++ 10 files changed, 131 insertions(+), 26 deletions(-) diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt index e29c82be1..7164d6876 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt @@ -77,6 +77,9 @@ constructor( override suspend fun getUnreadCount(contact: String): Int = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getUnreadCount(contact) } + override fun getUnreadCountFlow(contact: String): Flow = + dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountFlow(contact) } + override fun getFirstUnreadMessageUuid(contact: String): Flow = dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFirstUnreadMessageUuid(contact) } @@ -89,6 +92,9 @@ constructor( override suspend fun clearUnreadCount(contact: String, timestamp: Long) = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearUnreadCount(contact, timestamp) } + override suspend fun clearAllUnreadCounts() = + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearAllUnreadCounts() } + override suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) = withContext(dispatchers.io) { val dao = dbManager.currentDb.value.packetDao() diff --git a/core/database/src/androidDeviceTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt b/core/database/src/androidDeviceTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt index 71bd06e24..a75bfa07c 100644 --- a/core/database/src/androidDeviceTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt +++ b/core/database/src/androidDeviceTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt @@ -158,6 +158,26 @@ class PacketDaoTest { } } + @Test + fun test_getUnreadCount_excludesFiltered() = runBlocking { + val filteredContactKey = "0!filteredonly" + val filteredPacket = + Packet( + uuid = 0L, + myNodeNum = myNodeNum, + port_num = 1, + contact_key = filteredContactKey, + received_time = nowMillis, + read = false, + filtered = true, + data = DataPacket(DataPacket.ID_BROADCAST, 0, "Filtered message"), + ) + packetDao.insert(filteredPacket) + + val unreadCount = packetDao.getUnreadCount(filteredContactKey) + assertEquals(0, unreadCount) + } + @Test fun test_clearUnreadCount() = runBlocking { val timestamp = nowMillis diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt index 047b2b47c..f8d6947ad 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt @@ -94,16 +94,25 @@ interface PacketDao { """ SELECT COUNT(*) FROM packet WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) - AND port_num = 1 AND contact_key = :contact AND read = 0 + AND port_num = 1 AND contact_key = :contact AND read = 0 AND filtered = 0 """, ) suspend fun getUnreadCount(contact: String): Int + @Query( + """ + SELECT COUNT(*) FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND port_num = 1 AND contact_key = :contact AND read = 0 AND filtered = 0 + """, + ) + fun getUnreadCountFlow(contact: String): Flow + @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 + AND port_num = 1 AND contact_key = :contact AND read = 0 AND filtered = 0 ORDER BY received_time ASC LIMIT 1 """, @@ -114,7 +123,7 @@ interface PacketDao { """ 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 + AND port_num = 1 AND contact_key = :contact AND read = 0 AND filtered = 0 """, ) fun hasUnreadMessages(contact: String): Flow @@ -123,7 +132,7 @@ interface PacketDao { """ SELECT COUNT(*) FROM packet WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) - AND port_num = 1 AND read = 0 + AND port_num = 1 AND read = 0 AND filtered = 0 """, ) fun getUnreadCountTotal(): Flow @@ -133,11 +142,21 @@ interface PacketDao { UPDATE packet SET read = 1 WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) - AND port_num = 1 AND contact_key = :contact AND read = 0 AND received_time <= :timestamp + AND port_num = 1 AND contact_key = :contact AND read = 0 AND filtered = 0 AND received_time <= :timestamp """, ) suspend fun clearUnreadCount(contact: String, timestamp: Long) + @Query( + """ + UPDATE packet + SET read = 1 + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND port_num = 1 AND read = 0 AND filtered = 0 + """, + ) + suspend fun clearAllUnreadCounts() + @Upsert suspend fun insert(packet: Packet) @Transaction diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt index c43d559c4..6b5d545b1 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt @@ -49,6 +49,9 @@ interface PacketRepository { /** Returns the count of unread messages in a conversation. */ suspend fun getUnreadCount(contact: String): Int + /** Reactive flow of the unread message count in a conversation. */ + fun getUnreadCountFlow(contact: String): Flow + /** Reactive flow of the UUID of the first unread message in a conversation. */ fun getFirstUnreadMessageUuid(contact: String): Flow @@ -61,6 +64,9 @@ interface PacketRepository { /** Clears the unread status for messages in a conversation up to the given timestamp. */ suspend fun clearUnreadCount(contact: String, timestamp: Long) + /** Clears the unread status for all messages across all conversations. */ + suspend fun clearAllUnreadCounts() + /** Updates the identifier of the last read message in a conversation. */ suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Actions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Actions.kt index c58056d76..3506605e3 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Actions.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Actions.kt @@ -29,6 +29,7 @@ import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material.icons.rounded.MarkChatRead import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.QrCode2 import androidx.compose.material.icons.rounded.Refresh @@ -81,5 +82,8 @@ val MeshtasticIcons.SelectAll: ImageVector val MeshtasticIcons.ThumbUp: ImageVector get() = Icons.Rounded.ThumbUp +val MeshtasticIcons.MarkChatRead: ImageVector + get() = Icons.Rounded.MarkChatRead + val MeshtasticIcons.QrCode2: ImageVector get() = Icons.Rounded.QrCode2 diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt index 1f5c24626..c28a07792 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt @@ -61,6 +61,8 @@ import androidx.compose.material.icons.rounded.SelectAll import androidx.compose.material.icons.rounded.SpeakerNotesOff import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -222,6 +224,7 @@ fun MessageScreen( // Track unread messages using lightweight metadata queries val hasUnreadMessages by viewModel.hasUnreadMessages.collectAsStateWithLifecycle() + val unreadCount by viewModel.unreadCount.collectAsStateWithLifecycle() val firstUnreadMessageUuid by viewModel.firstUnreadMessageUuid.collectAsStateWithLifecycle() var hasPerformedInitialScroll by rememberSaveable(contactKey) { mutableStateOf(false) } @@ -231,21 +234,36 @@ fun MessageScreen( remember(pagedMessages.itemCount, firstUnreadMessageUuid) { derivedStateOf { firstUnreadMessageUuid?.let { uuid -> - (0 until pagedMessages.itemCount).firstOrNull { index -> pagedMessages[index]?.uuid == uuid } + pagedMessages.itemSnapshotList.indexOfFirst { it?.uuid == uuid }.takeIf { it != -1 } } } } // Scroll to first unread message on initial load - LaunchedEffect(hasPerformedInitialScroll, firstUnreadIndex, pagedMessages.itemCount) { + LaunchedEffect( + hasPerformedInitialScroll, + firstUnreadIndex, + pagedMessages.itemCount, + hasUnreadMessages, + firstUnreadMessageUuid, + ) { if (hasPerformedInitialScroll || pagedMessages.itemCount == 0) return@LaunchedEffect + if (hasUnreadMessages == null) return@LaunchedEffect // Wait for DB state to initialize - 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 (hasUnreadMessages == true) { + if (firstUnreadMessageUuid == null) return@LaunchedEffect // Wait for UUID query + + if (firstUnreadIndex != null) { + val targetIndex = (firstUnreadIndex!! - (UnreadUiDefaults.VISIBLE_CONTEXT_COUNT - 1)).coerceAtLeast(0) + listState.smartScrollToIndex(coroutineScope = coroutineScope, targetIndex = targetIndex) + hasPerformedInitialScroll = true + } else { + // The first unread message is deeper than the currently loaded pages. + // Scroll to the end of the loaded items to trigger the next page load. + // This will re-trigger this LaunchedEffect until we find the message. + listState.scrollToItem(pagedMessages.itemCount - 1) + } + } else { // If no unread messages, just scroll to bottom (most recent) listState.scrollToItem(0) hasPerformedInitialScroll = true @@ -410,7 +428,7 @@ fun MessageScreen( selectedIds = selectedMessageIds, contactKey = contactKey, firstUnreadMessageUuid = firstUnreadMessageUuid, - hasUnreadMessages = hasUnreadMessages, + hasUnreadMessages = hasUnreadMessages == true, filteredCount = filteredCount, showFiltered = showFiltered, filteringDisabled = filteringDisabled, @@ -430,7 +448,7 @@ fun MessageScreen( ) // Show FAB if we can scroll towards the newest messages (index 0). if (listState.canScrollBackward) { - ScrollToBottomFab(coroutineScope, listState) + ScrollToBottomFab(coroutineScope, listState, unreadCount) } } } @@ -441,9 +459,11 @@ fun MessageScreen( * * @param coroutineScope The coroutine scope for launching the scroll animation. * @param listState The [LazyListState] of the message list. + * @param unreadCount The number of unread messages to display as a badge. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun BoxScope.ScrollToBottomFab(coroutineScope: CoroutineScope, listState: LazyListState) { +private fun BoxScope.ScrollToBottomFab(coroutineScope: CoroutineScope, listState: LazyListState, unreadCount: Int) { FloatingActionButton( modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp), onClick = { @@ -453,10 +473,19 @@ private fun BoxScope.ScrollToBottomFab(coroutineScope: CoroutineScope, listState } }, ) { - Icon( - imageVector = Icons.Rounded.ArrowDownward, - contentDescription = stringResource(Res.string.scroll_to_bottom), - ) + if (unreadCount > 0) { + BadgedBox(badge = { Badge { Text(unreadCount.toString()) } }) { + Icon( + imageVector = Icons.Rounded.ArrowDownward, + contentDescription = stringResource(Res.string.scroll_to_bottom), + ) + } + } else { + Icon( + imageVector = Icons.Rounded.ArrowDownward, + contentDescription = stringResource(Res.string.scroll_to_bottom), + ) + } } } diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index d7abd4474..a767eaee0 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -127,11 +127,17 @@ constructor( .flatMapLatest { packetRepository.getFirstUnreadMessageUuid(it) } .stateInWhileSubscribed(null) - val hasUnreadMessages: StateFlow = + val hasUnreadMessages: StateFlow = contactKeyForPagedMessages .filterNotNull() .flatMapLatest { packetRepository.hasUnreadMessages(it) } - .stateInWhileSubscribed(false) + .stateInWhileSubscribed(null) + + val unreadCount: StateFlow = + contactKeyForPagedMessages + .filterNotNull() + .flatMapLatest { packetRepository.getUnreadCountFlow(it) } + .stateInWhileSubscribed(0) val filteredCount: StateFlow = contactKeyForPagedMessages diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/UnreadUiDefaults.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/UnreadUiDefaults.kt index 2c65b947c..0cdb7c50a 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/UnreadUiDefaults.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/UnreadUiDefaults.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-2026 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.messaging /** @@ -45,5 +44,5 @@ internal object UnreadUiDefaults { * A longer debounce prevents thrashing the database during quick scrubs yet still feels responsive once the user * settles on a position. */ - const val SCROLL_DEBOUNCE_MILLIS = 3_000L + const val SCROLL_DEBOUNCE_MILLIS = 500L } diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt index f256e23e2..82348cc07 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt @@ -80,6 +80,7 @@ import org.meshtastic.core.resources.currently import org.meshtastic.core.resources.delete import org.meshtastic.core.resources.delete_messages import org.meshtastic.core.resources.delete_selection +import org.meshtastic.core.resources.mark_as_read import org.meshtastic.core.resources.mute_1_week import org.meshtastic.core.resources.mute_8_hours import org.meshtastic.core.resources.mute_always @@ -99,6 +100,7 @@ import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.core.ui.component.smartScrollToTop import org.meshtastic.core.ui.icon.Close import org.meshtastic.core.ui.icon.Delete +import org.meshtastic.core.ui.icon.MarkChatRead import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.SelectAll import org.meshtastic.core.ui.icon.VolumeMuteTwoTone @@ -235,7 +237,17 @@ fun ContactsScreen( showNodeChip = ourNode != null && connectionState.isConnected(), canNavigateUp = false, onNavigateUp = {}, - actions = {}, + actions = { + val unreadCountTotal by viewModel.unreadCountTotal.collectAsStateWithLifecycle(0) + if (unreadCountTotal > 0) { + IconButton(onClick = { viewModel.markAllAsRead() }) { + Icon( + MeshtasticIcons.MarkChatRead, + contentDescription = stringResource(Res.string.mark_as_read), + ) + } + } + }, onClickChip = { onClickNodeChip(it.num) }, ) }, diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt index 2b645bac2..595e4a1e4 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt @@ -55,6 +55,8 @@ constructor( val connectionState = serviceRepository.connectionState + val unreadCountTotal = packetRepository.getUnreadCountTotal().stateInWhileSubscribed(0) + val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = ChannelSet()) // Combine node info and myId to reduce argument count in subsequent combines @@ -192,6 +194,8 @@ constructor( fun deleteContacts(contacts: List) = viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) } + fun markAllAsRead() = viewModelScope.launch(Dispatchers.IO) { packetRepository.clearAllUnreadCounts() } + fun setMuteUntil(contacts: List, until: Long) = viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) }