android, desktop: scrolling improvements (#5753)

* android, desktop: scrolling improvements

* more changes

* fixes

* search

* fix concurrency

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Stanislav Dmitrenko 2025-03-19 22:07:05 +07:00 committed by GitHub
parent 6e7df9c72d
commit 6b75f61537
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 305 additions and 122 deletions

View file

@ -6,6 +6,7 @@ import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.chatModel import chat.simplex.common.platform.chatModel
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.Instant
import kotlin.math.min import kotlin.math.min
const val TRIM_KEEP_COUNT = 200 const val TRIM_KEEP_COUNT = 200
@ -122,18 +123,20 @@ suspend fun processLoadedChat(
} }
} }
is ChatPagination.Around -> { is ChatPagination.Around -> {
val newSplits = if (openAroundItemId == null) { val newSplits: ArrayList<Long> = if (openAroundItemId == null) {
newItems.addAll(oldItems) newItems.addAll(oldItems)
removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed) ArrayList(removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed))
} else { } else {
emptyList() arrayListOf()
} }
// currently, items will always be added on top, which is index 0 val (itemIndex, splitIndex) = indexToInsertAround(chat.chatInfo.chatType, chat.chatItems.lastOrNull(), to = newItems, newSplits.toSet())
newItems.addAll(0, chat.chatItems) //indexToInsertAroundTest()
newItems.addAll(itemIndex, chat.chatItems)
newSplits.add(splitIndex, chat.chatItems.last().id)
withChats(contentTag) { withChats(contentTag) {
chatItems.replaceAll(newItems) chatItems.replaceAll(newItems)
splits.value = listOf(chat.chatItems.last().id) + newSplits splits.value = newSplits
unreadAfterItemId.value = chat.chatItems.last().id unreadAfterItemId.value = chat.chatItems.last().id
totalAfter.value = navInfo.afterTotal totalAfter.value = navInfo.afterTotal
unreadTotal.value = chat.chatStats.unreadCount unreadTotal.value = chat.chatStats.unreadCount
@ -151,10 +154,12 @@ suspend fun processLoadedChat(
} }
is ChatPagination.Last -> { is ChatPagination.Last -> {
newItems.addAll(oldItems) newItems.addAll(oldItems)
val newSplits = removeDuplicatesAndUnusedSplits(newItems, chat, chatState.splits.value)
removeDuplicates(newItems, chat) removeDuplicates(newItems, chat)
newItems.addAll(chat.chatItems) newItems.addAll(chat.chatItems)
withChats(contentTag) { withChats(contentTag) {
chatItems.replaceAll(newItems) chatItems.replaceAll(newItems)
chatState.splits.value = newSplits
unreadAfterNewestLoaded.value = 0 unreadAfterNewestLoaded.value = 0
} }
} }
@ -240,7 +245,15 @@ private fun removeDuplicatesAndModifySplitsOnAfterPagination(
val indexInSplitRanges = splits.value.indexOf(paginationChatItemId) val indexInSplitRanges = splits.value.indexOf(paginationChatItemId)
// Currently, it should always load from split range // Currently, it should always load from split range
val loadingFromSplitRange = indexInSplitRanges != -1 val loadingFromSplitRange = indexInSplitRanges != -1
val splitsToMerge = if (loadingFromSplitRange && indexInSplitRanges + 1 <= splits.value.size) ArrayList(splits.value.subList(indexInSplitRanges + 1, splits.value.size)) else ArrayList() val topSplits: List<Long>
val splitsToMerge: ArrayList<Long>
if (loadingFromSplitRange && indexInSplitRanges + 1 <= splits.value.size) {
splitsToMerge = ArrayList(splits.value.subList(indexInSplitRanges + 1, splits.value.size))
topSplits = splits.value.take(indexInSplitRanges + 1)
} else {
splitsToMerge = ArrayList()
topSplits = emptyList()
}
newItems.removeAll { newItems.removeAll {
val duplicate = newIds.contains(it.id) val duplicate = newIds.contains(it.id)
if (loadingFromSplitRange && duplicate) { if (loadingFromSplitRange && duplicate) {
@ -259,8 +272,8 @@ private fun removeDuplicatesAndModifySplitsOnAfterPagination(
} }
var newSplits: List<Long> = emptyList() var newSplits: List<Long> = emptyList()
if (firstItemIdBelowAllSplits != null) { if (firstItemIdBelowAllSplits != null) {
// no splits anymore, all were merged with bottom items // no splits below anymore, all were merged with bottom items
newSplits = emptyList() newSplits = topSplits
} else { } else {
if (splitsToRemove.isNotEmpty()) { if (splitsToRemove.isNotEmpty()) {
val new = ArrayList(splits.value) val new = ArrayList(splits.value)
@ -323,6 +336,31 @@ private fun removeDuplicatesAndUpperSplits(
return newSplits return newSplits
} }
private fun removeDuplicatesAndUnusedSplits(
newItems: SnapshotStateList<ChatItem>,
chat: Chat,
splits: List<Long>
): List<Long> {
if (splits.isEmpty()) {
removeDuplicates(newItems, chat)
return splits
}
val newSplits = splits.toMutableList()
val (newIds, _) = mapItemsToIds(chat.chatItems)
newItems.removeAll {
val duplicate = newIds.contains(it.id)
if (duplicate) {
val firstIndex = newSplits.indexOf(it.id)
if (firstIndex != -1) {
newSplits.removeAt(firstIndex)
}
}
duplicate
}
return newSplits
}
// ids, number of unread items // ids, number of unread items
private fun mapItemsToIds(items: List<ChatItem>): Pair<Set<Long>, Int> { private fun mapItemsToIds(items: List<ChatItem>): Pair<Set<Long>, Int> {
var unreadInLoaded = 0 var unreadInLoaded = 0
@ -343,3 +381,141 @@ private fun removeDuplicates(newItems: SnapshotStateList<ChatItem>, chat: Chat)
val (newIds, _) = mapItemsToIds(chat.chatItems) val (newIds, _) = mapItemsToIds(chat.chatItems)
newItems.removeAll { newIds.contains(it.id) } newItems.removeAll { newIds.contains(it.id) }
} }
private data class SameTimeItem(val index: Int, val item: ChatItem)
// return (item index, split index)
private fun indexToInsertAround(chatType: ChatType, lastNew: ChatItem?, to: List<ChatItem>, splits: Set<Long>): Pair<Int, Int> {
if (to.size <= 0 || lastNew == null) {
return 0 to 0
}
// group sorting: item_ts, item_id
// everything else: created_at, item_id
val compareByTimeTs = chatType == ChatType.Group
// in case several items have the same time as another item in the `to` array
var sameTime: ArrayList<SameTimeItem> = arrayListOf()
// trying to find new split index for item looks difficult but allows to not use one more loop.
// The idea is to memorize how many splits were till any index (map number of splits until index)
// and use resulting itemIndex to decide new split index position.
// Because of the possibility to have many items with the same timestamp, it's possible to see `itemIndex < || == || > i`.
val splitsTillIndex: ArrayList<Int> = arrayListOf()
var splitsPerPrevIndex = 0
for (i in to.indices) {
val item = to[i]
splitsPerPrevIndex = if (splits.contains(item.id)) splitsPerPrevIndex + 1 else splitsPerPrevIndex
splitsTillIndex.add(splitsPerPrevIndex)
val itemIsNewer = (if (compareByTimeTs) item.meta.itemTs > lastNew.meta.itemTs else item.meta.createdAt > lastNew.meta.createdAt)
if (itemIsNewer || i + 1 == to.size) {
val same = if (compareByTimeTs) lastNew.meta.itemTs == item.meta.itemTs else lastNew.meta.createdAt == item.meta.createdAt
if (same) {
sameTime.add(SameTimeItem(i, item))
}
// time to stop the loop. Item is newer, or it's the last item in `to` array, taking previous items and checking position inside them
val itemIndex: Int
val first = if (sameTime.size > 1) sameTime.sortedWith { prev, next -> prev.item.meta.itemId.compareTo(next.item.id) }.firstOrNull { same -> same.item.id > lastNew.id } else null
if (sameTime.size > 1 && first != null) {
itemIndex = first.index
} else if (sameTime.size == 1) {
itemIndex = if (sameTime[0].item.id > lastNew.id) sameTime[0].index else sameTime[0].index + 1
} else {
itemIndex = if (itemIsNewer) i else i + 1
}
val splitIndex = splitsTillIndex[min(itemIndex, splitsTillIndex.size - 1)]
val prevItemSplitIndex = if (itemIndex == 0) 0 else splitsTillIndex[min(itemIndex - 1, splitsTillIndex.size - 1)]
return Pair(itemIndex, if (splitIndex == prevItemSplitIndex) splitIndex else prevItemSplitIndex)
}
val same = if (compareByTimeTs) lastNew.meta.itemTs == item.meta.itemTs else lastNew.meta.createdAt == item.meta.createdAt
if (same) {
sameTime.add(SameTimeItem(index = i, item = item))
} else {
sameTime = arrayListOf()
}
}
// shouldn't be here
return Pair(to.size, splits.size)
}
private fun indexToInsertAroundTest() {
fun assert(one: Pair<Int, Int>, two: Pair<Int, Int>) {
if (one != two) {
throw Exception("$one != $two")
}
}
val itemsToInsert = listOf(ChatItem.getSampleData(3, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds( 3), ""))
val items1 = listOf(
ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds( 0), ""),
ChatItem.getSampleData(1, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds( 1), ""),
ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds( 2), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items1, setOf(1)), Pair(3, 1))
val items2 = listOf(
ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(0), ""),
ChatItem.getSampleData(1, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(1), ""),
ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items2, setOf(2)), Pair(3, 1))
val items3 = listOf(
ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(0), ""),
ChatItem.getSampleData(1, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""),
ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items3, setOf(1)), Pair(3, 1))
val items4 = listOf(
ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(0), ""),
ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""),
ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items4, setOf(4)), Pair(1, 0))
val items5 = listOf(
ChatItem.getSampleData(0, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(0), ""),
ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""),
ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items5, setOf(2)), Pair(2, 1))
val items6 = listOf(
ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""),
ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""),
ChatItem.getSampleData(6, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items6, setOf(5)), Pair(0, 0))
val items7 = listOf(
ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""),
ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""),
ChatItem.getSampleData(6, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "")
)
assert(indexToInsertAround(ChatType.Group, null, to = items7, setOf(6)), Pair(0, 0))
val items8 = listOf(
ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), ""),
ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""),
ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items8, setOf(2)), Pair(0, 0))
val items9 = listOf(
ChatItem.getSampleData(2, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""),
ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""),
ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items9, setOf(5)), Pair(1, 0))
val items10 = listOf(
ChatItem.getSampleData(4, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""),
ChatItem.getSampleData(5, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(3), ""),
ChatItem.getSampleData(6, CIDirection.GroupSnd(), Instant.fromEpochMilliseconds(4), "")
)
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items10, setOf(4)), Pair(0, 0))
val items11: List<ChatItem> = listOf()
assert(indexToInsertAround(ChatType.Group, itemsToInsert.lastOrNull(), to = items11, emptySet()), Pair(0, 0))
}

View file

@ -1227,7 +1227,7 @@ fun BoxScope.ChatItemsList(
if (reportsState != null) { if (reportsState != null) {
reportsListState = null reportsListState = null
reportsState reportsState
} else if (index <= 0) { } else if (index <= 0 || !searchValueIsEmpty.value) {
LazyListState(0, 0) LazyListState(0, 0)
} else { } else {
LazyListState(index + 1, -maxHeightForList.value) LazyListState(index + 1, -maxHeightForList.value)
@ -1242,19 +1242,19 @@ fun BoxScope.ChatItemsList(
if (searchValueIsEmpty.value && reversedChatItems.value.size < ChatPagination.INITIAL_COUNT) if (searchValueIsEmpty.value && reversedChatItems.value.size < ChatPagination.INITIAL_COUNT)
ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect) ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect)
} }
if (!loadingMoreItems.value) { PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), loadingMoreItems, resetListState, contentTag, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination ->
PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), contentTag, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination -> if (loadingMoreItems.value || chatId != chatModel.chatId.value) return@PreloadItems false
if (loadingMoreItems.value) return@PreloadItems false
try {
loadingMoreItems.value = true loadingMoreItems.value = true
withContext(NonCancellable) {
try {
loadMessages(chatId, pagination) { loadMessages(chatId, pagination) {
visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value) visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value)
} }
} finally { } finally {
loadingMoreItems.value = false loadingMoreItems.value = false
} }
true
} }
true
} }
val remoteHostIdUpdated = rememberUpdatedState(remoteHostId) val remoteHostIdUpdated = rememberUpdatedState(remoteHostId)
@ -1275,7 +1275,6 @@ fun BoxScope.ChatItemsList(
scrollToItemId.value = null } scrollToItemId.value = null }
} }
} }
LoadLastItems(loadingMoreItems, resetListState, remoteHostId, chatInfo)
SmallScrollOnNewMessage(listState, reversedChatItems) SmallScrollOnNewMessage(listState, reversedChatItems)
val finishedInitialComposition = remember { mutableStateOf(false) } val finishedInitialComposition = remember { mutableStateOf(false) }
NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed) NotifyChatListOnFinishingComposition(finishedInitialComposition, chatInfo, revealedItems, listState, onComposed)
@ -1583,7 +1582,7 @@ fun BoxScope.ChatItemsList(
} }
} }
} }
FloatingButtons(topPaddingToContent, topPaddingToContentPx, loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, markChatRead, listState) FloatingButtons(reversedChatItems, chatInfoUpdated, topPaddingToContent, topPaddingToContentPx, contentTag, loadingMoreItems, animatedScrollingInProgress, mergedItems, unreadCount, maxHeight, composeViewHeight, searchValue, markChatRead, listState, loadMessages)
FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent).align(Alignment.TopCenter), topPaddingToContentPx, mergedItems, listState) FloatingDate(Modifier.padding(top = 10.dp + topPaddingToContent).align(Alignment.TopCenter), topPaddingToContentPx, mergedItems, listState)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -1603,21 +1602,17 @@ fun BoxScope.ChatItemsList(
} }
} }
@Composable private suspend fun loadLastItems(chatId: State<ChatId>, contentTag: MsgContentTag?, listState: State<LazyListState>, loadItems: State<suspend (ChatId, ChatPagination) -> Boolean>) {
private fun LoadLastItems(loadingMoreItems: MutableState<Boolean>, resetListState: State<Boolean>, remoteHostId: Long?, chatInfo: ChatInfo) { val lastVisible = listState.value.layoutInfo.visibleItemsInfo.lastOrNull()
val contentTag = LocalContentTag.current val itemsCanCoverScreen = lastVisible != null && listState.value.layoutInfo.viewportEndOffset - listState.value.layoutInfo.afterContentPadding <= lastVisible.offset + lastVisible.size
LaunchedEffect(remoteHostId, chatInfo.id, resetListState.value) { if (!itemsCanCoverScreen) return
try {
loadingMoreItems.value = true val chatState = chatModel.chatStateForContent(contentTag)
if (chatModel.chatStateForContent(contentTag).totalAfter.value <= 0) return@LaunchedEffect val lastItemsLoaded = chatState.splits.value.isEmpty() || chatState.splits.value.firstOrNull() != chatModel.chatItemsForContent(contentTag).value.lastOrNull()?.id
if (lastItemsLoaded) return
delay(500) delay(500)
withContext(Dispatchers.Default) { loadItems.value(chatId.value, ChatPagination.Last(ChatPagination.INITIAL_COUNT))
apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, contentTag, ChatPagination.Last(ChatPagination.INITIAL_COUNT))
}
} finally {
loadingMoreItems.value = false
}
}
} }
// TODO: in extra rare case when after loading last items only 1 item is loaded, the view will jump like when receiving new message // TODO: in extra rare case when after loading last items only 1 item is loaded, the view will jump like when receiving new message
@ -1680,8 +1675,11 @@ private fun NotifyChatListOnFinishingComposition(
@Composable @Composable
fun BoxScope.FloatingButtons( fun BoxScope.FloatingButtons(
reversedChatItems: State<List<ChatItem>>,
chatInfo: State<ChatInfo>,
topPaddingToContent: Dp, topPaddingToContent: Dp,
topPaddingToContentPx: State<Int>, topPaddingToContentPx: State<Int>,
contentTag: MsgContentTag?,
loadingMoreItems: MutableState<Boolean>, loadingMoreItems: MutableState<Boolean>,
animatedScrollingInProgress: MutableState<Boolean>, animatedScrollingInProgress: MutableState<Boolean>,
mergedItems: State<MergedItems>, mergedItems: State<MergedItems>,
@ -1690,7 +1688,8 @@ fun BoxScope.FloatingButtons(
composeViewHeight: State<Dp>, composeViewHeight: State<Dp>,
searchValue: State<String>, searchValue: State<String>,
markChatRead: () -> Unit, markChatRead: () -> Unit,
listState: State<LazyListState> listState: State<LazyListState>,
loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val bottomUnreadCount = remember { val bottomUnreadCount = remember {
@ -1734,7 +1733,9 @@ fun BoxScope.FloatingButtons(
// Don't show top FAB if is in search // Don't show top FAB if is in search
if (searchValue.value.isNotEmpty()) return if (searchValue.value.isNotEmpty()) return
val fabSize = 56.dp val fabSize = 56.dp
val topUnreadCount = remember { derivedStateOf { if (bottomUnreadCount.value >= 0) (unreadCount.value - bottomUnreadCount.value).coerceAtLeast(0) else 0 } } val topUnreadCount = remember { derivedStateOf {
if (bottomUnreadCount.value >= 0) (unreadCount.value - bottomUnreadCount.value).coerceAtLeast(0) else 0 }
}
val showDropDown = remember { mutableStateOf(false) } val showDropDown = remember { mutableStateOf(false) }
TopEndFloatingButton( TopEndFloatingButton(
@ -1742,12 +1743,29 @@ fun BoxScope.FloatingButtons(
topUnreadCount, topUnreadCount,
animatedScrollingInProgress, animatedScrollingInProgress,
onClick = { onClick = {
scope.launch {
tryBlockAndSetLoadingMore(loadingMoreItems) {
if (chatModel.chatStateForContent(contentTag).splits.value.isNotEmpty()) {
val pagination = ChatPagination.Initial(ChatPagination.INITIAL_COUNT)
val oldSize = reversedChatItems.value.size
loadMessages(chatInfo.value.id, pagination) {
visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value)
}
var repeatsLeft = 100
while (oldSize == reversedChatItems.value.size && repeatsLeft > 0) {
delay(10)
repeatsLeft--
}
if (oldSize == reversedChatItems.value.size) {
return@tryBlockAndSetLoadingMore
}
}
val index = mergedItems.value.items.indexOfLast { it.hasUnread() } val index = mergedItems.value.items.indexOfLast { it.hasUnread() }
if (index != -1) { if (index != -1) {
// scroll to the top unread item // scroll to the top unread item
scope.launch {
animatedScrollingInProgress.value = true animatedScrollingInProgress.value = true
tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(index + 1, -maxHeight.value) } listState.value.animateScrollToItem(index + 1, -maxHeight.value)
}
} }
} }
}, },
@ -1777,6 +1795,8 @@ fun BoxScope.FloatingButtons(
fun PreloadItems( fun PreloadItems(
chatId: String, chatId: String,
ignoreLoadingRequests: MutableSet<Long>, ignoreLoadingRequests: MutableSet<Long>,
loadingMoreItems: State<Boolean>,
resetListState: State<Boolean>,
contentTag: MsgContentTag?, contentTag: MsgContentTag?,
mergedItems: State<MergedItems>, mergedItems: State<MergedItems>,
listState: State<LazyListState>, listState: State<LazyListState>,
@ -1788,13 +1808,32 @@ fun PreloadItems(
val chatId = rememberUpdatedState(chatId) val chatId = rememberUpdatedState(chatId)
val loadItems = rememberUpdatedState(loadItems) val loadItems = rememberUpdatedState(loadItems)
val ignoreLoadingRequests = rememberUpdatedState(ignoreLoadingRequests) val ignoreLoadingRequests = rememberUpdatedState(ignoreLoadingRequests)
PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, contentTag, mergedItems, listState, remaining, loadItems) LaunchedEffect(Unit) {
PreloadItemsAfter(allowLoad, chatId, contentTag, mergedItems, listState, remaining, loadItems) snapshotFlow { chatId.value }
.distinctUntilChanged()
.filterNotNull()
.collect {
allowLoad.value = false
delay(500)
allowLoad.value = true
}
}
if (allowLoad.value && !loadingMoreItems.value) {
LaunchedEffect(chatId.value, resetListState.value) {
snapshotFlow { listState.value.firstVisibleItemIndex }
.distinctUntilChanged()
.collect { firstVisibleIndex ->
if (!preloadItemsBefore(firstVisibleIndex, chatId, ignoreLoadingRequests, contentTag, mergedItems, listState, remaining, loadItems)) {
preloadItemsAfter(firstVisibleIndex, chatId, contentTag, mergedItems, remaining, loadItems)
}
loadLastItems(chatId, contentTag, listState, loadItems)
}
}
}
} }
@Composable private suspend fun preloadItemsBefore(
private fun PreloadItemsBefore( firstVisibleIndex: Int,
allowLoad: State<Boolean>,
chatId: State<String>, chatId: State<String>,
ignoreLoadingRequests: State<MutableSet<Long>>, ignoreLoadingRequests: State<MutableSet<Long>>,
contentTag: MsgContentTag?, contentTag: MsgContentTag?,
@ -1802,11 +1841,7 @@ private fun PreloadItemsBefore(
listState: State<LazyListState>, listState: State<LazyListState>,
remaining: Int, remaining: Int,
loadItems: State<suspend (ChatId, ChatPagination) -> Boolean>, loadItems: State<suspend (ChatId, ChatPagination) -> Boolean>,
) { ): Boolean {
KeyChangeEffect(allowLoad.value, chatId.value) {
snapshotFlow { listState.value.firstVisibleItemIndex }
.distinctUntilChanged()
.map { firstVisibleIndex ->
val splits = mergedItems.value.splits val splits = mergedItems.value.splits
val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0)
var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits) var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits)
@ -1814,16 +1849,9 @@ private fun PreloadItemsBefore(
if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) { if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) {
lastIndexToLoadFrom = items.lastIndex lastIndexToLoadFrom = items.lastIndex
} }
if (allowLoad.value && lastIndexToLoadFrom != null) { if (lastIndexToLoadFrom != null) {
items.getOrNull(lastIndexToLoadFrom)?.id val loadFromItemId = items.getOrNull(lastIndexToLoadFrom)?.id ?: return false
} else { if (!ignoreLoadingRequests.value.contains(loadFromItemId)) {
null
}
}
.filterNotNull()
.filter { !ignoreLoadingRequests.value.contains(it) }
.collect { loadFromItemId ->
withBGApi {
val items = reversedChatItemsStatic(contentTag) val items = reversedChatItemsStatic(contentTag)
val sizeWas = items.size val sizeWas = items.size
val oldestItemIdWas = items.lastOrNull()?.id val oldestItemIdWas = items.lastOrNull()?.id
@ -1831,55 +1859,30 @@ private fun PreloadItemsBefore(
val itemsUpdated = reversedChatItemsStatic(contentTag) val itemsUpdated = reversedChatItemsStatic(contentTag)
if (triedToLoad && sizeWas == itemsUpdated.size && oldestItemIdWas == itemsUpdated.lastOrNull()?.id) { if (triedToLoad && sizeWas == itemsUpdated.size && oldestItemIdWas == itemsUpdated.lastOrNull()?.id) {
ignoreLoadingRequests.value.add(loadFromItemId) ignoreLoadingRequests.value.add(loadFromItemId)
return false
}
return triedToLoad
} }
} }
} return false
}
} }
@Composable private suspend fun preloadItemsAfter(
private fun PreloadItemsAfter( firstVisibleIndex: Int,
allowLoad: MutableState<Boolean>,
chatId: State<String>, chatId: State<String>,
contentTag: MsgContentTag?, contentTag: MsgContentTag?,
mergedItems: State<MergedItems>, mergedItems: State<MergedItems>,
listState: State<LazyListState>,
remaining: Int, remaining: Int,
loadItems: State<suspend (ChatId, ChatPagination) -> Boolean>, loadItems: State<suspend (ChatId, ChatPagination) -> Boolean>,
) { ) {
LaunchedEffect(Unit) {
snapshotFlow { chatId.value }
.distinctUntilChanged()
.filterNotNull()
.collect {
allowLoad.value = listState.value.layoutInfo.totalItemsCount == listState.value.layoutInfo.visibleItemsInfo.size
delay(500)
allowLoad.value = true
}
}
LaunchedEffect(chatId.value) {
launch {
snapshotFlow { listState.value.firstVisibleItemIndex }
.distinctUntilChanged()
.map { firstVisibleIndex ->
val items = reversedChatItemsStatic(contentTag) val items = reversedChatItemsStatic(contentTag)
val splits = mergedItems.value.splits val splits = mergedItems.value.splits
val split = splits.lastOrNull { it.indexRangeInParentItems.contains(firstVisibleIndex) } val split = splits.lastOrNull { it.indexRangeInParentItems.contains(firstVisibleIndex) }
// we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom) // we're inside a splitRange (top --- [end of the splitRange --- we're here --- start of the splitRange] --- bottom)
if (split != null && split.indexRangeInParentItems.first + remaining > firstVisibleIndex) { if (split != null && split.indexRangeInParentItems.first + remaining > firstVisibleIndex) {
items.getOrNull(split.indexRangeInReversed.first)?.id val loadFromItemId = items.getOrNull(split.indexRangeInReversed.first)?.id ?: return
} else {
null
}
}
.filterNotNull()
.collect { loadFromItemId ->
withBGApi {
loadItems.value(chatId.value, ChatPagination.After(loadFromItemId, ChatPagination.PRELOAD_COUNT)) loadItems.value(chatId.value, ChatPagination.After(loadFromItemId, ChatPagination.PRELOAD_COUNT))
} }
}
}
}
} }
val MEMBER_IMAGE_SIZE: Dp = 37.dp val MEMBER_IMAGE_SIZE: Dp = 37.dp
@ -2115,7 +2118,7 @@ private fun DateSeparator(date: Instant) {
@Composable @Composable
private fun MarkItemsReadAfterDelay( private fun MarkItemsReadAfterDelay(
itemKey: String, itemKey: ChatViewItemKey,
itemIds: List<Long>, itemIds: List<Long>,
finishedInitialComposition: State<Boolean>, finishedInitialComposition: State<Boolean>,
chatId: ChatId, chatId: ChatId,
@ -2153,18 +2156,20 @@ private fun reversedChatItemsStatic(contentTag: MsgContentTag?): List<ChatItem>
private fun oldestPartiallyVisibleListItemInListStateOrNull(topPaddingToContentPx: State<Int>, mergedItems: State<MergedItems>, listState: State<LazyListState>): ListItem? { private fun oldestPartiallyVisibleListItemInListStateOrNull(topPaddingToContentPx: State<Int>, mergedItems: State<MergedItems>, listState: State<LazyListState>): ListItem? {
val lastFullyVisibleOffset = listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value val lastFullyVisibleOffset = listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value
return mergedItems.value.items.getOrNull((listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item -> val visibleKey: ChatViewItemKey? = listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item ->
item.offset <= lastFullyVisibleOffset item.offset <= lastFullyVisibleOffset
}?.index ?: listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index) ?: -1)?.oldest() }?.key as? ChatViewItemKey
return mergedItems.value.items.getOrNull((mergedItems.value.indexInParentItems[visibleKey?.first] ?: listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index) ?: -1)?.oldest()
} }
private fun lastFullyVisibleIemInListState(topPaddingToContentPx: State<Int>, density: Float, fontSizeSqrtMultiplier: Float, mergedItems: State<MergedItems>, listState: State<LazyListState>): ChatItem? { private fun lastFullyVisibleIemInListState(topPaddingToContentPx: State<Int>, density: Float, fontSizeSqrtMultiplier: Float, mergedItems: State<MergedItems>, listState: State<LazyListState>): ChatItem? {
val lastFullyVisibleOffsetMinusFloatingHeight = listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value - 50 * density * fontSizeSqrtMultiplier val lastFullyVisibleOffsetMinusFloatingHeight = listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value - 50 * density * fontSizeSqrtMultiplier
return mergedItems.value.items.getOrNull( val visibleKey: ChatViewItemKey? = listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item ->
(listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item ->
item.offset <= lastFullyVisibleOffsetMinusFloatingHeight && item.size > 0 item.offset <= lastFullyVisibleOffsetMinusFloatingHeight && item.size > 0
} }?.key as? ChatViewItemKey
?.index
return mergedItems.value.items.getOrNull(
(mergedItems.value.indexInParentItems[visibleKey?.first]
?: listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index) ?: listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index)
?: -1)?.newest()?.item ?: -1)?.newest()?.item
} }
@ -2652,7 +2657,9 @@ fun providerForGallery(
} }
} }
private fun keyForItem(item: ChatItem): String = (item.id to item.meta.createdAt.toEpochMilliseconds()).toString() typealias ChatViewItemKey = Pair<Long, Long>
private fun keyForItem(item: ChatItem): ChatViewItemKey = ChatViewItemKey(item.id, item.meta.createdAt.toEpochMilliseconds())
private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration { private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration {
override val longPressTimeoutMillis override val longPressTimeoutMillis

View file

@ -245,7 +245,7 @@ suspend fun apiFindMessages(ch: Chat, search: String, contentTag: MsgContentTag?
withChats(contentTag) { withChats(contentTag) {
chatItems.clearAndNotify() chatItems.clearAndNotify()
} }
apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, contentTag, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search = search) apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, contentTag, pagination = if (search.isNotEmpty()) ChatPagination.Last(ChatPagination.INITIAL_COUNT) else ChatPagination.Initial(ChatPagination.INITIAL_COUNT), search = search)
} }
suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope { suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope {