mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 10:11:48 -04:00
feat: Add "Mark all as read" and unread message count indicators (#4720)
This commit is contained in:
@@ -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<Int> =
|
||||
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountFlow(contact) }
|
||||
|
||||
override fun getFirstUnreadMessageUuid(contact: String): Flow<Long?> =
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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
|
||||
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<Boolean>
|
||||
@@ -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<Int>
|
||||
@@ -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
|
||||
|
||||
@@ -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<Int>
|
||||
|
||||
/** Reactive flow of the UUID of the first unread message in a conversation. */
|
||||
fun getFirstUnreadMessageUuid(contact: String): Flow<Long?>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user