From 53c77d310a776819487225839be34ce405d12688 Mon Sep 17 00:00:00 2001 From: Diogo Date: Mon, 28 Oct 2024 22:23:48 +0000 Subject: [PATCH] fetch both sides poc --- .../chat/simplex/common/model/ChatModel.kt | 2 + .../simplex/common/views/chat/ChatSections.kt | 125 ++++++++++++++--- .../simplex/common/views/chat/ChatView.kt | 131 ++++++++++++------ .../views/chatlist/ChatListNavLinkView.kt | 23 ++- 4 files changed, 218 insertions(+), 63 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 6bc565097f..6365eff33d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.text.style.TextDecoration import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* +import chat.simplex.common.views.chat.ChatSectionArea import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrationToDeviceState @@ -65,6 +66,7 @@ object ChatModel { // current chat val chatId = mutableStateOf(null) val chatItems = mutableStateOf(SnapshotStateList()) + var chatItemsSectionArea = mutableMapOf() // rhId, chatId val deletedChats = mutableStateOf>>(emptyList()) val chatItemStatuses = mutableMapOf() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatSections.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatSections.kt index 4691c80dff..5758a2749e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatSections.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatSections.kt @@ -2,7 +2,27 @@ package chat.simplex.common.views.chat import androidx.compose.runtime.snapshots.SnapshotStateList import chat.simplex.common.model.* -import java.util.UUID +import chat.simplex.common.platform.chatModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +enum class ChatSectionArea { + Bottom, + Current, + Destination +} + +data class ChatSectionAreaBoundary ( + var minIndex: Int, + var maxIndex: Int, + val area: ChatSectionArea +) + +data class ChatSection ( + val items: MutableList, + val area: ChatSectionArea, + val boundary: ChatSectionAreaBoundary +) data class SectionItems ( val mergeCategory: CIMergeCategory?, @@ -18,9 +38,9 @@ fun SectionItems.getPreviousChatItem(chatItem: ChatItem): ChatItem? { return items.getOrNull(itemIndex + 1) } -fun List.putIntoSections(revealedItems: Set): List { - val sections = mutableListOf() - +fun List.putIntoSections(revealedItems: Set): List { + val chatItemsSectionArea = chatModel.chatItemsSectionArea + val sections = mutableListOf() var recent: SectionItems = if (isNotEmpty()) { val first = this[0] @@ -47,7 +67,15 @@ fun List.putIntoSections(revealedItems: Set): List return emptyList() } - sections.add(recent) + val area = chatItemsSectionArea[recent.items[0].id] ?: ChatSectionArea.Bottom + + sections.add( + ChatSection( + items = mutableListOf(recent), + area = area, + boundary = ChatSectionAreaBoundary(minIndex = 0, maxIndex = 0, area = area) + ) + ) var prev = this[0] var index = 0 @@ -57,27 +85,47 @@ fun List.putIntoSections(revealedItems: Set): List continue } val item = this[index] - val category = item.mergeCategory - if (recent.mergeCategory == category) { - if (item.chatDir is CIDirection.GroupRcv && prev.chatDir is CIDirection.GroupRcv && item.chatDir.groupMember != (prev.chatDir as CIDirection.GroupRcv).groupMember) { - recent.showAvatar.add(item.id) - } + val itemArea = chatItemsSectionArea[item.id] ?: ChatSectionArea.Bottom + val existingSection = sections.find { it.area == itemArea } - recent.items.add(item) - recent.itemPositions[item.id] = index - } else { - recent = SectionItems( + if (existingSection == null) { + val newSection = SectionItems( mergeCategory = item.mergeCategory, items = SnapshotStateList().also { it.add(item) }, revealed = item.mergeCategory == null || revealedItems.contains(item.id), showAvatar = mutableSetOf().also { - if (item.chatDir is CIDirection.GroupRcv && (prev.chatDir !is CIDirection.GroupRcv || (prev.chatDir as CIDirection.GroupRcv).groupMember != item.chatDir.groupMember)) { - it.add(item.id) - } + it.add(item.id) }, itemPositions = mutableMapOf(item.id to index), ) - sections.add(recent) + sections.add( + ChatSection(items = mutableListOf(newSection), area = itemArea, boundary = ChatSectionAreaBoundary(minIndex = index, maxIndex = index, area = itemArea)) + ) + } else { + recent = existingSection.items.last() + val category = item.mergeCategory + if (recent.mergeCategory == category) { + if (item.chatDir is CIDirection.GroupRcv && prev.chatDir is CIDirection.GroupRcv && item.chatDir.groupMember != (prev.chatDir as CIDirection.GroupRcv).groupMember) { + recent.showAvatar.add(item.id) + } + + recent.items.add(item) + recent.itemPositions[item.id] = index + } else { + val newSectionItems = SectionItems( + mergeCategory = item.mergeCategory, + items = SnapshotStateList().also { it.add(item) }, + revealed = item.mergeCategory == null || revealedItems.contains(item.id), + showAvatar = mutableSetOf().also { + if (item.chatDir is CIDirection.GroupRcv && (prev.chatDir !is CIDirection.GroupRcv || (prev.chatDir as CIDirection.GroupRcv).groupMember != item.chatDir.groupMember)) { + it.add(item.id) + } + }, + itemPositions = mutableMapOf(item.id to index), + ) + existingSection.items.add(newSectionItems) + } + existingSection.boundary.maxIndex = index } prev = item index++ @@ -86,9 +134,44 @@ fun List.putIntoSections(revealedItems: Set): List return sections } -suspend fun apiLoadMessagesAroundItem(chatInfo: ChatInfo, chatModel: ChatModel, aroundItemId: Long, rhId: Long?) { - val pagination = ChatPagination.Around(aroundItemId, ChatPagination.PRELOAD_COUNT) +data class ChatSectionLoad ( + val position: Int, + val sectionArea: ChatSectionArea +) { + fun prepareItems(items: List): List { + val chatItemsSectionArea = chatModel.chatItemsSectionArea + val itemsToAdd = mutableListOf() + for (cItem in items) { + val itemSectionArea = chatItemsSectionArea[cItem.id] + if (itemSectionArea == null) { + itemsToAdd.add(cItem) + } else if (itemSectionArea != this.sectionArea) { + val targetSection = when (itemSectionArea) { + ChatSectionArea.Bottom -> ChatSectionArea.Bottom + ChatSectionArea.Current -> if (this.sectionArea == ChatSectionArea.Bottom) ChatSectionArea.Bottom else ChatSectionArea.Current + ChatSectionArea.Destination -> if (this.sectionArea == ChatSectionArea.Bottom) ChatSectionArea.Bottom else ChatSectionArea.Destination + } + + chatItemsSectionArea.filter { it.value == itemSectionArea } + .forEach { chatItemsSectionArea[it.key] = targetSection } + } + } + println("prepareItems ${itemsToAdd.size} ${sectionArea.name}") + chatItemsSectionArea.putAll(itemsToAdd.associate { it.id to sectionArea }) + + return itemsToAdd + } +} + +suspend fun apiLoadMessagesAroundItem(chatInfo: ChatInfo, chatModel: ChatModel, aroundItemId: Long, rhId: Long?, chatSectionLoad: ChatSectionLoad) { + val pagination = ChatPagination.Around(aroundItemId, ChatPagination.PRELOAD_COUNT * 2) val chat = chatModel.controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId, pagination) ?: return if (chatModel.chatId.value != chat.id) return - chatModel.chatItems.addAll(0, chat.chatItems) + withContext(Dispatchers.Main) { + val itemsToAdd = chatSectionLoad.prepareItems(chat.chatItems) + println("prepared items ${itemsToAdd.size}") + if (itemsToAdd.isNotEmpty()) { + chatModel.chatItems.addAll(chatSectionLoad.position, itemsToAdd) + } + } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 2124ed3c42..d52535f988 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -95,6 +95,7 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - showSearch.value = false searchText.value = "" selectedChatItems.value = null + chatModel.chatItemsSectionArea = mutableMapOf().also { it.putAll(chatModel.chatItems.value.associate { it.id to ChatSectionArea.Bottom }) } } } } @@ -290,19 +291,35 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - } } }, - loadMessages = { chatId, scrollDirection -> + loadMessages = { chatId, scrollDirection, section -> val c = chatModel.getChat(chatId) if (chatModel.chatId.value != chatId) return@ChatLayout when (scrollDirection) { ScrollDirection.Up -> { - val firstId = chatModel.chatItems.value.firstOrNull()?.id + println("requesting up ${section.area}") + val firstSectionItemIdx = chatModel.chatItems.size - 1 - section.maxIndex + val firstId = chatModel.chatItems.value.getOrNull(firstSectionItemIdx)?.id + if (c != null && firstId != null) { withBGApi { - apiLoadPrevMessages(c, chatModel, firstId, searchText.value) + val chatSectionLoad = ChatSectionLoad(firstSectionItemIdx, section.area) + apiLoadPrevMessages(c, chatModel, firstId, searchText.value, chatSectionLoad) + } + } + } + ScrollDirection.Down -> { + println("requesting down ${section.area}") + + val lastSectionItemIdx = chatModel.chatItems.size - 1 - section.minIndex + val lastId = chatModel.chatItems.value.getOrNull(lastSectionItemIdx)?.id + + if (c != null && lastId != null) { + withBGApi { + val chatSectionLoad = ChatSectionLoad(lastSectionItemIdx, section.area) + apiLoadAfterMessages(c, chatModel, lastId, searchText.value, chatSectionLoad) } } } - ScrollDirection.Down -> {} else -> {} } }, @@ -610,7 +627,7 @@ fun ChatLayout( back: () -> Unit, info: () -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit, - loadMessages: (ChatId, ScrollDirection) -> Unit, + loadMessages: (ChatId, ScrollDirection, ChatSectionAreaBoundary) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, @@ -638,7 +655,7 @@ fun ChatLayout( onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, showViaProxy: Boolean, - showSearch: MutableState + showSearch: MutableState, ) { val scope = rememberCoroutineScope() val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } } @@ -719,7 +736,7 @@ fun ChatLayout( useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, loadMessages, deleteMessage, deleteMessages, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, - setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools, showViaProxy, + setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools, showViaProxy ) } } @@ -950,7 +967,7 @@ fun BoxWithConstraintsScope.ChatItemsList( linkMode: SimplexLinkMode, selectedChatItems: MutableState?>, showMemberInfo: (GroupInfo, GroupMember) -> Unit, - loadMessages: (ChatId, ScrollDirection) -> Unit, + loadMessages: (ChatId, ScrollDirection, ChatSectionAreaBoundary) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessages: (List) -> Unit, receiveFile: (Long) -> Unit, @@ -972,7 +989,7 @@ fun BoxWithConstraintsScope.ChatItemsList( setFloatingButton: (@Composable () -> Unit) -> Unit, onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, - showViaProxy: Boolean + showViaProxy: Boolean, ) { val listState = rememberLazyListState() val scope = rememberCoroutineScope() @@ -989,27 +1006,42 @@ fun BoxWithConstraintsScope.ChatItemsList( scope.launch { listState.scrollToItem(0) } } } - val preloadItems = remember { mutableStateOf(true) } - PreloadItems(chatInfo.id, listState, ChatPagination.UNTIL_PRELOAD_COUNT, preloadItems.value, loadMessages) Spacer(Modifier.size(8.dp)) val reversedChatItems by remember { derivedStateOf { chatModel.chatItems.asReversed() } } val revealedItems = rememberSaveable { mutableStateOf(setOf()) } val sections by remember { derivedStateOf { (reversedChatItems).putIntoSections(revealedItems.value) } } + val preloadItems = remember { mutableStateOf(true) } + val boundaries = remember { derivedStateOf { sections.map { it.boundary } } } + + PreloadItems(chatInfo.id, listState, ChatPagination.UNTIL_PRELOAD_COUNT, preloadItems, boundaries, loadMessages) + val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() } + val animatedScrollToIndex: (Int) -> Unit = { idx -> + withBGApi { + scope.launch { + listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, idx + 1), -maxHeightRounded) + }.also { + it.join() + preloadItems.value = true + } + } + } val scrollToItem: (Long) -> Unit = { itemId: Long -> val index = reversedChatItems.indexOfFirst { it.id == itemId } - println("here") + preloadItems.value = false + if (index != -1) { - scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) } + animatedScrollToIndex(index) } else { - preloadItems.value = false withBGApi { try { - apiLoadMessagesAroundItem(rhId = remoteHostId, chatModel = chatModel, chatInfo = chatInfo, aroundItemId = itemId) + // TODO: If this section exists it should be removed. + val chatSectionLoad = ChatSectionLoad(0, ChatSectionArea.Destination) + apiLoadMessagesAroundItem(rhId = remoteHostId, chatModel = chatModel, chatInfo = chatInfo, aroundItemId = itemId, chatSectionLoad = chatSectionLoad) val idx = reversedChatItems.indexOfFirst { it.id == itemId } - scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, idx + 1), -maxHeightRounded) } - } finally { + animatedScrollToIndex(idx) + } catch (ex: Exception) { preloadItems.value = true } } @@ -1259,16 +1291,18 @@ fun BoxWithConstraintsScope.ChatItemsList( } } LazyColumnWithScrollBar(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) { - for (section in sections) { - if (section.revealed) { - itemsIndexed(section.items, key = { _, item -> (item.id to item.meta.createdAt.toEpochMilliseconds()).toString() }) { i, cItem -> - // index here is just temporary, should be removed at all or put in the section items - ChatViewListItem(section.itemPositions[cItem.id] ?: -1, section = section, showAvatar = section.showAvatar.contains(cItem.id), cItem) } - } else { - val item = section.items.last() - item(key = { (item.id to item.meta.createdAt.toEpochMilliseconds()).toString() }) { - // here you make one collapsed item from multiple items (should be already in section items) - ChatViewListItem(section.itemPositions[item.id] ?: -1, section = section, showAvatar = section.showAvatar.contains(item.id), item) + for (area in sections) { + for (section in area.items) { + if (section.revealed) { + itemsIndexed(section.items, key = { _, item -> (item.id to item.meta.createdAt.toEpochMilliseconds()).toString() }) { i, cItem -> + // index here is just temporary, should be removed at all or put in the section items + ChatViewListItem(section.itemPositions[cItem.id] ?: -1, section = section, showAvatar = section.showAvatar.contains(cItem.id), cItem) } + } else { + val item = section.items.last() + item(key = { (item.id to item.meta.createdAt.toEpochMilliseconds()).toString() }) { + // here you make one collapsed item from multiple items (should be already in section items) + ChatViewListItem(section.itemPositions[item.id] ?: -1, section = section, showAvatar = section.showAvatar.contains(item.id), item) + } } } } @@ -1437,8 +1471,9 @@ fun PreloadItems( chatId: String, listState: LazyListState, remaining: Int = 10, - enabled: Boolean, - onLoadMore: (ChatId, ScrollDirection) -> Unit, + enabled: State, + boundaries: State>, + onLoadMore: (ChatId, ScrollDirection, ChatSectionAreaBoundary) -> Unit, ) { // Prevent situation when initial load and load more happens one after another after selecting a chat with long scroll position from previous selection val allowLoad = remember { mutableStateOf(false) } @@ -1479,16 +1514,32 @@ fun PreloadItems( val lInfo = listState.layoutInfo val totalItemsNumber = lInfo.totalItemsCount val lastVisibleItemIndex = (lInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 - if (allowLoad.value && lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT) - totalItemsNumber + ChatPagination.PRELOAD_COUNT - else - 0 - } - .filter { it > 0 } - .collect { - if (enabled) { - onLoadMore.value(chatId.value, scrollDirection) + println("boundaries: $boundaries") + + val section = if (scrollDirection == ScrollDirection.Up) { + boundaries.value.find { listState.layoutInfo.visibleItemsInfo.lastIndex in it.minIndex..it.maxIndex } + } else { + boundaries.value.find { listState.firstVisibleItemIndex in it.minIndex..it.maxIndex } + } + + if (allowLoad.value && section != null && enabled.value) { + val numberOfItemsInSection = section.maxIndex.minus(section.minIndex) + 1 + println("checking: $numberOfItemsInSection $section") + + if (scrollDirection == ScrollDirection.Up && lastVisibleItemIndex > (section.maxIndex - remaining) && numberOfItemsInSection >= ChatPagination.INITIAL_COUNT) { + section + } else if (scrollDirection == ScrollDirection.Down && listState.firstVisibleItemIndex < (section.minIndex + remaining) && totalItemsNumber > remaining) { + section + } else { + null } + } else { + null + } + } + .filterNotNull() + .collect { + onLoadMore.value(chatId.value, scrollDirection, it) } } } @@ -2141,7 +2192,7 @@ fun PreviewChatLayout() { back = {}, info = {}, showMemberInfo = { _, _ -> }, - loadMessages = { _, _ -> }, + loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, deleteMessages = { _ -> }, receiveFile = { _ -> }, @@ -2213,7 +2264,7 @@ fun PreviewGroupChatLayout() { back = {}, info = {}, showMemberInfo = { _, _ -> }, - loadMessages = { _, _ -> }, + loadMessages = { _, _, _ -> }, deleteMessage = { _, _ -> }, deleteMessages = {}, receiveFile = { _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 3c7f1e781f..7fdb57a1d9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -32,6 +32,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.* +import kotlinx.coroutines.flow.map import kotlinx.datetime.Clock @Composable @@ -230,12 +231,30 @@ fun openLoadedChat(chat: Chat, chatModel: ChatModel) { chatModel.chatId.value = chat.chatInfo.id } -suspend fun apiLoadPrevMessages(ch: Chat, chatModel: ChatModel, beforeChatItemId: Long, search: String) { +suspend fun apiLoadPrevMessages(ch: Chat, chatModel: ChatModel, beforeChatItemId: Long, search: String, chatSectionLoad: ChatSectionLoad) { val chatInfo = ch.chatInfo val pagination = ChatPagination.Before(beforeChatItemId, ChatPagination.PRELOAD_COUNT) val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return if (chatModel.chatId.value != chat.id) return - chatModel.chatItems.addAll(0, chat.chatItems) + withContext(Dispatchers.Main) { + val itemsToAdd = chatSectionLoad.prepareItems(chat.chatItems) + if (itemsToAdd.isNotEmpty()) { + chatModel.chatItems.addAll(chatSectionLoad.position, itemsToAdd) + } + } +} + +suspend fun apiLoadAfterMessages(ch: Chat, chatModel: ChatModel, afterChatItemId: Long, search: String, chatSectionLoad: ChatSectionLoad) { + val chatInfo = ch.chatInfo + val pagination = ChatPagination.After(afterChatItemId, ChatPagination.PRELOAD_COUNT) + val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, pagination, search) ?: return + if (chatModel.chatId.value != chat.id) return + withContext(Dispatchers.Main) { + val itemsToAdd = chatSectionLoad.prepareItems(chat.chatItems) + if (itemsToAdd.isNotEmpty()) { + chatModel.chatItems.addAll(chatSectionLoad.position, itemsToAdd) + } + } } suspend fun apiFindMessages(ch: Chat, chatModel: ChatModel, search: String) {