diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt index d1c05cf7a..a0d4ed1f7 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf @@ -48,6 +49,8 @@ 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.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey @@ -295,6 +298,21 @@ private fun AutoScrollToBottomPaged( } } +private fun findFirstVisibleUnreadMessage(messages: LazyPagingItems, visibleIndex: Int): Message? { + val firstVisibleUnreadIndex = + (visibleIndex until messages.itemCount).firstOrNull { i -> + val msg = messages[i] + msg != null && !msg.read && !msg.fromLocal + } + return firstVisibleUnreadIndex?.let { messages[it] } +} + +private fun findLastUnreadMessageIndex(messages: LazyPagingItems): Int? = + (0 until messages.itemCount).lastOrNull { i -> + val msg = messages[i] + msg != null && !msg.read && !msg.fromLocal + } + @OptIn(FlowPreview::class) @Composable private fun UpdateUnreadCountPaged( @@ -303,6 +321,24 @@ private fun UpdateUnreadCountPaged( onUnreadChange: (Long, Long) -> Unit, ) { val currentOnUnreadChange by rememberUpdatedState(onUnreadChange) + val lifecycleOwner = LocalLifecycleOwner.current + var isResumed by remember { + mutableStateOf(lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) + } + + // Track lifecycle state changes + DisposableEffect(lifecycleOwner) { + val observer = + androidx.lifecycle.LifecycleEventObserver { _, event -> + when (event) { + androidx.lifecycle.Lifecycle.Event.ON_RESUME -> isResumed = true + androidx.lifecycle.Lifecycle.Event.ON_PAUSE -> isResumed = false + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } // Track remote message count to restart effect when remote messages change // This fixes race condition when sending/receiving messages during debounce period @@ -318,42 +354,27 @@ private fun UpdateUnreadCountPaged( // Mark messages as read after debounce period // Handles both scrolling cases and when all unread messages are visible without scrolling - LaunchedEffect(remoteMessageCount, listState) { + // Effect restarts when isResumed changes, so returning from background will restart the debounce + LaunchedEffect(remoteMessageCount, listState, isResumed) { snapshotFlow { // Emit when scroll stops OR when at initial position (covers no-scroll case) - if (listState.isScrollInProgress) { - null // Scrolling in progress, don't emit + // Include isResumed in the snapshot so lifecycle changes trigger new emissions + if (listState.isScrollInProgress || !isResumed) { + null // Scrolling in progress or not resumed, don't emit } else { - listState.firstVisibleItemIndex // Emit current position when not scrolling + listState.firstVisibleItemIndex // Emit current position when not scrolling and resumed } } .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 - } - + val lastUnreadIndex = findLastUnreadMessageIndex(messages) // 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) - } - } + val firstVisibleUnread = findFirstVisibleUnreadMessage(messages, index) + firstVisibleUnread?.let { currentOnUnreadChange(it.uuid, it.receivedTime) } } } }