From ffcd6c55a40c0f15fbbb175d7f405d16f19955dc Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:12:17 +0200 Subject: [PATCH 1/9] Clear the channel list and message search when the user logs out or another user connects --- .../channels/ChannelListViewModel.kt | 60 ++- .../channels/ChannelListViewModelTest.kt | 404 +++++++++++++++++- .../ui/common/utils/extensions/ClientState.kt | 40 ++ .../extensions/ClientStateExtensionsTest.kt | 50 +++ .../channels/ChannelListViewModel.kt | 51 ++- .../ui/viewmodel/search/SearchViewModel.kt | 15 +- .../channels/ChannelListViewModelTest.kt | 251 ++++++++++- .../viewmodels/search/SearchViewModelTest.kt | 152 ++++++- 8 files changed, 1004 insertions(+), 19 deletions(-) create mode 100644 stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/ClientState.kt create mode 100644 stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/extensions/ClientStateExtensionsTest.kt diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index 7832e66120ba..6a57d269d1a9 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt @@ -49,6 +49,7 @@ import io.getstream.chat.android.models.ConnectionState import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Mute import io.getstream.chat.android.models.TypingEvent @@ -59,13 +60,16 @@ import io.getstream.chat.android.ui.common.state.channels.actions.ChannelAction import io.getstream.chat.android.ui.common.utils.SearchDebounce import io.getstream.chat.android.ui.common.utils.extensions.defaultChannelListFilter import io.getstream.chat.android.ui.common.utils.extensions.isOneToOne +import io.getstream.chat.android.ui.common.utils.extensions.userSessionEnds import io.getstream.log.taggedLogger import io.getstream.result.Result import io.getstream.result.call.Call import io.getstream.result.call.toUnitCall +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -416,6 +420,11 @@ public class ChannelListViewModel internal constructor( */ private var lastNextQuery: QueryChannelsRequest? = null + /** + * Whether the filter was set through [setFilters], which a user change must not override. + */ + private var isFilterSetByApp = false + /** * Emits the effective query input to react to. Standard mode reacts to filter/sort changes * (via [queryConfigFlow]) in addition to search and refresh; Predefined and Grouped modes @@ -437,13 +446,26 @@ public class ChannelListViewModel internal constructor( if (mode is QueryMode.Standard && mode.initialFilter == null) { viewModelScope.launch { val filter = defaultChannelsFilter().first() - this@ChannelListViewModel.filterFlow.value = filter + if (!isFilterSetByApp) this@ChannelListViewModel.filterFlow.value = filter } } viewModelScope.launch { init() } + + viewModelScope.launch { + chatClient.clientState.userSessionEnds().collectLatest { + clearUserState() + // Reload once the next user is ready; their queries and searches are built from scratch. + chatClient.clientState.initializationState.first { it == InitializationState.COMPLETE } + if (followsDefaultFilter()) { + // The default filter is built from the current user. + filterFlow.value = defaultChannelsFilter().first() + } + refresh() + } + } } /** @@ -498,6 +520,7 @@ public class ChannelListViewModel internal constructor( } is QueryMode.Standard -> Unit } + isFilterSetByApp = true this.filterFlow.tryEmit(value = newFilters) } @@ -909,12 +932,12 @@ public class ChannelListViewModel internal constructor( * given [searchQuery] (via [buildQueryChannelsRequest]) and starts collecting from it. */ private fun observeQueryChannels(searchQuery: String) = - observeQueryChannelsInternal(tag = "observeQueryChannels") { + observeQueryChannelsInternal(tag = "observeQueryChannels") { scope -> val request = buildQueryChannelsRequest(searchQuery) ?: return@observeQueryChannelsInternal null chatClient.queryChannelsAsState( request = request, chatEventHandlerFactory = chatEventHandlerFactory, - coroutineScope = chListScope, + coroutineScope = scope, ) } @@ -924,23 +947,46 @@ public class ChannelListViewModel internal constructor( * `queryGroupedChannels` responses routed through the listener. */ private fun observeGroupedChannels(groupKey: String) = - observeQueryChannelsInternal(tag = "observeGroupedChannels") { + observeQueryChannelsInternal(tag = "observeGroupedChannels") { scope -> chatClient.initGroupedQueryChannelsAsState( identifier = QueryChannelsIdentifier.Grouped(groupKey), chatEventHandlerFactory = chatEventHandlerFactory, - coroutineScope = chListScope, + coroutineScope = scope, ) } + /** + * Whether the filter is the default one, built from the current user, rather than one the app provided. + */ + private fun followsDefaultFilter(): Boolean = + mode is QueryMode.Standard && mode.initialFilter == null && !isFilterSetByApp + + /** + * Drops the channels, search results and selection loaded for the previous user, and stops their queries. + */ + private fun clearUserState() { + logger.d { "[clearUserState] no args" } + chListScope.coroutineContext.cancelChildren() + searchScope.coroutineContext.cancelChildren() + queryChannelsState = MutableStateFlow(null) + searchMessageState.value = null + lastNextQuery = null + selectedChannel.value = null + activeChannelAction = null + channelsState = ChannelsState(searchQuery = _searchQuery.value) + } + /** * Shared implementation for observing a [QueryChannelsState] from a [createState] producer. */ private fun observeQueryChannelsInternal( tag: String, - createState: () -> StateFlow?, + createState: (CoroutineScope) -> StateFlow?, ) = runCatching { queryChannelDebouncer.submitSuspendable { - queryChannelsState = createState() ?: return@submitSuspendable + // Ends with this observation, so a replaced query stops instead of re-running for later users. + val observationScope = CoroutineScope(currentCoroutineContext()) + queryChannelsState = createState(observationScope) ?: return@submitSuspendable queryChannelsState.filterNotNull().collectLatest { queryChannelsState -> combine( queryChannelsState.channelsStateData, diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt index 8548c5fb08ec..5bebbfab6fef 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt @@ -31,6 +31,7 @@ import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.compose.state.channels.list.ChannelListAction import io.getstream.chat.android.compose.state.channels.list.ChannelListEvent +import io.getstream.chat.android.compose.state.channels.list.ChannelsState import io.getstream.chat.android.compose.state.channels.list.ItemState import io.getstream.chat.android.compose.state.channels.list.SearchQuery import io.getstream.chat.android.core.internal.InternalStreamChatApi @@ -60,8 +61,11 @@ import io.getstream.chat.android.test.TestCoroutineExtension import io.getstream.chat.android.test.asCall import io.getstream.chat.android.ui.common.state.channels.actions.DeleteConversation import io.getstream.chat.android.ui.common.utils.SearchDebounce +import io.getstream.chat.android.ui.common.utils.extensions.defaultChannelListFilter import io.getstream.result.Error import io.getstream.result.Result +import io.getstream.result.call.Call +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope @@ -79,6 +83,7 @@ import org.junit.jupiter.api.extension.ExtendWith import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq @@ -129,6 +134,369 @@ internal class ChannelListViewModelTest { assertFalse(channelsState.isLoading) } + @Test + fun `Given channel list in content state When another user connects Should clear the channels`() = runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + ) + .givenChannelMutes() + val viewModel = fixture.get(this) + viewModel.selectChannel(channel1) + + fixture.disconnectUser() + fixture.startConnectingUser(otherUser) + advanceUntilIdle() + + assertEquals(ChannelsState(), viewModel.channelsState) + assertNull(viewModel.selectedChannel.value) + } + + @Test + fun `Given channel list in content state When another user connects mid-disconnect Should clear the channels`() = + runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + ) + .givenChannelMutes() + val viewModel = fixture.get(this) + + fixture.startConnectingUser(otherUser) + advanceUntilIdle() + + assertEquals(ChannelsState(), viewModel.channelsState) + } + + @Test + fun `Given channel list in content state When the user disconnects Should clear the channels`() = runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + ) + .givenChannelMutes() + val viewModel = fixture.get(this) + viewModel.selectChannel(channel1) + + fixture.disconnectUser() + advanceUntilIdle() + + assertEquals(ChannelsState(), viewModel.channelsState) + assertNull(viewModel.selectedChannel.value) + } + + @Test + fun `Given channel list in content state When the same user logs in again Should reload the channels`() = runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + ) + .givenChannelMutes() + val viewModel = fixture.get(this) + viewModel.selectChannel(channel1) + + fixture.disconnectUser() + fixture.connectUser(User(id = currentUserId)) + advanceUntilIdle() + + assertEquals(2, viewModel.channelsState.channelItems.size) + assertNull(viewModel.selectedChannel.value) + } + + @Test + fun `Given channel list in content state When the same user connects again Should keep the channels`() = runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + ) + .givenChannelMutes() + val viewModel = fixture.get(this) + viewModel.selectChannel(channel1) + + fixture.reconnectSameUser() + advanceUntilIdle() + + assertEquals(2, viewModel.channelsState.channelItems.size) + assertEquals(channel1, viewModel.selectedChannel.value) + } + + @Test + fun `Given channel list in content state When refreshing Should keep the channels while loading`() = runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + ) + .givenChannelMutes() + val viewModel = fixture.get(this) + + fixture.givenChannelsState(channelsStateData = ChannelsStateData.Loading, loading = true) + viewModel.refresh() + advanceUntilIdle() + + assertEquals(2, viewModel.channelsState.channelItems.size) + assertTrue(viewModel.channelsState.isLoading) + } + + @Test + fun `Given no initial filter When another user connects Should query with their default filter`() = runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient, initialFilters = null) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + fixture.get(this) + + fixture.disconnectUser() + fixture.connectUser(otherUser) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + assertEquals(Filters.defaultChannelListFilter(otherUser), captor.lastValue.filter) + } + + @Test + fun `Given filters set by the app When another user connects Should keep querying with them`() = runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient, initialFilters = null) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + val viewModel = fixture.get(this) + val appFilter = Filters.eq("type", "livestream") + viewModel.setFilters(appFilter) + advanceUntilIdle() + + fixture.disconnectUser() + fixture.connectUser(otherUser) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + assertEquals(appFilter, captor.lastValue.filter) + } + + @Test + fun `Given message search results When another user connects Should clear the results`() = runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = channel1.cid)))) + .givenRepositorySelectChannels(listOf(channel1)) + val viewModel = fixture.get(this) + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + fixture.disconnectUser() + fixture.startConnectingUser(otherUser) + advanceUntilIdle() + + assertEquals(ChannelsState(searchQuery = SearchQuery.Messages("hello")), viewModel.channelsState) + } + + @Test + fun `Given message search results When another user connects Should search again`() = runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = channel1.cid)))) + .givenRepositorySelectChannels(listOf(channel1)) + val viewModel = fixture.get(this) + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + fixture.disconnectUser() + fixture.connectUser(otherUser) + advanceUntilIdle() + + verify(chatClient, times(2)).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + assertEquals(1, viewModel.channelsState.channelItems.size) + assertFalse(viewModel.channelsState.isLoading) + } + + @Test + fun `Given no initial filter and message search results When another user connects Should search once as them`() = + runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient, initialFilters = null) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = channel1.cid)))) + .givenRepositorySelectChannels(listOf(channel1)) + val viewModel = fixture.get(this) + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + fixture.disconnectUser() + fixture.startConnectingUser(otherUser) + // Longer than the search debounce, so a second search would not be merged into the first. + advanceTimeBy(10_000) + verify(chatClient, times(1)).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + fixture.connectUser(otherUser) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, times(2)).searchMessages( + captor.capture(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + assertEquals(Filters.defaultChannelListFilter(otherUser), captor.lastValue) + } + + @Test + fun `Given predefined filter and a channel search When another user connects Should search as them`() = runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenPredefined(name = "vip_filter") + val viewModel = fixture.get(this) + viewModel.setSearchQuery(SearchQuery.Channels("Search query")) + advanceUntilIdle() + + fixture.disconnectUser() + fixture.connectUser(otherUser) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + val membersIn = (captor.lastValue.filter as AndFilterObject).filterObjects.last() as InFilterObject + assertEquals(setOf(otherUser.id), membersIn.values) + } + + @Test + fun `Given filters set before the first user connects When they connect Should query with those filters`() = + runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient, initialFilters = null) + .givenNoUserYet() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + val viewModel = fixture.get(this) + val appFilter = Filters.eq("type", "livestream") + viewModel.setFilters(appFilter) + + fixture.connectUser(User(id = currentUserId)) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + assertEquals(appFilter, captor.lastValue.filter) + } + + @Test + fun `Given no initial filter When another user connects Should not re-send the previous user's query`() = runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient, initialFilters = null) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + fixture.get(this) + + fixture.disconnectUser() + fixture.connectUser(otherUser) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + val previousUserFilter = Filters.defaultChannelListFilter(User(id = currentUserId)) + assertEquals(1, captor.allValues.count { it.filter == previousUserFilter }) + } + + @Test + fun `Given a replaced filter When the same user connects again Should not re-send the replaced query`() = runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + val viewModel = fixture.get(this) + viewModel.setFilters(Filters.eq("type", "livestream")) + advanceUntilIdle() + + fixture.reconnectSameUser() + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + assertEquals(1, captor.allValues.count { it.filter == queryFilter }) + } + + @Test + fun `Given a next page loading When another user connects Should drop its result`() = runTest { + val nextPageRequest = QueryChannelsRequest(filter = queryFilter, querySort = querySort, offset = 30, limit = 30) + val nextPage = CompletableDeferred>>() + val nextPageCall = object : Call> { + override fun execute(): Result> = error("not used") + override fun enqueue(callback: Call.Callback>) = error("not used") + override suspend fun await(): Result> = nextPage.await() + override fun cancel() = Unit + } + val chatClient: ChatClient = mock() + whenever(chatClient.queryChannels(any())).doReturn(listOf(channel1, channel2).asCall(), nextPageCall) + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + nextPageRequest = nextPageRequest, + loading = false, + ) + .givenChannelMutes() + .givenIsOffline(false) + val viewModel = fixture.get(this) + viewModel.loadMore() + advanceUntilIdle() + + fixture.startConnectingUser(otherUser) + advanceUntilIdle() + nextPage.complete(Result.Failure(Error.GenericError("network error"))) + advanceUntilIdle() + + assertEquals(ChannelsState(), viewModel.channelsState) + } + @Test fun `Given channel list in content state When deleting a channel Should delete the channel`() = runTest { val chatClient: ChatClient = mock() @@ -1557,6 +1925,8 @@ internal class ChannelListViewModelTest { private val initialFilters: FilterObject? = queryFilter, ) { private val clientState: ClientState = mock() + private val user = MutableStateFlow(null) + private val initializationState = MutableStateFlow(InitializationState.NOT_INITIALIZED) private val stateRegistry: StateRegistry = mock() private val globalState: GlobalState = mock() private val repositoryFacade: RepositoryFacade = mock() @@ -1586,12 +1956,42 @@ internal class ChannelListViewModelTest { whenever(globalState.channelDraftMessages) doReturn MutableStateFlow(emptyMap()) } + fun givenNoUserYet() = apply { + whenever(clientState.user) doReturn user + whenever(clientState.initializationState) doReturn initializationState + whenever(chatClient.awaitInitializationState(any())) doReturn InitializationState.COMPLETE + } + fun givenCurrentUser(currentUser: User = User(id = currentUserId)) = apply { - whenever(clientState.user) doReturn MutableStateFlow(currentUser) - whenever(clientState.initializationState) doReturn MutableStateFlow(InitializationState.COMPLETE) + user.value = currentUser + initializationState.value = InitializationState.COMPLETE + whenever(clientState.user) doReturn user + whenever(clientState.initializationState) doReturn initializationState whenever(chatClient.awaitInitializationState(any())) doReturn InitializationState.COMPLETE } + fun reconnectSameUser() { + initializationState.value = InitializationState.INITIALIZING + initializationState.value = InitializationState.COMPLETE + } + + fun startConnectingUser(newUser: User) { + // connectUser sets the user before the client is ready. + initializationState.value = InitializationState.INITIALIZING + user.value = newUser + } + + fun disconnectUser() { + // Same order as ChatClient.disconnect. + initializationState.value = InitializationState.NOT_INITIALIZED + user.value = null + } + + fun connectUser(newUser: User) { + user.value = newUser + initializationState.value = InitializationState.COMPLETE + } + fun givenChannelMutes(channelMutes: List = emptyList()) = apply { whenever(globalState.channelMutes) doReturn MutableStateFlow(channelMutes) } diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/ClientState.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/ClientState.kt new file mode 100644 index 000000000000..04df053f52f4 --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/ClientState.kt @@ -0,0 +1,40 @@ +/* + * 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.ui.common.utils.extensions + +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map + +/** + * Emits each time the connected user's session ends, which makes data cached for that user stale: on disconnect, or + * when a different user replaces them. A first connect, the same user connecting again and profile updates emit + * nothing. + * + * A switch emits as soon as the new user is set, before the client finishes initializing for them. + */ +@InternalStreamChatApi +public fun ClientState.userSessionEnds(): Flow = flow { + var previousUserId: String? = null + user.map { it?.id }.distinctUntilChanged().collect { userId -> + if (previousUserId != null) emit(Unit) + previousUserId = userId + } +} diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/extensions/ClientStateExtensionsTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/extensions/ClientStateExtensionsTest.kt new file mode 100644 index 000000000000..c1fc7d75bc79 --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/extensions/ClientStateExtensionsTest.kt @@ -0,0 +1,50 @@ +/* + * 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.ui.common.utils.extensions + +import app.cash.turbine.test +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.models.User +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +internal class ClientStateExtensionsTest { + + private val user = MutableStateFlow(null) + private val clientState: ClientState = mock { on { it.user } doReturn user } + + @Test + fun `userSessionEnds emits on disconnect and on a switch to a different user`() = runTest { + clientState.userSessionEnds().test { + user.value = User(id = "a") + user.value = User(id = "a", name = "renamed") + expectNoEvents() + + user.value = null + awaitItem() + + user.value = User(id = "a") + expectNoEvents() + + user.value = User(id = "b") + awaitItem() + } + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt index bc456fabdb68..db9863d60fc4 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt @@ -38,10 +38,12 @@ import io.getstream.chat.android.models.ChannelMute import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.TypingEvent import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.ui.common.utils.extensions.defaultChannelListFilter +import io.getstream.chat.android.ui.common.utils.extensions.userSessionEnds import io.getstream.chat.android.ui.feature.channels.list.ChannelListView import io.getstream.chat.android.ui.utils.extensions.EXTRA_DATA_MUTED import io.getstream.chat.android.ui.utils.extensions.addFlow @@ -49,6 +51,7 @@ import io.getstream.chat.android.ui.utils.extensions.isMuted import io.getstream.log.TaggedLogger import io.getstream.log.taggedLogger import io.getstream.result.Error +import io.getstream.result.call.Call import io.getstream.result.call.enqueue import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -65,6 +68,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.isActive import kotlinx.coroutines.job import kotlinx.coroutines.launch +import kotlinx.coroutines.plus /** * ViewModel class for [ChannelListView]. @@ -242,13 +246,23 @@ public class ChannelListViewModel internal constructor( */ private var queryChannelsState: StateFlow = MutableStateFlow(null) + /** + * Whether the filter was set through [setFilters], which a user change must not override. + */ + private var isFilterSetByApp = false + + /** + * The in-flight request for the next page, if any. + */ + private var loadMoreCall: Call>? = null + init { when (mode) { is QueryMode.Standard -> { if (mode.initialFilter == null) { viewModelScope.launch { val resolvedFilter = buildDefaultFilter().first() - this@ChannelListViewModel.filterLiveData.value = resolvedFilter + if (!isFilterSetByApp) this@ChannelListViewModel.filterLiveData.value = resolvedFilter } } stateMerger.addSource(filterLiveData) { filter -> @@ -259,8 +273,32 @@ public class ChannelListViewModel internal constructor( } is QueryMode.Predefined -> initData() } + viewModelScope.launch { + chatClient.clientState.userSessionEnds().collectLatest { + // Drop the previous user's channels and stop their queries. + queryJob?.cancel() + loadMoreCall?.cancel() + queryChannelsState = MutableStateFlow(null) + stateMerger.value = INITIAL_STATE + paginationStateMerger.value = PaginationState() + // Reload once the next user is ready; their query is built from scratch. + chatClient.clientState.initializationState.first { it == InitializationState.COMPLETE } + if (followsDefaultFilter()) { + // The default filter is built from the current user, and setting it reloads. + filterLiveData.value = buildDefaultFilter().first() + } else { + initData() + } + } + } } + /** + * Whether the filter is the default one, built from the current user, rather than one the app provided. + */ + private fun followsDefaultFilter(): Boolean = + mode is QueryMode.Standard && mode.initialFilter == null && !isFilterSetByApp + /** * Builds the default channel filter, which represents "messaging" channels that the current user is a part of. */ @@ -285,9 +323,6 @@ public class ChannelListViewModel internal constructor( logger.v { "[init] rejected (filter not yet initialized)" } return } - queryChannelsState = - chatClient.queryChannelsAsState(queryChannelsRequest, chatEventHandlerFactory, viewModelScope) - /** * We clean up any previous loads to make sure the current one is the only one running. */ @@ -295,6 +330,9 @@ public class ChannelListViewModel internal constructor( val queryJob = Job(viewModelScope.coroutineContext.job).also { this.queryJob = it } + // Tied to the query job, so a replaced query stops instead of re-running for later users. + queryChannelsState = + chatClient.queryChannelsAsState(queryChannelsRequest, chatEventHandlerFactory, viewModelScope + queryJob) viewModelScope.launch(queryJob) { queryChannelsState.filterNotNull().collectLatest { queryChannelsState -> @@ -424,7 +462,9 @@ public class ChannelListViewModel internal constructor( val queryChannelsState = queryChannelsState.value ?: return val nextPageRequest = queryChannelsState.nextPageRequest.value ?: return viewModelScope.launch { - chatClient.queryChannels(nextPageRequest).enqueue( + // Repeating the request returns the call already in flight, so this tracks it either way. + loadMoreCall = chatClient.queryChannels(nextPageRequest) + loadMoreCall?.enqueue( onError = { streamError -> logger.e { "Could not load more channels. Error: ${streamError.message}. " + @@ -449,6 +489,7 @@ public class ChannelListViewModel internal constructor( return } logger.d { "[setFilters] filterObject: $filterObject" } + isFilterSetByApp = true this.filterLiveData.value = filterObject } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt index 2145d0635ea4..85464446597b 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt @@ -23,14 +23,17 @@ import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.state.Event import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.ui.common.model.MessageResult +import io.getstream.chat.android.ui.common.utils.extensions.userSessionEnds import io.getstream.log.taggedLogger import io.getstream.result.Error import io.getstream.result.Result import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch /** @@ -66,6 +69,13 @@ public class SearchViewModel( private val logger by taggedLogger("Chat:SearchViewModel") + init { + scope.launch { + // Drops the previous user's results; the search runs again once the next user is ready. + chatClient.clientState.userSessionEnds().collect { setQuery(_state.value!!.query) } + } + } + /** * Changes the current query state. An empty search query */ @@ -99,10 +109,9 @@ public class SearchViewModel( * is already in progress. */ public fun loadMore() { - job?.cancel() - val currentState = _state.value!! if (currentState.canLoadMore && !currentState.isLoading && !currentState.isLoadingMore) { + job?.cancel() _state.value = currentState.copy(isLoadingMore = true) searchMessages() } @@ -174,6 +183,8 @@ public class SearchViewModel( */ private suspend fun searchMessages(query: String, next: String?): Result { logger.d { "Searching for \"$query\" with next: $next" } + // A user who is still connecting, e.g. right after a switch, can't search yet. + chatClient.clientState.initializationState.first { it == InitializationState.COMPLETE } val currentUser = requireNotNull(chatClient.clientState.user.value) return chatClient .searchMessages( diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt index f81cdea0c0d5..32fee785afba 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt @@ -38,7 +38,9 @@ import io.getstream.chat.android.test.InstantTaskExecutorExtension import io.getstream.chat.android.test.TestCoroutineExtension import io.getstream.chat.android.test.asCall import io.getstream.chat.android.test.observeAll +import io.getstream.chat.android.ui.common.utils.extensions.defaultChannelListFilter import io.getstream.chat.android.ui.viewmodel.channels.ChannelListViewModel +import io.getstream.result.call.Call import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.amshove.kluent.`should be equal to` @@ -48,9 +50,11 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -102,6 +106,217 @@ internal class ChannelListViewModelTest { ) } + @Test + fun `Given channel list in content state When another user connects Should clear the channels`() = runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + endOfChannels = true, + ) + .givenChannelMutes() + val viewModel = fixture.get() + val state = viewModel.state.observeAll() + val paginationState = viewModel.paginationState.observeAll() + + fixture.disconnectUser() + fixture.startConnectingUser(User(id = "other")) + + state.last() shouldBeEqualTo ChannelListViewModel.State(isLoading = true, channels = emptyList()) + paginationState.last() shouldBeEqualTo ChannelListViewModel.PaginationState() + } + + @Test + fun `Given channel list in content state When another user connects mid-disconnect Should clear the channels`() = + runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + ) + .givenChannelMutes() + val viewModel = fixture.get() + val state = viewModel.state.observeAll() + + fixture.startConnectingUser(User(id = "other")) + + state.last() shouldBeEqualTo ChannelListViewModel.State(isLoading = true, channels = emptyList()) + } + + @Test + fun `Given channel list in content state When the user disconnects Should clear the channels`() = runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + endOfChannels = true, + ) + .givenChannelMutes() + val viewModel = fixture.get() + val state = viewModel.state.observeAll() + val paginationState = viewModel.paginationState.observeAll() + + fixture.disconnectUser() + + state.last() shouldBeEqualTo ChannelListViewModel.State(isLoading = true, channels = emptyList()) + paginationState.last() shouldBeEqualTo ChannelListViewModel.PaginationState() + } + + @Test + fun `Given no initial filter When another user connects Should not re-send the previous user's query`() = runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient, initialFilters = null) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + val viewModel = fixture.get() + viewModel.state.observeAll() + + fixture.disconnectUser() + fixture.connectUser(User(id = "other")) + + val captor = argumentCaptor() + verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + val previousUserFilter = Filters.defaultChannelListFilter(User(id = "Jc")) + captor.allValues.count { it.filter == previousUserFilter } shouldBeEqualTo 1 + } + + @Test + fun `Given channel list in content state When the same user connects again Should keep the channels`() = runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + endOfChannels = true, + ) + .givenChannelMutes() + val viewModel = fixture.get() + val state = viewModel.state.observeAll() + val emittedBefore = state.size + + fixture.reconnectSameUser() + + state.drop(emittedBefore).none { it.channels.isEmpty() } shouldBeEqualTo true + state.last().channels shouldBeEqualTo listOf(channel1, channel2) + } + + @Test + fun `Given no initial filter When another user connects Should query with their default filter`() = runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient, initialFilters = null) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + val viewModel = fixture.get() + viewModel.state.observeAll() + val otherUser = User(id = "other") + + fixture.disconnectUser() + fixture.connectUser(otherUser) + + val captor = argumentCaptor() + verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + captor.lastValue.filter shouldBeEqualTo Filters.defaultChannelListFilter(otherUser) + } + + @Test + fun `Given filters set by the app When another user connects Should keep querying with them`() = runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient, initialFilters = null) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + val viewModel = fixture.get() + viewModel.state.observeAll() + val appFilter = Filters.eq("type", "livestream") + viewModel.setFilters(appFilter) + + fixture.disconnectUser() + fixture.connectUser(User(id = "other")) + + val captor = argumentCaptor() + verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + captor.lastValue.filter shouldBeEqualTo appFilter + } + + @Test + fun `Given filters set before the first user connects When they connect Should query with those filters`() = + runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient, initialFilters = null) + .givenNoUserYet() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + val viewModel = fixture.get() + viewModel.state.observeAll() + val appFilter = Filters.eq("type", "livestream") + viewModel.setFilters(appFilter) + + fixture.connectUser(User(id = "Jc")) + + val captor = argumentCaptor() + verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + captor.lastValue.filter shouldBeEqualTo appFilter + } + + @Test + fun `Given a next page loading When reaching the end again Should not cancel it`() = runTest { + val chatClient: ChatClient = mock() + val nextPageRequest = QueryChannelsRequest(filter = queryFilter, querySort = querySort, offset = 30, limit = 30) + val nextPageCall: Call> = mock() + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + nextPageRequest = nextPageRequest, + ) + .givenChannelMutes() + whenever(chatClient.queryChannels(nextPageRequest)) doReturn nextPageCall + val viewModel = fixture.get() + viewModel.state.observeAll() + + viewModel.onAction(ChannelListViewModel.Action.ReachedEndOfList) + viewModel.onAction(ChannelListViewModel.Action.ReachedEndOfList) + + verify(nextPageCall, never()).cancel() + } + + @Test + fun `Given a next page loading When another user connects Should cancel it`() = runTest { + val chatClient: ChatClient = mock() + val nextPageRequest = QueryChannelsRequest(filter = queryFilter, querySort = querySort, offset = 30, limit = 30) + val nextPageCall: Call> = mock() + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + nextPageRequest = nextPageRequest, + ) + .givenChannelMutes() + whenever(chatClient.queryChannels(nextPageRequest)) doReturn nextPageCall + val viewModel = fixture.get() + viewModel.state.observeAll() + viewModel.onAction(ChannelListViewModel.Action.ReachedEndOfList) + + fixture.startConnectingUser(User(id = "other")) + + verify(nextPageCall).cancel() + } + @Test fun `Given channel list in content state When leaving a channel Should leave the channel`() = runTest { val chatClient: ChatClient = mock() @@ -262,6 +477,8 @@ internal class ChannelListViewModelTest { private val stateRegistry: StateRegistry = mock() private val clientState: ClientState = mock() + private val user = MutableStateFlow(null) + private val initializationState = MutableStateFlow(InitializationState.NOT_INITIALIZED) private val globalState: GlobalState = mock() private var predefinedFilterName: String? = null private var predefinedFilterValues: Map? = null @@ -277,13 +494,43 @@ internal class ChannelListViewModelTest { whenever(globalState.typingChannels) doReturn MutableStateFlow(emptyMap()) } + fun givenNoUserYet() = apply { + whenever(clientState.user) doReturn user + whenever(clientState.initializationState) doReturn initializationState + whenever(chatClient.awaitInitializationState(any())) doReturn InitializationState.COMPLETE + } + fun givenCurrentUser(currentUser: User = User(id = "Jc")) = apply { - whenever(clientState.user) doReturn MutableStateFlow(currentUser) - whenever(clientState.initializationState) doReturn MutableStateFlow(InitializationState.COMPLETE) + user.value = currentUser + initializationState.value = InitializationState.COMPLETE + whenever(clientState.user) doReturn user + whenever(clientState.initializationState) doReturn initializationState whenever(chatClient.getCurrentUser()) doReturn currentUser whenever(chatClient.awaitInitializationState(any())) doReturn InitializationState.COMPLETE } + fun reconnectSameUser() { + initializationState.value = InitializationState.INITIALIZING + initializationState.value = InitializationState.COMPLETE + } + + fun startConnectingUser(newUser: User) { + // connectUser sets the user before the client is ready. + initializationState.value = InitializationState.INITIALIZING + user.value = newUser + } + + fun connectUser(newUser: User) { + user.value = newUser + initializationState.value = InitializationState.COMPLETE + } + + fun disconnectUser() { + // Same order as ChatClient.disconnect. + initializationState.value = InitializationState.NOT_INITIALIZED + user.value = null + } + fun givenChannelMutes(channelMutes: List = emptyList()) = apply { whenever(globalState.channelMutes) doReturn MutableStateFlow(channelMutes) } diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt index 2a78f15a05e8..00d89fce13e9 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt @@ -20,6 +20,9 @@ import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.models.User import io.getstream.chat.android.randomMessage @@ -41,6 +44,7 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -154,11 +158,137 @@ internal class SearchViewModelTest { ) } + @Test + fun `Given search results When another user connects Should clear the results`() = runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) + .givenRepositorySelectChannels() + val viewModel = fixture.get() + val states = viewModel.state.observeAll() + viewModel.setQuery("hello") + + fixture.startConnectingUser(User(id = "other")) + + assertEquals(SearchViewModel.State(query = "hello", isLoading = true), states.last()) + } + + @Test + fun `Given search results When another user connects Should search again as that user`() = runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) + .givenRepositorySelectChannels() + val viewModel = fixture.get() + viewModel.state.observeAll() + viewModel.setQuery("hello") + + fixture.startConnectingUser(User(id = "other")) + fixture.finishConnecting() + + val captor = argumentCaptor() + verify(chatClient, times(2)).searchMessages( + captor.capture(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + assertEquals(Filters.`in`("members", listOf("other")), captor.lastValue) + assertEquals(1, viewModel.state.value!!.results.size) + } + + @Test + fun `Given a user switch When a new query is typed before the user is ready Should keep the new query`() = + runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) + .givenRepositorySelectChannels() + val viewModel = fixture.get() + viewModel.state.observeAll() + viewModel.setQuery("hello") + + fixture.startConnectingUser(User(id = "other")) + viewModel.setQuery("bye") + fixture.finishConnecting() + + val captor = argumentCaptor() + verify(chatClient, times(2)).searchMessages( + any(), + captor.capture(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + assertEquals(Filters.autocomplete("text", "bye"), captor.lastValue) + assertEquals("bye", viewModel.state.value!!.query) + } + + @Test + fun `Given search results When the user disconnects Should clear the results`() = runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) + .givenRepositorySelectChannels() + val viewModel = fixture.get() + val states = viewModel.state.observeAll() + viewModel.setQuery("hello") + + fixture.disconnectUser() + + assertEquals(SearchViewModel.State(query = "hello", isLoading = true), states.last()) + } + + @Test + fun `Given a search waiting for the next user When loading more Should keep the search`() = runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) + .givenRepositorySelectChannels() + val viewModel = fixture.get() + viewModel.state.observeAll() + viewModel.setQuery("hello") + + fixture.startConnectingUser(User(id = "other")) + viewModel.loadMore() + fixture.finishConnecting() + + verify(chatClient, times(2)).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + assertEquals(1, viewModel.state.value!!.results.size) + assertFalse(viewModel.state.value!!.isLoading) + } + + @Test + fun `Given a user still connecting When searching Should wait until they are ready`() = runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) + .givenRepositorySelectChannels() + val viewModel = fixture.get() + viewModel.state.observeAll() + + fixture.startConnectingUser(User(id = "other")) + viewModel.setQuery("hello") + verify(chatClient, never()).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + + fixture.finishConnecting() + verify(chatClient).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + } + private class Fixture( private val chatClient: ChatClient = mock(), ) { private val clientState: ClientState = mock() private val repositoryFacade: RepositoryFacade = mock() + private val user = MutableStateFlow(null) + private val initializationState = MutableStateFlow(InitializationState.NOT_INITIALIZED) init { whenever(chatClient.clientState) doReturn clientState @@ -166,7 +296,27 @@ internal class SearchViewModelTest { } fun givenCurrentUser(currentUser: User = User(id = "Jc")) = apply { - whenever(clientState.user) doReturn MutableStateFlow(currentUser) + user.value = currentUser + initializationState.value = InitializationState.COMPLETE + whenever(clientState.user) doReturn user + whenever(clientState.initializationState) doReturn initializationState + } + + fun disconnectUser() { + initializationState.value = InitializationState.NOT_INITIALIZED + user.value = null + } + + fun startConnectingUser(newUser: User) { + // Same order as ChatClient: disconnect, then connectUser sets the user before the client is ready. + initializationState.value = InitializationState.NOT_INITIALIZED + user.value = null + initializationState.value = InitializationState.INITIALIZING + user.value = newUser + } + + fun finishConnecting() { + initializationState.value = InitializationState.COMPLETE } fun givenSearchMessagesResult(result: SearchMessagesResult) = apply { From 7567c1c106d3a16465b6abdbd0933fa3ff48a7fe Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:50:07 +0200 Subject: [PATCH 2/9] Document that a channel list view model serves one user at a time --- .../viewmodel/channels/ChannelListViewModelFactory.kt | 5 +++++ .../ui/viewmodel/channels/ChannelListViewModelFactory.kt | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelFactory.kt index 65c4f790a861..e07bfeea55f4 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelFactory.kt @@ -34,6 +34,11 @@ import io.getstream.chat.android.models.querysort.QuerySorter * Builds the factory that contains all the dependencies required for the Channels Screen. * It currently provides the [ChannelListViewModel] using those dependencies. * + * A [ChannelListViewModel] serves one user at a time: when the user disconnects or another user connects, it drops + * the loaded channels and reloads once the next user is connected. The default filter follows the connected user, + * but filters and filter values that contain a user id don't, so a different user needs a new factory and view model, + * for example keyed by the user id. + * * @param chatClient The client used to fetch data. * @param channelLimit How many channels we fetch per page. * @param memberLimit How many members are fetched for each channel item when loading channels. diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory.kt index 5783b6c76a1b..5434b9eb322c 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory.kt @@ -31,6 +31,11 @@ import io.getstream.chat.android.ui.viewmodel.channels.ChannelListViewModel.Quer /** * Creates a channels view model factory. * + * A [ChannelListViewModel] serves one user at a time: when the user disconnects or another user connects, it drops + * the loaded channels and reloads once the next user is connected. The default filter follows the connected user, + * but filters and filter values that contain a user id don't, so a different user needs a new factory and view model, + * for example keyed by the user id. + * * @param limit How many channels to return. * @param memberLimit The number of members per channel. When `null`, the server-side default is used. * @param messageLimit The number of messages to fetch for each channel. When `null`, the server-side default is used. From a64c12359ad30d630f7571029e87b904db14159a Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:18:48 +0200 Subject: [PATCH 3/9] Keep the previous user's default filter out of queries until the next user is ready --- .../channels/ChannelListViewModel.kt | 7 ++- .../channels/ChannelListViewModelTest.kt | 44 ++++++++++++++ .../channels/ChannelListViewModel.kt | 2 + .../channels/ChannelListViewModelTest.kt | 58 +++++-------------- 4 files changed, 67 insertions(+), 44 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index 6a57d269d1a9..4ba02138f9dc 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt @@ -460,10 +460,11 @@ public class ChannelListViewModel internal constructor( // Reload once the next user is ready; their queries and searches are built from scratch. chatClient.clientState.initializationState.first { it == InitializationState.COMPLETE } if (followsDefaultFilter()) { - // The default filter is built from the current user. + // The default filter is built from the current user, and setting it reloads. filterFlow.value = defaultChannelsFilter().first() + } else { + refresh() } - refresh() } } } @@ -969,6 +970,8 @@ public class ChannelListViewModel internal constructor( chListScope.coroutineContext.cancelChildren() searchScope.coroutineContext.cancelChildren() queryChannelsState = MutableStateFlow(null) + // The default filter names the previous user, so nothing may query with it until it's rebuilt. + if (followsDefaultFilter()) filterFlow.value = null searchMessageState.value = null lastNextQuery = null selectedChannel.value = null diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt index 5bebbfab6fef..6231e81de4ca 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt @@ -497,6 +497,50 @@ internal class ChannelListViewModelTest { assertEquals(ChannelsState(), viewModel.channelsState) } + @Test + fun `Given no initial filter When the sort changes while another user connects Should not query with the previous user's filter`() = + runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient, initialFilters = null) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + val viewModel = fixture.get(this) + + fixture.disconnectUser() + fixture.startConnectingUser(otherUser) + viewModel.setQuerySort(QuerySortByField.descByName("created_at")) + advanceUntilIdle() + fixture.connectUser(otherUser) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + val previousUserFilter = Filters.defaultChannelListFilter(User(id = currentUserId)) + assertEquals(1, captor.allValues.count { it.filter == previousUserFilter }) + assertEquals(Filters.defaultChannelListFilter(otherUser), captor.lastValue.filter) + } + + @Test + fun `Given no initial filter When the same user logs in again Should query with their default filter`() = runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient, initialFilters = null) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + fixture.get(this) + + fixture.disconnectUser() + fixture.connectUser(User(id = currentUserId)) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, times(2)).queryChannels(captor.capture()) + assertTrue(captor.allValues.all { it.filter == Filters.defaultChannelListFilter(User(id = currentUserId)) }) + } + @Test fun `Given channel list in content state When deleting a channel Should delete the channel`() = runTest { val chatClient: ChatClient = mock() diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt index db9863d60fc4..f08cfe095a64 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt @@ -279,6 +279,8 @@ public class ChannelListViewModel internal constructor( queryJob?.cancel() loadMoreCall?.cancel() queryChannelsState = MutableStateFlow(null) + // The default filter names the previous user, so nothing may query with it until it's rebuilt. + if (followsDefaultFilter()) filterLiveData.value = null stateMerger.value = INITIAL_STATE paginationStateMerger.value = PaginationState() // Reload once the next user is ready; their query is built from scratch. diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt index 32fee785afba..2b81a0482b59 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt @@ -272,50 +272,24 @@ internal class ChannelListViewModelTest { } @Test - fun `Given a next page loading When reaching the end again Should not cancel it`() = runTest { - val chatClient: ChatClient = mock() - val nextPageRequest = QueryChannelsRequest(filter = queryFilter, querySort = querySort, offset = 30, limit = 30) - val nextPageCall: Call> = mock() - val fixture = Fixture(chatClient) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), - nextPageRequest = nextPageRequest, - ) - .givenChannelMutes() - whenever(chatClient.queryChannels(nextPageRequest)) doReturn nextPageCall - val viewModel = fixture.get() - viewModel.state.observeAll() - - viewModel.onAction(ChannelListViewModel.Action.ReachedEndOfList) - viewModel.onAction(ChannelListViewModel.Action.ReachedEndOfList) - - verify(nextPageCall, never()).cancel() - } - - @Test - fun `Given a next page loading When another user connects Should cancel it`() = runTest { - val chatClient: ChatClient = mock() - val nextPageRequest = QueryChannelsRequest(filter = queryFilter, querySort = querySort, offset = 30, limit = 30) - val nextPageCall: Call> = mock() - val fixture = Fixture(chatClient) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), - nextPageRequest = nextPageRequest, - ) - .givenChannelMutes() - whenever(chatClient.queryChannels(nextPageRequest)) doReturn nextPageCall - val viewModel = fixture.get() - viewModel.state.observeAll() - viewModel.onAction(ChannelListViewModel.Action.ReachedEndOfList) + fun `Given no initial filter When the same user logs in again Should query with their default filter`() = + runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient, initialFilters = null) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + val viewModel = fixture.get() + viewModel.state.observeAll() - fixture.startConnectingUser(User(id = "other")) + fixture.disconnectUser() + fixture.connectUser(User(id = "Jc")) - verify(nextPageCall).cancel() - } + val captor = argumentCaptor() + verify(chatClient, times(2)).queryChannels(captor.capture()) + captor.allValues.all { it.filter == Filters.defaultChannelListFilter(User(id = "Jc")) } shouldBeEqualTo true + } @Test fun `Given channel list in content state When leaving a channel Should leave the channel`() = runTest { From 487a91656ba3aa80bfd500b5c80292e88a5d13db Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:18:48 +0200 Subject: [PATCH 4/9] Make every refresh reload the channel list --- .../android/compose/viewmodel/channels/ChannelListViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index 4ba02138f9dc..de67a7b2f7db 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt @@ -84,6 +84,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.plus @@ -474,7 +475,7 @@ public class ChannelListViewModel internal constructor( */ public fun refresh() { logger.d { "[refresh] no args" } - refreshFlow.value = System.currentTimeMillis() + refreshFlow.update { it + 1 } } /** From 94479c8a2cc828b80e6c1052d8a5b4a60d06fa03 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:18:48 +0200 Subject: [PATCH 5/9] Fail a message search with no connected user instead of crashing --- .../ui/viewmodel/search/SearchViewModel.kt | 3 ++- .../viewmodels/search/SearchViewModelTest.kt | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt index 85464446597b..1398d25877ea 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt @@ -185,7 +185,8 @@ public class SearchViewModel( logger.d { "Searching for \"$query\" with next: $next" } // A user who is still connecting, e.g. right after a switch, can't search yet. chatClient.clientState.initializationState.first { it == InitializationState.COMPLETE } - val currentUser = requireNotNull(chatClient.clientState.user.value) + val currentUser = chatClient.clientState.user.value + ?: return Result.Failure(Error.GenericError("No user is connected")) return chatClient .searchMessages( channelFilter = Filters.`in`("members", listOf(currentUser.id)), diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt index 00d89fce13e9..e4e78d110ff7 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt @@ -282,6 +282,25 @@ internal class SearchViewModelTest { verify(chatClient).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } + @Test + fun `Given no user once the client is ready When searching Should report an error`() = runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) + .givenRepositorySelectChannels() + val viewModel = fixture.get() + val states = viewModel.state.observeAll() + val errors = viewModel.errorEvents.observeAll() + + fixture.loseUserOnceReady() + viewModel.setQuery("hello") + + verify(chatClient, never()).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + assertFalse(states.last().isLoading) + assertEquals(1, errors.size) + } + private class Fixture( private val chatClient: ChatClient = mock(), ) { @@ -315,6 +334,11 @@ internal class SearchViewModelTest { user.value = newUser } + fun loseUserOnceReady() { + user.value = null + initializationState.value = InitializationState.COMPLETE + } + fun finishConnecting() { initializationState.value = InitializationState.COMPLETE } From 6ee90c6dd8ea00c1cd0752a9da9e39971fcdbb33 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:19:08 +0200 Subject: [PATCH 6/9] Drop the XML load-more cancel that disconnect already covers --- .../ui/viewmodel/channels/ChannelListViewModel.kt | 11 +---------- .../viewmodels/channels/ChannelListViewModelTest.kt | 2 -- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt index f08cfe095a64..91583a69dfc0 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt @@ -51,7 +51,6 @@ import io.getstream.chat.android.ui.utils.extensions.isMuted import io.getstream.log.TaggedLogger import io.getstream.log.taggedLogger import io.getstream.result.Error -import io.getstream.result.call.Call import io.getstream.result.call.enqueue import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -251,11 +250,6 @@ public class ChannelListViewModel internal constructor( */ private var isFilterSetByApp = false - /** - * The in-flight request for the next page, if any. - */ - private var loadMoreCall: Call>? = null - init { when (mode) { is QueryMode.Standard -> { @@ -277,7 +271,6 @@ public class ChannelListViewModel internal constructor( chatClient.clientState.userSessionEnds().collectLatest { // Drop the previous user's channels and stop their queries. queryJob?.cancel() - loadMoreCall?.cancel() queryChannelsState = MutableStateFlow(null) // The default filter names the previous user, so nothing may query with it until it's rebuilt. if (followsDefaultFilter()) filterLiveData.value = null @@ -464,9 +457,7 @@ public class ChannelListViewModel internal constructor( val queryChannelsState = queryChannelsState.value ?: return val nextPageRequest = queryChannelsState.nextPageRequest.value ?: return viewModelScope.launch { - // Repeating the request returns the call already in flight, so this tracks it either way. - loadMoreCall = chatClient.queryChannels(nextPageRequest) - loadMoreCall?.enqueue( + chatClient.queryChannels(nextPageRequest).enqueue( onError = { streamError -> logger.e { "Could not load more channels. Error: ${streamError.message}. " + diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt index 2b81a0482b59..1fe910536e04 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt @@ -40,7 +40,6 @@ import io.getstream.chat.android.test.asCall import io.getstream.chat.android.test.observeAll import io.getstream.chat.android.ui.common.utils.extensions.defaultChannelListFilter import io.getstream.chat.android.ui.viewmodel.channels.ChannelListViewModel -import io.getstream.result.call.Call import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.amshove.kluent.`should be equal to` @@ -54,7 +53,6 @@ import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock -import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever From b939d97f73659ead7d7881f070bd1b86c5dee48a Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:19:08 +0200 Subject: [PATCH 7/9] Document setting the next user's filter before the channel list reloads --- .../compose/viewmodel/channels/ChannelListViewModel.kt | 2 ++ .../viewmodel/channels/ChannelListViewModelFactory.kt | 7 ++++--- .../android/ui/viewmodel/channels/ChannelListViewModel.kt | 2 ++ .../ui/viewmodel/channels/ChannelListViewModelFactory.kt | 7 ++++--- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index de67a7b2f7db..7c77d1c6aa33 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt @@ -508,6 +508,8 @@ public class ChannelListViewModel internal constructor( * Has no effect on view models constructed for a predefined-filter query (predefined identity is * fixed at construction) or for a grouped query (the group's filter is server-owned). * + * After a user disconnects, setting the next user's filter before they finish connecting makes the reload use it. + * * @param newFilters The new filters to be used as a baseline for filtering channels. */ public fun setFilters(newFilters: FilterObject) { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelFactory.kt index e07bfeea55f4..4026adad9414 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelFactory.kt @@ -35,9 +35,10 @@ import io.getstream.chat.android.models.querysort.QuerySorter * It currently provides the [ChannelListViewModel] using those dependencies. * * A [ChannelListViewModel] serves one user at a time: when the user disconnects or another user connects, it drops - * the loaded channels and reloads once the next user is connected. The default filter follows the connected user, - * but filters and filter values that contain a user id don't, so a different user needs a new factory and view model, - * for example keyed by the user id. + * the loaded channels and reloads once the next user is connected. The default filter follows the connected user. + * A filter that contains a user id doesn't: set the next user's filter with [ChannelListViewModel.setFilters] before + * the list reloads, for example when `clientState.user` changes. Predefined filter values can't be changed, so a + * different user needs a new view model. * * @param chatClient The client used to fetch data. * @param channelLimit How many channels we fetch per page. diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt index 91583a69dfc0..3b888548918e 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt @@ -474,6 +474,8 @@ public class ChannelListViewModel internal constructor( * Has no effect on view models constructed for a predefined-filter query — the predefined identity * is fixed at construction. A warning is logged in that case. * + * After a user disconnects, setting the next user's filter before they finish connecting makes the reload use it. + * * @param filterObject The new filter to be applied to the query which lets us fetch different data. */ public fun setFilters(filterObject: FilterObject) { diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory.kt index 5434b9eb322c..3322b4115ee2 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory.kt @@ -32,9 +32,10 @@ import io.getstream.chat.android.ui.viewmodel.channels.ChannelListViewModel.Quer * Creates a channels view model factory. * * A [ChannelListViewModel] serves one user at a time: when the user disconnects or another user connects, it drops - * the loaded channels and reloads once the next user is connected. The default filter follows the connected user, - * but filters and filter values that contain a user id don't, so a different user needs a new factory and view model, - * for example keyed by the user id. + * the loaded channels and reloads once the next user is connected. The default filter follows the connected user. + * A filter that contains a user id doesn't: set the next user's filter with [ChannelListViewModel.setFilters] before + * the list reloads, for example when `clientState.user` changes. Predefined filter values can't be changed, so a + * different user needs a new view model. * * @param limit How many channels to return. * @param memberLimit The number of members per channel. When `null`, the server-side default is used. From 335b0874a0634dbfeff7ea9e6297f5bf0247c2b3 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:07:14 +0200 Subject: [PATCH 8/9] Simplify the user session handling in the channel list and search view models --- .../channels/ChannelListViewModel.kt | 23 +++---- .../channels/ChannelListViewModelTest.kt | 25 +++++++ .../channels/ChannelListViewModel.kt | 34 +++++----- .../ui/viewmodel/search/SearchViewModel.kt | 1 - .../channels/ChannelListViewModelTest.kt | 65 +++++++++++++++++++ 5 files changed, 113 insertions(+), 35 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index 7c77d1c6aa33..90f9cb6291cd 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt @@ -422,9 +422,9 @@ public class ChannelListViewModel internal constructor( private var lastNextQuery: QueryChannelsRequest? = null /** - * Whether the filter was set through [setFilters], which a user change must not override. + * Whether the filter is the default one, built from the connected user, rather than one the app provided. */ - private var isFilterSetByApp = false + private var followsDefaultFilter = mode is QueryMode.Standard && mode.initialFilter == null /** * Emits the effective query input to react to. Standard mode reacts to filter/sort changes @@ -444,10 +444,10 @@ public class ChannelListViewModel internal constructor( * Combines the latest search query and filter to fetch channels and emit them to the UI. */ init { - if (mode is QueryMode.Standard && mode.initialFilter == null) { + if (followsDefaultFilter) { viewModelScope.launch { val filter = defaultChannelsFilter().first() - if (!isFilterSetByApp) this@ChannelListViewModel.filterFlow.value = filter + if (followsDefaultFilter) this@ChannelListViewModel.filterFlow.value = filter } } @@ -460,7 +460,7 @@ public class ChannelListViewModel internal constructor( clearUserState() // Reload once the next user is ready; their queries and searches are built from scratch. chatClient.clientState.initializationState.first { it == InitializationState.COMPLETE } - if (followsDefaultFilter()) { + if (followsDefaultFilter) { // The default filter is built from the current user, and setting it reloads. filterFlow.value = defaultChannelsFilter().first() } else { @@ -524,7 +524,7 @@ public class ChannelListViewModel internal constructor( } is QueryMode.Standard -> Unit } - isFilterSetByApp = true + followsDefaultFilter = false this.filterFlow.tryEmit(value = newFilters) } @@ -959,12 +959,6 @@ public class ChannelListViewModel internal constructor( ) } - /** - * Whether the filter is the default one, built from the current user, rather than one the app provided. - */ - private fun followsDefaultFilter(): Boolean = - mode is QueryMode.Standard && mode.initialFilter == null && !isFilterSetByApp - /** * Drops the channels, search results and selection loaded for the previous user, and stops their queries. */ @@ -974,11 +968,10 @@ public class ChannelListViewModel internal constructor( searchScope.coroutineContext.cancelChildren() queryChannelsState = MutableStateFlow(null) // The default filter names the previous user, so nothing may query with it until it's rebuilt. - if (followsDefaultFilter()) filterFlow.value = null + if (followsDefaultFilter) filterFlow.value = null searchMessageState.value = null lastNextQuery = null - selectedChannel.value = null - activeChannelAction = null + dismissChannelAction() channelsState = ChannelsState(searchQuery = _searchQuery.value) } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt index 6231e81de4ca..ac119969e6a7 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt @@ -541,6 +541,31 @@ internal class ChannelListViewModelTest { assertTrue(captor.allValues.all { it.filter == Filters.defaultChannelListFilter(User(id = currentUserId)) }) } + @Test + fun `Given no initial filter When the app sets filters before another user connects Should query with the app's filters`() = + runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient, initialFilters = null) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + val viewModel = fixture.get(this) + val appFilter = Filters.eq("type", "livestream") + + fixture.disconnectUser() + viewModel.setFilters(appFilter) + fixture.startConnectingUser(otherUser) + advanceUntilIdle() + fixture.connectUser(otherUser) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + assertEquals(appFilter, captor.lastValue.filter) + assertEquals(0, captor.allValues.count { it.filter == Filters.defaultChannelListFilter(otherUser) }) + } + @Test fun `Given channel list in content state When deleting a channel Should delete the channel`() = runTest { val chatClient: ChatClient = mock() diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt index 3b888548918e..1d7711f1b3e2 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt @@ -38,7 +38,6 @@ import io.getstream.chat.android.models.ChannelMute import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters -import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.TypingEvent import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter @@ -246,18 +245,15 @@ public class ChannelListViewModel internal constructor( private var queryChannelsState: StateFlow = MutableStateFlow(null) /** - * Whether the filter was set through [setFilters], which a user change must not override. + * Whether the filter is the default one, built from the connected user, rather than one the app provided. */ - private var isFilterSetByApp = false + private var followsDefaultFilter = mode is QueryMode.Standard && mode.initialFilter == null init { when (mode) { is QueryMode.Standard -> { - if (mode.initialFilter == null) { - viewModelScope.launch { - val resolvedFilter = buildDefaultFilter().first() - if (!isFilterSetByApp) this@ChannelListViewModel.filterLiveData.value = resolvedFilter - } + if (followsDefaultFilter) { + viewModelScope.launch { applyDefaultFilter() } } stateMerger.addSource(filterLiveData) { filter -> if (filter != null) { @@ -272,15 +268,13 @@ public class ChannelListViewModel internal constructor( // Drop the previous user's channels and stop their queries. queryJob?.cancel() queryChannelsState = MutableStateFlow(null) - // The default filter names the previous user, so nothing may query with it until it's rebuilt. - if (followsDefaultFilter()) filterLiveData.value = null stateMerger.value = INITIAL_STATE paginationStateMerger.value = PaginationState() - // Reload once the next user is ready; their query is built from scratch. - chatClient.clientState.initializationState.first { it == InitializationState.COMPLETE } - if (followsDefaultFilter()) { - // The default filter is built from the current user, and setting it reloads. - filterLiveData.value = buildDefaultFilter().first() + // Reload for the next user; the query itself waits until they're ready. + if (followsDefaultFilter) { + // The default filter names the previous user, so nothing may query with it until it's rebuilt. + filterLiveData.value = null + applyDefaultFilter() } else { initData() } @@ -289,10 +283,12 @@ public class ChannelListViewModel internal constructor( } /** - * Whether the filter is the default one, built from the current user, rather than one the app provided. + * Sets the default filter for the connected user, waiting for one if needed, unless the app has set its own. */ - private fun followsDefaultFilter(): Boolean = - mode is QueryMode.Standard && mode.initialFilter == null && !isFilterSetByApp + private suspend fun applyDefaultFilter() { + val filter = buildDefaultFilter().first() + if (followsDefaultFilter) filterLiveData.value = filter + } /** * Builds the default channel filter, which represents "messaging" channels that the current user is a part of. @@ -484,7 +480,7 @@ public class ChannelListViewModel internal constructor( return } logger.d { "[setFilters] filterObject: $filterObject" } - isFilterSetByApp = true + followsDefaultFilter = false this.filterLiveData.value = filterObject } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt index 1398d25877ea..88c9a4e3b8b6 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt @@ -111,7 +111,6 @@ public class SearchViewModel( public fun loadMore() { val currentState = _state.value!! if (currentState.canLoadMore && !currentState.isLoading && !currentState.isLoadingMore) { - job?.cancel() _state.value = currentState.copy(isLoadingMore = true) searchMessages() } diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt index 1fe910536e04..ec7ac5f09288 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt @@ -289,6 +289,71 @@ internal class ChannelListViewModelTest { captor.allValues.all { it.filter == Filters.defaultChannelListFilter(User(id = "Jc")) } shouldBeEqualTo true } + @Test + fun `Given no initial filter When another user is still connecting Should query only once they are ready`() = + runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient, initialFilters = null) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + val viewModel = fixture.get() + viewModel.state.observeAll() + val otherUser = User(id = "other") + + fixture.disconnectUser() + fixture.startConnectingUser(otherUser) + val captor = argumentCaptor() + verify(chatClient, times(1)).queryChannels(captor.capture()) + + fixture.connectUser(otherUser) + verify(chatClient, times(2)).queryChannels(captor.capture()) + captor.lastValue.filter shouldBeEqualTo Filters.defaultChannelListFilter(otherUser) + } + + @Test + fun `Given no initial filter When the app sets filters before another user connects Should query with the app's filters`() = + runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient, initialFilters = null) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + val viewModel = fixture.get() + viewModel.state.observeAll() + val appFilter = Filters.eq("type", "livestream") + + fixture.disconnectUser() + viewModel.setFilters(appFilter) + fixture.startConnectingUser(User(id = "other")) + fixture.connectUser(User(id = "other")) + + val captor = argumentCaptor() + verify(chatClient, times(2)).queryChannels(captor.capture()) + captor.lastValue.filter shouldBeEqualTo appFilter + } + + @Test + fun `Given an app filter When another user logs in Should query once they are ready`() = runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + val viewModel = fixture.get() + viewModel.state.observeAll() + + fixture.disconnectUser() + fixture.startConnectingUser(User(id = "other")) + verify(chatClient, times(1)).queryChannels(any()) + + fixture.connectUser(User(id = "other")) + verify(chatClient, times(2)).queryChannels(any()) + } + @Test fun `Given channel list in content state When leaving a channel Should leave the channel`() = runTest { val chatClient: ChatClient = mock() From b08f9a0815986940e6f3f357c468c89c0893e091 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:16:05 +0200 Subject: [PATCH 9/9] Remove duplicated setup and tests from the user session tests --- .../channels/ChannelListViewModelTest.kt | 230 +++++------------- .../channels/ChannelListViewModelTest.kt | 178 ++++---------- .../viewmodels/search/SearchViewModelTest.kt | 108 +++----- 3 files changed, 148 insertions(+), 368 deletions(-) diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt index ac119969e6a7..4fb816f65edf 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt @@ -136,14 +136,7 @@ internal class ChannelListViewModelTest { @Test fun `Given channel list in content state When another user connects Should clear the channels`() = runTest { - val fixture = Fixture() - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), - loading = false, - ) - .givenChannelMutes() + val fixture = connectedFixture(channels = listOf(channel1, channel2)) val viewModel = fixture.get(this) viewModel.selectChannel(channel1) @@ -158,32 +151,20 @@ internal class ChannelListViewModelTest { @Test fun `Given channel list in content state When another user connects mid-disconnect Should clear the channels`() = runTest { - val fixture = Fixture() - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), - loading = false, - ) - .givenChannelMutes() + val fixture = connectedFixture(channels = listOf(channel1, channel2)) val viewModel = fixture.get(this) + viewModel.selectChannel(channel1) fixture.startConnectingUser(otherUser) advanceUntilIdle() assertEquals(ChannelsState(), viewModel.channelsState) + assertNull(viewModel.selectedChannel.value) } @Test fun `Given channel list in content state When the user disconnects Should clear the channels`() = runTest { - val fixture = Fixture() - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), - loading = false, - ) - .givenChannelMutes() + val fixture = connectedFixture(channels = listOf(channel1, channel2)) val viewModel = fixture.get(this) viewModel.selectChannel(channel1) @@ -196,14 +177,7 @@ internal class ChannelListViewModelTest { @Test fun `Given channel list in content state When the same user logs in again Should reload the channels`() = runTest { - val fixture = Fixture() - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), - loading = false, - ) - .givenChannelMutes() + val fixture = connectedFixture(channels = listOf(channel1, channel2)) val viewModel = fixture.get(this) viewModel.selectChannel(channel1) @@ -217,14 +191,7 @@ internal class ChannelListViewModelTest { @Test fun `Given channel list in content state When the same user connects again Should keep the channels`() = runTest { - val fixture = Fixture() - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), - loading = false, - ) - .givenChannelMutes() + val fixture = connectedFixture(channels = listOf(channel1, channel2)) val viewModel = fixture.get(this) viewModel.selectChannel(channel1) @@ -237,14 +204,7 @@ internal class ChannelListViewModelTest { @Test fun `Given channel list in content state When refreshing Should keep the channels while loading`() = runTest { - val fixture = Fixture() - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), - loading = false, - ) - .givenChannelMutes() + val fixture = connectedFixture(channels = listOf(channel1, channel2)) val viewModel = fixture.get(this) fixture.givenChannelsState(channelsStateData = ChannelsStateData.Loading, loading = true) @@ -256,32 +216,25 @@ internal class ChannelListViewModelTest { } @Test - fun `Given no initial filter When another user connects Should query with their default filter`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient, initialFilters = null) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) - .givenChannelMutes() - fixture.get(this) + fun `Given no initial filter When another user connects Should query with their default filter instead of the previous user's`() = + runTest { + val fixture = connectedFixture(initialFilters = null) + fixture.get(this) - fixture.disconnectUser() - fixture.connectUser(otherUser) - advanceUntilIdle() + fixture.disconnectUser() + fixture.connectUser(otherUser) + advanceUntilIdle() - val captor = argumentCaptor() - verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) - assertEquals(Filters.defaultChannelListFilter(otherUser), captor.lastValue.filter) - } + val captor = argumentCaptor() + verify(fixture.chatClient, atLeastOnce()).queryChannels(captor.capture()) + val previousUserFilter = Filters.defaultChannelListFilter(User(id = currentUserId)) + assertEquals(1, captor.allValues.count { it.filter == previousUserFilter }) + assertEquals(Filters.defaultChannelListFilter(otherUser), captor.lastValue.filter) + } @Test fun `Given filters set by the app When another user connects Should keep querying with them`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient, initialFilters = null) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) - .givenChannelMutes() + val fixture = connectedFixture(initialFilters = null) val viewModel = fixture.get(this) val appFilter = Filters.eq("type", "livestream") viewModel.setFilters(appFilter) @@ -292,20 +245,13 @@ internal class ChannelListViewModelTest { advanceUntilIdle() val captor = argumentCaptor() - verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + verify(fixture.chatClient, atLeastOnce()).queryChannels(captor.capture()) assertEquals(appFilter, captor.lastValue.filter) } @Test fun `Given message search results When another user connects Should clear the results`() = runTest { - val fixture = Fixture() - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1)), - loading = false, - ) - .givenChannelMutes() + val fixture = connectedFixture() .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = channel1.cid)))) .givenRepositorySelectChannels(listOf(channel1)) val viewModel = fixture.get(this) @@ -321,15 +267,7 @@ internal class ChannelListViewModelTest { @Test fun `Given message search results When another user connects Should search again`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1)), - loading = false, - ) - .givenChannelMutes() + val fixture = connectedFixture() .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = channel1.cid)))) .givenRepositorySelectChannels(listOf(channel1)) val viewModel = fixture.get(this) @@ -340,7 +278,8 @@ internal class ChannelListViewModelTest { fixture.connectUser(otherUser) advanceUntilIdle() - verify(chatClient, times(2)).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + verify(fixture.chatClient, times(2)) + .searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) assertEquals(1, viewModel.channelsState.channelItems.size) assertFalse(viewModel.channelsState.isLoading) } @@ -348,12 +287,7 @@ internal class ChannelListViewModelTest { @Test fun `Given no initial filter and message search results When another user connects Should search once as them`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient, initialFilters = null) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) - .givenChannelMutes() + val fixture = connectedFixture(initialFilters = null) .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = channel1.cid)))) .givenRepositorySelectChannels(listOf(channel1)) val viewModel = fixture.get(this) @@ -364,12 +298,13 @@ internal class ChannelListViewModelTest { fixture.startConnectingUser(otherUser) // Longer than the search debounce, so a second search would not be merged into the first. advanceTimeBy(10_000) - verify(chatClient, times(1)).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + verify(fixture.chatClient, times(1)) + .searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) fixture.connectUser(otherUser) advanceUntilIdle() val captor = argumentCaptor() - verify(chatClient, times(2)).searchMessages( + verify(fixture.chatClient, times(2)).searchMessages( captor.capture(), any(), anyOrNull(), @@ -382,12 +317,7 @@ internal class ChannelListViewModelTest { @Test fun `Given predefined filter and a channel search When another user connects Should search as them`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) - .givenChannelMutes() + val fixture = connectedFixture() .givenPredefined(name = "vip_filter") val viewModel = fixture.get(this) viewModel.setSearchQuery(SearchQuery.Channels("Search query")) @@ -398,7 +328,7 @@ internal class ChannelListViewModelTest { advanceUntilIdle() val captor = argumentCaptor() - verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + verify(fixture.chatClient, atLeastOnce()).queryChannels(captor.capture()) val membersIn = (captor.lastValue.filter as AndFilterObject).filterObjects.last() as InFilterObject assertEquals(setOf(otherUser.id), membersIn.values) } @@ -406,9 +336,7 @@ internal class ChannelListViewModelTest { @Test fun `Given filters set before the first user connects When they connect Should query with those filters`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient, initialFilters = null) - .givenNoUserYet() + val fixture = Fixture(initialFilters = null) .givenChannelsQuery() .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) .givenChannelMutes() @@ -420,38 +348,13 @@ internal class ChannelListViewModelTest { advanceUntilIdle() val captor = argumentCaptor() - verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + verify(fixture.chatClient, atLeastOnce()).queryChannels(captor.capture()) assertEquals(appFilter, captor.lastValue.filter) } - @Test - fun `Given no initial filter When another user connects Should not re-send the previous user's query`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient, initialFilters = null) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) - .givenChannelMutes() - fixture.get(this) - - fixture.disconnectUser() - fixture.connectUser(otherUser) - advanceUntilIdle() - - val captor = argumentCaptor() - verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) - val previousUserFilter = Filters.defaultChannelListFilter(User(id = currentUserId)) - assertEquals(1, captor.allValues.count { it.filter == previousUserFilter }) - } - @Test fun `Given a replaced filter When the same user connects again Should not re-send the replaced query`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) - .givenChannelMutes() + val fixture = connectedFixture() val viewModel = fixture.get(this) viewModel.setFilters(Filters.eq("type", "livestream")) advanceUntilIdle() @@ -460,7 +363,7 @@ internal class ChannelListViewModelTest { advanceUntilIdle() val captor = argumentCaptor() - verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + verify(fixture.chatClient, atLeastOnce()).queryChannels(captor.capture()) assertEquals(1, captor.allValues.count { it.filter == queryFilter }) } @@ -474,17 +377,15 @@ internal class ChannelListViewModelTest { override suspend fun await(): Result> = nextPage.await() override fun cancel() = Unit } - val chatClient: ChatClient = mock() - whenever(chatClient.queryChannels(any())).doReturn(listOf(channel1, channel2).asCall(), nextPageCall) - val fixture = Fixture(chatClient) + val fixture = Fixture() .givenCurrentUser() .givenChannelsState( channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), nextPageRequest = nextPageRequest, - loading = false, ) .givenChannelMutes() - .givenIsOffline(false) + .givenIsOffline() + whenever(fixture.chatClient.queryChannels(any())).doReturn(listOf(channel1, channel2).asCall(), nextPageCall) val viewModel = fixture.get(this) viewModel.loadMore() advanceUntilIdle() @@ -500,12 +401,7 @@ internal class ChannelListViewModelTest { @Test fun `Given no initial filter When the sort changes while another user connects Should not query with the previous user's filter`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient, initialFilters = null) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) - .givenChannelMutes() + val fixture = connectedFixture(initialFilters = null) val viewModel = fixture.get(this) fixture.disconnectUser() @@ -516,7 +412,7 @@ internal class ChannelListViewModelTest { advanceUntilIdle() val captor = argumentCaptor() - verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + verify(fixture.chatClient, atLeastOnce()).queryChannels(captor.capture()) val previousUserFilter = Filters.defaultChannelListFilter(User(id = currentUserId)) assertEquals(1, captor.allValues.count { it.filter == previousUserFilter }) assertEquals(Filters.defaultChannelListFilter(otherUser), captor.lastValue.filter) @@ -524,12 +420,7 @@ internal class ChannelListViewModelTest { @Test fun `Given no initial filter When the same user logs in again Should query with their default filter`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient, initialFilters = null) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) - .givenChannelMutes() + val fixture = connectedFixture(initialFilters = null) fixture.get(this) fixture.disconnectUser() @@ -537,19 +428,14 @@ internal class ChannelListViewModelTest { advanceUntilIdle() val captor = argumentCaptor() - verify(chatClient, times(2)).queryChannels(captor.capture()) + verify(fixture.chatClient, times(2)).queryChannels(captor.capture()) assertTrue(captor.allValues.all { it.filter == Filters.defaultChannelListFilter(User(id = currentUserId)) }) } @Test fun `Given no initial filter When the app sets filters before another user connects Should query with the app's filters`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient, initialFilters = null) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) - .givenChannelMutes() + val fixture = connectedFixture(initialFilters = null) val viewModel = fixture.get(this) val appFilter = Filters.eq("type", "livestream") @@ -561,7 +447,7 @@ internal class ChannelListViewModelTest { advanceUntilIdle() val captor = argumentCaptor() - verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + verify(fixture.chatClient, atLeastOnce()).queryChannels(captor.capture()) assertEquals(appFilter, captor.lastValue.filter) assertEquals(0, captor.allValues.count { it.filter == Filters.defaultChannelListFilter(otherUser) }) } @@ -1987,8 +1873,17 @@ internal class ChannelListViewModelTest { } } + private fun connectedFixture( + channels: List = listOf(channel1), + initialFilters: FilterObject? = queryFilter, + ) = Fixture(initialFilters = initialFilters) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(channels)) + .givenChannelMutes() + private class Fixture( - private val chatClient: ChatClient = mock(), + val chatClient: ChatClient = mock(), private val channelClient: ChannelClient = mock(), private val initialSort: QuerySorter = querySort, private val initialFilters: FilterObject? = queryFilter, @@ -2021,22 +1916,15 @@ internal class ChannelListViewModelTest { whenever(chatClient.channel(any())) doReturn channelClient whenever(chatClient.channel(any(), any())) doReturn channelClient whenever(chatClient.clientState) doReturn clientState - whenever(chatClient.repositoryFacade) doReturn repositoryFacade - whenever(globalState.channelDraftMessages) doReturn MutableStateFlow(emptyMap()) - } - - fun givenNoUserYet() = apply { whenever(clientState.user) doReturn user whenever(clientState.initializationState) doReturn initializationState whenever(chatClient.awaitInitializationState(any())) doReturn InitializationState.COMPLETE + whenever(chatClient.repositoryFacade) doReturn repositoryFacade + whenever(globalState.channelDraftMessages) doReturn MutableStateFlow(emptyMap()) } fun givenCurrentUser(currentUser: User = User(id = currentUserId)) = apply { - user.value = currentUser - initializationState.value = InitializationState.COMPLETE - whenever(clientState.user) doReturn user - whenever(clientState.initializationState) doReturn initializationState - whenever(chatClient.awaitInitializationState(any())) doReturn InitializationState.COMPLETE + connectUser(currentUser) } fun reconnectSameUser() { diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt index ec7ac5f09288..baea6097a22b 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt @@ -106,15 +106,7 @@ internal class ChannelListViewModelTest { @Test fun `Given channel list in content state When another user connects Should clear the channels`() = runTest { - val fixture = Fixture() - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), - loading = false, - endOfChannels = true, - ) - .givenChannelMutes() + val fixture = fixtureWithChannels(initialFilters = queryFilter, endOfChannels = true).givenCurrentUser() val viewModel = fixture.get() val state = viewModel.state.observeAll() val paginationState = viewModel.paginationState.observeAll() @@ -129,33 +121,20 @@ internal class ChannelListViewModelTest { @Test fun `Given channel list in content state When another user connects mid-disconnect Should clear the channels`() = runTest { - val fixture = Fixture() - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), - loading = false, - ) - .givenChannelMutes() + val fixture = fixtureWithChannels(initialFilters = queryFilter, endOfChannels = true).givenCurrentUser() val viewModel = fixture.get() val state = viewModel.state.observeAll() + val paginationState = viewModel.paginationState.observeAll() fixture.startConnectingUser(User(id = "other")) state.last() shouldBeEqualTo ChannelListViewModel.State(isLoading = true, channels = emptyList()) + paginationState.last() shouldBeEqualTo ChannelListViewModel.PaginationState() } @Test fun `Given channel list in content state When the user disconnects Should clear the channels`() = runTest { - val fixture = Fixture() - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), - loading = false, - endOfChannels = true, - ) - .givenChannelMutes() + val fixture = fixtureWithChannels(initialFilters = queryFilter, endOfChannels = true).givenCurrentUser() val viewModel = fixture.get() val state = viewModel.state.observeAll() val paginationState = viewModel.paginationState.observeAll() @@ -166,37 +145,9 @@ internal class ChannelListViewModelTest { paginationState.last() shouldBeEqualTo ChannelListViewModel.PaginationState() } - @Test - fun `Given no initial filter When another user connects Should not re-send the previous user's query`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient, initialFilters = null) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) - .givenChannelMutes() - val viewModel = fixture.get() - viewModel.state.observeAll() - - fixture.disconnectUser() - fixture.connectUser(User(id = "other")) - - val captor = argumentCaptor() - verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) - val previousUserFilter = Filters.defaultChannelListFilter(User(id = "Jc")) - captor.allValues.count { it.filter == previousUserFilter } shouldBeEqualTo 1 - } - @Test fun `Given channel list in content state When the same user connects again Should keep the channels`() = runTest { - val fixture = Fixture() - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), - loading = false, - endOfChannels = true, - ) - .givenChannelMutes() + val fixture = fixtureWithChannels(initialFilters = queryFilter, endOfChannels = true).givenCurrentUser() val viewModel = fixture.get() val state = viewModel.state.observeAll() val emittedBefore = state.size @@ -208,33 +159,25 @@ internal class ChannelListViewModelTest { } @Test - fun `Given no initial filter When another user connects Should query with their default filter`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient, initialFilters = null) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) - .givenChannelMutes() - val viewModel = fixture.get() - viewModel.state.observeAll() - val otherUser = User(id = "other") + fun `Given no initial filter When another user connects Should query with their default filter instead of the previous user's`() = + runTest { + val fixture = fixtureWithChannels().givenCurrentUser() + fixture.get().state.observeAll() + val otherUser = User(id = "other") - fixture.disconnectUser() - fixture.connectUser(otherUser) + fixture.disconnectUser() + fixture.connectUser(otherUser) - val captor = argumentCaptor() - verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) - captor.lastValue.filter shouldBeEqualTo Filters.defaultChannelListFilter(otherUser) - } + val captor = argumentCaptor() + verify(fixture.chatClient, atLeastOnce()).queryChannels(captor.capture()) + val previousUserFilter = Filters.defaultChannelListFilter(User(id = "Jc")) + captor.allValues.count { it.filter == previousUserFilter } shouldBeEqualTo 1 + captor.lastValue.filter shouldBeEqualTo Filters.defaultChannelListFilter(otherUser) + } @Test fun `Given filters set by the app When another user connects Should keep querying with them`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient, initialFilters = null) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) - .givenChannelMutes() + val fixture = fixtureWithChannels().givenCurrentUser() val viewModel = fixture.get() viewModel.state.observeAll() val appFilter = Filters.eq("type", "livestream") @@ -244,19 +187,14 @@ internal class ChannelListViewModelTest { fixture.connectUser(User(id = "other")) val captor = argumentCaptor() - verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + verify(fixture.chatClient, atLeastOnce()).queryChannels(captor.capture()) captor.lastValue.filter shouldBeEqualTo appFilter } @Test fun `Given filters set before the first user connects When they connect Should query with those filters`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient, initialFilters = null) - .givenNoUserYet() - .givenChannelsQuery() - .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) - .givenChannelMutes() + val fixture = fixtureWithChannels() val viewModel = fixture.get() viewModel.state.observeAll() val appFilter = Filters.eq("type", "livestream") @@ -265,62 +203,45 @@ internal class ChannelListViewModelTest { fixture.connectUser(User(id = "Jc")) val captor = argumentCaptor() - verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + verify(fixture.chatClient, atLeastOnce()).queryChannels(captor.capture()) captor.lastValue.filter shouldBeEqualTo appFilter } @Test fun `Given no initial filter When the same user logs in again Should query with their default filter`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient, initialFilters = null) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) - .givenChannelMutes() - val viewModel = fixture.get() - viewModel.state.observeAll() + val fixture = fixtureWithChannels().givenCurrentUser() + fixture.get().state.observeAll() fixture.disconnectUser() fixture.connectUser(User(id = "Jc")) val captor = argumentCaptor() - verify(chatClient, times(2)).queryChannels(captor.capture()) + verify(fixture.chatClient, times(2)).queryChannels(captor.capture()) captor.allValues.all { it.filter == Filters.defaultChannelListFilter(User(id = "Jc")) } shouldBeEqualTo true } @Test fun `Given no initial filter When another user is still connecting Should query only once they are ready`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient, initialFilters = null) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) - .givenChannelMutes() - val viewModel = fixture.get() - viewModel.state.observeAll() + val fixture = fixtureWithChannels().givenCurrentUser() + fixture.get().state.observeAll() val otherUser = User(id = "other") fixture.disconnectUser() fixture.startConnectingUser(otherUser) val captor = argumentCaptor() - verify(chatClient, times(1)).queryChannels(captor.capture()) + verify(fixture.chatClient, times(1)).queryChannels(captor.capture()) fixture.connectUser(otherUser) - verify(chatClient, times(2)).queryChannels(captor.capture()) + verify(fixture.chatClient, times(2)).queryChannels(captor.capture()) captor.lastValue.filter shouldBeEqualTo Filters.defaultChannelListFilter(otherUser) } @Test fun `Given no initial filter When the app sets filters before another user connects Should query with the app's filters`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient, initialFilters = null) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) - .givenChannelMutes() + val fixture = fixtureWithChannels().givenCurrentUser() val viewModel = fixture.get() viewModel.state.observeAll() val appFilter = Filters.eq("type", "livestream") @@ -331,27 +252,21 @@ internal class ChannelListViewModelTest { fixture.connectUser(User(id = "other")) val captor = argumentCaptor() - verify(chatClient, times(2)).queryChannels(captor.capture()) + verify(fixture.chatClient, times(2)).queryChannels(captor.capture()) captor.lastValue.filter shouldBeEqualTo appFilter } @Test fun `Given an app filter When another user logs in Should query once they are ready`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) - .givenChannelMutes() - val viewModel = fixture.get() - viewModel.state.observeAll() + val fixture = fixtureWithChannels(initialFilters = queryFilter).givenCurrentUser() + fixture.get().state.observeAll() fixture.disconnectUser() fixture.startConnectingUser(User(id = "other")) - verify(chatClient, times(1)).queryChannels(any()) + verify(fixture.chatClient, times(1)).queryChannels(any()) fixture.connectUser(User(id = "other")) - verify(chatClient, times(2)).queryChannels(any()) + verify(fixture.chatClient, times(2)).queryChannels(any()) } @Test @@ -505,8 +420,19 @@ internal class ChannelListViewModelTest { verify(chatClient, times(1)).queryChannels(any()) } + private fun fixtureWithChannels( + initialFilters: FilterObject? = null, + endOfChannels: Boolean = false, + ) = Fixture(initialFilters = initialFilters) + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + endOfChannels = endOfChannels, + ) + .givenChannelMutes() + private class Fixture( - private val chatClient: ChatClient = mock(), + val chatClient: ChatClient = mock(), private val channelClient: ChannelClient = mock(), private val initialSort: QuerySortByField = querySort, private val initialFilters: FilterObject? = queryFilter, @@ -528,22 +454,16 @@ internal class ChannelListViewModelTest { whenever(statePlugin.resolveDependency(eq(StateRegistry::class))) doReturn stateRegistry whenever(chatClient.plugins) doReturn listOf(statePlugin) whenever(chatClient.clientState) doReturn clientState - whenever(globalState.typingChannels) doReturn MutableStateFlow(emptyMap()) - } - - fun givenNoUserYet() = apply { whenever(clientState.user) doReturn user whenever(clientState.initializationState) doReturn initializationState whenever(chatClient.awaitInitializationState(any())) doReturn InitializationState.COMPLETE + whenever(globalState.typingChannels) doReturn MutableStateFlow(emptyMap()) } fun givenCurrentUser(currentUser: User = User(id = "Jc")) = apply { user.value = currentUser initializationState.value = InitializationState.COMPLETE - whenever(clientState.user) doReturn user - whenever(clientState.initializationState) doReturn initializationState whenever(chatClient.getCurrentUser()) doReturn currentUser - whenever(chatClient.awaitInitializationState(any())) doReturn InitializationState.COMPLETE } fun reconnectSameUser() { diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt index e4e78d110ff7..bc27122c9040 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt @@ -159,55 +159,34 @@ internal class SearchViewModelTest { } @Test - fun `Given search results When another user connects Should clear the results`() = runTest { - val fixture = Fixture() - .givenCurrentUser() - .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) - .givenRepositorySelectChannels() - val viewModel = fixture.get() - val states = viewModel.state.observeAll() - viewModel.setQuery("hello") - - fixture.startConnectingUser(User(id = "other")) - - assertEquals(SearchViewModel.State(query = "hello", isLoading = true), states.last()) - } - - @Test - fun `Given search results When another user connects Should search again as that user`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient) - .givenCurrentUser() - .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) - .givenRepositorySelectChannels() - val viewModel = fixture.get() - viewModel.state.observeAll() - viewModel.setQuery("hello") + fun `Given search results When another user connects Should clear the results and search again as that user`() = + runTest { + val fixture = givenSearchResultsFixture() + val viewModel = fixture.get() + val states = viewModel.state.observeAll() + viewModel.setQuery("hello") - fixture.startConnectingUser(User(id = "other")) - fixture.finishConnecting() + fixture.startConnectingUser(User(id = "other")) + assertEquals(SearchViewModel.State(query = "hello", isLoading = true), states.last()) + fixture.finishConnecting() - val captor = argumentCaptor() - verify(chatClient, times(2)).searchMessages( - captor.capture(), - any(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - anyOrNull(), - ) - assertEquals(Filters.`in`("members", listOf("other")), captor.lastValue) - assertEquals(1, viewModel.state.value!!.results.size) - } + val captor = argumentCaptor() + verify(fixture.chatClient, times(2)).searchMessages( + captor.capture(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + anyOrNull(), + ) + assertEquals(Filters.`in`("members", listOf("other")), captor.lastValue) + assertEquals(1, viewModel.state.value!!.results.size) + } @Test fun `Given a user switch When a new query is typed before the user is ready Should keep the new query`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient) - .givenCurrentUser() - .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) - .givenRepositorySelectChannels() + val fixture = givenSearchResultsFixture() val viewModel = fixture.get() viewModel.state.observeAll() viewModel.setQuery("hello") @@ -217,7 +196,7 @@ internal class SearchViewModelTest { fixture.finishConnecting() val captor = argumentCaptor() - verify(chatClient, times(2)).searchMessages( + verify(fixture.chatClient, times(2)).searchMessages( any(), captor.capture(), anyOrNull(), @@ -231,10 +210,7 @@ internal class SearchViewModelTest { @Test fun `Given search results When the user disconnects Should clear the results`() = runTest { - val fixture = Fixture() - .givenCurrentUser() - .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) - .givenRepositorySelectChannels() + val fixture = givenSearchResultsFixture() val viewModel = fixture.get() val states = viewModel.state.observeAll() viewModel.setQuery("hello") @@ -246,11 +222,7 @@ internal class SearchViewModelTest { @Test fun `Given a search waiting for the next user When loading more Should keep the search`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient) - .givenCurrentUser() - .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) - .givenRepositorySelectChannels() + val fixture = givenSearchResultsFixture() val viewModel = fixture.get() viewModel.state.observeAll() viewModel.setQuery("hello") @@ -259,36 +231,30 @@ internal class SearchViewModelTest { viewModel.loadMore() fixture.finishConnecting() - verify(chatClient, times(2)).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + verify(fixture.chatClient, times(2)) + .searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) assertEquals(1, viewModel.state.value!!.results.size) assertFalse(viewModel.state.value!!.isLoading) } @Test fun `Given a user still connecting When searching Should wait until they are ready`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient) - .givenCurrentUser() - .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) - .givenRepositorySelectChannels() + val fixture = givenSearchResultsFixture() val viewModel = fixture.get() viewModel.state.observeAll() fixture.startConnectingUser(User(id = "other")) viewModel.setQuery("hello") - verify(chatClient, never()).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + verify(fixture.chatClient, never()) + .searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) fixture.finishConnecting() - verify(chatClient).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + verify(fixture.chatClient).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } @Test fun `Given no user once the client is ready When searching Should report an error`() = runTest { - val chatClient: ChatClient = mock() - val fixture = Fixture(chatClient) - .givenCurrentUser() - .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) - .givenRepositorySelectChannels() + val fixture = givenSearchResultsFixture() val viewModel = fixture.get() val states = viewModel.state.observeAll() val errors = viewModel.errorEvents.observeAll() @@ -296,13 +262,19 @@ internal class SearchViewModelTest { fixture.loseUserOnceReady() viewModel.setQuery("hello") - verify(chatClient, never()).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + verify(fixture.chatClient, never()) + .searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) assertFalse(states.last().isLoading) assertEquals(1, errors.size) } + private suspend fun givenSearchResultsFixture() = Fixture() + .givenCurrentUser() + .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) + .givenRepositorySelectChannels() + private class Fixture( - private val chatClient: ChatClient = mock(), + val chatClient: ChatClient = mock(), ) { private val clientState: ClientState = mock() private val repositoryFacade: RepositoryFacade = mock()