From deedd00995f6eb658d45bf14b470377f161cf8af Mon Sep 17 00:00:00 2001 From: Mac DeCourcy <49794076+mdecourcy@users.noreply.github.com> Date: Tue, 18 Nov 2025 05:27:11 -0800 Subject: [PATCH] feat: polish jump to unread message (#3710) --- .../meshtastic/feature/messaging/Message.kt | 8 +- .../feature/messaging/MessageList.kt | 127 ++++++++---------- .../feature/messaging/UnreadUiDefaults.kt | 49 +++++++ 3 files changed, 111 insertions(+), 73 deletions(-) create mode 100644 feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/UnreadUiDefaults.kt 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 5af3c7a1c..0504feac2 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 @@ -250,7 +250,13 @@ fun MessageScreen( LaunchedEffect(messages, initialUnreadIndex, earliestUnreadIndex) { if (!hasPerformedInitialScroll && messages.isNotEmpty()) { - val targetIndex = (initialUnreadIndex ?: earliestUnreadIndex ?: 0).coerceIn(0, messages.lastIndex) + 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) } diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt index e9de5c43e..a9f35b521 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageList.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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 @@ -82,6 +83,15 @@ internal data class MessageListHandlers( val onReply: (Message?) -> Unit, ) +private fun MutableState>.toggle(uuid: Long) { + value = + if (value.contains(uuid)) { + value - uuid + } else { + value + uuid + } +} + @Composable internal fun MessageList( modifier: Modifier = Modifier, @@ -136,10 +146,10 @@ internal fun MessageList( state = state, handlers = handlers, inSelectionMode = inSelectionMode, - onShowStatusDialog = { showStatusDialog = it }, - onShowReactions = { showReactionDialog = it }, coroutineScope = coroutineScope, haptics = haptics, + onShowStatusDialog = { showStatusDialog = it }, + onShowReactions = { showReactionDialog = it }, modifier = modifier, ) } @@ -151,50 +161,60 @@ private sealed interface MessageListRow { } @Composable -private fun MessageRowContent( - row: MessageListRow, +private fun MessageListContent( + listState: LazyListState, + messageRows: List, state: MessageListState, handlers: MessageListHandlers, inSelectionMode: Boolean, - listState: LazyListState, coroutineScope: CoroutineScope, haptics: HapticFeedback, onShowStatusDialog: (Message) -> Unit, onShowReactions: (List) -> Unit, + modifier: Modifier = Modifier, ) { - when (row) { - is MessageListRow.UnreadDivider -> UnreadMessagesDivider() - is MessageListRow.ChatMessage -> - state.ourNode?.let { ourNode -> - ChatMessageRow( - row = row, - state = state, - handlers = handlers, - inSelectionMode = inSelectionMode, - listState = listState, - coroutineScope = coroutineScope, - haptics = haptics, - onShowStatusDialog = onShowStatusDialog, - onShowReactions = onShowReactions, - ourNode = ourNode, - ) + 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 + } + }, + ) { row -> + when (row) { + is MessageListRow.UnreadDivider -> UnreadMessagesDivider(modifier = Modifier.animateItem()) + is MessageListRow.ChatMessage -> + renderChatMessageRow( + row = row, + state = state, + handlers = handlers, + inSelectionMode = inSelectionMode, + coroutineScope = coroutineScope, + haptics = haptics, + listState = listState, + onShowStatusDialog = onShowStatusDialog, + onShowReactions = onShowReactions, + ) } + } } } @Composable -private fun ChatMessageRow( +private fun LazyItemScope.renderChatMessageRow( row: MessageListRow.ChatMessage, state: MessageListState, handlers: MessageListHandlers, inSelectionMode: Boolean, - listState: LazyListState, coroutineScope: CoroutineScope, haptics: HapticFeedback, + listState: LazyListState, onShowStatusDialog: (Message) -> Unit, onShowReactions: (List) -> Unit, - ourNode: Node, ) { + val ourNode = state.ourNode ?: return val message = row.message val selected by remember(message.uuid, state.selectedIds.value) { @@ -206,19 +226,14 @@ private fun ChatMessageRow( } MessageItem( + modifier = Modifier.animateItem(), node = node, ourNode = ourNode, message = message, selected = selected, - onClick = { - if (inSelectionMode) { - state.selectedIds.value = - if (selected) state.selectedIds.value - message.uuid else state.selectedIds.value + message.uuid - } - }, + onClick = { if (inSelectionMode) state.selectedIds.toggle(message.uuid) }, onLongClick = { - state.selectedIds.value = - if (selected) state.selectedIds.value - message.uuid else state.selectedIds.value + message.uuid + state.selectedIds.toggle(message.uuid) haptics.performHapticFeedback(HapticFeedbackType.LongPress) }, onClickChip = handlers.onClickChip, @@ -238,44 +253,6 @@ private fun ChatMessageRow( ) } -@Composable -private fun MessageListContent( - listState: LazyListState, - messageRows: List, - state: MessageListState, - handlers: MessageListHandlers, - inSelectionMode: Boolean, - onShowStatusDialog: (Message) -> Unit, - onShowReactions: (List) -> Unit, - coroutineScope: CoroutineScope, - haptics: HapticFeedback, - 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 - } - }, - ) { row -> - MessageRowContent( - row = row, - state = state, - handlers = handlers, - inSelectionMode = inSelectionMode, - listState = listState, - coroutineScope = coroutineScope, - haptics = haptics, - onShowStatusDialog = onShowStatusDialog, - onShowReactions = onShowReactions, - ) - } - } -} - @Composable private fun MessageStatusDialog( message: Message, @@ -367,7 +344,13 @@ private fun AutoScrollToBottom( ) = with(listState) { val shouldAutoScroll by remember(hasUnreadMessages) { - derivedStateOf { !hasUnreadMessages && firstVisibleItemIndex < itemThreshold } + derivedStateOf { + val isAtBottom = + firstVisibleItemIndex == 0 && + firstVisibleItemScrollOffset <= UnreadUiDefaults.AUTO_SCROLL_BOTTOM_OFFSET_TOLERANCE + val isNearBottom = firstVisibleItemIndex <= itemThreshold + isAtBottom || (!hasUnreadMessages && isNearBottom) + } } if (shouldAutoScroll) { LaunchedEffect(list) { @@ -387,7 +370,7 @@ private fun UpdateUnreadCount( ) { LaunchedEffect(messages) { snapshotFlow { listState.firstVisibleItemIndex } - .debounce(timeoutMillis = 500L) + .debounce(timeoutMillis = UnreadUiDefaults.SCROLL_DEBOUNCE_MILLIS) .collectLatest { index -> val lastUnreadIndex = messages.indexOfLast { !it.read && !it.fromLocal } if (lastUnreadIndex != -1 && index <= lastUnreadIndex && index < messages.size) { 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 new file mode 100644 index 000000000..f9ba166e9 --- /dev/null +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/UnreadUiDefaults.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.feature.messaging + +/** + * Shared configuration for how unread markers behave in the message thread. + * + * Keeping these in one place makes it easier to reason about how the unread divider and auto-mark-as-read flows work + * across `Message` and `MessageList`. + */ +internal object UnreadUiDefaults { + /** + * The number of most-recent messages we attempt to keep visible when jumping to unread content. + * + * With the list reversed (newest at index 0) this translates to showing up to this many messages *above* the unread + * divider so the user can read into the conversation with enough context. + */ + const val VISIBLE_CONTEXT_COUNT = 5 + + /** + * Acceptable pixel offset from the absolute bottom of the list while still treating the user as "caught up". + * Compose list positioning can drift by a few pixels during fling settles, so this tolerance keeps the auto-scroll + * behavior feeling buttery when new packets arrive. + */ + const val AUTO_SCROLL_BOTTOM_OFFSET_TOLERANCE = 8 + + /** + * Delay (in milliseconds) before we persist a new "last read" marker while scrolling. + * + * 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 = 5_000L +}