feat: polish jump to unread message (#3710)

This commit is contained in:
Mac DeCourcy
2025-11-18 05:27:11 -08:00
committed by GitHub
parent 7e8a4262f2
commit deedd00995
3 changed files with 111 additions and 73 deletions

View File

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

View File

@@ -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<Set<Long>>.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<MessageListRow>,
state: MessageListState,
handlers: MessageListHandlers,
inSelectionMode: Boolean,
listState: LazyListState,
coroutineScope: CoroutineScope,
haptics: HapticFeedback,
onShowStatusDialog: (Message) -> Unit,
onShowReactions: (List<Reaction>) -> 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<Reaction>) -> 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<MessageListRow>,
state: MessageListState,
handlers: MessageListHandlers,
inSelectionMode: Boolean,
onShowStatusDialog: (Message) -> Unit,
onShowReactions: (List<Reaction>) -> 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 <T> 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) {

View File

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