diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationListUiAction.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationListUiAction.kt index bc9c1942..71cd26e7 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationListUiAction.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationListUiAction.kt @@ -123,4 +123,6 @@ sealed interface ConversationListUiAction { val conversationId: Uuid, val imageBytes: ByteArray, ) : ConversationListUiAction + + data class LoadMoreMessages(val conversationId: Uuid) : ConversationListUiAction } diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationListUiState.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationListUiState.kt index 8b0b545a..094124a5 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationListUiState.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationListUiState.kt @@ -47,6 +47,8 @@ data class MessageListUiState( val userDefaultReactions: ImmutableList = persistentListOf(), val uploadProgress: ImmutableMap = persistentMapOf(), val isLoadingMessages: Boolean = true, + val hasMoreMessages: Boolean = false, + val isLoadingMoreMessages: Boolean = false, val scrollPosition: ScrollPosition? = null, val fullScreenOverlay: FullScreenOverlay? = null, val replyToMessage: MessageUiModel? = null, diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationListViewModel.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationListViewModel.kt index 2b081259..5e1185bf 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationListViewModel.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/conversationlist/ConversationListViewModel.kt @@ -385,6 +385,24 @@ class ConversationListViewModel( } } + is ConversationListUiAction.LoadMoreMessages -> { + val conversationId = action.conversationId + if (_messagesUiState.value.isLoadingMoreMessages) return + _messagesUiState.update { it.copy(isLoadingMoreMessages = true) } + viewModelScope.launch { + try { + chatMessageStream.loadMoreMessages(conversationId) + } finally { + _messagesUiState.update { + it.copy( + isLoadingMoreMessages = false, + hasMoreMessages = chatMessageStream.hasMoreMessages(conversationId) + ) + } + } + } + } + is ConversationListUiAction.EditMessage -> { viewModelScope.launch { try { @@ -1654,6 +1672,7 @@ class ConversationListViewModel( _messagesUiState.update { it.copy( isLoadingMessages = false, + hasMoreMessages = chatMessageStream.hasMoreMessages(conversationId), messages = messagesModels.toPersistentList(), scrollPosition = if (indexOfMessageForScroll == null) { if (setInitialScroll) getScrollPosition(conversationId) else null diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/ChatMessageStream.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/ChatMessageStream.kt index 1866accc..14e7455b 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/ChatMessageStream.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/services/ChatMessageStream.kt @@ -61,6 +61,8 @@ class ChatMessageStream( private val chatDrive = chatTargetDrive.alias private var isSyncing = false private val loadedConversations = mutableSetOf() + private val conversationCursors = mutableMapOf() + private val conversationHasMore = mutableMapOf() init { scope.launch { @@ -105,12 +107,27 @@ class ChatMessageStream( suspend fun loadConversation(conversationId: Uuid) { Logger.d("ChatMessageStream: loadConversation($conversationId)") loadedConversations += conversationId - val result = fetchMessages(conversationId) - Logger.d("ChatMessageStream: loadConversation($conversationId) → ${result.records.size} messages") + val result = fetchMessages(conversationId, limit = INITIAL_MESSAGE_LOAD) + Logger.d("ChatMessageStream: loadConversation($conversationId) → ${result.records.size} messages, hasMore=${result.hasMoreRows}") + conversationCursors[conversationId] = result.cursor + conversationHasMore[conversationId] = result.hasMoreRows conversationState.set(conversationId, result.records) } -// ---------- EVENT HANDLING ---------- + fun hasMoreMessages(conversationId: Uuid): Boolean = conversationHasMore[conversationId] == true + + suspend fun loadMoreMessages(conversationId: Uuid) { + if (!hasMoreMessages(conversationId)) return + val cursor = conversationCursors[conversationId] + Logger.d("ChatMessageStream: loadMoreMessages($conversationId)") + val result = fetchMessages(conversationId, limit = SUBSEQUENT_MESSAGE_LOAD, cursor = cursor) + Logger.d("ChatMessageStream: loadMoreMessages($conversationId) → ${result.records.size} messages, hasMore=${result.hasMoreRows}") + conversationCursors[conversationId] = result.cursor + conversationHasMore[conversationId] = result.hasMoreRows + conversationState.upsert(conversationId, result.records) + } + + // ---------- EVENT HANDLING ---------- private suspend fun processIncrementalBatch(files: List) { val messages = @@ -126,8 +143,9 @@ class ChatMessageStream( private suspend fun refreshLoadedConversations() { Logger.d("ChatMessageStream: refreshLoadedConversations called, ${loadedConversations.size} active conversations") loadedConversations.forEach { conversationId -> - val result = fetchMessages(conversationId) - Logger.d("ChatMessageStream: fetchMessages($conversationId) → ${result.records.size} messages") + val result = fetchMessages(conversationId, limit = INITIAL_MESSAGE_LOAD) + conversationCursors[conversationId] = result.cursor + conversationHasMore[conversationId] = result.hasMoreRows conversationState.set(conversationId, result.records) } } @@ -339,6 +357,8 @@ class ChatMessageStream( } companion object { + const val INITIAL_MESSAGE_LOAD = 50 + const val SUBSEQUENT_MESSAGE_LOAD = 500 private fun getDeliveryStatus(header: HomebaseFile): ChatDeliveryStatus { diff --git a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/widget/ConversationMessagesPane.kt b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/widget/ConversationMessagesPane.kt index afdedec9..a9f5d02c 100644 --- a/homebase-chat/src/commonMain/kotlin/id/homebase/chat/widget/ConversationMessagesPane.kt +++ b/homebase-chat/src/commonMain/kotlin/id/homebase/chat/widget/ConversationMessagesPane.kt @@ -126,6 +126,35 @@ fun ConversationMessagesPane( } } + // Trigger load-more when user scrolls near the top (older messages) + LaunchedEffect(listState, uiState.hasMoreMessages, uiState.isLoadingMoreMessages) { + snapshotFlow { listState.firstVisibleItemIndex } + .distinctUntilChanged() + .collect { index -> + if (index <= 5 && uiState.hasMoreMessages && !uiState.isLoadingMoreMessages) { + onUiAction(ConversationListUiAction.LoadMoreMessages(conversation.conversation.id)) + } + } + } + + // Compensate scroll position after older messages are prepended + var prevIsLoadingMore by remember { mutableStateOf(false) } + var itemCountAtLoadStart by remember { mutableStateOf(0) } + LaunchedEffect(uiState.isLoadingMoreMessages) { + if (uiState.isLoadingMoreMessages) { + itemCountAtLoadStart = listState.layoutInfo.totalItemsCount + } else if (prevIsLoadingMore) { + val added = listState.layoutInfo.totalItemsCount - itemCountAtLoadStart + if (added > 0) { + listState.scrollToItem( + listState.firstVisibleItemIndex + added, + listState.firstVisibleItemScrollOffset + ) + } + } + prevIsLoadingMore = uiState.isLoadingMoreMessages + } + val seen = remember(conversation.conversation.id) { mutableSetOf() } val messageIdByKey = remember(uiState.messages) { uiState.messages.associateNotNull { item ->