From a5b54cb83e43898a72759c8bc8ffd9110bd5363d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 21 Oct 2026 14:05:14 +0100 Subject: [PATCH 1/7] Keep disabled channels out of grouped lists The backend leaves disabled channels out of grouped queries. A channel update or partial update that disables a listed channel now removes it, re-enabling it adds it back, and events for a disabled channel no longer add it. The grouped checks read the typed `Channel.disabled`. --- .../internal/GroupAwareChatEventHandler.kt | 7 +- .../grouped/internal/GroupedChannelFilter.kt | 7 +- .../GroupAwareChatEventHandlerTest.kt | 157 +++++++++++++----- .../internal/GroupedChannelFilterTest.kt | 5 +- ...EventHandlerSequentialHiddenChannelTest.kt | 1 + .../logic/internal/LogicRegistryTest.kt | 1 + ...QueryChannelsLogicGroupedUnreadBaseTest.kt | 1 + 7 files changed, 131 insertions(+), 48 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt index e0634227a74f..bb16dd09546d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt @@ -152,14 +152,17 @@ internal class GroupAwareChatEventHandler( } } + /** Whether [channel] belongs in this group. A disabled channel never does, since the backend leaves it out. */ private fun channelBelongsHere(channel: Channel, filter: FilterObject): Boolean = - resolver.resolve(channel).contains(groupKey) && + !channel.disabled && + resolver.resolve(channel).contains(groupKey) && filter.matchesGroupedChannel(group = channel.group, queryTimestamp = channel.queryTimestamp, now = now()) /** [channelBelongsHere] with the cutoff passed, for a channel whose last activity is unknown or may be stale. */ private fun groupMatches(channel: Channel, filter: FilterObject): Boolean { val checkedAt = now() - return resolver.resolve(channel).contains(groupKey) && + return !channel.disabled && + resolver.resolve(channel).contains(groupKey) && filter.matchesGroupedChannel(group = channel.group, queryTimestamp = checkedAt, now = checkedAt) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedChannelFilter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedChannelFilter.kt index 9e4622d44d29..ef674d6ad2a1 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedChannelFilter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedChannelFilter.kt @@ -35,7 +35,6 @@ import java.util.concurrent.TimeUnit private const val QUERY_TIMESTAMP_FIELD_NAME = "query_timestamp" private const val TOUCHED_AT_FIELD_NAME = "touched_at" -private const val DISABLED_FIELD_NAME = "disabled" private const val RELATIVE_DATE_PREFIX = "now-" private const val RELATIVE_DATE_DAYS_SUFFIX = "d" @@ -58,11 +57,7 @@ private val Channel.touchedAt: Date? * messages, as the backend counts it. */ internal fun Channel.isUnreadInGroupFor(userId: String): Boolean = - hidden != true && !isDisabled && currentUserUnreadCount(userId) > 0 - -// The model has no field for it, so the server's `disabled` flag arrives in the extra data. -private val Channel.isDisabled: Boolean - get() = extraData[DISABLED_FIELD_NAME] == true + hidden != true && !disabled && currentUserUnreadCount(userId) > 0 private val queryTimestampDescSort = QuerySortByField.descByName(QUERY_TIMESTAMP_FIELD_NAME) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt index 796990ae13e4..1eefcb60ee90 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt @@ -44,6 +44,7 @@ import org.mockito.kotlin.whenever import java.util.Date import java.util.concurrent.TimeUnit +@Suppress("LargeClass") internal class GroupAwareChatEventHandlerTest { private val defaultResolver = DefaultChannelGroupResolver() @@ -53,7 +54,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel belongs to this group and is not cached When ChannelUpdatedEvent arrives Should add`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val handler = handlerFor( groupKey = "vip", @@ -70,7 +71,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel belongs to this group and is already cached When ChannelUpdatedEvent arrives Should skip`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val handler = handlerFor( groupKey = "vip", @@ -86,7 +87,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel moved to another group and is currently cached When ChannelUpdatedEvent arrives Should remove`() { - val channel = randomChannel(extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) val handler = handlerFor(groupKey = "vip", cachedChannels = mapOf(channel.cid to channel)) val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) @@ -97,7 +98,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel belongs to another group and is not cached When ChannelUpdatedEvent arrives Should skip`() { - val channel = randomChannel(extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) @@ -109,7 +110,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given handler is for the all group When ChannelUpdatedEvent arrives Should always add`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val handler = handlerFor( groupKey = "all", @@ -125,7 +126,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel does not belong here When NotificationAddedToChannelEvent arrives Should skip`() { - val channel = randomChannel(extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) val event = randomNotificationAddedToChannelEvent(cid = channel.cid, channel = channel) @@ -136,7 +137,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel belongs here When NotificationAddedToChannelEvent arrives Should watch and add`() { - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) val event = randomNotificationAddedToChannelEvent(cid = channel.cid, channel = channel) @@ -147,7 +148,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel does not belong here When NotificationMessageNewEvent arrives Should skip`() { - val channel = randomChannel(extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) val event = randomNotificationMessageNewEvent(cid = channel.cid, channel = channel) @@ -158,7 +159,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given no cached channel When ChannelVisibleEvent arrives Should route by the event channel`() { - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) val event = randomChannelVisibleEvent(cid = channel.cid, channel = channel) @@ -169,7 +170,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a cached channel When ChannelVisibleEvent carries only type and id Should route by the cached channel`() { - val cachedChannel = randomChannel(extraData = mapOf("group" to "vip")) + val cachedChannel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) val event = randomChannelVisibleEvent( cid = cachedChannel.cid, @@ -183,7 +184,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given no cached channel When ChannelVisibleEvent carries only type and id Should watch and add`() { - val channel = randomChannel() + val channel = randomChannel(disabled = false) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) val event = randomChannelVisibleEvent( cid = channel.cid, @@ -197,7 +198,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a stale cached channel When ChannelVisibleEvent carries the full channel Should route by the event channel`() { - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(extraData = mapOf("group" to "other")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) val event = randomChannelVisibleEvent(cid = channel.cid, channel = channel) @@ -210,7 +211,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given current user joined with matching cached channel When MemberAddedEvent arrives Should add`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) val handler = handlerFor( groupKey = "vip", cachedChannels = emptyMap(), @@ -229,7 +230,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given current user joined with non-matching cached channel When MemberAddedEvent arrives Should skip`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) val handler = handlerFor( groupKey = "vip", cachedChannels = emptyMap(), @@ -248,7 +249,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given current user left a cached channel When MemberRemovedEvent arrives Should remove regardless of group`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) val handler = handlerFor( groupKey = "vip", cachedChannels = mapOf(channel.cid to channel), @@ -267,7 +268,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given cachedChannel has no membership for current user When ChannelUpdatedEvent moves channel into this group Should skip`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "old")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "old")) val cachedChannel = channel.copy(membership = null) val handler = handlerFor( groupKey = "old", @@ -284,7 +285,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given current user lost membership while channel still resolves here When ChannelUpdatedEvent arrives Should remove`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "old")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "old")) val cachedChannel = channel.copy(membership = null) val handler = handlerFor( groupKey = "old", @@ -301,7 +302,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given cached channel is hidden When ChannelUpdatedEvent moves channel into this group Should skip`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = true) val handler = handlerFor( groupKey = "vip", @@ -318,7 +319,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given cached channel is hidden and is in the list When ChannelUpdatedEvent arrives Should remove`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = true) val handler = handlerFor( groupKey = "vip", @@ -332,9 +333,54 @@ internal class GroupAwareChatEventHandlerTest { assertEquals(EventHandlingResult.Remove(channel.cid), result) } + @Test + fun `Given a channel in the list When ChannelUpdatedEvent disables it Should remove`() { + val currentUser = randomUser() + val channel = randomChannel(disabled = true, extraData = mapOf("group" to "vip")) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + currentUser = currentUser, + ) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given a disabled channel not in the list When ChannelUpdatedEvent enables it Should add`() { + val currentUser = randomUser() + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false, disabled = true) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = emptyMap(), + currentUser = currentUser, + ) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Add(channel), result) + } + + @Test + fun `Given a disabled channel When NotificationAddedToChannelEvent arrives Should skip`() { + val channel = randomChannel(disabled = true, extraData = mapOf("group" to "vip")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNotificationAddedToChannelEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + @Test fun `Given no connected user When ChannelUpdatedEvent arrives Should skip`() { - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(), hidden = false) val handler = handlerFor( groupKey = "vip", @@ -351,7 +397,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given current user has a connection but no cachedChannel When ChannelUpdatedEvent arrives Should skip`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "old")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "old")) val handler = handlerFor( groupKey = "old", cachedChannels = emptyMap(), @@ -366,7 +412,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a cached channel When ChannelDeletedEvent arrives Should remove regardless of group`() { - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) val handler = handlerFor( groupKey = "vip", cachedChannels = mapOf(channel.cid to channel), @@ -381,7 +427,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a custom resolver that reads a different field When ChannelUpdatedEvent arrives Should use custom field`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("tier" to "vip")) + val channel = randomChannel(disabled = false, extraData = mapOf("tier" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val customResolver = ChannelGroupResolver { ch -> setOfNotNull(ch.extraData["tier"] as? String) @@ -403,6 +449,7 @@ internal class GroupAwareChatEventHandlerTest { fun `Given a filtered group and a channel past the cutoff in the list When ChannelUpdatedEvent arrives Should remove`() { val currentUser = randomUser() val channel = randomChannel( + disabled = false, extraData = mapOf("group" to "expired"), createdAt = daysAgo(30), lastMessageAt = daysAgo(15), @@ -424,6 +471,7 @@ internal class GroupAwareChatEventHandlerTest { fun `Given a channel kept within the cutoff by touched_at When ChannelUpdatedEvent arrives Should keep it`() { val currentUser = randomUser() val channel = randomChannel( + disabled = false, extraData = mapOf("group" to "expired", "touched_at" to daysAgo(1)), createdAt = daysAgo(30), lastMessageAt = daysAgo(20), @@ -445,6 +493,7 @@ internal class GroupAwareChatEventHandlerTest { fun `Given a filtered group and a channel within the cutoff When ChannelUpdatedEvent arrives Should add`() { val currentUser = randomUser() val channel = randomChannel( + disabled = false, extraData = mapOf("group" to "expired"), createdAt = daysAgo(30), lastMessageAt = daysAgo(3), @@ -460,7 +509,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given the all group filter excludes a group When NotificationMessageNewEvent arrives for it Should skip`() { - val channel = randomChannel(extraData = mapOf("group" to "bee"), lastMessageAt = daysAgo(0)) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "bee"), lastMessageAt = daysAgo(0)) val handler = handlerFor(groupKey = "all", cachedChannels = emptyMap()) val event = randomNotificationMessageNewEvent(cid = channel.cid, channel = channel) @@ -472,6 +521,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a cached channel past the cutoff When a new message arrives Should add it back`() { val cachedChannel = randomChannel( + disabled = false, extraData = mapOf("group" to "expired"), createdAt = daysAgo(30), lastMessageAt = daysAgo(20), @@ -492,6 +542,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a cached channel past the cutoff When a thread reply not shown in the channel arrives Should skip`() { val cachedChannel = randomChannel( + disabled = false, extraData = mapOf("group" to "expired"), createdAt = daysAgo(30), lastMessageAt = daysAgo(20), @@ -511,7 +562,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel in the list When a partial update moves it to another group Should remove`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val handler = handlerFor( groupKey = "vip", @@ -528,7 +579,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel in the list When a partial update removes its group Should remove`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val handler = handlerFor( groupKey = "vip", @@ -545,7 +596,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel in the list When a partial update keeps it in the group Should skip`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val handler = handlerFor( groupKey = "vip", @@ -561,7 +612,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel not in local state When a partial update moves it into the group Should watch and add`() { - val channel = randomChannel(extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) @@ -570,11 +621,40 @@ internal class GroupAwareChatEventHandlerTest { assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) } + @Test + fun `Given a channel in the list When a partial update disables it Should remove`() { + val currentUser = randomUser() + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + currentUser = currentUser, + ) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip"), disabled = true) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given a channel not in local state When a partial update moves it into the group disabled Should skip`() { + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip"), disabled = true) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + @Test fun `Given a stale copy past the cutoff and not in the list When a partial update moves it into the group Should watch and add`() { // The copy's last activity can be stale, so the list checks the cutoff after the watch. val currentUser = randomUser() val channel = randomChannel( + disabled = false, createdAt = daysAgo(30), lastMessageAt = daysAgo(20), extraData = mapOf("group" to "other"), @@ -591,7 +671,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a cached channel not in the list When a partial update moves it into the group Should watch and add`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = currentUser) val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) @@ -603,7 +683,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel not in local state When a partial update keeps it in another group Should skip`() { - val channel = randomChannel(extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) val event = partialUpdateOf(channel, custom = mapOf("group" to "other")) @@ -615,7 +695,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given the user hid the channel When a partial update moves it into the group Should skip`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = true) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = currentUser) val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) @@ -627,7 +707,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a cached channel the user left When a partial update moves it into the group Should skip`() { - val channel = randomChannel(extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) val cachedChannel = channel.copy(membership = null, hidden = false) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) @@ -641,6 +721,7 @@ internal class GroupAwareChatEventHandlerTest { fun `Given a recent last message in local state When a partial update sets an old touched_at Should keep it`() { val currentUser = randomUser() val channel = randomChannel( + disabled = false, createdAt = daysAgo(30), lastMessageAt = daysAgo(1), extraData = mapOf("group" to "vip"), @@ -662,6 +743,7 @@ internal class GroupAwareChatEventHandlerTest { fun `Given a channel in a filtered group When a partial update leaves it past the cutoff Should remove`() { val currentUser = randomUser() val channel = randomChannel( + disabled = false, createdAt = daysAgo(30), lastMessageAt = daysAgo(20), extraData = mapOf("group" to "vip", "touched_at" to daysAgo(1)), @@ -681,7 +763,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel not in local state When a partial update touches it within the cutoff Should watch and add`() { - val channel = randomChannel(createdAt = daysAgo(30), extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, createdAt = daysAgo(30), extraData = mapOf("group" to "vip")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) val event = partialUpdateOf(channel, custom = mapOf("group" to "vip", "touched_at" to daysAgo(1))) @@ -693,7 +775,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel not in local state When a partial update moves it into a filtered group Should watch and add`() { // The last message time is unknown without local data, so the list checks the cutoff after the watch. - val channel = randomChannel(createdAt = daysAgo(30), extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, createdAt = daysAgo(30), extraData = mapOf("group" to "other")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) @@ -704,7 +786,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel not in local state When a partial update moves it into a group the filter excludes Should skip`() { - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) val handler = handlerFor(groupKey = "all", cachedChannels = emptyMap(), currentUser = randomUser()) val event = partialUpdateOf(channel, custom = mapOf("group" to "archived")) val filter = Filters.and(Filters.ne("group", "archived"), cutoff14Days) @@ -715,7 +797,7 @@ internal class GroupAwareChatEventHandlerTest { } /** The event as the backend sends it: only the channel's basic fields and its custom data. */ - private fun partialUpdateOf(channel: Channel, custom: Map) = + private fun partialUpdateOf(channel: Channel, custom: Map, disabled: Boolean = false) = randomNotificationChannelPartialUpdatedEvent( cid = channel.cid, channelType = channel.type, @@ -725,7 +807,8 @@ internal class GroupAwareChatEventHandlerTest { type = channel.type, createdAt = channel.createdAt, updatedAt = channel.updatedAt, - extraData = custom + ("disabled" to false), + disabled = disabled, + extraData = custom + ("disabled" to disabled), ), ) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedChannelFilterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedChannelFilterTest.kt index f38ca14c0d65..9d227ed331ff 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedChannelFilterTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedChannelFilterTest.kt @@ -124,14 +124,13 @@ internal class GroupedChannelFilterTest { val user = randomUser() val unread = randomChannel( hidden = false, - extraData = mapOf("disabled" to false), + disabled = false, read = listOf(randomChannelUserRead(user = user, unreadMessages = 2)), ) assertTrue(unread.isUnreadInGroupFor(user.id)) - assertTrue(unread.copy(extraData = emptyMap()).isUnreadInGroupFor(user.id)) assertFalse(unread.copy(hidden = true).isUnreadInGroupFor(user.id)) - assertFalse(unread.copy(extraData = mapOf("disabled" to true)).isUnreadInGroupFor(user.id)) + assertFalse(unread.copy(disabled = true).isUnreadInGroupFor(user.id)) assertFalse( unread.copy(read = listOf(randomChannelUserRead(user = user, unreadMessages = 0))).isUnreadInGroupFor(user.id), ) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialHiddenChannelTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialHiddenChannelTest.kt index bad517ed25dd..c5fcd950d1ac 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialHiddenChannelTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialHiddenChannelTest.kt @@ -195,6 +195,7 @@ internal class EventHandlerSequentialHiddenChannelTest { private fun updatedChannel(): Channel = randomChannel( id = CHANNEL_ID, type = CHANNEL_TYPE, + disabled = false, extraData = mapOf("group" to "ended"), ) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistryTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistryTest.kt index 371c92f22f78..28a64d086189 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistryTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistryTest.kt @@ -1296,6 +1296,7 @@ internal class LogicRegistryTest { createdAt = recent, lastMessageAt = recent, hidden = false, + disabled = false, extraData = emptyMap(), read = listOf(randomChannelUserRead(user = user, unreadMessages = 1)), ) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedUnreadBaseTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedUnreadBaseTest.kt index 93bf34560fcb..b23749a48eff 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedUnreadBaseTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedUnreadBaseTest.kt @@ -215,6 +215,7 @@ internal class QueryChannelsLogicGroupedUnreadBaseTest { private fun channelWithUnread(user: User, unreadMessages: Int) = randomChannel( hidden = false, + disabled = false, read = listOf(randomChannelUserRead(user = user, unreadMessages = unreadMessages)), ) From bc71cc9a166e30e75d20a8460341358c56d179b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 21 Oct 2026 14:32:31 +0100 Subject: [PATCH 2/7] Keep hidden channels hidden for messages that do not un-hide them on the server A new message shows a hidden channel again only when it is a regular message or a reply that is not shadowed, has no campaign id, and was not sent in a blocked channel or by a blocked user. The channel state (both channel logics), the stored channel and grouped lists follow the same rule. --- .../internal/GroupAwareChatEventHandler.kt | 22 ++- .../GroupAwareChatEventHandlerFactory.kt | 3 + .../internal/EventHandlerSequential.kt | 10 +- .../internal/ChannelEventHandlerImpl.kt | 23 ++- .../legacy/ChannelEventHandlerLegacyImpl.kt | 10 +- .../internal/legacy/ChannelStateLogic.kt | 15 +- .../plugin/logic/internal/LogicRegistry.kt | 1 + .../state/utils/internal/ChannelVisibility.kt | 41 ++++ .../channel/controller/WhenHandleEvent.kt | 2 +- .../GroupAwareChatEventHandlerTest.kt | 175 ++++++++++++++---- ...EventHandlerSequentialHiddenChannelTest.kt | 67 ++++++- .../internal/EventHandlerSequentialTest.kt | 50 +++++ .../internal/ChannelEventHandlerImplTest.kt | 49 ++++- .../ChannelEventHandlerLegacyImplTest.kt | 20 +- .../internal/legacy/ChannelStateLogicTest.kt | 32 +++- .../utils/internal/ChannelVisibilityTest.kt | 74 ++++++++ 16 files changed, 502 insertions(+), 92 deletions(-) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelVisibility.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelVisibilityTest.kt diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt index bb16dd09546d..2e13c17aa074 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt @@ -29,6 +29,7 @@ import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationMessageNewEvent import io.getstream.chat.android.client.internal.state.utils.internal.mergePartialUpdate +import io.getstream.chat.android.client.internal.state.utils.internal.unhidesChannel import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.client.utils.channel.calculateNewLastMessageAt import io.getstream.chat.android.models.Channel @@ -54,6 +55,7 @@ internal class GroupAwareChatEventHandler( private val resolver: ChannelGroupResolver, channels: StateFlow?>, clientState: ClientState, + private val blockedUserIds: () -> Collection, private val now: () -> Date = ::Date, ) : DefaultChatEventHandler(channels, clientState) { @@ -69,9 +71,14 @@ internal class GroupAwareChatEventHandler( is NotificationChannelPartialUpdatedEvent -> routePartialUpdate(event, cachedChannel, filter) - is NotificationAddedToChannelEvent, - is NotificationMessageNewEvent, - -> watchAndAddIfBelongsHere(event.cid, event.channel, filter) + is NotificationMessageNewEvent -> + if ((cachedChannel ?: event.channel).staysHiddenAfter(event.message)) { + EventHandlingResult.Skip + } else { + watchAndAddIfBelongsHere(event.cid, event.channel, filter) + } + + is NotificationAddedToChannelEvent -> watchAndAddIfBelongsHere(event.cid, event.channel, filter) // channel.visible replayed by /sync can carry a channel with only its type and id. is ChannelVisibleEvent -> { @@ -93,12 +100,17 @@ internal class GroupAwareChatEventHandler( filter: FilterObject, cachedChannel: Channel?, ): EventHandlingResult { - val defaultResult = super.handleCidEvent(event, filter, cachedChannel) - // The cached channel can predate the new message, which may bring it back within a cutoff. + // The cached channel can predate the new message, which may bring it back within a cutoff or un-hide it. val newMessage = (event as? NewMessageEvent)?.message + if (newMessage != null && cachedChannel?.staysHiddenAfter(newMessage) == true) return EventHandlingResult.Skip + val defaultResult = super.handleCidEvent(event, filter, cachedChannel) return filterResultByGroup(defaultResult, cachedChannel, filter, newMessage) } + /** Whether this channel is hidden and the new [message] keeps it hidden on the server. */ + private fun Channel.staysHiddenAfter(message: Message): Boolean = + hidden == true && !message.unhidesChannel(blocked, blockedUserIds()) + /** * The membership guard prevents a `ChannelUpdatedEvent` from (re-)adding a channel the user * has already left — relies on [cachedChannel] (in-memory state after `member.removed` is diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerFactory.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerFactory.kt index 3bb48ace0dab..4b306069db58 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerFactory.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerFactory.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.client.internal.state.event.handler.grouped.in import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.event.ChatEventHandler import io.getstream.chat.android.client.api.event.ChatEventHandlerFactory +import io.getstream.chat.android.client.api.state.globalState import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Channel @@ -34,6 +35,7 @@ internal class GroupAwareChatEventHandlerFactory( private val groupKey: String, private val resolver: ChannelGroupResolver = DefaultChannelGroupResolver(), private val clientState: ClientState = ChatClient.instance().clientState, + private val blockedUserIds: () -> Collection = { ChatClient.instance().globalState.blockedUserIds.value }, ) : ChatEventHandlerFactory(clientState) { override fun chatEventHandler(channels: StateFlow?>): ChatEventHandler = @@ -42,6 +44,7 @@ internal class GroupAwareChatEventHandlerFactory( resolver = resolver, channels = channels, clientState = clientState, + blockedUserIds = blockedUserIds, ) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt index 8b7c6fda4b9c..b47458a6f55c 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt @@ -114,6 +114,7 @@ import io.getstream.chat.android.client.internal.state.plugin.logic.internal.Log import io.getstream.chat.android.client.internal.state.plugin.logic.querychannels.internal.QueryChannelsLogic import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.client.internal.state.utils.internal.mergePartialUpdate +import io.getstream.chat.android.client.internal.state.utils.internal.unhidesChannel import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.client.utils.channel.calculateNewLastMessageAt @@ -121,6 +122,7 @@ import io.getstream.chat.android.client.utils.internal.ChannelId import io.getstream.chat.android.client.utils.mergePartially import io.getstream.chat.android.client.utils.observable.Disposable import io.getstream.chat.android.core.internal.lazy.parameterizedLazy +import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelCapabilities import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message @@ -661,7 +663,7 @@ internal class EventHandlerSequential( skipLastMsgUpdateForSystemMsgs = channel.config.skipLastMsgUpdateForSystemMsgs, ) val updatedChannel = channel.copy( - hidden = channel.hidden.takeIf { enrichedMessage.shadowed } ?: false, + hidden = channel.hiddenAfter(enrichedMessage), messages = channel.messages + listOf(enrichedMessage), messageCount = event.channelMessageCount ?: channel.messageCount, lastMessageAt = newLastMessageAt, @@ -702,7 +704,7 @@ internal class EventHandlerSequential( batch.addMessageData(event.createdAt, event.cid, event.message) val channel = batch.getCurrentChannel(event.cid) ?.mergeChannelFromEvent(event.channel) ?: event.channel - batch.addChannel(channel.copy(hidden = false)) + batch.addChannel(channel.copy(hidden = channel.hiddenAfter(event.message))) } is NotificationAddedToChannelEvent -> { val channel = batch.getCurrentChannel(event.cid) @@ -987,6 +989,10 @@ internal class EventHandlerSequential( logger.v { "[updateOfflineStorage] completed batchId: ${batchEvent.id}" } } + /** The hidden state of this channel after the new [message]. */ + private fun Channel.hiddenAfter(message: Message): Boolean? = + if (message.unhidesChannel(blocked, mutableGlobalState.blockedUserIds.value)) false else hidden + private fun List.extractMessageIds() = mapNotNull { event -> (event as? HasMessage)?.message?.id } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImpl.kt index 5f23ae867768..faedeece2f81 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImpl.kt @@ -74,6 +74,7 @@ import io.getstream.chat.android.client.internal.state.event.handler.internal.ut import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateImpl import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.client.internal.state.utils.internal.mergePartialUpdate +import io.getstream.chat.android.client.internal.state.utils.internal.unhidesChannel import io.getstream.chat.android.client.utils.message.isPinned import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageReminder @@ -151,10 +152,7 @@ internal class ChannelEventHandlerImpl( } // Update channel read state for the current user state.updateCurrentUserRead(event.createdAt, enrichedMessage) - // Update hidden state if the message is not shadowed - if (!enrichedMessage.shadowed) { - state.setHidden(false) - } + unhideFor(enrichedMessage) // Update message count event.channelMessageCount?.let(state::setMessageCount) // Update last message at @@ -177,10 +175,7 @@ internal class ChannelEventHandlerImpl( is NotificationMessageNewEvent -> { // Update channel read state state.updateCurrentUserRead(event.createdAt, event.message) - // Update hidden state if the message is not shadowed - if (!event.message.shadowed) { - state.setHidden(false) - } + unhideFor(event.message) // Update last message at state.updateLastMessageAt(event.message) // Insert the message into the appropriate message list @@ -238,10 +233,7 @@ internal class ChannelEventHandlerImpl( is NotificationThreadMessageNewEvent -> { // Handle only if thread reply was sent to channel as well if (event.message.showInChannel) { - // Update hidden state if the message is not shadowed - if (!event.message.shadowed) { - state.setHidden(false) - } + unhideFor(event.message) // Insert the message into the appropriate message list if (state.insideSearch.value) { state.upsertCachedMessage(event.message) @@ -393,6 +385,13 @@ internal class ChannelEventHandlerImpl( } } + /** Shows the channel again when the new [message] makes the server show it. */ + private fun unhideFor(message: Message) { + if (message.unhidesChannel(state.channelData.value.blocked, globalState.blockedUserIds.value)) { + state.setHidden(false) + } + } + private fun updateReminder(messageId: String, reminder: MessageReminder) { state.updateMessageById(messageId) { message -> message.copy(reminder = reminder.toMessageReminderInfo()) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt index 48b45de80f5f..a1443fa945f2 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt @@ -123,10 +123,7 @@ internal class ChannelEventHandlerLegacyImpl( // Must run before the upsert: messages already in the state do not update the unread count. stateLogic.updateCurrentUserRead(event.createdAt, event.message) upsertMessage(event.message, preserveCreatedLocallyAt) - // Update hidden state if the message is not shadowed - if (!event.message.shadowed) { - stateLogic.setHidden(false) - } + stateLogic.unhideFor(event.message) // Update message count event.channelMessageCount?.let(stateLogic::updateMessageCount) // Update lastMessageAt @@ -139,10 +136,7 @@ internal class ChannelEventHandlerLegacyImpl( if (!mutableState.insideSearch.value) { upsertMessage(event.message) } - // Update hidden state if the message is not shadowed - if (!event.message.shadowed) { - stateLogic.setHidden(false) - } + stateLogic.unhideFor(event.message) // Update lastMessageAt stateLogic.updateLastMessageAt(event.message) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt index 5aabbd9b38fb..2bd69dd90633 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt @@ -33,6 +33,7 @@ import io.getstream.chat.android.client.internal.state.plugin.logic.channel.inte import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateLegacyImpl import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.MarkReadResult import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState +import io.getstream.chat.android.client.internal.state.utils.internal.unhidesChannel import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.client.utils.channel.calculateNewLastMessageAt import io.getstream.chat.android.client.utils.message.isDeleted @@ -58,7 +59,7 @@ import io.getstream.result.Error import kotlinx.coroutines.CoroutineScope import java.util.Date -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LargeClass") /** * The logic of the state of a channel. This class contains the logic of how to * update the state of the channel in the SDK. @@ -621,6 +622,18 @@ internal class ChannelStateLogic( mutableState.setHidden(hidden) } + /** + * Shows the channel again when the new [message] makes the server show it. + * + * @param message The new message of the channel. + */ + fun unhideFor(message: Message) { + val channelBlocked = mutableState.channelData.value.blocked + if (message.unhidesChannel(channelBlocked, globalMutableState.blockedUserIds.value)) { + setHidden(false) + } + } + /** * Sets a replied message. * diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistry.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistry.kt index de582efeb5ff..f73b4e36bda5 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistry.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistry.kt @@ -130,6 +130,7 @@ internal class LogicRegistry internal constructor( mutableState.chatEventHandlerFactory = GroupAwareChatEventHandlerFactory( groupKey = identifier.groupKey, clientState = clientState, + blockedUserIds = { mutableGlobalState.blockedUserIds.value }, ) } val queryChannelsStateLogic = QueryChannelsStateLogic( diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelVisibility.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelVisibility.kt new file mode 100644 index 000000000000..89c156210645 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelVisibility.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.internal.state.utils.internal + +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.MessageType + +private const val CREATED_BY_CAMPAIGN_ID_FIELD_NAME = "created_by_campaign_id" + +/** + * Whether this new message makes the server show a hidden channel again for the current user. Only a regular message + * or a reply does, and not when it is shadowed, sent by a campaign, sent in a blocked channel or sent by a user the + * current user blocked. + * + * @param channelBlocked Whether the current user blocked the channel, when known. + * @param blockedUserIds The ids of the users the current user blocked. + */ +internal fun Message.unhidesChannel(channelBlocked: Boolean?, blockedUserIds: Collection): Boolean = + when (type) { + MessageType.REGULAR, MessageType.REPLY -> + !shadowed && + extraData[CREATED_BY_CAMPAIGN_ID_FIELD_NAME] == null && + channelBlocked != true && + user.id !in blockedUserIds + + else -> false + } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/channel/controller/WhenHandleEvent.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/channel/controller/WhenHandleEvent.kt index 3c1b59c1a757..4d645942f1c1 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/channel/controller/WhenHandleEvent.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/channel/controller/WhenHandleEvent.kt @@ -126,7 +126,7 @@ internal class WhenHandleEvent : SynchronizedCoroutineTest { userStartWatchingEvent.createdAt, userStartWatchingEvent.message, ) - verify(channelStateLogic).setHidden(false) + verify(channelStateLogic).unhideFor(userStartWatchingEvent.message) } // Message update diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt index 1eefcb60ee90..4eae8c9f4857 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt @@ -54,7 +54,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel belongs to this group and is not cached When ChannelUpdatedEvent arrives Should add`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val handler = handlerFor( groupKey = "vip", @@ -71,7 +71,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel belongs to this group and is already cached When ChannelUpdatedEvent arrives Should skip`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val handler = handlerFor( groupKey = "vip", @@ -87,7 +87,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel moved to another group and is currently cached When ChannelUpdatedEvent arrives Should remove`() { - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "other")) val handler = handlerFor(groupKey = "vip", cachedChannels = mapOf(channel.cid to channel)) val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) @@ -98,7 +98,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel belongs to another group and is not cached When ChannelUpdatedEvent arrives Should skip`() { - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "other")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) @@ -110,7 +110,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given handler is for the all group When ChannelUpdatedEvent arrives Should always add`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val handler = handlerFor( groupKey = "all", @@ -126,7 +126,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel does not belong here When NotificationAddedToChannelEvent arrives Should skip`() { - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "other")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) val event = randomNotificationAddedToChannelEvent(cid = channel.cid, channel = channel) @@ -137,7 +137,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel belongs here When NotificationAddedToChannelEvent arrives Should watch and add`() { - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) val event = randomNotificationAddedToChannelEvent(cid = channel.cid, channel = channel) @@ -148,7 +148,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel does not belong here When NotificationMessageNewEvent arrives Should skip`() { - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "other")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) val event = randomNotificationMessageNewEvent(cid = channel.cid, channel = channel) @@ -159,7 +159,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given no cached channel When ChannelVisibleEvent arrives Should route by the event channel`() { - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) val event = randomChannelVisibleEvent(cid = channel.cid, channel = channel) @@ -170,7 +170,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a cached channel When ChannelVisibleEvent carries only type and id Should route by the cached channel`() { - val cachedChannel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val cachedChannel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) val event = randomChannelVisibleEvent( cid = cachedChannel.cid, @@ -184,7 +184,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given no cached channel When ChannelVisibleEvent carries only type and id Should watch and add`() { - val channel = randomChannel(disabled = false) + val channel = randomChannel(disabled = false, blocked = false) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) val event = randomChannelVisibleEvent( cid = channel.cid, @@ -198,7 +198,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a stale cached channel When ChannelVisibleEvent carries the full channel Should route by the event channel`() { - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(extraData = mapOf("group" to "other")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) val event = randomChannelVisibleEvent(cid = channel.cid, channel = channel) @@ -211,7 +211,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given current user joined with matching cached channel When MemberAddedEvent arrives Should add`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val handler = handlerFor( groupKey = "vip", cachedChannels = emptyMap(), @@ -230,7 +230,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given current user joined with non-matching cached channel When MemberAddedEvent arrives Should skip`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "other")) val handler = handlerFor( groupKey = "vip", cachedChannels = emptyMap(), @@ -249,7 +249,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given current user left a cached channel When MemberRemovedEvent arrives Should remove regardless of group`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val handler = handlerFor( groupKey = "vip", cachedChannels = mapOf(channel.cid to channel), @@ -268,7 +268,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given cachedChannel has no membership for current user When ChannelUpdatedEvent moves channel into this group Should skip`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "old")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "old")) val cachedChannel = channel.copy(membership = null) val handler = handlerFor( groupKey = "old", @@ -285,7 +285,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given current user lost membership while channel still resolves here When ChannelUpdatedEvent arrives Should remove`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "old")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "old")) val cachedChannel = channel.copy(membership = null) val handler = handlerFor( groupKey = "old", @@ -302,7 +302,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given cached channel is hidden When ChannelUpdatedEvent moves channel into this group Should skip`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = true) val handler = handlerFor( groupKey = "vip", @@ -319,7 +319,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given cached channel is hidden and is in the list When ChannelUpdatedEvent arrives Should remove`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = true) val handler = handlerFor( groupKey = "vip", @@ -336,7 +336,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel in the list When ChannelUpdatedEvent disables it Should remove`() { val currentUser = randomUser() - val channel = randomChannel(disabled = true, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = true, blocked = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val handler = handlerFor( groupKey = "vip", @@ -353,7 +353,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a disabled channel not in the list When ChannelUpdatedEvent enables it Should add`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false, disabled = true) val handler = handlerFor( groupKey = "vip", @@ -369,7 +369,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a disabled channel When NotificationAddedToChannelEvent arrives Should skip`() { - val channel = randomChannel(disabled = true, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = true, blocked = false, extraData = mapOf("group" to "vip")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) val event = randomNotificationAddedToChannelEvent(cid = channel.cid, channel = channel) @@ -380,7 +380,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given no connected user When ChannelUpdatedEvent arrives Should skip`() { - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(), hidden = false) val handler = handlerFor( groupKey = "vip", @@ -397,7 +397,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given current user has a connection but no cachedChannel When ChannelUpdatedEvent arrives Should skip`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "old")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "old")) val handler = handlerFor( groupKey = "old", cachedChannels = emptyMap(), @@ -412,7 +412,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a cached channel When ChannelDeletedEvent arrives Should remove regardless of group`() { - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val handler = handlerFor( groupKey = "vip", cachedChannels = mapOf(channel.cid to channel), @@ -427,7 +427,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a custom resolver that reads a different field When ChannelUpdatedEvent arrives Should use custom field`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("tier" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("tier" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val customResolver = ChannelGroupResolver { ch -> setOfNotNull(ch.extraData["tier"] as? String) @@ -450,6 +450,7 @@ internal class GroupAwareChatEventHandlerTest { val currentUser = randomUser() val channel = randomChannel( disabled = false, + blocked = false, extraData = mapOf("group" to "expired"), createdAt = daysAgo(30), lastMessageAt = daysAgo(15), @@ -472,6 +473,7 @@ internal class GroupAwareChatEventHandlerTest { val currentUser = randomUser() val channel = randomChannel( disabled = false, + blocked = false, extraData = mapOf("group" to "expired", "touched_at" to daysAgo(1)), createdAt = daysAgo(30), lastMessageAt = daysAgo(20), @@ -494,6 +496,7 @@ internal class GroupAwareChatEventHandlerTest { val currentUser = randomUser() val channel = randomChannel( disabled = false, + blocked = false, extraData = mapOf("group" to "expired"), createdAt = daysAgo(30), lastMessageAt = daysAgo(3), @@ -509,7 +512,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given the all group filter excludes a group When NotificationMessageNewEvent arrives for it Should skip`() { - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "bee"), lastMessageAt = daysAgo(0)) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "bee"), lastMessageAt = daysAgo(0)) val handler = handlerFor(groupKey = "all", cachedChannels = emptyMap()) val event = randomNotificationMessageNewEvent(cid = channel.cid, channel = channel) @@ -522,6 +525,7 @@ internal class GroupAwareChatEventHandlerTest { fun `Given a cached channel past the cutoff When a new message arrives Should add it back`() { val cachedChannel = randomChannel( disabled = false, + blocked = false, extraData = mapOf("group" to "expired"), createdAt = daysAgo(30), lastMessageAt = daysAgo(20), @@ -539,10 +543,96 @@ internal class GroupAwareChatEventHandlerTest { assertEquals(EventHandlingResult.Add(cachedChannel.copy(lastMessageAt = now)), result) } + @Test + fun `Given a hidden cached channel When a campaign message arrives Should skip`() { + val cachedChannel = randomChannel( + disabled = false, + blocked = false, + extraData = mapOf("group" to "vip"), + hidden = true, + ) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val message = channelMessage(createdAt = now).copy(extraData = mapOf("created_by_campaign_id" to "campaign")) + val event = randomNewMessageEvent(cid = cachedChannel.cid, createdAt = now, message = message) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given a hidden cached channel When a regular message arrives Should add it back`() { + val cachedChannel = randomChannel( + disabled = false, + blocked = false, + extraData = mapOf("group" to "vip"), + hidden = true, + lastMessageAt = now, + ) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNewMessageEvent( + cid = cachedChannel.cid, + createdAt = now, + message = channelMessage(createdAt = now), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Add(cachedChannel), result) + } + + @Test + fun `Given a hidden cached channel When NotificationMessageNewEvent from a blocked user arrives Should skip`() { + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) + val sender = randomUser() + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), blockedUserIds = listOf(sender.id)) + val event = randomNotificationMessageNewEvent( + cid = channel.cid, + channel = channel, + message = channelMessage(createdAt = now).copy(user = sender), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = channel.copy(hidden = true)) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given a hidden cached channel When NotificationMessageNewEvent in a blocked channel arrives Should skip`() { + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNotificationMessageNewEvent( + cid = channel.cid, + channel = channel, + message = channelMessage(createdAt = now), + ) + val cachedChannel = channel.copy(hidden = true, blocked = true) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given a hidden cached channel When NotificationMessageNewEvent with a regular message arrives Should watch and add`() { + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNotificationMessageNewEvent( + cid = channel.cid, + channel = channel, + message = channelMessage(createdAt = now), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = channel.copy(hidden = true)) + + assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) + } + @Test fun `Given a cached channel past the cutoff When a thread reply not shown in the channel arrives Should skip`() { val cachedChannel = randomChannel( disabled = false, + blocked = false, extraData = mapOf("group" to "expired"), createdAt = daysAgo(30), lastMessageAt = daysAgo(20), @@ -562,7 +652,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel in the list When a partial update moves it to another group Should remove`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val handler = handlerFor( groupKey = "vip", @@ -579,7 +669,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel in the list When a partial update removes its group Should remove`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val handler = handlerFor( groupKey = "vip", @@ -596,7 +686,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel in the list When a partial update keeps it in the group Should skip`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val handler = handlerFor( groupKey = "vip", @@ -612,7 +702,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel not in local state When a partial update moves it into the group Should watch and add`() { - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "other")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) @@ -624,7 +714,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel in the list When a partial update disables it Should remove`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val handler = handlerFor( groupKey = "vip", @@ -640,7 +730,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel not in local state When a partial update moves it into the group disabled Should skip`() { - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "other")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) val event = partialUpdateOf(channel, custom = mapOf("group" to "vip"), disabled = true) @@ -655,6 +745,7 @@ internal class GroupAwareChatEventHandlerTest { val currentUser = randomUser() val channel = randomChannel( disabled = false, + blocked = false, createdAt = daysAgo(30), lastMessageAt = daysAgo(20), extraData = mapOf("group" to "other"), @@ -671,7 +762,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a cached channel not in the list When a partial update moves it into the group Should watch and add`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "other")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = currentUser) val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) @@ -683,7 +774,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel not in local state When a partial update keeps it in another group Should skip`() { - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "other")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) val event = partialUpdateOf(channel, custom = mapOf("group" to "other")) @@ -695,7 +786,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given the user hid the channel When a partial update moves it into the group Should skip`() { val currentUser = randomUser() - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "other")) val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = true) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = currentUser) val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) @@ -707,7 +798,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a cached channel the user left When a partial update moves it into the group Should skip`() { - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "other")) val cachedChannel = channel.copy(membership = null, hidden = false) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) @@ -722,6 +813,7 @@ internal class GroupAwareChatEventHandlerTest { val currentUser = randomUser() val channel = randomChannel( disabled = false, + blocked = false, createdAt = daysAgo(30), lastMessageAt = daysAgo(1), extraData = mapOf("group" to "vip"), @@ -744,6 +836,7 @@ internal class GroupAwareChatEventHandlerTest { val currentUser = randomUser() val channel = randomChannel( disabled = false, + blocked = false, createdAt = daysAgo(30), lastMessageAt = daysAgo(20), extraData = mapOf("group" to "vip", "touched_at" to daysAgo(1)), @@ -763,7 +856,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel not in local state When a partial update touches it within the cutoff Should watch and add`() { - val channel = randomChannel(disabled = false, createdAt = daysAgo(30), extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, createdAt = daysAgo(30), extraData = mapOf("group" to "vip")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) val event = partialUpdateOf(channel, custom = mapOf("group" to "vip", "touched_at" to daysAgo(1))) @@ -775,7 +868,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel not in local state When a partial update moves it into a filtered group Should watch and add`() { // The last message time is unknown without local data, so the list checks the cutoff after the watch. - val channel = randomChannel(disabled = false, createdAt = daysAgo(30), extraData = mapOf("group" to "other")) + val channel = randomChannel(disabled = false, blocked = false, createdAt = daysAgo(30), extraData = mapOf("group" to "other")) val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) @@ -786,7 +879,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a channel not in local state When a partial update moves it into a group the filter excludes Should skip`() { - val channel = randomChannel(disabled = false, extraData = mapOf("group" to "vip")) + val channel = randomChannel(disabled = false, blocked = false, extraData = mapOf("group" to "vip")) val handler = handlerFor(groupKey = "all", cachedChannels = emptyMap(), currentUser = randomUser()) val event = partialUpdateOf(channel, custom = mapOf("group" to "archived")) val filter = Filters.and(Filters.ne("group", "archived"), cutoff14Days) @@ -828,6 +921,7 @@ internal class GroupAwareChatEventHandlerTest { cachedChannels: Map, resolver: ChannelGroupResolver = defaultResolver, currentUser: User? = null, + blockedUserIds: List = emptyList(), ): GroupAwareChatEventHandler { val clientState = mock { whenever(it.user) doReturn MutableStateFlow(currentUser) @@ -837,6 +931,7 @@ internal class GroupAwareChatEventHandlerTest { resolver = resolver, channels = MutableStateFlow(cachedChannels), clientState = clientState, + blockedUserIds = { blockedUserIds }, now = { now }, ) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialHiddenChannelTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialHiddenChannelTest.kt index c5fcd950d1ac..8d9195285e12 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialHiddenChannelTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialHiddenChannelTest.kt @@ -37,6 +37,7 @@ import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.Member +import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.toChannelData @@ -192,6 +193,57 @@ internal class EventHandlerSequentialHiddenChannelTest { groupedState.rawChannels.orEmpty().keys `should contain` CID } + @ParameterizedTest(name = "legacy channel state: {0}") + @ValueSource(booleans = [false, true]) + fun `a campaign message does not bring a hidden channel back to a grouped query`(legacy: Boolean) = runTest { + val fixture = Fixture(legacy) + fixture.withActiveChannel(CHANNEL_TYPE, CHANNEL_ID, randomMember(user = fixture.currentUser)) + val groupedState = fixture.withLoadedGroupedQuery(groupKey = "all") + val eventHandler = fixture.get() + + eventHandler.handleEvents(hiddenEvent(fixture.currentUser)) + eventHandler.handleEvents( + newMessageEvent( + randomMessage( + type = "regular", + shadowed = false, + extraData = mapOf("created_by_campaign_id" to "campaign-id"), + ), + ), + ) + + groupedState.rawChannels.orEmpty().keys `should not contain` CID + } + + @ParameterizedTest(name = "legacy channel state: {0}") + @ValueSource(booleans = [false, true]) + fun `a regular message brings a hidden channel back to a grouped query`(legacy: Boolean) = runTest { + val fixture = Fixture(legacy) + fixture.withActiveChannel(CHANNEL_TYPE, CHANNEL_ID, randomMember(user = fixture.currentUser)) + val groupedState = fixture.withLoadedGroupedQuery(groupKey = "all") + val eventHandler = fixture.get() + + eventHandler.handleEvents(hiddenEvent(fixture.currentUser)) + eventHandler.handleEvents(newMessageEvent(randomMessage(type = "regular", shadowed = false))) + + groupedState.rawChannels.orEmpty().keys `should contain` CID + } + + private fun hiddenEvent(user: User) = randomChannelHiddenEvent( + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + user = user, + clearHistory = false, + ) + + private fun newMessageEvent(message: Message) = randomNewMessageEvent( + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + message = message, + ) + private fun updatedChannel(): Channel = randomChannel( id = CHANNEL_ID, type = CHANNEL_TYPE, @@ -232,7 +284,13 @@ internal class EventHandlerSequentialHiddenChannelTest { /** Watches the channel the way an open chat screen does, seeding the current user's membership. */ fun withActiveChannel(channelType: String, channelId: String, membership: Member) { logicRegistry.channel(channelType, channelId) - val channelData = randomChannel(id = channelId, type = channelType, membership = membership) + val channelData = randomChannel( + id = channelId, + type = channelType, + membership = membership, + disabled = false, + blocked = false, + ) .toChannelData() if (legacyChannelState) { stateRegistry.legacyChannelState(channelType, channelId).setChannelData(channelData) @@ -264,6 +322,13 @@ internal class EventHandlerSequentialHiddenChannelTest { return stateRegistry.queryChannels(identifier).toMutableState() } + /** Registers a grouped query with a loaded page that holds another channel. */ + suspend fun withLoadedGroupedQuery(groupKey: String): QueryChannelsMutableState { + val identifier = QueryChannelsIdentifier.Grouped(groupKey) + logicRegistry.queryChannels(identifier).addChannel(randomChannel(hidden = false)) + return stateRegistry.queryChannels(identifier).toMutableState() + } + fun get() = EventHandlerSequential( currentUserId = currentUser.id, subscribeForEvents = { EventHandlerSequential.EMPTY_DISPOSABLE }, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt index 090dff9b5779..a7f89333c006 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt @@ -47,9 +47,11 @@ import io.getstream.chat.android.client.test.randomNotificationRemovedFromChanne import io.getstream.chat.android.client.test.randomPollDeletedEvent import io.getstream.chat.android.client.test.randomThreadUpdatedEvent import io.getstream.chat.android.client.utils.observable.Disposable +import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelCapabilities import io.getstream.chat.android.models.ChannelMute import io.getstream.chat.android.models.Location +import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Mute import io.getstream.chat.android.models.User import io.getstream.chat.android.positiveRandomInt @@ -72,6 +74,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.amshove.kluent.`should be equal to` @@ -94,6 +97,7 @@ import org.mockito.kotlin.whenever import java.util.concurrent.atomic.AtomicReference @kotlinx.coroutines.ExperimentalCoroutinesApi +@Suppress("LargeClass") internal class EventHandlerSequentialTest { @ParameterizedTest @@ -747,6 +751,52 @@ internal class EventHandlerSequentialTest { } } + @Test + fun `When handling NewMessageEvent with a campaign message, The stored hidden channel should stay hidden`() = + runTest { + val message = randomMessage( + type = "regular", + shadowed = false, + extraData = mapOf("created_by_campaign_id" to "campaign-id"), + ) + + storedHiddenChannelAfter(message).hidden `should be equal to` true + } + + @Test + fun `When handling NewMessageEvent in a blocked channel, The stored hidden channel should stay hidden`() = + runTest { + val message = randomMessage(type = "regular", shadowed = false, extraData = emptyMap()) + + storedHiddenChannelAfter(message, blocked = true).hidden `should be equal to` true + } + + @Test + fun `When handling NewMessageEvent with a regular message, The stored hidden channel should be shown`() = runTest { + val message = randomMessage(type = "regular", shadowed = false, extraData = emptyMap()) + + storedHiddenChannelAfter(message).hidden `should be equal to` false + } + + /** Handles a new [message] in a stored hidden channel and returns the channel as it is stored again. */ + private suspend fun TestScope.storedHiddenChannelAfter(message: Message, blocked: Boolean = false): Channel { + val storedChannel = randomChannel(hidden = true, blocked = blocked, extraData = emptyMap()) + val repos: RepositoryFacade = mock() + whenever(repos.selectMessages(any())) doReturn emptyList() + whenever(repos.selectChannels(any())) doReturn listOf(storedChannel) + whenever(repos.selectThreads(any())) doReturn emptyList() + whenever(repos.selectChannel(storedChannel.cid)) doReturn storedChannel + val handler = Fixture() + .withRepositoryFacade(repos) + .get(this) + + handler.handleEvents(randomNewMessageEvent(cid = storedChannel.cid, message = message)) + + val inserted = argumentCaptor>() + verify(repos).insertChannels(inserted.capture()) + return inserted.firstValue.single() + } + private fun queryChannelsLogicHandlingNoEvents(): QueryChannelsLogic = mock { onBlocking { parseChatEventResults(any()) } doReturn emptyList() } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImplTest.kt index ebe35498bd74..591639d16676 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImplTest.kt @@ -78,6 +78,7 @@ import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelData import io.getstream.chat.android.models.EventType import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.MessageType import io.getstream.chat.android.models.TypingEvent import io.getstream.chat.android.models.TypingUser import io.getstream.chat.android.models.toChannelData @@ -132,8 +133,11 @@ internal class ChannelEventHandlerImplTest { state = mock { on(it.cid) doReturn cid on(it.insideSearch) doReturn MutableStateFlow(false) + on(it.channelData) doReturn MutableStateFlow(randomChannel(blocked = false).toChannelData()) + } + globalState = mock { + on(it.blockedUserIds) doReturn MutableStateFlow(emptyList()) } - globalState = mock() getCurrentUserId = { currentUserId } handler = ChannelEventHandlerImpl( cid = cid, @@ -173,7 +177,7 @@ internal class ChannelEventHandlerImplTest { @Test fun `When NewMessageEvent with non-shadowed message is handled, Then channel is unhidden`() { - val message = randomMessage(shadowed = false) + val message = randomMessage(type = MessageType.REGULAR, shadowed = false) val event = randomNewMessageEvent(cid = cid, message = message) handler.handle(event) @@ -246,11 +250,44 @@ internal class ChannelEventHandlerImplTest { // endregion + @Test + fun `When NewMessageEvent with a system message is handled, Then channel is not unhidden`() { + val message = randomMessage(type = MessageType.SYSTEM, shadowed = false) + val event = randomNewMessageEvent(cid = cid, message = message) + + handler.handle(event) + + verify(state, never()).setHidden(any()) + } + + @Test + fun `When NewMessageEvent is handled in a blocked channel, Then channel is not unhidden`() { + whenever(state.channelData).thenReturn(MutableStateFlow(randomChannel(blocked = true).toChannelData())) + val message = randomMessage(type = MessageType.REGULAR, shadowed = false) + val event = randomNewMessageEvent(cid = cid, message = message) + + handler.handle(event) + + verify(state, never()).setHidden(any()) + } + + @Test + fun `When NotificationMessageNewEvent from a blocked user is handled, Then channel is not unhidden`() { + val sender = randomUser() + whenever(globalState.blockedUserIds).thenReturn(MutableStateFlow(listOf(sender.id))) + val message = randomMessage(type = MessageType.REGULAR, shadowed = false, user = sender) + val event = randomNotificationMessageNewEvent(cid = cid, message = message) + + handler.handle(event) + + verify(state, never()).setHidden(any()) + } + // region NotificationMessageNewEvent tests @Test fun `When NotificationMessageNewEvent is handled and not inside search, Then message is upserted`() { - val message = randomMessage() + val message = randomMessage(type = MessageType.REGULAR, shadowed = false) whenever(state.insideSearch).thenReturn(MutableStateFlow(false)) val event = randomNotificationMessageNewEvent(cid = cid, message = message) @@ -263,7 +300,7 @@ internal class ChannelEventHandlerImplTest { @Test fun `When NotificationMessageNewEvent is handled and inside search, Then message is upserted to cached messages`() { - val message = randomMessage() + val message = randomMessage(type = MessageType.REGULAR, shadowed = false) whenever(state.insideSearch).thenReturn(MutableStateFlow(true)) val event = randomNotificationMessageNewEvent(cid = cid, message = message) @@ -499,7 +536,7 @@ internal class ChannelEventHandlerImplTest { @Test fun `When NotificationThreadMessageNewEvent is handled with showInChannel, Then message is upserted`() { - val message = randomMessage(showInChannel = true, shadowed = false) + val message = randomMessage(type = MessageType.REPLY, showInChannel = true, shadowed = false) val event = NotificationThreadMessageNewEvent( type = EventType.NOTIFICATION_THREAD_MESSAGE_NEW, createdAt = randomDate(), @@ -543,7 +580,7 @@ internal class ChannelEventHandlerImplTest { @Test fun `When NotificationThreadMessageNewEvent is handled and inside search, Then message is upserted to cached`() { - val message = randomMessage(showInChannel = true, shadowed = false) + val message = randomMessage(type = MessageType.REPLY, showInChannel = true, shadowed = false) whenever(state.insideSearch).thenReturn(MutableStateFlow(true)) val event = NotificationThreadMessageNewEvent( type = EventType.NOTIFICATION_THREAD_MESSAGE_NEW, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImplTest.kt index d88dea129cc5..3527f323a521 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImplTest.kt @@ -158,23 +158,13 @@ internal class ChannelEventHandlerLegacyImplTest { } @Test - fun `When NewMessageEvent with non-shadowed message is handled, Then channel is unhidden`() { - val message = randomMessage(shadowed = false) - val event = randomNewMessageEvent(cid = cid, message = message) - - handler.handle(event) - - verify(stateLogic).setHidden(false) - } - - @Test - fun `When NewMessageEvent with shadowed message is handled, Then channel is not unhidden`() { - val message = randomMessage(shadowed = true) + fun `When NewMessageEvent is handled, Then the message decides whether the channel is shown again`() { + val message = randomMessage() val event = randomNewMessageEvent(cid = cid, message = message) handler.handle(event) - verify(stateLogic, never()).setHidden(false) + verify(stateLogic).unhideFor(message) } @Test @@ -326,7 +316,7 @@ internal class ChannelEventHandlerLegacyImplTest { verify(stateLogic).upsertMessage(message) verify(stateLogic).updateCurrentUserRead(event.createdAt, message) - verify(stateLogic).setHidden(false) + verify(stateLogic).unhideFor(message) } @Test @@ -339,7 +329,7 @@ internal class ChannelEventHandlerLegacyImplTest { verify(stateLogic, never()).upsertMessage(message) verify(stateLogic).updateCurrentUserRead(event.createdAt, message) - verify(stateLogic).setHidden(false) + verify(stateLogic).unhideFor(message) } @Test diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt index aa7c9f2d5dd5..420bf6199d45 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt @@ -107,7 +107,7 @@ internal class ChannelStateLogicTest { lastRead = Date(Long.MIN_VALUE), lastReadMessageId = randomString(), ) - _channelData.value = randomChannel().toChannelData() + _channelData.value = randomChannel(blocked = false).toChannelData() _reads = emptyMap() _insideSearch.value = false _watcherCount.value = 0 @@ -475,6 +475,36 @@ internal class ChannelStateLogicTest { channelStateLogic.getPoll(poll.id) `should be equal to` null } + @Test + fun `Given a regular message, When calling unhideFor, Then the channel is shown`() { + val message = randomMessage(type = MessageType.REGULAR, shadowed = false, extraData = emptyMap()) + + channelStateLogic.unhideFor(message) + + verify(mutableState).setHidden(false) + } + + @Test + fun `Given a message from a blocked user, When calling unhideFor, Then the channel stays hidden`() { + val sender = randomUser() + spyMutableGlobalState.setBlockedUserIds(listOf(sender.id)) + val message = randomMessage(type = MessageType.REGULAR, shadowed = false, extraData = emptyMap(), user = sender) + + channelStateLogic.unhideFor(message) + + verify(mutableState, never()).setHidden(any()) + } + + @Test + fun `Given a blocked channel, When calling unhideFor, Then the channel stays hidden`() { + _channelData.value = randomChannel(blocked = true).toChannelData() + val message = randomMessage(type = MessageType.REGULAR, shadowed = false, extraData = emptyMap()) + + channelStateLogic.unhideFor(message) + + verify(mutableState, never()).setHidden(any()) + } + @Test fun `Given user has no messages, When calling deleteMessagesFromUser, Then no action is taken`() { // given diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelVisibilityTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelVisibilityTest.kt new file mode 100644 index 000000000000..7c87d7cc14ee --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelVisibilityTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.internal.state.utils.internal + +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.MessageType +import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomString +import io.getstream.chat.android.randomUser +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class ChannelVisibilityTest { + + @Test + fun `A regular message un-hides the channel`() { + assertTrue(message(type = MessageType.REGULAR).unhidesChannel(channelBlocked = null, emptyList())) + } + + @Test + fun `A reply un-hides the channel`() { + assertTrue(message(type = MessageType.REPLY).unhidesChannel(channelBlocked = false, emptyList())) + } + + @Test + fun `A system message keeps the channel hidden`() { + assertFalse(message(type = MessageType.SYSTEM).unhidesChannel(channelBlocked = false, emptyList())) + } + + @Test + fun `A shadowed message keeps the channel hidden`() { + assertFalse(message(shadowed = true).unhidesChannel(channelBlocked = false, emptyList())) + } + + @Test + fun `A campaign message keeps the channel hidden`() { + val message = message(extraData = mapOf("created_by_campaign_id" to randomString())) + + assertFalse(message.unhidesChannel(channelBlocked = false, emptyList())) + } + + @Test + fun `A message in a blocked channel keeps the channel hidden`() { + assertFalse(message().unhidesChannel(channelBlocked = true, emptyList())) + } + + @Test + fun `A message from a blocked user keeps the channel hidden`() { + val message = message() + + assertFalse(message.unhidesChannel(channelBlocked = false, listOf(message.user.id))) + } + + private fun message( + type: String = MessageType.REGULAR, + shadowed: Boolean = false, + extraData: Map = emptyMap(), + ): Message = randomMessage(type = type, shadowed = shadowed, extraData = extraData, user = randomUser()) +} From 63ac2cef8026c753fa2edd67660d8f0e006a5109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 21 Oct 2026 14:48:30 +0100 Subject: [PATCH 3/7] Keep the stored hidden state on events with shared channels The channels in notification.message_new, channel.updated, channel.updated_by_user and both truncated events are shared by all members, so they carry no hidden value. The stored hidden state is now kept, so a later message that does not un-hide the channel keeps it hidden. The stored blocked state was already kept. --- .../internal/EventHandlerSequential.kt | 17 ++- .../internal/EventHandlerSequentialTest.kt | 107 +++++++++++++++++- 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt index b47458a6f55c..88151a6bc72d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt @@ -703,7 +703,7 @@ internal class EventHandlerSequential( is NotificationMessageNewEvent -> { batch.addMessageData(event.createdAt, event.cid, event.message) val channel = batch.getCurrentChannel(event.cid) - ?.mergeChannelFromEvent(event.channel) ?: event.channel + ?.mergeSharedEventChannel(event.channel) ?: event.channel batch.addChannel(channel.copy(hidden = channel.hiddenAfter(event.message))) } is NotificationAddedToChannelEvent -> { @@ -818,12 +818,12 @@ internal class EventHandlerSequential( } is ChannelUpdatedEvent -> { val channel = batch.getCurrentChannel(event.cid) - ?.mergeChannelFromEvent(event.channel) ?: event.channel + ?.mergeSharedEventChannel(event.channel) ?: event.channel batch.addChannel(channel) } is ChannelUpdatedByUserEvent -> { val channel = batch.getCurrentChannel(event.cid) - ?.mergeChannelFromEvent(event.channel) ?: event.channel + ?.mergeSharedEventChannel(event.channel) ?: event.channel batch.addChannel(channel) } is NotificationChannelPartialUpdatedEvent -> { @@ -839,7 +839,7 @@ internal class EventHandlerSequential( } is ChannelTruncatedEvent -> { val channel = batch.getCurrentChannel(event.cid) - ?.mergeChannelFromEvent(event.channel) ?: event.channel + ?.mergeSharedEventChannel(event.channel) ?: event.channel batch.addChannel(channel) } is NotificationChannelDeletedEvent -> { @@ -849,7 +849,7 @@ internal class EventHandlerSequential( } is NotificationChannelTruncatedEvent -> { val channel = batch.getCurrentChannel(event.cid) - ?.mergeChannelFromEvent(event.channel) ?: event.channel + ?.mergeSharedEventChannel(event.channel) ?: event.channel batch.addChannel(channel) } @@ -989,6 +989,13 @@ internal class EventHandlerSequential( logger.v { "[updateOfflineStorage] completed batchId: ${batchEvent.id}" } } + /** + * This stored channel with the channel of an event applied. The event's channel is shared by all members, so it + * carries no `hidden` value of the current user, and the stored one is kept. + */ + private fun Channel.mergeSharedEventChannel(eventChannel: Channel): Channel = + mergeChannelFromEvent(eventChannel).copy(hidden = eventChannel.hidden ?: hidden) + /** The hidden state of this channel after the new [message]. */ private fun Channel.hiddenAfter(message: Message): Boolean? = if (message.unhidesChannel(blocked, mutableGlobalState.blockedUserIds.value)) false else hidden diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt index a7f89333c006..75a03a22c8c5 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt @@ -28,6 +28,8 @@ import io.getstream.chat.android.client.internal.state.plugin.logic.querychannel import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.client.test.randomChannelTruncatedEvent +import io.getstream.chat.android.client.test.randomChannelUpdatedByUserEvent import io.getstream.chat.android.client.test.randomChannelUpdatedEvent import io.getstream.chat.android.client.test.randomConnectedEvent import io.getstream.chat.android.client.test.randomDraftMessageDeletedEvent @@ -768,7 +770,9 @@ internal class EventHandlerSequentialTest { runTest { val message = randomMessage(type = "regular", shadowed = false, extraData = emptyMap()) - storedHiddenChannelAfter(message, blocked = true).hidden `should be equal to` true + val storedChannel = randomChannel(hidden = true, blocked = true, extraData = emptyMap()) + + storedHiddenChannelAfter(message, storedChannel = storedChannel).hidden `should be equal to` true } @Test @@ -778,9 +782,82 @@ internal class EventHandlerSequentialTest { storedHiddenChannelAfter(message).hidden `should be equal to` false } + @Test + fun `When handling NotificationMessageNewEvent with a campaign message, The stored hidden channel should stay hidden`() = + runTest { + val message = randomMessage( + type = "regular", + shadowed = false, + extraData = mapOf("created_by_campaign_id" to "campaign-id"), + ) + + storedHiddenChannelAfter(message, ::notificationMessageNewIn).hidden `should be equal to` true + } + + @Test + fun `When handling NotificationMessageNewEvent in a blocked channel, The stored hidden channel should stay hidden`() = + runTest { + val message = randomMessage(type = "regular", shadowed = false, extraData = emptyMap()) + val storedChannel = randomChannel(hidden = true, blocked = true, extraData = emptyMap()) + + val result = storedHiddenChannelAfter(message, ::notificationMessageNewIn, storedChannel) + + result.hidden `should be equal to` true + result.blocked `should be equal to` true + } + + @Test + fun `When handling NotificationMessageNewEvent with a regular message, The stored hidden channel should be shown`() = + runTest { + val message = randomMessage(type = "regular", shadowed = false, extraData = emptyMap()) + + storedHiddenChannelAfter(message, ::notificationMessageNewIn).hidden `should be equal to` false + } + + @ParameterizedTest(name = "{0}") + @MethodSource("sharedChannelEventArguments") + fun `When handling an event with a shared channel, The stored hidden and blocked state should be kept`( + eventType: String, + eventOf: (cid: String, channel: Channel) -> ChatEvent, + ) = runTest { + val storedChannel = randomChannel(hidden = true, blocked = true, extraData = mapOf("group" to "a")) + + val result = storedChannelAfter(storedChannel, eventOf(storedChannel.cid, sharedEventChannelOf(storedChannel))) + + result.hidden `should be equal to` true + result.blocked `should be equal to` true + result.extraData `should be equal to` mapOf("group" to "b") + } + + /** The channel of an event as the backend sends it: shared by all members, so with no state of the current user. */ + private fun sharedEventChannelOf(storedChannel: Channel): Channel = randomChannel( + id = storedChannel.id, + type = storedChannel.type, + hidden = null, + blocked = null, + extraData = mapOf("group" to "b"), + ) + + /** A `notification.message_new` event whose channel, like the backend's, carries no state of the current user. */ + private fun notificationMessageNewIn(channel: Channel, message: Message): ChatEvent = + randomNotificationMessageNewEvent( + cid = channel.cid, + channel = Channel(id = channel.id, type = channel.type), + message = message, + ) + + private fun newMessageIn(channel: Channel, message: Message): ChatEvent = + randomNewMessageEvent(cid = channel.cid, message = message) + /** Handles a new [message] in a stored hidden channel and returns the channel as it is stored again. */ - private suspend fun TestScope.storedHiddenChannelAfter(message: Message, blocked: Boolean = false): Channel { - val storedChannel = randomChannel(hidden = true, blocked = blocked, extraData = emptyMap()) + private suspend fun TestScope.storedHiddenChannelAfter( + message: Message, + eventIn: (Channel, Message) -> ChatEvent = ::newMessageIn, + storedChannel: Channel = randomChannel(hidden = true, blocked = false, extraData = emptyMap()), + ): Channel = storedChannelAfter(storedChannel, eventIn(storedChannel, message)) + + /** Handles [event] for [storedChannel] and returns the channel as it is stored again. */ + private suspend fun TestScope.storedChannelAfter(storedChannel: Channel, event: ChatEvent): Channel { val repos: RepositoryFacade = mock() whenever(repos.selectMessages(any())) doReturn emptyList() whenever(repos.selectChannels(any())) doReturn listOf(storedChannel) @@ -790,7 +867,7 @@ internal class EventHandlerSequentialTest { .withRepositoryFacade(repos) .get(this) - handler.handleEvents(randomNewMessageEvent(cid = storedChannel.cid, message = message)) + handler.handleEvents(event) val inserted = argumentCaptor>() verify(repos).insertChannels(inserted.capture()) @@ -1103,6 +1180,28 @@ internal class EventHandlerSequentialTest { ) } + @JvmStatic + fun sharedChannelEventArguments() = listOf( + Arguments.of( + "channel.updated", + { cid: String, channel: Channel -> randomChannelUpdatedEvent(cid = cid, channel = channel) }, + ), + Arguments.of( + "channel.updated_by_user", + { cid: String, channel: Channel -> randomChannelUpdatedByUserEvent(cid = cid, channel = channel) }, + ), + Arguments.of( + "channel.truncated", + { cid: String, channel: Channel -> randomChannelTruncatedEvent(cid = cid, channel = channel) }, + ), + Arguments.of( + "notification.channel_truncated", + { cid: String, channel: Channel -> + randomNotificationChannelTruncatedEvent(cid = cid, channel = channel) + }, + ), + ) + @JvmStatic fun sharedLocationArguments() = listOf( run { From bab2b78e44fdfc332945525c44432738d6ba6972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 21 Oct 2026 14:55:44 +0100 Subject: [PATCH 4/7] Do not watch partially updated channels for grouped lists queried without watch A partial update that moves a channel into a grouped list queried without watch now adds the stored copy with the update applied, instead of watching the channel. A channel with no local copy is skipped and shows when its page loads. --- .../internal/GroupAwareChatEventHandler.kt | 2 +- .../internal/GroupedPartialUpdateWatch.kt | 19 ++++++++ .../internal/QueryChannelsLogic.kt | 2 + .../QueryChannelsLogicGroupedWatchTest.kt | 43 +++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt index 2e13c17aa074..2894582d19b8 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt @@ -145,7 +145,7 @@ internal class GroupAwareChatEventHandler( * channel only the group is checked: without [cachedChannel] the last activity is unknown, and a stored copy can * be stale, so the cutoff is left to the list, which checks the watched channel before showing it. The event is * only sent to members, and a hidden channel is dropped after the watch. The query skips the watch when the - * channel sorts below its loaded pages. + * channel sorts below its loaded pages, and does not watch it when the list was not queried with `watch`. */ private fun routePartialUpdate( event: NotificationChannelPartialUpdatedEvent, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt index 14bb5b9e256f..09c88cf85afb 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt @@ -43,6 +43,25 @@ internal fun EventHandlingResult.skipWatchBelowLoadedChannels( return if (state.isWithinLoadedChannels(channel)) this else EventHandlingResult.Skip } +/** + * Drops the watch from a [EventHandlingResult.WatchAndAdd] for a partial channel update when the grouped list was not + * queried with `watch`. A channel with a local copy is added from that copy with the update applied. A channel without + * one is skipped and shown when its page loads. Other results and events are returned unchanged. + * + * @param event The event that produced this result. + * @param cachedChannel The local copy of the event's channel, or `null` when there is none. + * @param watch Whether the grouped list was queried with `watch`. + */ +internal fun EventHandlingResult.withoutWatchUnlessListWatches( + event: ChatEvent, + cachedChannel: Channel?, + watch: Boolean, +): EventHandlingResult { + if (this !is EventHandlingResult.WatchAndAdd || event !is NotificationChannelPartialUpdatedEvent) return this + if (watch) return this + return cachedChannel?.mergePartialUpdate(event.channel)?.let(EventHandlingResult::Add) ?: EventHandlingResult.Skip +} + /** * Whether [channel] sorts within the loaded channels: the group has no more pages, or the channel sorts at or above * the last loaded channel. Without local data the channel has no last message time, so its position is a lower bound: diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt index 86d6c2b96598..d31ae314729b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt @@ -688,6 +688,8 @@ internal class QueryChannelsLogic( when (identifier) { is QueryChannelsIdentifier.Grouped -> result.skipWatchBelowLoadedChannels(event, channel, queryChannelsStateLogic.getState()) + // Without a known request, the list is treated as watching, as the reconnect sync does. + .withoutWatchUnlessListWatches(event, channel, watch = groupedQueryConfig()?.watch ?: true) else -> result } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt index f66e48a71e2d..96ae9ad91a46 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt @@ -19,9 +19,11 @@ package io.getstream.chat.android.client.internal.state.plugin.logic.querychanne import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.event.EventHandlingResult import io.getstream.chat.android.client.api.state.QueryChannelsState +import io.getstream.chat.android.client.api.state.querychannels.GroupedQueryConfig import io.getstream.chat.android.client.channel.ChannelClient import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier +import io.getstream.chat.android.client.internal.state.utils.internal.mergePartialUpdate import io.getstream.chat.android.client.test.randomNotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.test.randomNotificationMessageNewEvent import io.getstream.chat.android.models.Channel @@ -124,6 +126,47 @@ internal class QueryChannelsLogicGroupedWatchTest { assertEquals(listOf(EventHandlingResult.WatchAndAdd(event.cid)), results) } + @Test + fun `a partial update skips a channel without a local copy when the list was queried without watch`() = runTest { + givenLoadedChannels(lastLoadedAt = daysAgo(5), endOfChannels = false) + givenGroupedQueryWatch(watch = false) + val event = partialUpdate(createdAt = daysAgo(1)) + + val results = logic.parseChatEventResults(listOf(event)) + + assertEquals(listOf(EventHandlingResult.Skip), results) + } + + @Test + fun `a partial update adds a stored channel without watching it when the list was queried without watch`() = + runTest { + givenLoadedChannels(lastLoadedAt = daysAgo(5), endOfChannels = false) + givenGroupedQueryWatch(watch = false) + val event = partialUpdate(createdAt = daysAgo(1)) + val stored = randomChannel(id = event.channelId, type = event.channelType, createdAt = daysAgo(1)) + whenever(queryChannelsDatabaseLogic.selectChannels(listOf(event.cid))) doReturn listOf(stored) + + val results = logic.parseChatEventResults(listOf(event)) + + assertEquals(listOf(EventHandlingResult.Add(stored.mergePartialUpdate(event.channel))), results) + } + + @Test + fun `a partial update watches a channel without a local copy when the list was queried with watch`() = runTest { + givenLoadedChannels(lastLoadedAt = daysAgo(5), endOfChannels = false) + givenGroupedQueryWatch(watch = true) + val event = partialUpdate(createdAt = daysAgo(1)) + + val results = logic.parseChatEventResults(listOf(event)) + + assertEquals(listOf(EventHandlingResult.WatchAndAdd(event.cid)), results) + } + + private fun givenGroupedQueryWatch(watch: Boolean) { + whenever(queryChannelsStateLogic.getGroupedQueryConfig()) doReturn + GroupedQueryConfig(limit = null, pageSize = null, watch = watch, presence = false) + } + private suspend fun givenLoadedChannels(lastLoadedAt: Date, endOfChannels: Boolean) { val loaded = listOf( randomChannel(createdAt = lastLoadedAt, lastMessageAt = null), From fcfe66657097d314035028d323b22c48d06ada44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 21 Oct 2026 15:09:58 +0100 Subject: [PATCH 5/7] Create the channel state for channels that events add to a list A channel that an event adds to a list without active state is now added like a channel from a query result: its state is created and the query is stored, so later events and restarts keep it up to date. A channel with active state is still only tracked, so a stored copy does not replace its live state. --- .../internal/EventHandlerSequential.kt | 9 +++----- .../internal/QueryChannelsLogic.kt | 11 +++++++++ .../internal/QueryChannelsStateLogic.kt | 4 ++++ .../internal/EventHandlerSequentialTest.kt | 16 +++++++++++++ .../internal/QueryChannelsLogicTest.kt | 23 +++++++++++++++++++ .../internal/QueryChannelsStateLogicTest.kt | 16 +++++++++++++ 6 files changed, 73 insertions(+), 6 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt index 88151a6bc72d..6ad0f6dd8045 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt @@ -319,12 +319,9 @@ internal class EventHandlerSequential( queryChannelsLogic.parseChatEventResults(batchEvent.sortedEvents).forEach { result -> when (result) { is EventHandlingResult.Add -> { - // Use trackChannel instead of addChannel to avoid overwriting the shared - // per-channel state with a potentially stale DB-cached channel. - // Channel events have already updated per-channel state (e.g., lastMessageAt) - // before this method runs, and refreshChannelsState below will reconcile - // the query map with the live per-channel data. - queryChannelsLogic.trackChannel(result.channel) + // Channel events have already updated the state of active channels before this method runs, and + // refreshChannelsState below reconciles the query map with it. + queryChannelsLogic.addChannelFromEvent(result.channel) } is EventHandlingResult.WatchAndAdd -> queryChannelsLogic.watchAndAddChannel(result.cid) is EventHandlingResult.Remove -> queryChannelsLogic.removeChannel(result.cid) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt index d31ae314729b..f4726dc8f393 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt @@ -291,6 +291,17 @@ internal class QueryChannelsLogic( queryChannelsStateLogic.trackChannel(channel) } + /** + * Adds [channel], resolved from an event, to the query. A channel with active state is only tracked, so its live + * state is not replaced by a possibly stale copy. Any other channel is added like a channel from a query result, + * which creates its state and stores the query. + * + * @param channel The channel to add. + */ + internal suspend fun addChannelFromEvent(channel: Channel) { + if (queryChannelsStateLogic.isActiveChannel(channel.cid)) trackChannel(channel) else addChannel(channel) + } + /** * Calls watch channel and adds result to the query. A grouped query does not add the channel when it is hidden. * diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt index dedc1451b0a4..2409356f9ff4 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt @@ -306,6 +306,10 @@ internal class QueryChannelsStateLogic( mutableState.setChannels(newChannels) } + /** Whether the channel with [cid] has active in-memory state. */ + internal fun isActiveChannel(cid: String): Boolean = + ChannelId.fromCid(cid)?.let(stateRegistry::isActiveChannel) == true + /** * Returns the current [Channel] snapshot from the in-memory per-channel state if the * channel is active, or `null` otherwise. diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt index 75a03a22c8c5..271fb2729742 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt @@ -20,6 +20,7 @@ import app.cash.turbine.test import io.getstream.chat.android.client.ChatEventListener import io.getstream.chat.android.client.api.MessageBufferConfig import io.getstream.chat.android.client.api.MessageBufferOverflow +import io.getstream.chat.android.client.api.event.EventHandlingResult import io.getstream.chat.android.client.api.state.StateRegistry import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.internal.state.event.handler.internal.batch.BatchEvent @@ -317,6 +318,21 @@ internal class EventHandlerSequentialTest { verify(queryChannelsLogic).handleMarkAllRead() } + @Test + fun `An event that adds a channel to a query adds it as a channel from an event`() = runTest { + val channel = randomChannel() + val queryChannelsLogic: QueryChannelsLogic = mock { + onBlocking { parseChatEventResults(any()) } doReturn listOf(EventHandlingResult.Add(channel)) + } + val handler = Fixture() + .withActiveQueryChannelsLogic(queryChannelsLogic) + .get(this) + + handler.handleEvents(randomChannelUpdatedEvent(cid = channel.cid, channel = channel)) + + verify(queryChannelsLogic).addChannelFromEvent(channel) + } + @Test fun `NotificationMarkReadEvent keeps the unread channels not loaded of active queries`() = runTest { val queryChannelsLogic = queryChannelsLogicHandlingNoEvents() diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt index 9722cd891119..eee965d508e3 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt @@ -592,5 +592,28 @@ internal class QueryChannelsLogicTest { assertEquals(request, logic.currentRequest()) } + @Test + fun `addChannelFromEvent tracks a channel with active state`() = runTest { + val channel = randomChannel() + whenever(queryChannelsStateLogic.isActiveChannel(channel.cid)) doReturn true + + logic.addChannelFromEvent(channel) + + verify(queryChannelsStateLogic).trackChannel(channel) + verify(queryChannelsStateLogic, never()).addChannelsState(any()) + } + + @Test + fun `addChannelFromEvent adds and stores a channel without active state`() = runTest { + val channel = randomChannel() + whenever(queryChannelsStateLogic.isActiveChannel(channel.cid)) doReturn false + + logic.addChannelFromEvent(channel) + + verify(queryChannelsStateLogic).addChannelsState(listOf(channel)) + verify(queryChannelsDatabaseLogic).insertQueryChannels(queryChannelsSpec) + verify(queryChannelsStateLogic, never()).trackChannel(any()) + } + // endregion } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt index efa960a56624..68297a499593 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt @@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -238,6 +239,21 @@ internal class QueryChannelsStateLogicTest { assertNull(queryChannelsStateLogic.getActiveChannelState("not-a-cid")) } + @Test + fun `isActiveChannel reads the active state from the state registry`() { + whenever(stateRegistry.isActiveChannel(testChannelId)) doReturn true + + assertTrue(queryChannelsStateLogic.isActiveChannel(testCid)) + } + + @Test + fun `isActiveChannel is false for a channel without active state or a malformed cid`() { + whenever(stateRegistry.isActiveChannel(testChannelId)) doReturn false + + assertFalse(queryChannelsStateLogic.isActiveChannel(testCid)) + assertFalse(queryChannelsStateLogic.isActiveChannel("not-a-cid")) + } + @Test fun `addChannelsState drops channels with malformed cid`() = runTest { val valid = randomChannel(type = "messaging", id = "valid-${randomString()}") From a0925fa52ae4135950346f13ac5ca2e0eeee62ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 21 Oct 2026 15:20:18 +0100 Subject: [PATCH 6/7] Do not move group badges for hidden or disabled channels The backend does not count hidden or disabled channels in grouped unread counts, so a group change of such a channel no longer moves a badge. --- .../internal/GroupedUnreadChannelsUpdater.kt | 3 +- .../GroupedUnreadChannelsUpdaterTest.kt | 32 +++++++++++++++++++ ...EventHandlerSequentialPartialUpdateTest.kt | 2 ++ .../internal/EventHandlerSequentialTest.kt | 2 ++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt index 5b4fc0468a19..a54eff69e479 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt @@ -28,7 +28,6 @@ import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationMarkReadEvent import io.getstream.chat.android.client.events.NotificationMarkUnreadEvent import io.getstream.chat.android.client.events.NotificationMessageNewEvent -import io.getstream.chat.android.client.extensions.currentUserUnreadCount import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.GroupedChannels import io.getstream.chat.android.models.UserId @@ -171,7 +170,7 @@ internal class GroupedUnreadChannelsUpdater( private fun hadUnreadFor(cid: String, oldChannel: Channel): Boolean = when { cid in hadUnreadOverride -> hadUnreadOverride.getValue(cid) markAllReadApplied -> false - else -> oldChannel.currentUserUnreadCount(currentUserId) > 0 + else -> oldChannel.isUnreadInGroupFor(currentUserId) } private fun rotateBatchIfNeeded(batchId: Int) { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt index b63ae2b11723..f7c1f6074ca3 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt @@ -231,6 +231,36 @@ internal class GroupedUnreadChannelsUpdaterTest { assertEquals(mapOf("a" to 2, "b" to 2), next) } + @Test + fun `NotificationChannelPartialUpdatedEvent for a hidden stored channel returns current map unchanged`() = + runTest { + val stored = channel(group = "a").copy( + hidden = true, + read = listOf(randomChannelUserRead(user = User(id = CURRENT_USER_ID), unreadMessages = 2)), + ) + val current = mapOf("a" to 3, "b" to 1) + val partialChannel = Channel(id = CHANNEL_ID, type = CHANNEL_TYPE, extraData = mapOf("group" to "b")) + + val next = updater.calculateUpdatedCounts(current, BATCH_ID, partialUpdatedEvent(partialChannel)) { stored } + + assertSame(current, next) + } + + @Test + fun `NotificationChannelPartialUpdatedEvent for a disabled stored channel returns current map unchanged`() = + runTest { + val stored = channel(group = "a").copy( + disabled = true, + read = listOf(randomChannelUserRead(user = User(id = CURRENT_USER_ID), unreadMessages = 2)), + ) + val current = mapOf("a" to 3, "b" to 1) + val partialChannel = Channel(id = CHANNEL_ID, type = CHANNEL_TYPE, extraData = mapOf("group" to "b")) + + val next = updater.calculateUpdatedCounts(current, BATCH_ID, partialUpdatedEvent(partialChannel)) { stored } + + assertSame(current, next) + } + @Test fun `NotificationChannelPartialUpdatedEvent for a channel with no local copy returns current map unchanged`() = runTest { @@ -523,6 +553,8 @@ internal class GroupedUnreadChannelsUpdaterTest { type = CHANNEL_TYPE, extraData = group?.let { mapOf("group" to it) } ?: emptyMap(), read = emptyList(), + hidden = false, + disabled = false, ) private fun channelUpdatedEvent(newChannel: Channel): ChannelUpdatedEvent = ChannelUpdatedEvent( diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialPartialUpdateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialPartialUpdateTest.kt index 807cbeaf6400..4b68cb3a9198 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialPartialUpdateTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialPartialUpdateTest.kt @@ -69,6 +69,8 @@ internal class EventHandlerSequentialPartialUpdateTest { id = "channel-id", type = "messaging", extraData = mapOf("group" to "a"), + hidden = false, + disabled = false, read = listOf(randomChannelUserRead(user = currentUser, unreadMessages = 1)), ) val repos: RepositoryFacade = mock() diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt index 271fb2729742..5467fd88a1f4 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt @@ -990,6 +990,8 @@ internal class EventHandlerSequentialTest { id = channelId, type = channelType, extraData = extraData, + hidden = false, + disabled = false, read = listOf( io.getstream.chat.android.randomChannelUserRead( user = currentUser, From 222aac97ffb98d692e2127c54ce4f947d6d8c5c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 21 Oct 2026 15:48:04 +0100 Subject: [PATCH 7/7] Place partially updated grouped channels by the last message time of the event ChannelPartialUpdateResponse is regenerated with last_message_at, and a partial update keeps the later of the local and the event value, in the stored channel and in the channel state. A stored channel that is not in the list is checked against the cutoff when the event carries its last message time; without it, only the group is checked, as before. --- .../client/api2/mapping/DomainMapping.kt | 1 + .../internal/GroupAwareChatEventHandler.kt | 18 ++++---- .../utils/internal/ChannelPartialUpdate.kt | 7 ++- .../models/ChannelPartialUpdateResponse.kt | 3 ++ .../android/client/EventChatJsonProvider.kt | 1 + .../client/api2/mapping/DomainMappingTest.kt | 10 ++++ .../api2/mapping/EventMappingTestArguments.kt | 2 + .../GroupAwareChatEventHandlerTest.kt | 46 ++++++++++++++++++- .../QueryChannelsLogicGroupedWatchTest.kt | 19 +++++++- .../internal/ChannelPartialUpdateTest.kt | 30 ++++++++++++ .../android/client/parser/EventArguments.kt | 1 + 11 files changed, 125 insertions(+), 13 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt index da1b1d25b18d..b13c1399739d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt @@ -441,6 +441,7 @@ internal class DomainMapping( disabled = disabled, createdAt = createdAt, updatedAt = updatedAt, + lastMessageAt = lastMessageAt, team = team.orEmpty(), extraData = custom.withDeclared("disabled" to disabled) .mapNotNull { (key, value) -> value?.let { key to it } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt index 2894582d19b8..9700f5f262e6 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt @@ -138,14 +138,14 @@ internal class GroupAwareChatEventHandler( /** * Same checks as [routeByGroup], against [cachedChannel] with the partial update applied. The event's channel - * lacks data such as members and the last message time, so a channel that is not in the list yet is watched to - * get its full data instead of being added from the event. + * lacks data such as members, so a channel that is not in the list yet is watched to get its full data instead of + * being added from the event. * - * The cutoff is checked only for a channel in the list, whose last activity is up to date. For any other - * channel only the group is checked: without [cachedChannel] the last activity is unknown, and a stored copy can - * be stale, so the cutoff is left to the list, which checks the watched channel before showing it. The event is - * only sent to members, and a hidden channel is dropped after the watch. The query skips the watch when the - * channel sorts below its loaded pages, and does not watch it when the list was not queried with `watch`. + * The cutoff is checked when the channel's last activity is known: the channel is in the list, or it has a local + * copy and the event carries its last message time. For any other channel only the group is checked, and the + * cutoff is left to the list, which checks the watched channel before showing it. The event is only sent to + * members, and a hidden channel is dropped after the watch. The query skips the watch when the channel sorts below + * its loaded pages, and does not watch it when the list was not queried with `watch`. */ private fun routePartialUpdate( event: NotificationChannelPartialUpdatedEvent, @@ -154,7 +154,9 @@ internal class GroupAwareChatEventHandler( ): EventHandlingResult { val channel = cachedChannel?.mergePartialUpdate(event.channel) ?: event.channel val isInList = channels.value?.containsKey(event.cid) == true - val matches = if (isInList) channelBelongsHere(channel, filter) else groupMatches(channel, filter) + // A stored copy can miss recent messages, so its last activity is known only with the event's value. + val isLastActivityKnown = isInList || (cachedChannel != null && event.channel.lastMessageAt != null) + val matches = if (isLastActivityKnown) channelBelongsHere(channel, filter) else groupMatches(channel, filter) val belongsHere = matches && (cachedChannel == null || (isCurrentUserMember(cachedChannel) && cachedChannel.hidden != true)) return when { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelPartialUpdate.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelPartialUpdate.kt index 6966530fb6b8..05addb5b4a05 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelPartialUpdate.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelPartialUpdate.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.client.internal.state.utils.internal import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.parser2.adapters.GENERATED_CHANNEL_EXTRA_DATA_KEYS import io.getstream.chat.android.client.parser2.adapters.LEGACY_CHANNEL_EXTRA_DATA_KEYS +import io.getstream.chat.android.core.utils.date.max import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelData @@ -30,7 +31,7 @@ private val serverFieldsInExtraData = LEGACY_CHANNEL_EXTRA_DATA_KEYS + GENERATED /** * Applies the fields carried by [NotificationChannelPartialUpdatedEvent.channel] to this channel and keeps all - * the others, such as members, reads and [Channel.lastMessageAt]. + * the others, such as members and reads. [Channel.lastMessageAt] takes the later of the local and the event value. */ internal fun Channel.mergePartialUpdate(partial: Channel): Channel = copy( name = partial.name, @@ -40,12 +41,13 @@ internal fun Channel.mergePartialUpdate(partial: Channel): Channel = copy( team = partial.team, createdAt = partial.createdAt, updatedAt = partial.updatedAt, + lastMessageAt = max(lastMessageAt, partial.lastMessageAt), extraData = mergePartialExtraData(current = extraData, partial = partial.extraData), ) /** * Applies the fields carried by [NotificationChannelPartialUpdatedEvent.channel] to this channel data and keeps - * all the others. + * all the others. [ChannelData.lastMessageAt] takes the later of the local and the event value. */ internal fun ChannelData.mergePartialUpdate(partial: Channel): ChannelData = copy( name = partial.name, @@ -55,6 +57,7 @@ internal fun ChannelData.mergePartialUpdate(partial: Channel): ChannelData = cop team = partial.team, createdAt = partial.createdAt, updatedAt = partial.updatedAt, + lastMessageAt = max(lastMessageAt, partial.lastMessageAt), extraData = mergePartialExtraData(current = extraData, partial = partial.extraData), ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelPartialUpdateResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelPartialUpdateResponse.kt index dbf14acabe51..0846ad898e58 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelPartialUpdateResponse.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelPartialUpdateResponse.kt @@ -54,6 +54,9 @@ internal data class ChannelPartialUpdateResponse( @Json(name = "custom") internal val custom: Map = emptyMap(), + @Json(name = "last_message_at") + internal val lastMessageAt: java.util.Date? = null, + @Json(name = "team") internal val team: String? = null, ) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt index d585d371955d..ff42fdd3d244 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt @@ -280,6 +280,7 @@ internal fun createNotificationChannelPartialUpdatedEventStringJson() = "cid": "channelType:channelId", "created_at": "2020-06-29T06:14:28.000Z", "updated_at": "2020-06-29T06:14:28.000Z", + "last_message_at": "2020-06-29T06:14:28.000Z", "frozen": true, "disabled": false, "team": "team1", diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt index 16747425f42f..166b01ff1f7a 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt @@ -721,6 +721,16 @@ internal class DomainMappingTest { assertEquals("", result.team) } + @Test + fun `ChannelPartialUpdateResponse keeps the last message time of the event`() { + val lastMessageAt = randomDate() + val sut = Fixture().get() + + val result = with(sut) { channelPartialUpdateResponse().copy(lastMessageAt = lastMessageAt).toDomain() } + + assertEquals(lastMessageAt, result.lastMessageAt) + } + private fun channelPartialUpdateResponse() = ChannelPartialUpdateResponse( cid = "messaging:123", createdAt = Date(), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt index 2deb7c261631..8a8b22912fe9 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt @@ -577,6 +577,7 @@ internal object EventMappingTestArguments { type = CHANNEL_TYPE, updatedAt = DATE, custom = mapOf("name" to "Channel name", "image" to "image.png", "group" to "current"), + lastMessageAt = DATE, team = "team1", ), ) @@ -1353,6 +1354,7 @@ internal object EventMappingTestArguments { disabled = true, createdAt = DATE, updatedAt = DATE, + lastMessageAt = DATE, team = "team1", extraData = mapOf("group" to "current", "disabled" to true), ), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt index 4eae8c9f4857..f1d408d443e1 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt @@ -759,6 +759,44 @@ internal class GroupAwareChatEventHandlerTest { assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) } + @Test + fun `Given a stale copy not in the list When a partial update carries a recent last message time Should watch and add`() { + val currentUser = randomUser() + val channel = randomChannel( + disabled = false, + blocked = false, + createdAt = daysAgo(30), + lastMessageAt = daysAgo(20), + extraData = mapOf("group" to "other"), + ) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = currentUser) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip"), lastMessageAt = daysAgo(1)) + + val result = handler.handleChatEvent(event, cutoff14Days, cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) + } + + @Test + fun `Given a cached channel not in the list When a partial update carries a last message time past the cutoff Should skip`() { + val currentUser = randomUser() + val channel = randomChannel( + disabled = false, + blocked = false, + createdAt = daysAgo(30), + lastMessageAt = daysAgo(20), + extraData = mapOf("group" to "other"), + ) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = currentUser) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip"), lastMessageAt = daysAgo(20)) + + val result = handler.handleChatEvent(event, cutoff14Days, cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Skip, result) + } + @Test fun `Given a cached channel not in the list When a partial update moves it into the group Should watch and add`() { val currentUser = randomUser() @@ -890,7 +928,12 @@ internal class GroupAwareChatEventHandlerTest { } /** The event as the backend sends it: only the channel's basic fields and its custom data. */ - private fun partialUpdateOf(channel: Channel, custom: Map, disabled: Boolean = false) = + private fun partialUpdateOf( + channel: Channel, + custom: Map, + disabled: Boolean = false, + lastMessageAt: Date? = null, + ) = randomNotificationChannelPartialUpdatedEvent( cid = channel.cid, channelType = channel.type, @@ -900,6 +943,7 @@ internal class GroupAwareChatEventHandlerTest { type = channel.type, createdAt = channel.createdAt, updatedAt = channel.updatedAt, + lastMessageAt = lastMessageAt, disabled = disabled, extraData = custom + ("disabled" to disabled), ), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt index 96ae9ad91a46..a104162a8a38 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt @@ -151,6 +151,16 @@ internal class QueryChannelsLogicGroupedWatchTest { assertEquals(listOf(EventHandlingResult.Add(stored.mergePartialUpdate(event.channel))), results) } + @Test + fun `a partial update places a channel without a local copy by the last message time of the event`() = runTest { + givenLoadedChannels(lastLoadedAt = daysAgo(5), endOfChannels = false) + val event = partialUpdate(createdAt = daysAgo(30), lastMessageAt = daysAgo(1)) + + val results = logic.parseChatEventResults(listOf(event)) + + assertEquals(listOf(EventHandlingResult.WatchAndAdd(event.cid)), results) + } + @Test fun `a partial update watches a channel without a local copy when the list was queried with watch`() = runTest { givenLoadedChannels(lastLoadedAt = daysAgo(5), endOfChannels = false) @@ -179,8 +189,13 @@ internal class QueryChannelsLogicGroupedWatchTest { } /** A partial update the group's handler resolved to a watch, carrying only the slim channel. */ - private fun partialUpdate(createdAt: Date): NotificationChannelPartialUpdatedEvent { - val slim = Channel(id = randomString(), type = "messaging", createdAt = createdAt) + private fun partialUpdate(createdAt: Date, lastMessageAt: Date? = null): NotificationChannelPartialUpdatedEvent { + val slim = Channel( + id = randomString(), + type = "messaging", + createdAt = createdAt, + lastMessageAt = lastMessageAt, + ) val event = randomNotificationChannelPartialUpdatedEvent( cid = slim.cid, channelType = slim.type, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelPartialUpdateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelPartialUpdateTest.kt index 2b5fc6246b39..133c55c596c1 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelPartialUpdateTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelPartialUpdateTest.kt @@ -24,6 +24,7 @@ import io.getstream.chat.android.randomMember import io.getstream.chat.android.randomString import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import java.util.Date internal class ChannelPartialUpdateTest { @@ -68,6 +69,24 @@ internal class ChannelPartialUpdateTest { assertEquals(local.lastMessageAt, merged.lastMessageAt) } + @Test + fun `merging a partial update takes a later last message time from the event`() { + val local = randomChannel(lastMessageAt = Date(1_000)) + + val merged = local.mergePartialUpdate(partial.copy(lastMessageAt = Date(2_000))) + + assertEquals(Date(2_000), merged.lastMessageAt) + } + + @Test + fun `merging a partial update keeps a later local last message time`() { + val local = randomChannel(lastMessageAt = Date(2_000)) + + val merged = local.mergePartialUpdate(partial.copy(lastMessageAt = Date(1_000))) + + assertEquals(Date(2_000), merged.lastMessageAt) + } + @Test fun `merging a partial update replaces the custom data and keeps the server fields`() { val local = randomChannel( @@ -99,4 +118,15 @@ internal class ChannelPartialUpdateTest { assertEquals(local.mergePartialUpdate(partial).toChannelData(), merged) } + + @Test + fun `merging a partial update with a later last message time into channel data matches the channel`() { + val local = randomChannel(lastMessageAt = Date(1_000)) + val laterPartial = partial.copy(lastMessageAt = Date(2_000)) + + val merged = local.toChannelData().mergePartialUpdate(laterPartial) + + assertEquals(Date(2_000), merged.lastMessageAt) + assertEquals(local.mergePartialUpdate(laterPartial).toChannelData(), merged) + } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt index e42f3b6fbb8d..b8bc618c9bee 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt @@ -622,6 +622,7 @@ internal object EventArguments { frozen = true, createdAt = date, updatedAt = date, + lastMessageAt = date, team = "team1", extraData = mutableMapOf("group" to "current", "disabled" to false), ),