From 3060be3cf0fdbaa3e0af72c50d67d5bc48656954 Mon Sep 17 00:00:00 2001 From: "sergei.bakhtiarov" Date: Mon, 2 Feb 2026 17:56:21 +0100 Subject: [PATCH] fix: delay before updating unread messages badge (WPB-23126) Co-Authored-By: Claude Opus 4.5 --- .../di/accountScoped/ConversationModule.kt | 6 ++ .../home/conversations/ConversationScreen.kt | 10 +++ .../composer/MessageComposerViewModel.kt | 25 ++++++- .../MessageComposerViewModelArrangement.kt | 7 ++ .../composer/MessageComposerViewModelTest.kt | 69 +++++++++++++++++++ kalium | 2 +- 6 files changed, 116 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt index f724cd8d85..127a4430fc 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/ConversationModule.kt @@ -57,6 +57,7 @@ import com.wire.kalium.logic.feature.conversation.UpdateConversationAccessRoleUs import com.wire.kalium.logic.feature.conversation.UpdateConversationArchivedStatusUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationMemberRoleUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationMutedStatusUseCase +import com.wire.kalium.logic.feature.conversation.MarkConversationAsReadLocallyUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationReadDateUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationReceiptModeUseCase import com.wire.kalium.logic.feature.conversation.apps.ChangeAccessForAppsInConversationUseCase @@ -187,6 +188,11 @@ class ConversationModule { fun provideUpdateConversationReadDateUseCase(conversationScope: ConversationScope): UpdateConversationReadDateUseCase = conversationScope.updateConversationReadDateUseCase + @ViewModelScoped + @Provides + fun provideMarkConversationAsReadLocallyUseCase(conversationScope: ConversationScope): MarkConversationAsReadLocallyUseCase = + conversationScope.markConversationAsReadLocally + @ViewModelScoped @Provides fun provideUpdateConversationAccessUseCase(conversationScope: ConversationScope): UpdateConversationAccessRoleUseCase = diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 28d7b25b92..b4e0887243 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState @@ -665,6 +666,15 @@ fun ConversationScreen( isWireCellsEnabled = conversationInfoViewModel.conversationInfoViewState.isWireCellEnabled, ) BackHandler { conversationScreenOnBackButtonClick(messageComposerViewModel, messageComposerStateHolder, navigator) } + + // Mark conversation as read when leaving, regardless of how the user exits + // (back button, system gesture, navigation to another screen, etc.) + DisposableEffect(messageComposerViewModel) { + onDispose { + messageComposerViewModel.onConversationClosed() + } + } + DeleteMessageDialog( dialogState = conversationMessagesViewModel.deleteMessageDialogState, deleteMessage = conversationMessagesViewModel::deleteMessage, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModel.kt index 73cb0dcdcb..7b21855621 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModel.kt @@ -47,6 +47,7 @@ import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.logic.feature.conversation.IsInteractionAvailableResult import com.wire.kalium.logic.feature.conversation.MembersToMentionUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationInteractionAvailabilityUseCase +import com.wire.kalium.logic.feature.conversation.MarkConversationAsReadLocallyUseCase import com.wire.kalium.logic.feature.conversation.SendTypingEventUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationReadDateUseCase import com.wire.kalium.logic.feature.message.ephemeral.EnqueueMessageSelfDeletionUseCase @@ -55,6 +56,7 @@ import com.wire.kalium.logic.feature.session.CurrentSessionFlowUseCase import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.user.IsFileSharingEnabledUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first @@ -74,6 +76,7 @@ class MessageComposerViewModel @Inject constructor( private val isFileSharingEnabled: IsFileSharingEnabledUseCase, private val observeConversationInteractionAvailability: ObserveConversationInteractionAvailabilityUseCase, private val updateConversationReadDate: UpdateConversationReadDateUseCase, + private val markConversationAsReadLocally: MarkConversationAsReadLocallyUseCase, private val contactMapper: ContactMapper, private val membersToMention: MembersToMentionUseCase, private val enqueueMessageSelfDeletion: EnqueueMessageSelfDeletionUseCase, @@ -106,6 +109,8 @@ class MessageComposerViewModel @Inject constructor( InvalidLinkDialogState.Hidden ) + private var lastReadInstant: Instant? = null + init { initTempWritableVideoUri() initTempWritableImageUri() @@ -195,8 +200,24 @@ class MessageComposerViewModel @Inject constructor( } fun updateConversationReadDate(utcISO: String) { - viewModelScope.launch(dispatchers.io()) { - updateConversationReadDate(conversationId, Instant.parse(utcISO)) + val instant = Instant.parse(utcISO) + lastReadInstant = instant + viewModelScope.launch(NonCancellable) { + updateConversationReadDate(conversationId, instant) + } + } + + /** + * Called when the user leaves the conversation. + * Immediately updates the local read date to clear unread badges + * without waiting for the debounced update to complete. + * If the user viewed messages, uses the last read timestamp. + */ + fun onConversationClosed() { + lastReadInstant?.let { instant -> + viewModelScope.launch(NonCancellable) { + markConversationAsReadLocally(conversationId, instant) + } } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt index 0a40f151b3..98f2de6a68 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt @@ -60,6 +60,7 @@ import com.wire.kalium.logic.feature.call.usecase.ObserveOngoingCallsUseCase import com.wire.kalium.logic.feature.conversation.IsInteractionAvailableResult import com.wire.kalium.logic.feature.conversation.MembersToMentionUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationInteractionAvailabilityUseCase +import com.wire.kalium.logic.feature.conversation.MarkConversationAsReadLocallyUseCase import com.wire.kalium.logic.feature.conversation.SendTypingEventUseCase import com.wire.kalium.logic.feature.conversation.UpdateConversationReadDateUseCase import com.wire.kalium.logic.feature.message.ephemeral.EnqueueMessageSelfDeletionUseCase @@ -67,6 +68,7 @@ import com.wire.kalium.logic.feature.selfDeletingMessages.PersistNewSelfDeletion import com.wire.kalium.logic.feature.session.CurrentSessionFlowUseCase import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.user.IsFileSharingEnabledUseCase +import com.wire.kalium.common.functional.Either import com.wire.kalium.logic.sync.ObserveSyncStateUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -100,6 +102,7 @@ internal class MessageComposerViewModelArrangement { } returns flowOf(CurrentSessionResult.Success(AccountInfo.Valid(TestUser.USER_ID))) coEvery { globalDataStore.enterToSendFlow() } returns flowOf(false) coEvery { observeEstablishedCalls() } returns emptyFlow() + coEvery { markConversationAsReadLocallyUseCase(any(), any()) } returns Either.Right(false) } @MockK @@ -120,6 +123,9 @@ internal class MessageComposerViewModelArrangement { @MockK private lateinit var updateConversationReadDateUseCase: UpdateConversationReadDateUseCase + @MockK + lateinit var markConversationAsReadLocallyUseCase: MarkConversationAsReadLocallyUseCase + @MockK private lateinit var observeSyncState: ObserveSyncStateUseCase @@ -158,6 +164,7 @@ internal class MessageComposerViewModelArrangement { dispatchers = TestDispatcherProvider(), isFileSharingEnabled = isFileSharingEnabledUseCase, updateConversationReadDate = updateConversationReadDateUseCase, + markConversationAsReadLocally = markConversationAsReadLocallyUseCase, observeConversationInteractionAvailability = observeConversationInteractionAvailabilityUseCase, contactMapper = contactMapper, membersToMention = membersToMention, diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelTest.kt index cdbd1c7019..a65c98da7a 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelTest.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -93,4 +94,72 @@ class MessageComposerViewModelTest { // then assertTrue(!viewModel.messageComposerViewState.value.enterToSend) } + + @Test + fun `given messages were viewed, when conversation is closed, then mark as read with last viewed timestamp`() = runTest { + // given + val (arrangement, viewModel) = MessageComposerViewModelArrangement() + .withSuccessfulViewModelInit() + .arrange() + val timestamp = "2024-01-15T10:30:00Z" + val expectedInstant = Instant.parse(timestamp) + + // when + viewModel.updateConversationReadDate(timestamp) + advanceUntilIdle() + viewModel.onConversationClosed() + advanceUntilIdle() + + // then + coVerify(exactly = 1) { + arrangement.markConversationAsReadLocallyUseCase( + arrangement.conversationId, + expectedInstant + ) + } + } + + @Test + fun `given no messages were viewed, when conversation is closed, then mark as read is not called`() = runTest { + // given + val (arrangement, viewModel) = MessageComposerViewModelArrangement() + .withSuccessfulViewModelInit() + .arrange() + advanceUntilIdle() + + // when - close without calling updateConversationReadDate + viewModel.onConversationClosed() + advanceUntilIdle() + + // then + coVerify(exactly = 0) { + arrangement.markConversationAsReadLocallyUseCase(any(), any()) + } + } + + @Test + fun `given multiple read date updates, when conversation is closed, then use the most recent timestamp`() = runTest { + // given + val (arrangement, viewModel) = MessageComposerViewModelArrangement() + .withSuccessfulViewModelInit() + .arrange() + val olderTimestamp = "2024-01-15T10:00:00Z" + val newerTimestamp = "2024-01-15T10:30:00Z" + val expectedInstant = Instant.parse(newerTimestamp) + + // when + viewModel.updateConversationReadDate(olderTimestamp) + viewModel.updateConversationReadDate(newerTimestamp) + advanceUntilIdle() + viewModel.onConversationClosed() + advanceUntilIdle() + + // then + coVerify(exactly = 1) { + arrangement.markConversationAsReadLocallyUseCase( + arrangement.conversationId, + expectedInstant + ) + } + } } diff --git a/kalium b/kalium index ae71a9a75a..f5f605b3cb 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit ae71a9a75ae63027c8970905ef5bdeabc59fee8b +Subproject commit f5f605b3cb0863ffbced97bd0255ae6556542ce5