mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-06-29 04:39:53 +00:00
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:
parent
6e7df9c72d
commit
6b75f61537
3 changed files with 305 additions and 122 deletions
|
@ -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))
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue