From 9bc1b87e75ca3da3d86fe43c4a76915a88692802 Mon Sep 17 00:00:00 2001 From: Mac DeCourcy <49794076+mdecourcy@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:05:38 -0800 Subject: [PATCH] fix: Prevent message list jumping during pagination updates (#3829) --- .../feature/messaging/MessageListPaged.kt | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) 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 78ef0eda6..38c60a54e 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 @@ -128,6 +128,9 @@ internal fun MessageListPaged( val coroutineScope = rememberCoroutineScope() + // Disable auto-scroll when any dialog is open to prevent list jumping + val hasDialogOpen = showStatusDialog != null || showReactionDialog != null + // Track unread count based on scroll position UpdateUnreadCountPaged(listState = listState, messages = state.messages, onUnreadChange = handlers.onUnreadChanged) @@ -136,6 +139,7 @@ internal fun MessageListPaged( listState = listState, messages = state.messages, hasUnreadMessages = state.hasUnreadMessages, + hasDialogOpen = hasDialogOpen, ) MessageListPagedContent( @@ -272,24 +276,52 @@ private fun LazyItemScope.renderPagedChatMessageRow( ) } +@Suppress("CyclomaticComplexMethod") @Composable private fun AutoScrollToBottomPaged( listState: LazyListState, messages: LazyPagingItems, hasUnreadMessages: Boolean, + hasDialogOpen: Boolean = false, itemThreshold: Int = 3, ) = with(listState) { - val shouldAutoScroll by - remember(hasUnreadMessages) { + val shouldStickToBottom by + remember(hasUnreadMessages, hasDialogOpen) { derivedStateOf { - val isAtBottom = - firstVisibleItemIndex == 0 && - firstVisibleItemScrollOffset <= UnreadUiDefaults.AUTO_SCROLL_BOTTOM_OFFSET_TOLERANCE - val isNearBottom = firstVisibleItemIndex <= itemThreshold - isAtBottom || (!hasUnreadMessages && isNearBottom) + if (hasDialogOpen) { + false + } else { + val isAtBottom = + firstVisibleItemIndex == 0 && + firstVisibleItemScrollOffset <= UnreadUiDefaults.AUTO_SCROLL_BOTTOM_OFFSET_TOLERANCE + val isNearBottom = firstVisibleItemIndex <= itemThreshold + isAtBottom || (!hasUnreadMessages && isNearBottom) + } } } - if (shouldAutoScroll) { + + val isRefreshing by remember { derivedStateOf { messages.loadState.refresh is LoadState.Loading } } + var wasPreviouslyRefreshing by remember { mutableStateOf(false) } + + // Maintain scroll position during and after refresh + LaunchedEffect(isRefreshing, shouldStickToBottom) { + if (!shouldStickToBottom) return@LaunchedEffect + + if (isRefreshing) { + wasPreviouslyRefreshing = true + if (!isScrollInProgress && messages.itemCount > 0) { + scrollToItem(0) + } + } else if (wasPreviouslyRefreshing) { + wasPreviouslyRefreshing = false + if (messages.itemCount > 0) { + scrollToItem(0) + } + } + } + + // Normal auto-scroll for new messages (when not refreshing) + if (shouldStickToBottom && !isRefreshing) { LaunchedEffect(messages.itemCount) { if (!isScrollInProgress && messages.itemCount > 0) { scrollToItem(0)