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 kotlinx.coroutines.*
import kotlinx.coroutines.flow.StateFlow
import kotlinx.datetime.Instant
import kotlin.math.min
const val TRIM_KEEP_COUNT = 200
@ -122,18 +123,20 @@ suspend fun processLoadedChat(
}
}
is ChatPagination.Around -> {
val newSplits = if (openAroundItemId == null) {
val newSplits: ArrayList<Long> = if (openAroundItemId == null) {
newItems.addAll(oldItems)
removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed)
ArrayList(removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed))
} else {
emptyList()
arrayListOf()
}
// currently, items will always be added on top, which is index 0
newItems.addAll(0, chat.chatItems)
val (itemIndex, splitIndex) = indexToInsertAround(chat.chatInfo.chatType, chat.chatItems.lastOrNull(), to = newItems, newSplits.toSet())
//indexToInsertAroundTest()
newItems.addAll(itemIndex, chat.chatItems)
newSplits.add(splitIndex, chat.chatItems.last().id)
withChats(contentTag) {
chatItems.replaceAll(newItems)
splits.value = listOf(chat.chatItems.last().id) + newSplits
splits.value = newSplits
unreadAfterItemId.value = chat.chatItems.last().id
totalAfter.value = navInfo.afterTotal
unreadTotal.value = chat.chatStats.unreadCount
@ -151,10 +154,12 @@ suspend fun processLoadedChat(
}
is ChatPagination.Last -> {
newItems.addAll(oldItems)
val newSplits = removeDuplicatesAndUnusedSplits(newItems, chat, chatState.splits.value)
removeDuplicates(newItems, chat)
newItems.addAll(chat.chatItems)
withChats(contentTag) {
chatItems.replaceAll(newItems)
chatState.splits.value = newSplits
unreadAfterNewestLoaded.value = 0
}
}
@ -240,7 +245,15 @@ private fun removeDuplicatesAndModifySplitsOnAfterPagination(
val indexInSplitRanges = splits.value.indexOf(paginationChatItemId)
// Currently, it should always load from split range
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 {
val duplicate = newIds.contains(it.id)
if (loadingFromSplitRange && duplicate) {
@ -259,8 +272,8 @@ private fun removeDuplicatesAndModifySplitsOnAfterPagination(
}
var newSplits: List<Long> = emptyList()
if (firstItemIdBelowAllSplits != null) {
// no splits anymore, all were merged with bottom items
newSplits = emptyList()
// no splits below anymore, all were merged with bottom items
newSplits = topSplits
} else {
if (splitsToRemove.isNotEmpty()) {
val new = ArrayList(splits.value)
@ -323,6 +336,31 @@ private fun removeDuplicatesAndUpperSplits(
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
private fun mapItemsToIds(items: List<ChatItem>): Pair<Set<Long>, Int> {
var unreadInLoaded = 0
@ -343,3 +381,141 @@ private fun removeDuplicates(newItems: SnapshotStateList<ChatItem>, chat: Chat)
val (newIds, _) = mapItemsToIds(chat.chatItems)
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) {
reportsListState = null
reportsState
} else if (index <= 0) {
} else if (index <= 0 || !searchValueIsEmpty.value) {
LazyListState(0, 0)
} else {
LazyListState(index + 1, -maxHeightForList.value)
@ -1242,19 +1242,19 @@ fun BoxScope.ChatItemsList(
if (searchValueIsEmpty.value && reversedChatItems.value.size < ChatPagination.INITIAL_COUNT)
ignoreLoadingRequests.add(reversedChatItems.value.lastOrNull()?.id ?: return@LaunchedEffect)
}
if (!loadingMoreItems.value) {
PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), contentTag, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination ->
if (loadingMoreItems.value) return@PreloadItems false
PreloadItems(chatInfo.id, if (searchValueIsEmpty.value) ignoreLoadingRequests else mutableSetOf(), loadingMoreItems, resetListState, contentTag, mergedItems, listState, ChatPagination.UNTIL_PRELOAD_COUNT) { chatId, pagination ->
if (loadingMoreItems.value || chatId != chatModel.chatId.value) return@PreloadItems false
loadingMoreItems.value = true
withContext(NonCancellable) {
try {
loadingMoreItems.value = true
loadMessages(chatId, pagination) {
visibleItemIndexesNonReversed(mergedItems, reversedChatItems.value.size, listState.value)
}
} finally {
loadingMoreItems.value = false
}
true
}
true
}
val remoteHostIdUpdated = rememberUpdatedState(remoteHostId)
@ -1275,7 +1275,6 @@ fun BoxScope.ChatItemsList(
scrollToItemId.value = null }
}
}
LoadLastItems(loadingMoreItems, resetListState, remoteHostId, chatInfo)
SmallScrollOnNewMessage(listState, reversedChatItems)
val finishedInitialComposition = remember { mutableStateOf(false) }
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)
LaunchedEffect(Unit) {
@ -1603,21 +1602,17 @@ fun BoxScope.ChatItemsList(
}
}
@Composable
private fun LoadLastItems(loadingMoreItems: MutableState<Boolean>, resetListState: State<Boolean>, remoteHostId: Long?, chatInfo: ChatInfo) {
val contentTag = LocalContentTag.current
LaunchedEffect(remoteHostId, chatInfo.id, resetListState.value) {
try {
loadingMoreItems.value = true
if (chatModel.chatStateForContent(contentTag).totalAfter.value <= 0) return@LaunchedEffect
delay(500)
withContext(Dispatchers.Default) {
apiLoadMessages(remoteHostId, chatInfo.chatType, chatInfo.apiId, contentTag, ChatPagination.Last(ChatPagination.INITIAL_COUNT))
}
} finally {
loadingMoreItems.value = false
}
}
private suspend fun loadLastItems(chatId: State<ChatId>, contentTag: MsgContentTag?, listState: State<LazyListState>, loadItems: State<suspend (ChatId, ChatPagination) -> Boolean>) {
val lastVisible = listState.value.layoutInfo.visibleItemsInfo.lastOrNull()
val itemsCanCoverScreen = lastVisible != null && listState.value.layoutInfo.viewportEndOffset - listState.value.layoutInfo.afterContentPadding <= lastVisible.offset + lastVisible.size
if (!itemsCanCoverScreen) return
val chatState = chatModel.chatStateForContent(contentTag)
val lastItemsLoaded = chatState.splits.value.isEmpty() || chatState.splits.value.firstOrNull() != chatModel.chatItemsForContent(contentTag).value.lastOrNull()?.id
if (lastItemsLoaded) return
delay(500)
loadItems.value(chatId.value, ChatPagination.Last(ChatPagination.INITIAL_COUNT))
}
// 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
fun BoxScope.FloatingButtons(
reversedChatItems: State<List<ChatItem>>,
chatInfo: State<ChatInfo>,
topPaddingToContent: Dp,
topPaddingToContentPx: State<Int>,
contentTag: MsgContentTag?,
loadingMoreItems: MutableState<Boolean>,
animatedScrollingInProgress: MutableState<Boolean>,
mergedItems: State<MergedItems>,
@ -1690,7 +1688,8 @@ fun BoxScope.FloatingButtons(
composeViewHeight: State<Dp>,
searchValue: State<String>,
markChatRead: () -> Unit,
listState: State<LazyListState>
listState: State<LazyListState>,
loadMessages: suspend (ChatId, ChatPagination, visibleItemIndexesNonReversed: () -> IntRange) -> Unit
) {
val scope = rememberCoroutineScope()
val bottomUnreadCount = remember {
@ -1734,7 +1733,9 @@ fun BoxScope.FloatingButtons(
// Don't show top FAB if is in search
if (searchValue.value.isNotEmpty()) return
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) }
TopEndFloatingButton(
@ -1742,14 +1743,31 @@ fun BoxScope.FloatingButtons(
topUnreadCount,
animatedScrollingInProgress,
onClick = {
val index = mergedItems.value.items.indexOfLast { it.hasUnread() }
if (index != -1) {
// scroll to the top unread item
scope.launch {
animatedScrollingInProgress.value = true
tryBlockAndSetLoadingMore(loadingMoreItems) { listState.value.animateScrollToItem(index + 1, -maxHeight.value) }
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() }
if (index != -1) {
// scroll to the top unread item
animatedScrollingInProgress.value = true
listState.value.animateScrollToItem(index + 1, -maxHeight.value)
}
}
}
}
},
onLongClick = { showDropDown.value = true }
)
@ -1777,6 +1795,8 @@ fun BoxScope.FloatingButtons(
fun PreloadItems(
chatId: String,
ignoreLoadingRequests: MutableSet<Long>,
loadingMoreItems: State<Boolean>,
resetListState: State<Boolean>,
contentTag: MsgContentTag?,
mergedItems: State<MergedItems>,
listState: State<LazyListState>,
@ -1788,13 +1808,32 @@ fun PreloadItems(
val chatId = rememberUpdatedState(chatId)
val loadItems = rememberUpdatedState(loadItems)
val ignoreLoadingRequests = rememberUpdatedState(ignoreLoadingRequests)
PreloadItemsBefore(allowLoad, chatId, ignoreLoadingRequests, contentTag, mergedItems, listState, remaining, loadItems)
PreloadItemsAfter(allowLoad, chatId, contentTag, mergedItems, listState, remaining, loadItems)
LaunchedEffect(Unit) {
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 fun PreloadItemsBefore(
allowLoad: State<Boolean>,
private suspend fun preloadItemsBefore(
firstVisibleIndex: Int,
chatId: State<String>,
ignoreLoadingRequests: State<MutableSet<Long>>,
contentTag: MsgContentTag?,
@ -1802,83 +1841,47 @@ private fun PreloadItemsBefore(
listState: State<LazyListState>,
remaining: Int,
loadItems: State<suspend (ChatId, ChatPagination) -> Boolean>,
) {
KeyChangeEffect(allowLoad.value, chatId.value) {
snapshotFlow { listState.value.firstVisibleItemIndex }
.distinctUntilChanged()
.map { firstVisibleIndex ->
val splits = mergedItems.value.splits
val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0)
var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits)
val items = reversedChatItemsStatic(contentTag)
if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) {
lastIndexToLoadFrom = items.lastIndex
}
if (allowLoad.value && lastIndexToLoadFrom != null) {
items.getOrNull(lastIndexToLoadFrom)?.id
} else {
null
}
}
.filterNotNull()
.filter { !ignoreLoadingRequests.value.contains(it) }
.collect { loadFromItemId ->
withBGApi {
val items = reversedChatItemsStatic(contentTag)
val sizeWas = items.size
val oldestItemIdWas = items.lastOrNull()?.id
val triedToLoad = loadItems.value(chatId.value, ChatPagination.Before(loadFromItemId, ChatPagination.PRELOAD_COUNT))
val itemsUpdated = reversedChatItemsStatic(contentTag)
if (triedToLoad && sizeWas == itemsUpdated.size && oldestItemIdWas == itemsUpdated.lastOrNull()?.id) {
ignoreLoadingRequests.value.add(loadFromItemId)
}
}
}
): Boolean {
val splits = mergedItems.value.splits
val lastVisibleIndex = (listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0)
var lastIndexToLoadFrom: Int? = findLastIndexToLoadFromInSplits(firstVisibleIndex, lastVisibleIndex, remaining, splits)
val items = reversedChatItemsStatic(contentTag)
if (splits.isEmpty() && items.isNotEmpty() && lastVisibleIndex > mergedItems.value.items.size - remaining) {
lastIndexToLoadFrom = items.lastIndex
}
if (lastIndexToLoadFrom != null) {
val loadFromItemId = items.getOrNull(lastIndexToLoadFrom)?.id ?: return false
if (!ignoreLoadingRequests.value.contains(loadFromItemId)) {
val items = reversedChatItemsStatic(contentTag)
val sizeWas = items.size
val oldestItemIdWas = items.lastOrNull()?.id
val triedToLoad = loadItems.value(chatId.value, ChatPagination.Before(loadFromItemId, ChatPagination.PRELOAD_COUNT))
val itemsUpdated = reversedChatItemsStatic(contentTag)
if (triedToLoad && sizeWas == itemsUpdated.size && oldestItemIdWas == itemsUpdated.lastOrNull()?.id) {
ignoreLoadingRequests.value.add(loadFromItemId)
return false
}
return triedToLoad
}
}
return false
}
@Composable
private fun PreloadItemsAfter(
allowLoad: MutableState<Boolean>,
private suspend fun preloadItemsAfter(
firstVisibleIndex: Int,
chatId: State<String>,
contentTag: MsgContentTag?,
mergedItems: State<MergedItems>,
listState: State<LazyListState>,
remaining: Int,
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 splits = mergedItems.value.splits
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)
if (split != null && split.indexRangeInParentItems.first + remaining > firstVisibleIndex) {
items.getOrNull(split.indexRangeInReversed.first)?.id
} else {
null
}
}
.filterNotNull()
.collect { loadFromItemId ->
withBGApi {
loadItems.value(chatId.value, ChatPagination.After(loadFromItemId, ChatPagination.PRELOAD_COUNT))
}
}
}
val items = reversedChatItemsStatic(contentTag)
val splits = mergedItems.value.splits
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)
if (split != null && split.indexRangeInParentItems.first + remaining > firstVisibleIndex) {
val loadFromItemId = items.getOrNull(split.indexRangeInReversed.first)?.id ?: return
loadItems.value(chatId.value, ChatPagination.After(loadFromItemId, ChatPagination.PRELOAD_COUNT))
}
}
@ -2115,7 +2118,7 @@ private fun DateSeparator(date: Instant) {
@Composable
private fun MarkItemsReadAfterDelay(
itemKey: String,
itemKey: ChatViewItemKey,
itemIds: List<Long>,
finishedInitialComposition: State<Boolean>,
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? {
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
}?.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? {
val lastFullyVisibleOffsetMinusFloatingHeight = listState.value.layoutInfo.viewportEndOffset - topPaddingToContentPx.value - 50 * density * fontSizeSqrtMultiplier
val visibleKey: ChatViewItemKey? = listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item ->
item.offset <= lastFullyVisibleOffsetMinusFloatingHeight && item.size > 0
}?.key as? ChatViewItemKey
return mergedItems.value.items.getOrNull(
(listState.value.layoutInfo.visibleItemsInfo.lastOrNull { item ->
item.offset <= lastFullyVisibleOffsetMinusFloatingHeight && item.size > 0
}
?.index
(mergedItems.value.indexInParentItems[visibleKey?.first]
?: listState.value.layoutInfo.visibleItemsInfo.lastOrNull()?.index)
?: -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 {
override val longPressTimeoutMillis

View file

@ -245,7 +245,7 @@ suspend fun apiFindMessages(ch: Chat, search: String, contentTag: MsgContentTag?
withChats(contentTag) {
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 {