feat: Add "Mark all as read" and unread message count indicators (#4720)

This commit is contained in:
James Rich 2026-03-05 12:18:34 -06:00 committed by GitHub
parent 6a1a612c38
commit b0258d0cf1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 131 additions and 26 deletions

View file

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

View file

@ -127,11 +127,17 @@ constructor(
.flatMapLatest { packetRepository.getFirstUnreadMessageUuid(it) }
.stateInWhileSubscribed(null)
val hasUnreadMessages: StateFlow<Boolean> =
val hasUnreadMessages: StateFlow<Boolean?> =
contactKeyForPagedMessages
.filterNotNull()
.flatMapLatest { packetRepository.hasUnreadMessages(it) }
.stateInWhileSubscribed(false)
.stateInWhileSubscribed(null)
val unreadCount: StateFlow<Int> =
contactKeyForPagedMessages
.filterNotNull()
.flatMapLatest { packetRepository.getUnreadCountFlow(it) }
.stateInWhileSubscribed(0)
val filteredCount: StateFlow<Int> =
contactKeyForPagedMessages

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View file

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

View file

@ -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<String>) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) }
fun markAllAsRead() = viewModelScope.launch(Dispatchers.IO) { packetRepository.clearAllUnreadCounts() }
fun setMuteUntil(contacts: List<String>, until: Long) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) }