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..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 @@ -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 @@ -80,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 @@ -416,6 +421,11 @@ public class ChannelListViewModel internal constructor( */ private var lastNextQuery: QueryChannelsRequest? = null + /** + * Whether the filter is the default one, built from the connected user, rather than one the app provided. + */ + 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 * (via [queryConfigFlow]) in addition to search and refresh; Predefined and Grouped modes @@ -434,16 +444,30 @@ 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() - this@ChannelListViewModel.filterFlow.value = filter + if (followsDefaultFilter) 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, and setting it reloads. + filterFlow.value = defaultChannelsFilter().first() + } else { + refresh() + } + } + } } /** @@ -451,7 +475,7 @@ public class ChannelListViewModel internal constructor( */ public fun refresh() { logger.d { "[refresh] no args" } - refreshFlow.value = System.currentTimeMillis() + refreshFlow.update { it + 1 } } /** @@ -484,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) { @@ -498,6 +524,7 @@ public class ChannelListViewModel internal constructor( } is QueryMode.Standard -> Unit } + followsDefaultFilter = false this.filterFlow.tryEmit(value = newFilters) } @@ -909,12 +936,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 +951,41 @@ 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, ) } + /** + * 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) + // 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 + dismissChannelAction() + 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/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..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 @@ -34,6 +34,12 @@ 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. + * 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. * @param memberLimit How many members are fetched for each channel item when loading channels. 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..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 @@ -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,324 @@ 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 = connectedFixture(channels = listOf(channel1, channel2)) + 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 = 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 = connectedFixture(channels = listOf(channel1, channel2)) + 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 = connectedFixture(channels = listOf(channel1, channel2)) + 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 = connectedFixture(channels = listOf(channel1, channel2)) + 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 = connectedFixture(channels = listOf(channel1, channel2)) + 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 instead of the previous user's`() = + runTest { + val fixture = connectedFixture(initialFilters = null) + fixture.get(this) + + fixture.disconnectUser() + fixture.connectUser(otherUser) + advanceUntilIdle() + + 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 fixture = connectedFixture(initialFilters = null) + 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(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 = connectedFixture() + .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 fixture = connectedFixture() + .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(fixture.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 fixture = connectedFixture(initialFilters = null) + .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(fixture.chatClient, times(1)) + .searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + fixture.connectUser(otherUser) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(fixture.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 fixture = connectedFixture() + .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(fixture.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 fixture = Fixture(initialFilters = null) + .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(fixture.chatClient, atLeastOnce()).queryChannels(captor.capture()) + assertEquals(appFilter, captor.lastValue.filter) + } + + @Test + fun `Given a replaced filter When the same user connects again Should not re-send the replaced query`() = runTest { + val fixture = connectedFixture() + val viewModel = fixture.get(this) + viewModel.setFilters(Filters.eq("type", "livestream")) + advanceUntilIdle() + + fixture.reconnectSameUser() + advanceUntilIdle() + + val captor = argumentCaptor() + verify(fixture.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 fixture = Fixture() + .givenCurrentUser() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + nextPageRequest = nextPageRequest, + ) + .givenChannelMutes() + .givenIsOffline() + whenever(fixture.chatClient.queryChannels(any())).doReturn(listOf(channel1, channel2).asCall(), nextPageCall) + 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 no initial filter When the sort changes while another user connects Should not query with the previous user's filter`() = + runTest { + val fixture = connectedFixture(initialFilters = null) + 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(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 no initial filter When the same user logs in again Should query with their default filter`() = runTest { + val fixture = connectedFixture(initialFilters = null) + fixture.get(this) + + fixture.disconnectUser() + fixture.connectUser(User(id = currentUserId)) + advanceUntilIdle() + + val captor = argumentCaptor() + 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 fixture = connectedFixture(initialFilters = null) + 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(fixture.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() @@ -1550,13 +1873,24 @@ 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, ) { 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() @@ -1582,14 +1916,37 @@ internal class ChannelListViewModelTest { whenever(chatClient.channel(any())) doReturn channelClient whenever(chatClient.channel(any(), any())) doReturn channelClient whenever(chatClient.clientState) doReturn clientState + 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 { - whenever(clientState.user) doReturn MutableStateFlow(currentUser) - whenever(clientState.initializationState) doReturn MutableStateFlow(InitializationState.COMPLETE) - whenever(chatClient.awaitInitializationState(any())) doReturn InitializationState.COMPLETE + connectUser(currentUser) + } + + 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 { 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..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 @@ -42,6 +42,7 @@ 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 @@ -65,6 +66,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,14 +244,16 @@ public class ChannelListViewModel internal constructor( */ private var queryChannelsState: StateFlow = MutableStateFlow(null) + /** + * Whether the filter is the default one, built from the connected user, rather than one the app provided. + */ + 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() - this@ChannelListViewModel.filterLiveData.value = resolvedFilter - } + if (followsDefaultFilter) { + viewModelScope.launch { applyDefaultFilter() } } stateMerger.addSource(filterLiveData) { filter -> if (filter != null) { @@ -259,6 +263,31 @@ 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() + queryChannelsState = MutableStateFlow(null) + stateMerger.value = INITIAL_STATE + paginationStateMerger.value = PaginationState() + // 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() + } + } + } + } + + /** + * Sets the default filter for the connected user, waiting for one if needed, unless the app has set its own. + */ + private suspend fun applyDefaultFilter() { + val filter = buildDefaultFilter().first() + if (followsDefaultFilter) filterLiveData.value = filter } /** @@ -285,9 +314,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 +321,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 -> @@ -441,6 +470,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) { @@ -449,6 +480,7 @@ public class ChannelListViewModel internal constructor( return } logger.d { "[setFilters] filterObject: $filterObject" } + followsDefaultFilter = false this.filterLiveData.value = 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 5783b6c76a1b..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 @@ -31,6 +31,12 @@ 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. + * 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. * @param messageLimit The number of messages to fetch for each channel. When `null`, the server-side default is used. 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..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 @@ -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,8 +109,6 @@ public class SearchViewModel( * is already in progress. */ public fun loadMore() { - job?.cancel() - val currentState = _state.value!! if (currentState.canLoadMore && !currentState.isLoading && !currentState.isLoadingMore) { _state.value = currentState.copy(isLoadingMore = true) @@ -174,7 +182,10 @@ public class SearchViewModel( */ private suspend fun searchMessages(query: String, next: String?): Result { logger.d { "Searching for \"$query\" with next: $next" } - val currentUser = requireNotNull(chatClient.clientState.user.value) + // 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 = 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/channels/ChannelListViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt index f81cdea0c0d5..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 @@ -38,6 +38,7 @@ 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 kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest @@ -48,6 +49,7 @@ 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 @@ -102,6 +104,171 @@ internal class ChannelListViewModelTest { ) } + @Test + fun `Given channel list in content state When another user connects Should clear the channels`() = runTest { + val fixture = fixtureWithChannels(initialFilters = queryFilter, endOfChannels = true).givenCurrentUser() + 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 = 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 = fixtureWithChannels(initialFilters = queryFilter, endOfChannels = true).givenCurrentUser() + 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 channel list in content state When the same user connects again Should keep the channels`() = runTest { + val fixture = fixtureWithChannels(initialFilters = queryFilter, endOfChannels = true).givenCurrentUser() + 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 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) + + 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 fixture = fixtureWithChannels().givenCurrentUser() + 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(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 fixture = fixtureWithChannels() + 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(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 fixture = fixtureWithChannels().givenCurrentUser() + fixture.get().state.observeAll() + + fixture.disconnectUser() + fixture.connectUser(User(id = "Jc")) + + val captor = argumentCaptor() + 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 fixture = fixtureWithChannels().givenCurrentUser() + fixture.get().state.observeAll() + val otherUser = User(id = "other") + + fixture.disconnectUser() + fixture.startConnectingUser(otherUser) + val captor = argumentCaptor() + verify(fixture.chatClient, times(1)).queryChannels(captor.capture()) + + fixture.connectUser(otherUser) + 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 fixture = fixtureWithChannels().givenCurrentUser() + 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(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 fixture = fixtureWithChannels(initialFilters = queryFilter).givenCurrentUser() + fixture.get().state.observeAll() + + fixture.disconnectUser() + fixture.startConnectingUser(User(id = "other")) + verify(fixture.chatClient, times(1)).queryChannels(any()) + + fixture.connectUser(User(id = "other")) + verify(fixture.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() @@ -253,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, @@ -262,6 +440,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 @@ -274,14 +454,38 @@ internal class ChannelListViewModelTest { whenever(statePlugin.resolveDependency(eq(StateRegistry::class))) doReturn stateRegistry whenever(chatClient.plugins) doReturn listOf(statePlugin) whenever(chatClient.clientState) doReturn clientState + 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 { - whenever(clientState.user) doReturn MutableStateFlow(currentUser) - whenever(clientState.initializationState) doReturn MutableStateFlow(InitializationState.COMPLETE) + user.value = currentUser + initializationState.value = InitializationState.COMPLETE 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 { 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..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 @@ -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,128 @@ internal class SearchViewModelTest { ) } + @Test + 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")) + assertEquals(SearchViewModel.State(query = "hello", isLoading = true), states.last()) + fixture.finishConnecting() + + 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 fixture = givenSearchResultsFixture() + val viewModel = fixture.get() + viewModel.state.observeAll() + viewModel.setQuery("hello") + + fixture.startConnectingUser(User(id = "other")) + viewModel.setQuery("bye") + fixture.finishConnecting() + + val captor = argumentCaptor() + verify(fixture.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 = givenSearchResultsFixture() + 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 fixture = givenSearchResultsFixture() + val viewModel = fixture.get() + viewModel.state.observeAll() + viewModel.setQuery("hello") + + fixture.startConnectingUser(User(id = "other")) + viewModel.loadMore() + fixture.finishConnecting() + + 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 fixture = givenSearchResultsFixture() + val viewModel = fixture.get() + viewModel.state.observeAll() + + fixture.startConnectingUser(User(id = "other")) + viewModel.setQuery("hello") + verify(fixture.chatClient, never()) + .searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + + fixture.finishConnecting() + 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 fixture = givenSearchResultsFixture() + val viewModel = fixture.get() + val states = viewModel.state.observeAll() + val errors = viewModel.errorEvents.observeAll() + + fixture.loseUserOnceReady() + viewModel.setQuery("hello") + + 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() + private val user = MutableStateFlow(null) + private val initializationState = MutableStateFlow(InitializationState.NOT_INITIALIZED) init { whenever(chatClient.clientState) doReturn clientState @@ -166,7 +287,32 @@ 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 loseUserOnceReady() { + user.value = null + initializationState.value = InitializationState.COMPLETE + } + + fun finishConnecting() { + initializationState.value = InitializationState.COMPLETE } fun givenSearchMessagesResult(result: SearchMessagesResult) = apply {