mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 16:55:02 -04:00
feat: polish jump to unread message (#3710)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user