From a0d88ee5fc3d6559664d58061917cbacc25e0073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 28 Jul 2026 11:33:06 +0100 Subject: [PATCH 1/6] e2e: Re-enable the unread messages tests and cover mark-as-unread The mock server now maintains per-user read state, so the unread separator and jump-to-unread tests (11453-11455) run again. Add the mark-as-unread flow (6073) and a channel list unread badge assertion, waiting for the badge before reopening the channel so the sticky unread label is computed from settled state instead of racing the event queue. The badge exposes only a content description (clearAndSetSemantics in UnreadCountIndicator removes the text and tag from the semantics tree), so the assertion matches the stream_compose_channel_item_unread plural and the stale resource-id selector is removed. --- .../android/compose/pages/ChannelListPage.kt | 1 - .../chat/android/compose/robots/UserRobot.kt | 6 ++++ .../robots/UserRobotChannelListAsserts.kt | 13 +++++++ .../compose/tests/UnreadMessagesTests.kt | 34 ++++++++++++++++--- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ChannelListPage.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ChannelListPage.kt index 68bbd5355ab..3a42983cecd 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ChannelListPage.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/pages/ChannelListPage.kt @@ -59,7 +59,6 @@ class ChannelListPage { // The channel preview renders the failed state via MessageReadStatusIcon, // unlike the message list, which uses its own failed icon val deliveryStatusIsFailed: BySelector = By.res("Stream_MessageReadStatus_isError") - val unreadCountIndicator = By.res("Stream_UnreadCountIndicator") val timestamp = By.res("Stream_Timestamp") val typingIndicator = By.res("Stream_ChannelListTypingIndicator") val mutedIcon = By.res("Stream_ChannelMutedIcon") diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt index 2bf28311e6d..696c93c7fbc 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt @@ -186,6 +186,12 @@ class UserRobot { return this } + fun markMessageAsUnread(messageCellIndex: Int = 0): UserRobot { + openContextMenu(messageCellIndex) + ContextMenu.markAsUnread.waitToAppear().click() + return this + } + fun pinMessage(messageCellIndex: Int = 0): UserRobot { openContextMenu(messageCellIndex) ContextMenu.pin.waitToAppearAndClick() diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotChannelListAsserts.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotChannelListAsserts.kt index 0b9d9793726..f47a79ced22 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotChannelListAsserts.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobotChannelListAsserts.kt @@ -28,6 +28,7 @@ import io.getstream.chat.android.e2e.test.uiautomator.waitToDisappear import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import io.getstream.chat.android.compose.R as ComposeR import io.getstream.chat.android.ui.common.R as UiCommonR fun UserRobot.assertChannelAvatar(): UserRobot { @@ -35,6 +36,18 @@ fun UserRobot.assertChannelAvatar(): UserRobot { return this } +fun UserRobot.assertChannelUnreadCount(count: Int): UserRobot { + // The badge exposes only a content description; its text is cleared from the + // semantics tree by clearAndSetSemantics in UnreadCountIndicator. + val expectedDescription = appContext.resources.getQuantityString( + ComposeR.plurals.stream_compose_channel_item_unread, + count, + count, + ) + assertTrue(By.desc(expectedDescription).waitDisplayed()) + return this +} + fun UserRobot.assertChannelActionsSheetForGroupChannel(): UserRobot { assertTrue(ChannelMenu.viewInfo.waitDisplayed()) assertTrue(ChannelMenu.leaveGroup.isDisplayed()) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/UnreadMessagesTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/UnreadMessagesTests.kt index a9ae767775a..c5451dc1591 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/UnreadMessagesTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/UnreadMessagesTests.kt @@ -16,12 +16,13 @@ package io.getstream.chat.android.compose.tests +import io.getstream.chat.android.compose.robots.assertChannelUnreadCount +import io.getstream.chat.android.compose.robots.assertMessage import io.getstream.chat.android.compose.robots.assertScrollToFirstUnreadButton import io.getstream.chat.android.compose.robots.assertUnreadSeparator import io.getstream.chat.android.compose.sample.ui.InitTestActivity import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId -import org.junit.Ignore import org.junit.Test class UnreadMessagesTests : StreamTestCase() { @@ -30,7 +31,6 @@ class UnreadMessagesTests : StreamTestCase() { private val sampleText = "Test" @AllureId("11453") - @Ignore("https://linear.app/stream/issue/AND-1329") @Test fun test_unreadSeparatorIsShown_whenParticipantSendsMessagesWhileUserIsAway() { val unreadCount = 2 @@ -43,6 +43,9 @@ class UnreadMessagesTests : StreamTestCase() { step("WHEN participant sends new messages") { participantRobot.sendMultipleMessages(text = "New", count = unreadCount) } + step("AND the channel preview shows the unread count") { + userRobot.assertChannelUnreadCount(unreadCount) + } step("AND user reopens the channel") { userRobot.openChannel() } @@ -52,7 +55,6 @@ class UnreadMessagesTests : StreamTestCase() { } @AllureId("11454") - @Ignore("https://linear.app/stream/issue/AND-1329") @Test fun test_userScrollsToFirstUnreadMessage() { val unreadCount = 25 @@ -65,6 +67,9 @@ class UnreadMessagesTests : StreamTestCase() { step("AND participant sends new messages") { participantRobot.sendMultipleMessages(text = "New", count = unreadCount) } + step("AND the channel preview shows the unread count") { + userRobot.assertChannelUnreadCount(unreadCount) + } step("WHEN user reopens the channel") { userRobot.openChannel() } @@ -79,8 +84,29 @@ class UnreadMessagesTests : StreamTestCase() { } } + @AllureId("6073") + @Test + fun test_userMarksMessageAsUnread() { + val unreadCount = 2 + step("GIVEN user opens the channel and sends the message") { + userRobot.login().openChannel().sendMessage(sampleText) + } + step("AND participant sends messages") { + participantRobot.sendMultipleMessages(text = "New", count = unreadCount) + userRobot.assertMessage("New-$unreadCount") + } + step("WHEN user marks the first participant message as unread") { + userRobot.markMessageAsUnread(messageCellIndex = 1) + } + step("THEN the unread separator is shown with the unread count") { + userRobot.assertUnreadSeparator(unreadCount = unreadCount) + } + step("AND the channel preview shows the unread count") { + userRobot.moveToChannelListFromMessageList().assertChannelUnreadCount(unreadCount) + } + } + @AllureId("11455") - @Ignore("https://linear.app/stream/issue/AND-1329") @Test fun test_userDismissesTheUnreadIndicator() { val unreadCount = 25 From 561cef6bcfa6b6bc58dc4bfb5c040fd02a90c418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 28 Jul 2026 18:23:25 +0100 Subject: [PATCH 2/6] e2e: Wait for the message delivery before the participant acts in the unread tests On a slow emulator the participant's first message could reach the mock server before the user's own message, changing the message order the tests rely on for the separator position and the marked message index. --- .../android/compose/tests/UnreadMessagesTests.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/UnreadMessagesTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/UnreadMessagesTests.kt index c5451dc1591..a55db4cba6e 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/UnreadMessagesTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/UnreadMessagesTests.kt @@ -18,9 +18,11 @@ package io.getstream.chat.android.compose.tests import io.getstream.chat.android.compose.robots.assertChannelUnreadCount import io.getstream.chat.android.compose.robots.assertMessage +import io.getstream.chat.android.compose.robots.assertMessageDeliveryStatus import io.getstream.chat.android.compose.robots.assertScrollToFirstUnreadButton import io.getstream.chat.android.compose.robots.assertUnreadSeparator import io.getstream.chat.android.compose.sample.ui.InitTestActivity +import io.getstream.chat.android.e2e.test.mockserver.MessageDeliveryStatus import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId import org.junit.Test @@ -37,6 +39,9 @@ class UnreadMessagesTests : StreamTestCase() { step("GIVEN user opens the channel and sends the message") { userRobot.login().openChannel().sendMessage(sampleText) } + step("AND the message is delivered") { + userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.SENT) + } step("AND user moves back to the channel list") { userRobot.moveToChannelListFromMessageList() } @@ -61,6 +66,9 @@ class UnreadMessagesTests : StreamTestCase() { step("GIVEN user opens the channel and sends the message") { userRobot.login().openChannel().sendMessage(sampleText) } + step("AND the message is delivered") { + userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.SENT) + } step("AND user moves back to the channel list") { userRobot.moveToChannelListFromMessageList() } @@ -91,6 +99,9 @@ class UnreadMessagesTests : StreamTestCase() { step("GIVEN user opens the channel and sends the message") { userRobot.login().openChannel().sendMessage(sampleText) } + step("AND the message is delivered") { + userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.SENT) + } step("AND participant sends messages") { participantRobot.sendMultipleMessages(text = "New", count = unreadCount) userRobot.assertMessage("New-$unreadCount") @@ -113,6 +124,9 @@ class UnreadMessagesTests : StreamTestCase() { step("GIVEN user opens the channel and sends the message") { userRobot.login().openChannel().sendMessage(sampleText) } + step("AND the message is delivered") { + userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.SENT) + } step("AND user moves back to the channel list") { userRobot.moveToChannelListFromMessageList() } From 7fa07acf85a250e43dfc5dfc7be4ff342c40d6ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 29 Jul 2026 09:34:43 +0100 Subject: [PATCH 3/6] e2e: Mark the message as unread by text instead of position Selecting by cell index assumed both participant messages had rendered; openContextMenu falls back to the last message when the index is out of range, which could mark the wrong message on a slow emulator. --- .../io/getstream/chat/android/compose/robots/UserRobot.kt | 4 ++-- .../chat/android/compose/tests/UnreadMessagesTests.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt index 696c93c7fbc..71fbd0b8c36 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt @@ -186,8 +186,8 @@ class UserRobot { return this } - fun markMessageAsUnread(messageCellIndex: Int = 0): UserRobot { - openContextMenu(messageCellIndex) + fun markMessageAsUnread(text: String): UserRobot { + openContextMenu(text) ContextMenu.markAsUnread.waitToAppear().click() return this } diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/UnreadMessagesTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/UnreadMessagesTests.kt index a55db4cba6e..0de39279d69 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/UnreadMessagesTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/UnreadMessagesTests.kt @@ -107,7 +107,7 @@ class UnreadMessagesTests : StreamTestCase() { userRobot.assertMessage("New-$unreadCount") } step("WHEN user marks the first participant message as unread") { - userRobot.markMessageAsUnread(messageCellIndex = 1) + userRobot.markMessageAsUnread("New-1") } step("THEN the unread separator is shown with the unread count") { userRobot.assertUnreadSeparator(unreadCount = unreadCount) From 9324c79d074e4e399e20edf5ed7787a4c62c5155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 29 Jul 2026 15:11:54 +0100 Subject: [PATCH 4/6] ui-common: Compute the unread label before marking the channel read Marking read zeroes the read state optimistically while keeping last_read_message_id, and the unread label pipeline collects a conflating flow. On a busy main thread the collector could observe only the zeroed state, compute a zero-count label, and the sticky design kept it forever, so the unread separator never rendered. Computing the label from the current read state right before the mark-read call makes the order deterministic, since the controller triggers both. --- .../messages/list/MessageListController.kt | 77 +++++++++++++------ .../list/MessageListControllerTests.kt | 51 ++++++++++++ 2 files changed, 103 insertions(+), 25 deletions(-) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt index 05ad9711949..828d9f83b97 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt @@ -147,7 +147,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.util.Date @@ -236,7 +235,7 @@ public class MessageListController( * via [messageListState], which mirrors this value. */ public val unreadLabelState: MutableStateFlow = MutableStateFlow(null) - private val showUnreadButtonState = MutableSharedFlow(extraBufferCapacity = 1) + private val showUnreadButtonState = MutableStateFlow(true) private var lastProcessedReadMessageId: String? = null private val originalTranslationsStore by lazy { MessageOriginalTranslationsStore.forChannel(cid) } @@ -597,34 +596,51 @@ public class MessageListController( * logic of determining unread message state, including edge cases for own messages, * mark as unread functionality, and offline/pending message scenarios. */ - @Suppress("MagicNumber") private fun observeUnreadLabelState() { combine( - showUnreadButtonState.onStart { emit(true) }, + showUnreadButtonState, channelState.filterNotNull(), channelState.filterNotNull().flatMapLatest { it.read }, ) { shouldShowButton, channel, read -> - read - ?.takeIf { !isStartedForThread } - ?.takeIf { it.lastReadMessageId != null && lastProcessedReadMessageId != it.lastReadMessageId } - ?.let { channelUserRead -> - lastProcessedReadMessageId = channelUserRead.lastReadMessageId - - // Delegate to the calculator for the complex unread label logic - val unreadLabel = unreadLabelCalculator.calculateUnreadLabel( - channelUserRead = channelUserRead, - messages = channel.messages.value, - currentUserId = clientState.user.value?.id, - shouldShowButton = shouldShowButton, - ) + computeUnreadLabel( + channelUserRead = read, + channel = channel, + shouldShowButton = shouldShowButton, + ) + }.launchIn(scope) + } - // Only update the label if the calculator produced a non-null result. This makes the label sticky: - // once shown, it persists until the user leaves the channel. - if (unreadLabel != null) { - unreadLabelState.value = unreadLabel - } + /** + * Recalculates the unread label from the given read state and updates [unreadLabelState]. + * + * The label is recalculated whenever [ChannelUserRead.lastReadMessageId] changes, and the + * result is "sticky": a null calculation never overwrites a non-null label, so the separator + * persists when the user auto-reads messages by scrolling, while mark-as-unread events + * (which move [ChannelUserRead.lastReadMessageId] backward) still produce a new label. + */ + private fun computeUnreadLabel( + channelUserRead: ChannelUserRead?, + channel: ChannelState, + shouldShowButton: Boolean, + ) { + channelUserRead + ?.takeIf { !isStartedForThread } + ?.takeIf { it.lastReadMessageId != null && lastProcessedReadMessageId != it.lastReadMessageId } + ?.let { read -> + lastProcessedReadMessageId = read.lastReadMessageId + + // Delegate to the calculator for the complex unread label logic + val unreadLabel = unreadLabelCalculator.calculateUnreadLabel( + channelUserRead = read, + messages = channel.messages.value, + currentUserId = clientState.user.value?.id, + shouldShowButton = shouldShowButton, + ) + + if (unreadLabel != null) { + unreadLabelState.value = unreadLabel } - }.launchIn(scope) + } } /** @@ -638,7 +654,7 @@ public class MessageListController( */ public fun disableUnreadLabelButton() { val currentLabel = unreadLabelState.value ?: return - showUnreadButtonState.tryEmit(false) + showUnreadButtonState.value = false unreadLabelState.value = currentLabel.copy(buttonVisibility = false) } @@ -1761,6 +1777,17 @@ public class MessageListController( if (isInThread) { markThreadAsRead() } else { + // Compute the unread label before marking read. Marking the channel read zeroes the + // read state optimistically, and observeUnreadLabelState collects a conflating flow, + // so on a busy main thread it can observe only the zeroed state and never produce + // the label. + channelState.value?.let { channel -> + computeUnreadLabel( + channelUserRead = channel.read.value, + channel = channel, + shouldShowButton = showUnreadButtonState.value, + ) + } markChannelAsRead() } } @@ -1844,7 +1871,7 @@ public class MessageListController( ErrorEvent.MarkUnreadError(it) } } else { - showUnreadButtonState.tryEmit(false) + showUnreadButtonState.value = false } } } diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListControllerTests.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListControllerTests.kt index 5b9a134eb60..0edd70cfca2 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListControllerTests.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListControllerTests.kt @@ -783,6 +783,57 @@ internal class MessageListControllerTests { unreadLabel.buttonVisibility.`should be true`() } + @Test + fun `Keep unread label, when marking read zeroes the read state`() = + runTest { + val chatClient: ChatClient = mock() + val lastReadMessage = randomMessage(id = "last_read_message_id", deletedAt = null, deletedForMe = false) + val messages = listOf( + lastReadMessage, + randomMessage( + id = "first_unread_message_id", + user = user2, + syncStatus = SyncStatus.COMPLETED, + deletedAt = null, + deletedForMe = false, + ), + ) + val channelRead = MutableStateFlow( + randomChannelUserRead( + user = user1, + lastReadMessageId = lastReadMessage.id, + unreadMessages = 1, + ), + ) + val controller = Fixture(chatClient = chatClient) + .givenCurrentUser(user1) + .givenChannelQuery() + .givenChannelState( + messagesState = MutableStateFlow(messages), + read = channelRead, + ) + .get() + + // The state plugin zeroes the read state optimistically when marking read; + // the label must already be computed when the mark-read call goes out. + var labelWhenMarkingRead: MessageListController.UnreadLabel? = null + whenever(chatClient.markRead(any(), any())) doAnswer { + labelWhenMarkingRead = controller.unreadLabelState.value + channelRead.value = channelRead.value.copy( + unreadMessages = 0, + lastRead = Date(), + lastReceivedEventDate = Date(), + ) + Unit.asCall() + } + controller.markLastMessageRead() + delay(1000) + + verify(chatClient, times(1)).markRead(any(), any()) + labelWhenMarkingRead.`should not be null`().unreadCount `should be equal to` 1 + controller.unreadLabelState.value.`should not be null`().unreadCount `should be equal to` 1 + } + @Test fun `Show unread label, when message is marked as unread`() = runTest { From d4ffa73ccdf9c856ff0494874b763c911c83d2d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 30 Jul 2026 09:13:56 +0100 Subject: [PATCH 5/6] ui-common: Keep the unread label recalculation open on null results The read-state id was marked as processed before the calculator ran, so a first emission arriving while the message list is still empty produced no label and permanently blocked recalculation for that id. The id is now recorded only when a label is produced, so the capture before mark-read computes the label once the messages are loaded. --- .../messages/list/MessageListController.kt | 6 ++- .../list/MessageListControllerTests.kt | 43 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt index 828d9f83b97..d99d06ac690 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt @@ -627,8 +627,6 @@ public class MessageListController( ?.takeIf { !isStartedForThread } ?.takeIf { it.lastReadMessageId != null && lastProcessedReadMessageId != it.lastReadMessageId } ?.let { read -> - lastProcessedReadMessageId = read.lastReadMessageId - // Delegate to the calculator for the complex unread label logic val unreadLabel = unreadLabelCalculator.calculateUnreadLabel( channelUserRead = read, @@ -638,6 +636,10 @@ public class MessageListController( ) if (unreadLabel != null) { + // Marking the read state as processed only on a produced label keeps the + // recalculation open while the reason for a null result is transient, for + // example a message list that has not loaded yet. + lastProcessedReadMessageId = read.lastReadMessageId unreadLabelState.value = unreadLabel } } diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListControllerTests.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListControllerTests.kt index 0edd70cfca2..6f537b44cdb 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListControllerTests.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListControllerTests.kt @@ -783,6 +783,49 @@ internal class MessageListControllerTests { unreadLabel.buttonVisibility.`should be true`() } + @Test + fun `Show unread label, when the read state arrives before the messages are loaded`() = + runTest { + val chatClient: ChatClient = mock() + val messagesState = MutableStateFlow(emptyList()) + val channelRead = MutableStateFlow( + randomChannelUserRead( + user = user1, + lastReadMessageId = "last_read_message_id", + unreadMessages = 2, + ), + ) + val controller = Fixture(chatClient = chatClient) + .givenCurrentUser(user1) + .givenChannelQuery() + .givenMarkRead() + .givenChannelState( + messagesState = messagesState, + read = channelRead, + ) + .get() + // The read state is processed while the message list is still empty, + // so no label can be calculated yet. + delay(1000) + controller.unreadLabelState.value `should be equal to` null + + messagesState.value = listOf( + randomMessage(id = "last_read_message_id", user = user1, deletedAt = null, deletedForMe = false), + randomMessage(id = "unread_1", user = user2, deletedAt = null, deletedForMe = false), + randomMessage( + id = "unread_2", + user = user2, + syncStatus = SyncStatus.COMPLETED, + deletedAt = null, + deletedForMe = false, + ), + ) + controller.markLastMessageRead() + delay(1000) + + controller.unreadLabelState.value.`should not be null`().unreadCount `should be equal to` 2 + } + @Test fun `Keep unread label, when marking read zeroes the read state`() = runTest { From 045a6b48b0f1b2261c3f4158271472874a202a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 30 Jul 2026 16:25:17 +0100 Subject: [PATCH 6/6] e2e: Use the stale-safe click for the mark-as-unread menu option Aligns with #6603, which converted the remaining UserRobot clicks. --- .../io/getstream/chat/android/compose/robots/UserRobot.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt index 71fbd0b8c36..7d03617ee15 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt @@ -188,7 +188,7 @@ class UserRobot { fun markMessageAsUnread(text: String): UserRobot { openContextMenu(text) - ContextMenu.markAsUnread.waitToAppear().click() + ContextMenu.markAsUnread.waitToAppearAndClick() return this }