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..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 @@ -186,6 +186,12 @@ class UserRobot { return this } + fun markMessageAsUnread(text: String): UserRobot { + openContextMenu(text) + ContextMenu.markAsUnread.waitToAppearAndClick() + 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..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 @@ -16,12 +16,15 @@ 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.Ignore import org.junit.Test class UnreadMessagesTests : StreamTestCase() { @@ -30,19 +33,24 @@ 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 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() } 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,19 +60,24 @@ class UnreadMessagesTests : StreamTestCase() { } @AllureId("11454") - @Ignore("https://linear.app/stream/issue/AND-1329") @Test fun test_userScrollsToFirstUnreadMessage() { val unreadCount = 25 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() } 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,14 +92,41 @@ 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 the message is delivered") { + userRobot.assertMessageDeliveryStatus(MessageDeliveryStatus.SENT) + } + 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("New-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 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() } 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..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 @@ -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,53 @@ 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 -> + // 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) { + // 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 } - }.launchIn(scope) + } } /** @@ -638,7 +656,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 +1779,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 +1873,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..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,100 @@ 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 { + 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 {