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 @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,26 @@ 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 {
assertTrue(Channel.avatar.isDisplayed())
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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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()
}
Expand All @@ -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()
}
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -236,7 +235,7 @@ public class MessageListController(
* via [messageListState], which mirrors this value.
*/
public val unreadLabelState: MutableStateFlow<UnreadLabel?> = MutableStateFlow(null)
private val showUnreadButtonState = MutableSharedFlow<Boolean>(extraBufferCapacity = 1)
private val showUnreadButtonState = MutableStateFlow(true)
private var lastProcessedReadMessageId: String? = null
private val originalTranslationsStore by lazy { MessageOriginalTranslationsStore.forChannel(cid) }

Expand Down Expand Up @@ -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)
}
}

/**
Expand All @@ -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)
}

Expand Down Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -1844,7 +1873,7 @@ public class MessageListController(
ErrorEvent.MarkUnreadError(it)
}
} else {
showUnreadButtonState.tryEmit(false)
showUnreadButtonState.value = false
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Message>())
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 {
Expand Down
Loading