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 7e431f86014b..9de5e5142c43 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 @@ -208,6 +208,7 @@ internal class DomainMapping( frozen = frozen, createdAt = created_at, updatedAt = updated_at, + lastMessageAt = last_message_at, team = team, extraData = (extraData + (CHANNEL_DISABLED_FIELD to disabled)).toMutableMap(), ).let(channelTransformer::transform) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt index 613543a16958..1aba7fe65840 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt @@ -62,6 +62,7 @@ internal data class DownstreamChannelPartialUpdateDto( val image: String?, val created_at: Date?, val updated_at: Date?, + val last_message_at: Date? = null, val frozen: Boolean, val disabled: Boolean, val team: String = "", 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 a87d9de89072..a7322e63296a 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 @@ -251,6 +251,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 42a4b0f413c4..7f22f9f5d7d6 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 @@ -306,6 +306,28 @@ internal class DomainMappingTest { assertEquals("", result.image) } + @Test + fun `Partially updated channel keeps the last message time of the event`() { + val lastMessageAt = randomDate() + val dto = DownstreamChannelPartialUpdateDto( + cid = "messaging:123", + id = "123", + type = "messaging", + name = null, + image = null, + created_at = null, + updated_at = null, + last_message_at = lastMessageAt, + frozen = false, + disabled = false, + extraData = emptyMap(), + ) + + val result = with(Fixture().get()) { dto.toDomain() } + + assertEquals(lastMessageAt, result.lastMessageAt) + } + @Test fun `DownstreamChannelDto is correctly mapped to Channel`() { val downstreamChannelDto = randomDownstreamChannelDto() 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 c13051dcd9d1..a9a3417f6e99 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 @@ -561,6 +561,7 @@ internal object EventArguments { frozen = true, createdAt = date, updatedAt = date, + lastMessageAt = date, team = "team1", extraData = mutableMapOf("group" to "current", "disabled" to false), ), diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt index f9b6a0eaa42d..5216ff83707f 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt @@ -54,10 +54,19 @@ class GroupedChannelListTests : StreamTestCase() { step("GIVEN backend has two pages of new channels") { seedNewChannels(channelsCount = 20) } + step("AND user logs in and reconnects once, so the app has a last sync date") { + // After a fresh login, the first sync date is stored by the first reconnection. Pages are kept only after + // a sync that starts from a known date. + userRobot.login().waitForChannelListToLoad() + device.disableInternetConnection() + userRobot.sleep(2.seconds) + device.enableInternetConnection() + userRobot.sleep(3.seconds) + } step("AND user scrolls to the end of the second page of the new tab") { userRobot - .login() - .waitForChannelListToLoad() + // Opened online: a tab asks for its next page when it is shown, and a request rejected while offline + // is not sent again. .openGroupTab("new") // The first scroll loads the second page, the second one reaches its end. .scrollChannelListDown() diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Actions.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Actions.kt index 5f28ff1e942c..10aa929db2ad 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Actions.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Actions.kt @@ -92,9 +92,11 @@ public fun UiDevice.goToForeground() { } public fun UiDevice.enableInternetConnection() { - executeShellCommand("svc data enable") + // Wi-Fi goes first. When mobile data connects first, Wi-Fi takes over the default network a few seconds later and + // slows down the requests the app has in flight. executeShellCommand("svc wifi enable") waitForInternetConnection() + executeShellCommand("svc data enable") } public fun UiDevice.disableInternetConnection() { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt index c34a1ef3c487..1617f07697d6 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt @@ -34,6 +34,7 @@ import io.getstream.chat.android.state.event.handler.chat.DefaultChatEventHandle import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult import io.getstream.chat.android.state.utils.internal.calculateNewLastMessageAt import io.getstream.chat.android.state.utils.internal.mergePartialUpdate +import io.getstream.chat.android.state.utils.internal.unhidesChannel import kotlinx.coroutines.flow.StateFlow import java.util.Date @@ -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,14 +71,16 @@ internal class GroupAwareChatEventHandler( is NotificationChannelPartialUpdatedEvent -> routePartialUpdate(event, cachedChannel, filter) + is NotificationMessageNewEvent -> + if ((cachedChannel ?: event.channel).staysHiddenAfter(event.message)) { + EventHandlingResult.Skip + } else { + watchAndAddIfBelongsHere(event.channel, filter) + } + is NotificationAddedToChannelEvent, - is NotificationMessageNewEvent, is ChannelVisibleEvent, - -> if (channelBelongsHere(event.channel, filter)) { - EventHandlingResult.WatchAndAdd(event.cid) - } else { - EventHandlingResult.Skip - } + -> watchAndAddIfBelongsHere(event.channel, filter) else -> super.handleChatEvent(event, filter, cachedChannel) } @@ -87,12 +91,24 @@ 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) } + private fun watchAndAddIfBelongsHere(channel: Channel, filter: FilterObject): EventHandlingResult = + if (channelBelongsHere(channel, filter)) { + EventHandlingResult.WatchAndAdd(channel.cid) + } else { + EventHandlingResult.Skip + } + + /** 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(extraData, 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 @@ -117,26 +133,27 @@ 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. * - * Without [cachedChannel], only the group is checked: the last activity is unknown, 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. + * 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, cachedChannel: Channel?, filter: FilterObject, ): EventHandlingResult { - val belongsHere = if (cachedChannel == null) { - groupMatches(event.channel, filter) - } else { - channelBelongsHere(cachedChannel.mergePartialUpdate(event.channel), filter) && - isCurrentUserMember(cachedChannel) && - cachedChannel.hidden != true - } + val channel = cachedChannel?.mergePartialUpdate(event.channel) ?: event.channel val isInList = channels.value?.containsKey(event.cid) == true + // 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 { belongsHere && !isInList -> EventHandlingResult.WatchAndAdd(event.cid) !belongsHere && isInList -> EventHandlingResult.Remove(event.cid) @@ -144,14 +161,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.isDisabled && + 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. */ + /** [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.isDisabled && + resolver.resolve(channel).contains(groupKey) && filter.matchesGroupedChannel(group = channel.group, queryTimestamp = checkedAt, now = checkedAt) } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerFactory.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerFactory.kt index b594fc593711..000a365532f0 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerFactory.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerFactory.kt @@ -22,6 +22,7 @@ import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Channel import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory +import io.getstream.chat.android.state.extensions.globalState import kotlinx.coroutines.flow.StateFlow /** @@ -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-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilter.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilter.kt index 5686f81a3a94..7fc6230fd567 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilter.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilter.kt @@ -61,7 +61,7 @@ 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 +internal val Channel.isDisabled: Boolean get() = extraData[DISABLED_FIELD_NAME] == true private val queryTimestampDescSort = QuerySortByField.descByName(QUERY_TIMESTAMP_FIELD_NAME) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt index 69ca04415f58..edcb43a9bd2d 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt @@ -28,7 +28,6 @@ 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.cidToTypeAndId -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 @@ -96,7 +95,7 @@ internal class GroupedUnreadChannelsUpdater( current: Map, batchId: Int, event: ChannelUpdatedEvent, - ): Map = applyDelta(current, batchId, event.cid, event.channel) + ): Map = applyDelta(current, batchId, event.cid, event.channel, cachedChannel(event.cid)) /** * `channel.updated_by_user` delta. Same semantics as the [ChannelUpdatedEvent] overload. @@ -105,17 +104,21 @@ internal class GroupedUnreadChannelsUpdater( current: Map, batchId: Int, event: ChannelUpdatedByUserEvent, - ): Map = applyDelta(current, batchId, event.cid, event.channel) + ): Map = applyDelta(current, batchId, event.cid, event.channel, cachedChannel(event.cid)) /** * `notification.channel_partial_updated` delta. Same semantics as the [ChannelUpdatedEvent] overload. The - * event's channel carries the custom data, so it holds the new group. + * event's channel carries the custom data, so it holds the new group. The event goes to members who are not + * watching the channel, so it usually has no active state: its old group and unread state then come from + * [storedChannel], which is only read in that case. */ - fun calculateUpdatedCounts( + suspend fun calculateUpdatedCounts( current: Map, batchId: Int, event: NotificationChannelPartialUpdatedEvent, - ): Map = applyDelta(current, batchId, event.cid, event.channel) + storedChannel: suspend () -> Channel?, + ): Map = + applyDelta(current, batchId, event.cid, event.channel, cachedChannel(event.cid) ?: storedChannel()) /** * Records that channel [cid] has been removed within [batchId] — either deleted, or the @@ -147,10 +150,11 @@ internal class GroupedUnreadChannelsUpdater( batchId: Int, cid: String, newChannel: Channel, + oldChannel: Channel?, ): Map { rotateBatchIfNeeded(batchId) if (cid in removedCids || cid in processedCids) return current - val oldChannel = cachedChannel(cid) ?: return current + if (oldChannel == null) return current val oldGroup = oldChannel.group val newGroup = newChannel.group if (oldGroup == newGroup) return current @@ -167,7 +171,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-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt index 909a58969776..c074979f7b45 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt @@ -102,6 +102,7 @@ import io.getstream.chat.android.client.setup.state.ClientState 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 @@ -125,6 +126,8 @@ import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.state.utils.internal.calculateNewLastMessageAt import io.getstream.chat.android.state.utils.internal.mergePartialUpdate +import io.getstream.chat.android.state.utils.internal.unhidesChannel +import io.getstream.chat.android.state.utils.internal.withUserStateOf import io.getstream.log.StreamLog import io.getstream.log.taggedLogger import kotlinx.coroutines.CoroutineExceptionHandler @@ -312,12 +315,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) @@ -455,7 +455,7 @@ internal class EventHandlerSequential( } (event as? NotificationChannelPartialUpdatedEvent)?.let { e -> groupedUnreadChannels = groupedUnreadChannelsUpdater - .calculateUpdatedCounts(groupedUnreadChannels, batchEvent.id, e) + .calculateUpdatedCounts(groupedUnreadChannels, batchEvent.id, e) { repos.selectChannel(e.cid) } } (event as? HasOwnUser)?.let { modifyValuesFromUser(it.me) } (event as? HasUnreadThreadCounts)?.let { modifyUnreadThreadsCount(it) } @@ -649,7 +649,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, @@ -689,8 +689,8 @@ internal class EventHandlerSequential( is NotificationMessageNewEvent -> { 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)) + ?.mergeSharedEventChannel(event.channel) ?: event.channel + batch.addChannel(channel.copy(hidden = channel.hiddenAfter(event.message))) } is NotificationAddedToChannelEvent -> { val channel = batch.getCurrentChannel(event.cid) @@ -804,12 +804,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 -> { @@ -825,7 +825,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 -> { @@ -835,7 +835,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) } @@ -958,6 +958,17 @@ 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 the + * current user's `hidden` and `blocked` state is kept from this channel. + */ + private fun Channel.mergeSharedEventChannel(eventChannel: Channel): Channel = + mergeChannelFromEvent(eventChannel).withUserStateOf(this) + + /** The hidden state of this channel after the new [message]. */ + private fun Channel.hiddenAfter(message: Message): Boolean? = + if (message.unhidesChannel(extraData, mutableGlobalState.blockedUserIds.value)) false else hidden + private fun List.extractMessageIds() = mapNotNull { event -> (event as? HasMessage)?.message?.id } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandler.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandler.kt index d810fca392e7..1c3b4cade694 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandler.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandler.kt @@ -129,10 +129,7 @@ internal class ChannelEventHandler( upsertMessage(event.message, preserveCreatedLocallyAt) // Update channel read state stateLogic.updateCurrentUserRead(event.createdAt, event.message) - // 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 @@ -145,10 +142,7 @@ internal class ChannelEventHandler( } // Update channel read state stateLogic.updateCurrentUserRead(event.createdAt, 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-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogic.kt index b17b39961c78..b48c92530381 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogic.kt @@ -47,6 +47,7 @@ import io.getstream.chat.android.state.message.attachments.internal.AttachmentUr import io.getstream.chat.android.state.plugin.state.channel.internal.ChannelMutableState import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.state.utils.internal.calculateNewLastMessageAt +import io.getstream.chat.android.state.utils.internal.unhidesChannel import io.getstream.log.StreamLog import io.getstream.log.taggedLogger import io.getstream.result.Error @@ -588,6 +589,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 extraData = mutableState.channelData.value.extraData + if (message.unhidesChannel(extraData, globalMutableState.blockedUserIds.value)) { + setHidden(false) + } + } + /** * Sets a replied message. * diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt index d9dffcb5f07c..8c677566bddb 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt @@ -122,6 +122,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-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt index 66b6936d50c8..f4818aef5c53 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt @@ -43,9 +43,29 @@ 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. + * the last loaded channel. Without local data the channel has no last message time, so its position is a lower bound: + * a channel with recent activity but no local copy can be skipped, and it then shows after the next refresh. */ private fun QueryChannelsState.isWithinLoadedChannels(channel: Channel): Boolean { if (endOfChannels.value) return true diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt index 5a173994e389..5d86a5a6f2db 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/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. * @@ -687,6 +698,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-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt index 293c2b5ffb10..16f2ffd30d69 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt @@ -306,6 +306,12 @@ internal class QueryChannelsStateLogic( mutableState.setChannels(newChannels) } + /** Whether the channel with [cid] has active in-memory state. */ + internal fun isActiveChannel(cid: String): Boolean { + val (type, id) = cid.cidToTypeAndId() + return stateRegistry.isActiveChannel(type, id) + } + /** * 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-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt index 3d97508cbd0c..2375fe50c318 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt @@ -532,10 +532,15 @@ internal class SyncManager( .toMap() .takeIf { it.isNotEmpty() } ?.let { groups -> - val keysKeepingPages = activeGroupedLogics - .filter { syncedCids.containsAll(it.cids()) } - .mapNotNull { it.groupKey() } - .toSet() + // `/sync` does not subscribe to presence, so only pages fetched again would get presence events. + val keysKeepingPages = if (shared?.presence == true) { + emptySet() + } else { + activeGroupedLogics + .filter { syncedCids.containsAll(it.cids()) } + .mapNotNull { it.groupKey() } + .toSet() + } ReconnectGroupQueries(groups, keysKeepingPages) } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdate.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdate.kt index 8c1269e16fbd..d738e33d6c03 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdate.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdate.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.state.utils.internal import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent +import io.getstream.chat.android.core.utils.date.max import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelData @@ -40,7 +41,7 @@ private val serverFieldsInExtraData = setOf( /** * 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, @@ -49,12 +50,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, @@ -63,6 +65,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-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelVisibility.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelVisibility.kt new file mode 100644 index 000000000000..fdcc542944b5 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelVisibility.kt @@ -0,0 +1,54 @@ +/* + * 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.state.utils.internal + +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.MessageType + +private const val BLOCKED_FIELD_NAME = "blocked" +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 channelExtraData The channel's extra data, which holds the server's `blocked` flag. + * @param blockedUserIds The ids of the users the current user blocked. + */ +internal fun Message.unhidesChannel(channelExtraData: Map, blockedUserIds: Collection): Boolean = + when (type) { + MessageType.REGULAR, MessageType.REPLY -> + !shadowed && + extraData[CREATED_BY_CAMPAIGN_ID_FIELD_NAME] == null && + channelExtraData[BLOCKED_FIELD_NAME] != true && + user.id !in blockedUserIds + + else -> false + } + +/** + * This channel with the current user's `hidden` and `blocked` state taken from [stored] when this channel lacks it. + * Channels in events are shared by all members, so they carry no state of the current user. + * + * @param stored The stored copy of the channel. + */ +internal fun Channel.withUserStateOf(stored: Channel): Channel = copy( + hidden = hidden ?: stored.hidden, + extraData = stored.extraData.filterKeys(BLOCKED_FIELD_NAME::equals) + extraData, +) diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/channel/controller/WhenHandleEvent.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/channel/controller/WhenHandleEvent.kt index fd4c0ca60cbd..c2320c39ae40 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/channel/controller/WhenHandleEvent.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/channel/controller/WhenHandleEvent.kt @@ -133,7 +133,7 @@ internal class WhenHandleEvent : SynchronizedCoroutineTest { verify(channelStateLogic).upsertMessage(newMessage) verify(channelStateLogic).updateCurrentUserRead(userStartWatchingEvent.createdAt, userStartWatchingEvent.message) - verify(channelStateLogic).setHidden(false) + verify(channelStateLogic).unhideFor(userStartWatchingEvent.message) } // Message update diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt index 65375c78258d..d13d9919bdba 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt @@ -43,6 +43,7 @@ import org.mockito.kotlin.whenever import java.util.Date import java.util.concurrent.TimeUnit +@Suppress("LargeClass") internal class GroupAwareChatEventHandlerTest { private val defaultResolver = DefaultChannelGroupResolver() @@ -280,6 +281,55 @@ 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(extraData = mapOf("group" to "vip", "disabled" to true)) + 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(extraData = mapOf("group" to "vip", "disabled" to false)) + val cachedChannel = channel.copy( + membership = randomMember(user = currentUser), + hidden = false, + extraData = mapOf("group" to "vip", "disabled" to 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(extraData = mapOf("group" to "vip", "disabled" to true)) + 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")) @@ -437,6 +487,64 @@ 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(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(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(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 with a regular message arrives Should watch and add`() { + val channel = randomChannel(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( @@ -518,6 +626,86 @@ 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(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(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( + 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")) + + val result = handler.handleChatEvent(event, cutoff14Days, cachedChannel = cachedChannel) + + 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( + 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( + 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() @@ -645,7 +833,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) = + private fun partialUpdateOf( + channel: Channel, + custom: Map, + disabled: Boolean = false, + lastMessageAt: Date? = null, + ) = randomNotificationChannelPartialUpdatedEvent( cid = channel.cid, channelType = channel.type, @@ -655,7 +848,8 @@ internal class GroupAwareChatEventHandlerTest { type = channel.type, createdAt = channel.createdAt, updatedAt = channel.updatedAt, - extraData = custom + ("disabled" to false), + lastMessageAt = lastMessageAt, + extraData = custom + ("disabled" to disabled), ), ) @@ -675,6 +869,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) @@ -684,6 +879,7 @@ internal class GroupAwareChatEventHandlerTest { resolver = resolver, channels = MutableStateFlow(cachedChannels), clientState = clientState, + blockedUserIds = { blockedUserIds }, now = { now }, ) } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt index 3cb74a6eb3f0..b3d1c767addb 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt @@ -36,6 +36,7 @@ import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomUser import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.channel.internal.ChannelMutableState +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Test @@ -203,16 +204,58 @@ internal class GroupedUnreadChannelsUpdaterTest { } @Test - fun `NotificationChannelPartialUpdatedEvent applies the same migration semantics`() { + fun `NotificationChannelPartialUpdatedEvent applies the same migration semantics`() = runTest { seedActiveChannel(oldGroup = "a", 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)) + val next = updater.calculateUpdatedCounts(current, BATCH_ID, partialUpdatedEvent(partialChannel)) { + error("The stored channel is not read for an active channel") + } assertEquals(mapOf("a" to 2, "b" to 2), next) } + @Test + fun `NotificationChannelPartialUpdatedEvent for an inactive channel migrates the count of its stored copy`() = + runTest { + val stored = channel(group = "a").copy( + 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 } + + 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 channel with no local copy returns current map unchanged`() = + runTest { + 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)) { null } + + assertSame(current, next) + } + // endregion // region Same-batch dedup @@ -494,6 +537,7 @@ internal class GroupedUnreadChannelsUpdaterTest { type = CHANNEL_TYPE, extraData = group?.let { mapOf("group" to it) } ?: emptyMap(), read = emptyList(), + hidden = false, ) private fun channelUpdatedEvent(newChannel: Channel): ChannelUpdatedEvent = ChannelUpdatedEvent( diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialHiddenChannelTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialHiddenChannelTest.kt index 66920c316818..d699bc9534bd 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialHiddenChannelTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialHiddenChannelTest.kt @@ -29,6 +29,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.randomChannel @@ -181,6 +182,55 @@ internal class EventHandlerSequentialHiddenChannelTest { groupedState.rawChannels.orEmpty().keys `should contain` CID } + @Test + fun `a campaign message does not bring a hidden channel back to a grouped query`() = runTest { + val fixture = Fixture() + 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 + } + + @Test + fun `a regular message brings a hidden channel back to a grouped query`() = runTest { + val fixture = Fixture() + 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, @@ -245,6 +295,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-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt index 5a765ddcff45..11e40ae3cae8 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt @@ -44,6 +44,7 @@ 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 @@ -51,6 +52,7 @@ import io.getstream.chat.android.randomBoolean import io.getstream.chat.android.randomCID import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomChannelMute +import io.getstream.chat.android.randomChannelUserRead import io.getstream.chat.android.randomDraftMessage import io.getstream.chat.android.randomLocation import io.getstream.chat.android.randomMember @@ -71,6 +73,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` @@ -92,6 +95,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.util.concurrent.atomic.AtomicReference +@Suppress("LargeClass") @kotlinx.coroutines.ExperimentalCoroutinesApi internal class EventHandlerSequentialTest { @@ -307,6 +311,36 @@ internal class EventHandlerSequentialTest { mutableGlobalState.groupedUnreadChannels.value `should be equal to` mapOf("a" to 0, "b" to 1) } + @Test + fun `NotificationChannelPartialUpdatedEvent migrates grouped unread count of a stored channel that is not active`() = + runTest { + val stored = randomChannel( + id = "channel-id", + type = "messaging", + extraData = mapOf("group" to "a"), + hidden = false, + read = listOf(randomChannelUserRead(user = currentUser, unreadMessages = 1)), + ) + val repos: RepositoryFacade = mock() + whenever(repos.selectMessages(any())) doReturn emptyList() + whenever(repos.selectChannels(any())) doReturn listOf(stored) + whenever(repos.selectThreads(any())) doReturn emptyList() + whenever(repos.selectChannel(stored.cid)) doReturn stored + val mutableGlobalState = MutableGlobalState(currentUser.id).apply { + setGroupedUnreadChannels(mapOf("a" to 1, "b" to 0)) + } + val partialChannel = Channel(id = "channel-id", type = "messaging", extraData = mapOf("group" to "b")) + val handler = Fixture() + .withCurrentUser(currentUser) + .withRepositoryFacade(repos) + .withMutableGlobalState(mutableGlobalState) + .get(this) + + handler.handleEvents(randomNotificationChannelPartialUpdatedEvent(cid = stored.cid, channel = partialChannel)) + + mutableGlobalState.groupedUnreadChannels.value `should be equal to` mapOf("a" to 0, "b" to 1) + } + @Test fun `When handling NotificationChannelPartialUpdatedEvent, The stored channel should get the partial update`() = runTest { @@ -335,6 +369,117 @@ internal class EventHandlerSequentialTest { storedResult.extraData `should be equal to` mapOf("group" to "b") } + @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 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 + } + + @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, extraData = mapOf("blocked" to true)) + + val result = storedHiddenChannelAfter(message, ::notificationMessageNewIn, storedChannel) + + result.hidden `should be equal to` true + result.extraData["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 + } + + @Test + fun `When handling ChannelUpdatedEvent, The stored hidden and blocked state should be kept`() = runTest { + val storedChannel = randomChannel(hidden = true, extraData = mapOf("blocked" to true, "group" to "a")) + val repos: RepositoryFacade = mock() + whenever(repos.selectMessages(any())) doReturn emptyList() + whenever(repos.selectChannels(any())) doReturn listOf(storedChannel) + whenever(repos.selectThreads(any())) doReturn emptyList() + val handler = Fixture() + .withRepositoryFacade(repos) + .get(this) + // Like the backend's, the event's channel carries no state of the current user. + val eventChannel = randomChannel( + id = storedChannel.id, + type = storedChannel.type, + hidden = null, + extraData = mapOf("group" to "b"), + ) + + handler.handleEvents(randomChannelUpdatedEvent(cid = storedChannel.cid, channel = eventChannel)) + + val inserted = argumentCaptor>() + verify(repos).insertChannels(inserted.capture()) + val result = inserted.firstValue.single() + result.hidden `should be equal to` true + result.extraData `should be equal to` mapOf("blocked" to true, "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, + eventIn: (Channel, Message) -> ChatEvent = ::newMessageIn, + storedChannel: Channel = randomChannel(hidden = true, extraData = emptyMap()), + ): Channel { + 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(eventIn(storedChannel, message)) + + val inserted = argumentCaptor>() + verify(repos).insertChannels(inserted.capture()) + return inserted.firstValue.single() + } + @Test fun `When handling NotificationChannelPartialUpdatedEvent, A channel that is not stored should not be inserted`() = runTest { @@ -774,6 +919,7 @@ internal class EventHandlerSequentialTest { id = channelId, type = channelType, extraData = extraData, + hidden = false, read = listOf( io.getstream.chat.android.randomChannelUserRead( user = currentUser, diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt index e8fbf4eca1f8..929ddf2b7ef7 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt @@ -1370,6 +1370,13 @@ internal class SyncManagerTest { assertEquals(setOf("all"), keysKeepingPages()) } + @Test + fun `on reconnect a grouped list queried with presence is reset`() = runTest(testDispatcher) { + givenReconnect(listOf(groupedQueryLogic(watch = true, cids = setOf("messaging:a"), presence = true))) + + assertEquals(emptySet(), keysKeepingPages()) + } + @Test fun `on reconnect a grouped list is reset when the sync reported too many events`() = runTest(testDispatcher) { givenReconnect( @@ -1501,6 +1508,7 @@ internal class SyncManagerTest { watch: Boolean, cids: Set = emptySet(), groupKey: String = "all", + presence: Boolean = false, ): QueryChannelsLogic = mock { on(it.groupKey()) doReturn groupKey on(it.currentRequest()) doReturn mock() @@ -1510,7 +1518,7 @@ internal class SyncManagerTest { limit = null, pageSize = null, watch = watch, - presence = false, + presence = presence, ) } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandlerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandlerTest.kt index d8dfa212bbee..75e25158b2f2 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandlerTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandlerTest.kt @@ -158,23 +158,13 @@ internal class ChannelEventHandlerTest { } @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 @@ -316,7 +306,7 @@ internal class ChannelEventHandlerTest { verify(stateLogic).upsertMessage(message) verify(stateLogic).updateCurrentUserRead(event.createdAt, message) - verify(stateLogic).setHidden(false) + verify(stateLogic).unhideFor(message) } @Test @@ -329,7 +319,7 @@ internal class ChannelEventHandlerTest { verify(stateLogic, never()).upsertMessage(message) verify(stateLogic).updateCurrentUserRead(event.createdAt, message) - verify(stateLogic).setHidden(false) + verify(stateLogic).unhideFor(message) } // NotificationThreadMessageNewEvent tests diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogicTest.kt index 3b8503edff2e..8193673395af 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogicTest.kt @@ -461,6 +461,26 @@ 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 user has no messages, When calling deleteMessagesFromUser, Then no action is taken`() { // given diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt index 81b1bc6d0afe..09fd001a09d6 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt @@ -27,7 +27,9 @@ import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomString import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult +import io.getstream.chat.android.state.plugin.state.querychannels.GroupedQueryConfig import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState +import io.getstream.chat.android.state.utils.internal.mergePartialUpdate import io.getstream.chat.android.test.asCall import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest @@ -124,6 +126,57 @@ 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 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) + 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), @@ -136,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-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt index 36b416270ae1..a65b27d95353 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/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-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdateTest.kt index 23daea0dab89..ad2dc1df68f3 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdateTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/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-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelVisibilityTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelVisibilityTest.kt new file mode 100644 index 000000000000..e67f538456e6 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelVisibilityTest.kt @@ -0,0 +1,98 @@ +/* + * 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.state.utils.internal + +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.MessageType +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomString +import io.getstream.chat.android.randomUser +import org.junit.jupiter.api.Assertions.assertEquals +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(emptyMap(), emptyList())) + } + + @Test + fun `A reply un-hides the channel`() { + assertTrue(message(type = MessageType.REPLY).unhidesChannel(emptyMap(), emptyList())) + } + + @Test + fun `A system message keeps the channel hidden`() { + assertFalse(message(type = MessageType.SYSTEM).unhidesChannel(emptyMap(), emptyList())) + } + + @Test + fun `A shadowed message keeps the channel hidden`() { + assertFalse(message(shadowed = true).unhidesChannel(emptyMap(), emptyList())) + } + + @Test + fun `A campaign message keeps the channel hidden`() { + val message = message(extraData = mapOf("created_by_campaign_id" to randomString())) + + assertFalse(message.unhidesChannel(emptyMap(), emptyList())) + } + + @Test + fun `A message in a blocked channel keeps the channel hidden`() { + assertFalse(message().unhidesChannel(mapOf("blocked" to true), emptyList())) + } + + @Test + fun `A message from a blocked user keeps the channel hidden`() { + val message = message() + + assertFalse(message.unhidesChannel(emptyMap(), listOf(message.user.id))) + } + + @Test + fun `A channel from an event takes the stored hidden and blocked state`() { + val stored = randomChannel(hidden = true, extraData = mapOf("blocked" to true)) + val fromEvent = randomChannel(hidden = null, extraData = mapOf("group" to "vip")) + + val result = fromEvent.withUserStateOf(stored) + + assertEquals(true, result.hidden) + assertEquals(mapOf("blocked" to true, "group" to "vip"), result.extraData) + } + + @Test + fun `A channel from an event keeps the hidden and blocked state it carries`() { + val stored = randomChannel(hidden = true, extraData = mapOf("blocked" to true)) + val fromEvent = randomChannel(hidden = false, extraData = mapOf("blocked" to false)) + + val result = fromEvent.withUserStateOf(stored) + + assertEquals(false, result.hidden) + assertEquals(mapOf("blocked" to false), result.extraData) + } + + private fun message( + type: String = MessageType.REGULAR, + shadowed: Boolean = false, + extraData: Map = emptyMap(), + ): Message = randomMessage(type = type, shadowed = shadowed, extraData = extraData, user = randomUser()) +}