Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.annotation.VisibleForTesting
import androidx.compose.ui.text.style.TextOverflow
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.paging.LoadState
Expand Down Expand Up @@ -675,6 +676,8 @@ fun ConversationScreen(
},
onAttachmentClick = messageAttachmentsViewModel::onAttachmentClicked,
onAttachmentMenuClick = messageAttachmentsViewModel::onAttachmentMenuClicked,
isFetchingOlderMessages = conversationMessagesViewModel.conversationViewState.isFetchingOlderMessages,
hasMoreRemoteMessages = conversationMessagesViewModel.conversationViewState.hasMoreRemoteMessages,
isWireCellsEnabled = conversationInfoViewModel.conversationInfoViewState.isWireCellEnabled,
)
BackHandler { conversationScreenOnBackButtonClick(messageComposerViewModel, messageComposerStateHolder, navigator) }
Expand Down Expand Up @@ -941,6 +944,8 @@ private fun ConversationScreen(
onAttachmentMenuClick: (AttachmentDraftUi) -> Unit,
currentTimeInMillisFlow: Flow<Long> = flow { },
onReachedOldestMessage: () -> Unit = {},
isFetchingOlderMessages: Boolean = false,
hasMoreRemoteMessages: Boolean = false,
isWireCellsEnabled: Boolean = false,
) {
val context = LocalContext.current
Expand Down Expand Up @@ -1043,6 +1048,8 @@ private fun ConversationScreen(
onAttachmentClick = onAttachmentClick,
onAttachmentMenuClick = onAttachmentMenuClick,
showHistoryLoadingIndicator = conversationInfoViewState.showHistoryLoadingIndicator,
isFetchingOlderMessages = conversationMessagesViewState.isFetchingOlderMessages,
hasMoreRemoteMessages = conversationMessagesViewState.hasMoreRemoteMessages,
isBubbleUiEnabled = IS_BUBBLE_UI_ENABLED,
isWireCellsEnabled = isWireCellsEnabled,
)
Expand Down Expand Up @@ -1129,6 +1136,8 @@ private fun ConversationScreenContent(
currentTimeInMillisFlow: Flow<Long> = flow {},
onReachedOldestMessage: () -> Unit = {},
showHistoryLoadingIndicator: Boolean = false,
isFetchingOlderMessages: Boolean = false,
hasMoreRemoteMessages: Boolean = false,
isBubbleUiEnabled: Boolean = false,
isWireCellsEnabled: Boolean = false,
) {
Expand Down Expand Up @@ -1176,6 +1185,8 @@ private fun ConversationScreenContent(
currentTimeInMillisFlow = currentTimeInMillisFlow,
onReachedOldestMessage = onReachedOldestMessage,
showHistoryLoadingIndicator = showHistoryLoadingIndicator,
isFetchingOlderMessages = isFetchingOlderMessages,
hasMoreRemoteMessages = hasMoreRemoteMessages,
isBubbleUiEnabled = isBubbleUiEnabled,
isWireCellsEnabled = isWireCellsEnabled,
)
Expand Down Expand Up @@ -1258,6 +1269,8 @@ fun MessageList(
modifier: Modifier = Modifier,
currentTimeInMillisFlow: Flow<Long> = flow { },
showHistoryLoadingIndicator: Boolean = false,
isFetchingOlderMessages: Boolean = false,
hasMoreRemoteMessages: Boolean = false,
isBubbleUiEnabled: Boolean = false,
isWireCellsEnabled: Boolean = false,
onReachedOldestMessage: () -> Unit = {},
Expand All @@ -1278,6 +1291,18 @@ fun MessageList(
lazyListState.stopScroll()
lazyListState.animateScrollToItem(0)
}
if (shouldAutoTriggerOldestFetch(
selectedMessageId = selectedMessageId,
isScrollInProgress = lazyListState.isScrollInProgress,
canScrollForward = lazyListState.canScrollForward,
hasMoreRemoteMessages = hasMoreRemoteMessages,
isFetchingOlderMessages = isFetchingOlderMessages,
)
) {
onReachedOldestMessage()
} else {
shouldTriggerOldestMessageFetch.value = true
}
prevItemCount.value = lazyPagingMessages.itemCount
}
}
Expand Down Expand Up @@ -1473,6 +1498,24 @@ fun MessageList(
}
)
}
if (isFetchingOlderMessages && hasMoreRemoteMessages && lazyPagingMessages.itemCount > 0) {
item(
key = "nomad_prepend_loading_indicator",
contentType = "nomad_prepend_loading_indicator",
content = {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(dimensions().spacing16x),
) {
PageLoadingIndicator(
text = stringResource(R.string.conversation_history_loading),
)
}
}
)
}
}
ScrollDateOverlay(
lazyListState = lazyListState,
Expand All @@ -1489,6 +1532,20 @@ fun MessageList(
}
}

@VisibleForTesting
internal fun shouldAutoTriggerOldestFetch(
selectedMessageId: String?,
isScrollInProgress: Boolean,
canScrollForward: Boolean,
hasMoreRemoteMessages: Boolean,
isFetchingOlderMessages: Boolean,
): Boolean =
selectedMessageId == null &&
!isScrollInProgress &&
!canScrollForward &&
hasMoreRemoteMessages &&
!isFetchingOlderMessages

private fun UIMessage.audioMessageScopedKeyOrNull(): String? =
(this as? UIMessage.Regular)
?.takeIf { it.messageContent is UIMessageContent.AudioAssetMessage }
Expand Down Expand Up @@ -1864,5 +1921,7 @@ fun PreviewConversationScreen() = WireTheme {
onAttachmentMenuClick = {},
onAttachmentPicked = {},
onAudioRecorded = {},
isFetchingOlderMessages = false,
hasMoreRemoteMessages = false,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,20 @@ class ConversationMessagesViewModel @Inject constructor(
}

fun fetchOlderMessagesIfNeeded() {
if (conversationViewState.isFetchingOlderMessages || !conversationViewState.hasMoreRemoteMessages) {
return
}

viewModelScope.launch {
fetchOlderNomadMessages(conversationId)
conversationViewState = conversationViewState.copy(isFetchingOlderMessages = true)
try {
val result = fetchOlderNomadMessages(conversationId)
conversationViewState = conversationViewState.copy(
hasMoreRemoteMessages = result.hasMore,
)
} finally {
conversationViewState = conversationViewState.copy(isFetchingOlderMessages = false)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ data class ConversationMessagesViewState(
val downloadedAssetDialogState: DownloadedAssetDialogVisibilityState = DownloadedAssetDialogVisibilityState.Hidden,
val playingAudioMessage: PlayingAudioMessage = PlayingAudioMessage.None,
val assetStatuses: PersistentMap<String, MessageAssetStatus> = persistentMapOf(),
val searchedMessageId: String? = null
val searchedMessageId: String? = null,
val isFetchingOlderMessages: Boolean = false,
val hasMoreRemoteMessages: Boolean = true,
)

sealed class DownloadedAssetDialogVisibilityState {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Wire
* Copyright (C) 2026 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*/

package com.wire.android.ui.home.conversations

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue

class ConversationScreenTest {

@Test
fun givenSelectedMessage_whenCheckingAutoTrigger_thenReturnsFalse() {
val result = shouldAutoTriggerOldestFetch(
selectedMessageId = "message",
isScrollInProgress = false,
canScrollForward = false,
hasMoreRemoteMessages = true,
isFetchingOlderMessages = false,
)

assertFalse(result)
}

@Test
fun givenEligibleState_whenCheckingAutoTrigger_thenReturnsTrue() {
val result = shouldAutoTriggerOldestFetch(
selectedMessageId = null,
isScrollInProgress = false,
canScrollForward = false,
hasMoreRemoteMessages = true,
isFetchingOlderMessages = false,
)

assertTrue(result)
}

@Test
fun givenRemoteHistoryExhausted_whenCheckingAutoTrigger_thenReturnsFalse() {
val result = shouldAutoTriggerOldestFetch(
selectedMessageId = null,
isScrollInProgress = false,
canScrollForward = false,
hasMoreRemoteMessages = false,
isFetchingOlderMessages = false,
)

assertFalse(result)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import com.wire.kalium.logic.feature.message.DeleteMessageUseCase
import com.wire.kalium.logic.feature.message.FetchOlderNomadMessagesByConversationUseCase
import com.wire.kalium.logic.feature.message.GetMessageByIdUseCase
import com.wire.kalium.logic.feature.message.GetSearchedConversationMessagePositionUseCase
import com.wire.kalium.logic.data.message.paging.NomadMessagePagingResult
import com.wire.kalium.logic.feature.message.MessageOperationResult
import com.wire.kalium.logic.feature.message.ToggleReactionResult
import com.wire.kalium.logic.feature.message.ToggleReactionUseCase
Expand Down Expand Up @@ -160,7 +161,7 @@ class ConversationMessagesViewModelArrangement {
coEvery { toggleReaction(any(), any(), any()) } returns ToggleReactionResult.Success
coEvery { observeConversationDetails(any()) } returns flowOf()
coEvery { getMessagesForConversationUseCase(any(), any()) } returns messagesChannel.consumeAsFlow()
coEvery { fetchOlderNomadMessagesByConversationUseCase(any(), any()) } returns Unit
coEvery { fetchOlderNomadMessagesByConversationUseCase(any(), any()) } returns NomadMessagePagingResult(hasMore = false)
coEvery { getConversationUnreadEventsCount(any()) } returns GetConversationUnreadEventsCountUseCase.Result.Success(0L)
coEvery { updateAssetMessageDownloadStatus(any(), any(), any()) } returns UpdateTransferStatusResult.Success
coEvery { clearUsersTypingEvents() } returns Unit
Expand Down Expand Up @@ -228,6 +229,19 @@ class ConversationMessagesViewModelArrangement {
coEvery { resetSession(any(), any(), any()) } returns resetSessionResult
}

fun withNomadPagingResult(result: NomadMessagePagingResult) = apply {
coEvery { fetchOlderNomadMessagesByConversationUseCase(any(), any()) } returns result
}

fun withSuspendedNomadPagingResult(result: NomadMessagePagingResult): CompletableDeferred<Unit> {
val gate = CompletableDeferred<Unit>()
coEvery { fetchOlderNomadMessagesByConversationUseCase(any(), any()) } coAnswers {
gate.await()
result
}
return gate
}

fun withSuccessfulSaveAssetMessage(
assetMimeType: String,
assetName: String,
Expand All @@ -251,7 +265,6 @@ class ConversationMessagesViewModelArrangement {

fun withFailureOnDeletingMessages() = apply {
coEvery { deleteMessage(any(), any(), any()) } returns MessageOperationResult.Failure(CoreFailure.Unknown(null))
return this
}

fun withWireCellEnabled() = apply {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import com.wire.android.ui.home.conversations.composer.mockUITextMessage
import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogState
import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogType
import com.wire.android.util.ui.UIText
import com.wire.kalium.logic.data.message.paging.NomadMessagePagingResult
import com.wire.kalium.common.error.StorageFailure
import com.wire.kalium.logic.data.message.Message
import com.wire.kalium.logic.data.message.MessageContent
Expand All @@ -42,6 +43,7 @@ import com.wire.kalium.logic.feature.conversation.GetConversationUnreadEventsCou
import io.mockk.coVerify
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
Expand Down Expand Up @@ -233,6 +235,48 @@ class ConversationMessagesViewModelTest {
}
}

@Test
fun `given nomad paging result, when fetching older messages, then view state updates`() = runTest {
val arrangement = ConversationMessagesViewModelArrangement()
.withSuccessfulViewModelInit()
val gate = arrangement.withSuspendedNomadPagingResult(NomadMessagePagingResult(hasMore = true))
val (_, viewModel) = arrangement.arrange()

viewModel.fetchOlderMessagesIfNeeded()
advanceUntilIdle()
assertEquals(true, viewModel.conversationViewState.isFetchingOlderMessages)

gate.complete(Unit)
advanceUntilIdle()

assertEquals(false, viewModel.conversationViewState.isFetchingOlderMessages)
assertEquals(true, viewModel.conversationViewState.hasMoreRemoteMessages)
}

@Test
fun `given nomad fetch in progress, when fetching older messages twice, then use case is invoked only once`() = runTest {
val arrangement = ConversationMessagesViewModelArrangement()
.withSuccessfulViewModelInit()
val gate = arrangement.withSuspendedNomadPagingResult(NomadMessagePagingResult(hasMore = true))
val (_, viewModel) = arrangement.arrange()

val firstFetch = launch { viewModel.fetchOlderMessagesIfNeeded() }
advanceUntilIdle()
assertEquals(true, viewModel.conversationViewState.isFetchingOlderMessages)

viewModel.fetchOlderMessagesIfNeeded()
advanceUntilIdle()

coVerify(exactly = 1) { arrangement.fetchOlderNomadMessagesByConversationUseCase(any(), any()) }

gate.complete(Unit)
advanceUntilIdle()
firstFetch.join()

assertEquals(false, viewModel.conversationViewState.isFetchingOlderMessages)
assertEquals(true, viewModel.conversationViewState.hasMoreRemoteMessages)
}

@Test
fun `given a message and a reaction, when toggleReaction is called, then should call ToggleReactionUseCase`() = runTest {
val (arrangement, viewModel) = ConversationMessagesViewModelArrangement()
Expand Down
Loading