diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt index 7d2b513260b0..7147e8b7e133 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModel.kt @@ -29,7 +29,7 @@ import kotlinx.coroutines.flow.StateFlow * @param controller The [ThreadListController] handling the business logic and the state management for the threads * list. */ -public class ThreadListViewModel(private val controller: ThreadListController) : ViewModel() { +public class ThreadListViewModel(private val controller: ThreadListController) : ViewModel(controller) { /** * The current thread list state. diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModelFactoryTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModelFactoryTest.kt index 4457a8c1f025..a85491022d13 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModelFactoryTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModelFactoryTest.kt @@ -19,12 +19,13 @@ package io.getstream.chat.android.compose.viewmodel.threads import androidx.lifecycle.ViewModel import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryThreadsRequest -import kotlinx.coroutines.test.TestScope +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.models.InitializationState +import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Test import org.junit.jupiter.api.assertInstanceOf -import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock @@ -55,9 +56,12 @@ internal class ThreadListViewModelFactoryTest { private class Fixture { + private val mockClientState: ClientState = mock { + on { user } doReturn MutableStateFlow(null) + on { initializationState } doReturn MutableStateFlow(InitializationState.NOT_INITIALIZED) + } private val mockChatClient: ChatClient = mock { - on { clientState } doReturn mock() - on { inheritScope(any()) } doReturn TestScope() + on { clientState } doReturn mockClientState } fun get() = ThreadsViewModelFactory( diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModelTest.kt index cf3325baf11a..2dfb54b0ff0b 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/threads/ThreadListViewModelTest.kt @@ -16,6 +16,9 @@ package io.getstream.chat.android.compose.viewmodel.threads +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore import app.cash.turbine.test import io.getstream.chat.android.randomBoolean import io.getstream.chat.android.randomInt @@ -71,6 +74,17 @@ internal class ThreadListViewModelTest { fixture.verifyLoadNextPage() } + @Test + fun `clearing the view model closes the controller`() { + val fixture = Fixture() + val store = ViewModelStore() + ViewModelProvider(store, fixture.factory())[ThreadListViewModel::class.java] + + store.clear() + + fixture.verifyClose() + } + private class Fixture { private val mockController: ThreadListController = mock() @@ -86,6 +100,15 @@ internal class ThreadListViewModelTest { verify(mockController).loadNextPage() } + fun verifyClose() = apply { + verify(mockController).close() + } + + fun factory() = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = get() as T + } + fun get() = ThreadListViewModel( controller = mockController, ) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListController.kt index d6dc34a19f9b..f54348dfb9d2 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListController.kt @@ -25,6 +25,8 @@ import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.ui.common.model.MessageResult import io.getstream.chat.android.ui.common.state.mentions.MentionListEvent import io.getstream.chat.android.ui.common.state.mentions.MentionListState +import io.getstream.chat.android.ui.common.utils.extensions.awaitConnectedUser +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 @@ -35,9 +37,8 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update @@ -75,14 +76,21 @@ public class MentionListController( init { loadRequests.onStart { emit(Unit) } // Triggers the initial load - .flatMapLatest { flowOf(searchMentions()) } - .onEach { result -> - when (result) { + // A new load, or the reset when a user session ends, cancels the previous result's handling too. + .mapLatest { + when (val result = searchMentions()) { is Result.Success -> onSuccessResult(result.value) is Result.Failure -> onFailureResult(result.value) } } .launchIn(scope) + chatClient.clientState.userSessionEnds() + .onEach { + // Drops the previous user's mentions and loads the next user's once they're connected. + _state.value = MentionListState() + loadRequests.tryEmit(Unit) + } + .launchIn(scope) } /** @@ -125,7 +133,9 @@ public class MentionListController( } private suspend fun searchMentions(): Result { - val currentUser = requireNotNull(chatClient.getCurrentUser()) + // A user who is still connecting, e.g. right after a switch, can't search yet. + val currentUser = chatClient.clientState.awaitConnectedUser() + ?: return Result.Failure(Error.GenericError("No user is connected")) val channelFilter = Filters.`in`("members", listOf(currentUser.id)) val messageFilter = Filters.contains("mentioned_users.id", currentUser.id) val currentState = _state.value diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt index c352e4b17b02..690bc9b628d0 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListController.kt @@ -25,13 +25,17 @@ import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.ConnectionState import io.getstream.chat.android.models.User import io.getstream.chat.android.ui.common.state.threads.ThreadListState +import io.getstream.chat.android.ui.common.utils.extensions.userSessionEnds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch @@ -41,22 +45,26 @@ import kotlinx.coroutines.launch * * @param query The [QueryThreadsRequest] object containing the parameters for querying threads. * @param chatClient The [ChatClient] instance for retrieving the Threads related data. - * @param scope The [CoroutineScope] on which coroutines should be launched. - * @param queryThreadsAsState Function that returns a [StateFlow] of [QueryThreadsState]. + * @param scope The [CoroutineScope] the controller's work runs under, in a child scope that [close] cancels; the given + * scope itself is never cancelled. The default one outlives user sessions, so the list can follow the next user, and + * runs one task at a time. + * @param queryThreadsAsState Function that returns a [StateFlow] of [QueryThreadsState], built in the given scope. */ @OptIn(ExperimentalCoroutinesApi::class) @InternalStreamChatApi public class ThreadListController( private val query: QueryThreadsRequest, private val chatClient: ChatClient = ChatClient.instance(), - private val scope: CoroutineScope = chatClient.inheritScope { DispatcherProvider.IO }, - queryThreadsAsState: () -> StateFlow = { + scope: CoroutineScope = CoroutineScope(DispatcherProvider.IO.limitedParallelism(1)), + queryThreadsAsState: (CoroutineScope) -> StateFlow = { stateScope -> chatClient.queryThreadsAsState( request = query, - coroutineScope = scope, + coroutineScope = stateScope, ) }, -) { +) : AutoCloseable { + + private val ownScope = CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext[Job])) /** * Exposes the current thread list state. @@ -75,27 +83,40 @@ public class ThreadListController( */ public val user: StateFlow = chatClient.clientState.user - private val queryThreadsState = queryThreadsAsState() + private val queryThreadsState = queryThreadsAsState(ownScope) init { - scope.launch { + ownScope.launch { queryThreadsState - .filterNotNull() - .flatMapLatest { - combine( - it.threads, - it.loading, - it.loadingMore, - it.unseenThreadIds, - it.loadingError, - ) { threads, loading, loadingMore, unseenThreadIds, loadingError -> - ThreadListState(threads, loading, loadingMore, unseenThreadIds.size, loadingError) - } + // Null while no user is connected: stop following the previous state. + .flatMapLatest { state -> + state?.let { + combine( + it.threads, + it.loading, + it.loadingMore, + it.unseenThreadIds, + it.loadingError, + ) { threads, loading, loadingMore, unseenThreadIds, loadingError -> + ThreadListState(threads, loading, loadingMore, unseenThreadIds.size, loadingError) + } + } ?: emptyFlow() } .collectLatest { _state.value = it } } + ownScope.launch { + // Drops the previous user's threads; the query runs again once the next user is connected. + chatClient.clientState.userSessionEnds().collect { _state.value = INITIAL_STATE } + } + } + + /** + * Cancels the work started by this controller, without cancelling the scope it was given. + */ + override fun close() { + ownScope.cancel() } /** 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 index 04df053f52f4..ecc22c5aa834 100644 --- 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 @@ -18,8 +18,11 @@ 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 io.getstream.chat.android.models.InitializationState +import io.getstream.chat.android.models.User import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map @@ -38,3 +41,12 @@ public fun ClientState.userSessionEnds(): Flow = flow { previousUserId = userId } } + +/** + * Waits until the client is ready for a user and returns them, or `null` if they disconnected in the meantime. + */ +@InternalStreamChatApi +public suspend fun ClientState.awaitConnectedUser(): User? { + initializationState.first { it == InitializationState.COMPLETE } + return user.value +} diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListControllerTest.kt index 169020cf8c4a..6770049521fc 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/mentions/MentionListControllerTest.kt @@ -19,7 +19,11 @@ package io.getstream.chat.android.ui.common.feature.mentions import app.cash.turbine.test 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.Message import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.models.User @@ -29,11 +33,14 @@ import io.getstream.chat.android.test.TestCall import io.getstream.chat.android.test.asCall import io.getstream.chat.android.ui.common.model.MessageResult import io.getstream.chat.android.ui.common.state.mentions.MentionListEvent +import io.getstream.chat.android.ui.common.state.mentions.MentionListState import io.getstream.result.Error import io.getstream.result.Result +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -46,10 +53,14 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertInstanceOf import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever internal class MentionListControllerTest { @@ -99,6 +110,96 @@ internal class MentionListControllerTest { } } + @Test + fun `logout clears the mentions`() = runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenChannels(channels = emptyList()) + .givenSearchMessagesResult(next = null, result = SearchMessagesResult(messages = listOf(Message(cid = CID)))) + val sut = fixture.get(backgroundScope) + testScheduler.runCurrent() + assertEquals(1, sut.state.value.results.size) + + fixture.disconnectUser() + testScheduler.runCurrent() + + assertEquals(MentionListState(), sut.state.value) + } + + @Test + fun `another user connecting loads their mentions`() = runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenChannels(channels = emptyList()) + .givenSearchMessagesResult(next = null, result = SearchMessagesResult(messages = listOf(Message(cid = CID)))) + val sut = fixture.get(backgroundScope) + testScheduler.runCurrent() + + fixture.disconnectUser() + fixture.connectUser(User(id = "other")) + testScheduler.runCurrent() + + val captor = argumentCaptor() + verify(fixture.chatClient, times(2)).searchMessages( + channelFilter = captor.capture(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = anyOrNull(), + sort = anyOrNull(), + ) + assertEquals(Filters.`in`("members", listOf("other")), captor.lastValue) + assertEquals(1, sut.state.value.results.size) + } + + @Test + fun `no user connected yet waits for one`() = runTest { + val fixture = Fixture() + .givenChannels(channels = emptyList()) + .givenSearchMessagesResult(next = null, result = SearchMessagesResult(messages = listOf(Message(cid = CID)))) + val sut = fixture.get(backgroundScope) + testScheduler.runCurrent() + assertTrue(sut.state.value.isLoading) + + fixture.connectUser(User) + testScheduler.runCurrent() + + assertEquals(1, sut.state.value.results.size) + } + + @Test + fun `a result still being handled when the user logs out is dropped`() = runTest { + val channels = CompletableDeferred>() + val fixture = Fixture() + .givenCurrentUser() + .givenChannelsWhen(channels) + .givenSearchMessagesResult(next = null, result = SearchMessagesResult(messages = listOf(Message(cid = CID)))) + val sut = fixture.get(backgroundScope) + testScheduler.runCurrent() + + fixture.disconnectUser() + testScheduler.runCurrent() + channels.complete(emptyList()) + testScheduler.runCurrent() + + assertEquals(MentionListState(), sut.state.value) + } + + @Test + fun `no user once the client is ready reports an error`() = runTest { + val fixture = Fixture() + .givenChannels(channels = emptyList()) + .givenSearchMessagesResult(next = null, result = SearchMessagesResult(messages = listOf(Message(cid = CID)))) + val sut = fixture.get(backgroundScope) + + sut.events.test { + fixture.loseUserOnceReady() + testScheduler.runCurrent() + assertInstanceOf(awaitItem()) + } + assertFalse(sut.state.value.isLoading) + } + @Test fun `initial load error`() = runTest { val error = Error.GenericError("error") @@ -351,12 +452,30 @@ private class Fixture { private val sort: QuerySorter = mock() private val repositoryFacade: RepositoryFacade = mock() - private val chatClient: ChatClient = mock { + private val userFlow = MutableStateFlow(null) + private val initializationState = MutableStateFlow(InitializationState.NOT_INITIALIZED) + private val clientState: ClientState = mock { + on { user } doReturn userFlow + on { initializationState } doReturn initializationState + } + val chatClient: ChatClient = mock { whenever(mock.repositoryFacade) doReturn repositoryFacade + whenever(mock.clientState) doReturn clientState + whenever(mock.getCurrentUser()) doAnswer { userFlow.value } } fun givenCurrentUser(currentUser: User = User) = apply { - whenever(chatClient.getCurrentUser()) doReturn currentUser + connectUser(currentUser) + } + + fun connectUser(currentUser: User) { + userFlow.value = currentUser + initializationState.value = InitializationState.COMPLETE + } + + fun disconnectUser() { + initializationState.value = InitializationState.NOT_INITIALIZED + userFlow.value = null } fun givenSearchMessagesResult( @@ -388,6 +507,15 @@ private class Fixture { } } + suspend fun givenChannelsWhen(channels: CompletableDeferred>) = apply { + whenever(repositoryFacade.selectChannels(channelCIDs = any())) doSuspendableAnswer { channels.await() } + } + + fun loseUserOnceReady() { + userFlow.value = null + initializationState.value = InitializationState.COMPLETE + } + suspend fun givenChannels(channels: List) = apply { whenever(repositoryFacade.selectChannels(channelCIDs = any())) doReturn channels } diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt index f485d03eb4b8..f94e4b451dbc 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/threads/ThreadListControllerTest.kt @@ -21,18 +21,27 @@ import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.api.state.QueryThreadsState import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.ConnectionState import io.getstream.chat.android.models.QueryThreadsResult +import io.getstream.chat.android.models.Thread +import io.getstream.chat.android.models.User import io.getstream.chat.android.randomString import io.getstream.chat.android.randomThread import io.getstream.result.call.Call import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.job +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -203,23 +212,157 @@ internal class ThreadListControllerTest { } } + @Test + fun `logout clears the threads`() = runTest { + val threads = MutableStateFlow(listOf(randomThread())) + val fixture = Fixture() + .givenCurrentUser() + .givenQueryThreadsState(threadsState(threads)) + val sut = fixture.get(backgroundScope) + testScheduler.runCurrent() + assertEquals(1, sut.state.value.threads.size) + + fixture.disconnectUser() + testScheduler.runCurrent() + assertTrue(sut.state.value.threads.isEmpty()) + assertTrue(sut.state.value.isLoading) + + threads.value = listOf(randomThread(), randomThread()) + testScheduler.runCurrent() + assertTrue(sut.state.value.threads.isEmpty()) + } + + @Test + fun `close stops following the threads`() = runTest { + val threads = MutableStateFlow(listOf(randomThread())) + val fixture = Fixture() + .givenCurrentUser() + .givenQueryThreadsState(threadsState(threads)) + val callerScope = CoroutineScope(coroutineContext + SupervisorJob(coroutineContext.job)) + val sut = fixture.get(callerScope) + testScheduler.runCurrent() + + sut.close() + threads.value = listOf(randomThread(), randomThread()) + testScheduler.runCurrent() + + assertEquals(1, sut.state.value.threads.size) + assertTrue(callerScope.isActive) + callerScope.cancel() + } + + @Test + fun `another user connecting loads their threads`() = runTest { + val fixture = Fixture() + .givenCurrentUser() + .givenQueryThreadsState(threadsState(MutableStateFlow(listOf(randomThread())))) + val sut = fixture.get(backgroundScope) + testScheduler.runCurrent() + + fixture.disconnectUser() + testScheduler.runCurrent() + fixture.connectUser(User(id = "other"), threadsState(MutableStateFlow(listOf(randomThread(), randomThread())))) + testScheduler.runCurrent() + + assertEquals(2, sut.state.value.threads.size) + assertFalse(sut.state.value.isLoading) + } + + @Test + fun `with the default scope another user connecting loads their threads`() = runTest { + val testDispatcher = StandardTestDispatcher(testScheduler) + DispatcherProvider.set(testDispatcher, testDispatcher) + try { + val fixture = Fixture() + .givenCurrentUser() + .givenQueryThreadsState(threadsState(MutableStateFlow(listOf(randomThread())))) + val sut = fixture.getWithDefaultScope() + testScheduler.runCurrent() + + fixture.disconnectUser() + testScheduler.runCurrent() + fixture.connectUser(User(id = "other"), threadsState(MutableStateFlow(listOf(randomThread(), randomThread())))) + testScheduler.runCurrent() + + assertEquals(2, sut.state.value.threads.size) + sut.close() + } finally { + DispatcherProvider.reset() + } + } + + @Test + fun `the same user connecting again keeps the threads`() = runTest { + val state = threadsState(MutableStateFlow(listOf(randomThread()))) + val fixture = Fixture() + .givenCurrentUser() + .givenQueryThreadsState(state) + val sut = fixture.get(backgroundScope) + testScheduler.runCurrent() + + // The client's state goes null and back while the same user connects again; the user may be re-emitted. + fixture.publishQueryState(null) + testScheduler.runCurrent() + assertEquals(1, sut.state.value.threads.size) + assertFalse(sut.state.value.isLoading) + fixture.givenCurrentUser(User(id = "user", name = "renamed")) + fixture.publishQueryState(state) + testScheduler.runCurrent() + + assertEquals(1, sut.state.value.threads.size) + assertFalse(sut.state.value.isLoading) + } + + private fun threadsState(threads: MutableStateFlow>): QueryThreadsState = mock { + on { it.threads } doReturn threads + on { loading } doReturn MutableStateFlow(false) + on { loadingMore } doReturn MutableStateFlow(false) + on { unseenThreadIds } doReturn MutableStateFlow(emptySet()) + on { loadingError } doReturn MutableStateFlow(false) + } + private class Fixture { private val queryThreadsStateFlow = MutableStateFlow(null) + private val userFlow = MutableStateFlow(null) private val mockClientState: ClientState = mock { on { connectionState } doReturn MutableStateFlow(ConnectionState.Connected) - on { user } doReturn MutableStateFlow(null) + on { user } doReturn userFlow } + // Stands in for the client's per-user scope, which disconnect cancels. + private val userSessionScope = CoroutineScope(SupervisorJob()) + private val mockChatClient: ChatClient = mock { on { clientState } doReturn mockClientState + on { inheritScope(any()) } doReturn userSessionScope } fun givenQueryThreadsState(state: QueryThreadsState) = apply { queryThreadsStateFlow.value = state } + fun givenCurrentUser(currentUser: User = User(id = "user")) = apply { + userFlow.value = currentUser + } + + fun disconnectUser() { + // Same order as ChatClient: the query state goes null, the user's scope is cancelled, then the user goes. + queryThreadsStateFlow.value = null + userSessionScope.cancel() + userFlow.value = null + } + + fun connectUser(newUser: User, state: QueryThreadsState) { + userFlow.value = newUser + queryThreadsStateFlow.value = state + } + + fun publishQueryState(state: QueryThreadsState?) { + queryThreadsStateFlow.value = state + } + fun givenQueryThreadsRequest(query: QueryThreadsRequest) = apply { whenever(mockChatClient.queryThreads(query)) doReturn mock>() @@ -242,5 +385,11 @@ internal class ThreadListControllerTest { scope = scope, queryThreadsAsState = { queryThreadsStateFlow }, ) + + fun getWithDefaultScope() = ThreadListController( + query = QueryThreadsRequest(), + chatClient = mockChatClient, + queryThreadsAsState = { queryThreadsStateFlow }, + ) } } 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 88c9a4e3b8b6..d75645b9698a 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,9 +23,9 @@ 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.awaitConnectedUser import io.getstream.chat.android.ui.common.utils.extensions.userSessionEnds import io.getstream.log.taggedLogger import io.getstream.result.Error @@ -33,7 +33,6 @@ 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 /** @@ -183,8 +182,7 @@ 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 = chatClient.clientState.user.value + val currentUser = chatClient.clientState.awaitConnectedUser() ?: return Result.Failure(Error.GenericError("No user is connected")) return chatClient .searchMessages( diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt index 46046113f365..9a922b4f2622 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/threads/ThreadListViewModel.kt @@ -28,7 +28,7 @@ import io.getstream.chat.android.ui.common.state.threads.ThreadListState * @param controller The [ThreadListController] handling the business logic and the state management for the threads * list. */ -public class ThreadListViewModel(private val controller: ThreadListController) : ViewModel() { +public class ThreadListViewModel(private val controller: ThreadListController) : ViewModel(controller) { /** * The current thread list state. diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/threads/ThreadListViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/threads/ThreadListViewModelTest.kt new file mode 100644 index 000000000000..ddbab351bebb --- /dev/null +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/threads/ThreadListViewModelTest.kt @@ -0,0 +1,71 @@ +/* + * 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.viewmodels.threads + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import io.getstream.chat.android.test.InstantTaskExecutorExtension +import io.getstream.chat.android.test.TestCoroutineExtension +import io.getstream.chat.android.ui.common.feature.threads.ThreadListController +import io.getstream.chat.android.ui.common.state.threads.ThreadListState +import io.getstream.chat.android.ui.viewmodel.threads.ThreadListViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +internal class ThreadListViewModelTest { + + @Test + fun `clearing the view model closes the controller`() { + val controller: ThreadListController = mock { + on { state } doReturn MutableStateFlow( + ThreadListState( + threads = emptyList(), + isLoading = true, + isLoadingMore = false, + unseenThreadsCount = 0, + ), + ) + } + val store = ViewModelStore() + ViewModelProvider( + store, + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = ThreadListViewModel(controller) as T + }, + )[ThreadListViewModel::class.java] + + store.clear() + + verify(controller).close() + } + + companion object { + @JvmField + @RegisterExtension + val testCoroutines: TestCoroutineExtension = TestCoroutineExtension() + + @JvmField + @RegisterExtension + val instantExecutorExtension: InstantTaskExecutorExtension = InstantTaskExecutorExtension() + } +}