diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index f44ff472b91f..1e46c03b50e3 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -7015,6 +7015,14 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelL public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;Z)V public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/jvm/functions/Function1;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/jvm/functions/Function1;I)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/jvm/functions/Function1;ILjava/lang/Integer;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/jvm/functions/Function1;ILjava/lang/Integer;Ljava/lang/Integer;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/jvm/functions/Function1;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/jvm/functions/Function1;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;Z)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/jvm/functions/Function1;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/jvm/functions/Function1;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;)V public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/util/Map;)V public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V @@ -7025,9 +7033,21 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelL public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;Z)V public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/util/Map;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/util/Map;I)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/util/Map;ILjava/lang/Integer;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;Z)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lkotlin/jvm/functions/Function1;)V public fun (Ljava/lang/String;)V public fun (Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V public synthetic fun (Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public fun (Lkotlin/jvm/functions/Function1;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; public fun create (Ljava/lang/Class;Landroidx/lifecycle/viewmodel/CreationExtras;)Landroidx/lifecycle/ViewModel; public fun create (Lkotlin/reflect/KClass;Landroidx/lifecycle/viewmodel/CreationExtras;)Landroidx/lifecycle/ViewModel; 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..3dd32741d9ed 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 @@ -125,12 +125,14 @@ public class ChannelListViewModel internal constructor( data class Standard( val initialFilter: FilterObject?, val initialSort: QuerySorter, + val filterForUser: ((User) -> FilterObject)? = null, ) : QueryMode data class Predefined( val name: String, val filterValues: Map?, val sortValues: Map?, + val filterValuesForUser: ((User) -> Map)? = null, ) : QueryMode data class Grouped(val groupKey: String) : QueryMode @@ -425,6 +427,13 @@ public class ChannelListViewModel internal constructor( */ private var isFilterSetByApp = false + /** + * Builds the filter for the connected user: the app's, or the default one when no filter was given. + */ + private val userFilter: ((User) -> FilterObject)? = (mode as? QueryMode.Standard)?.let { standard -> + standard.filterForUser ?: DEFAULT_USER_FILTER.takeIf { standard.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 @@ -443,10 +452,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) { + userFilter?.let { buildFilter -> viewModelScope.launch { - val filter = defaultChannelsFilter().first() - if (!isFilterSetByApp) this@ChannelListViewModel.filterFlow.value = filter + val user = chatClient.clientState.user.filterNotNull().first() + if (!isFilterSetByApp) this@ChannelListViewModel.filterFlow.value = buildFilter(user) } } @@ -459,9 +468,8 @@ 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()) { - // The default filter is built from the current user. - filterFlow.value = defaultChannelsFilter().first() + userFilter.takeUnless { isFilterSetByApp }?.let { buildFilter -> + chatClient.clientState.user.value?.let { filterFlow.value = buildFilter(it) } } refresh() } @@ -955,12 +963,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. */ @@ -1040,9 +1042,9 @@ public class ChannelListViewModel internal constructor( } /** - * Builds a [QueryChannelsRequest] for the current [mode] and [searchQuery]. Returns `null` in Standard - * mode when the filter has not yet been resolved (e.g. before [buildDefaultFilter] completes); in that - * case the caller should skip the request — the next emission of [filterFlow] will re-trigger. + * Builds a [QueryChannelsRequest] for the current [mode] and [searchQuery]. Returns `null` when the filter + * or filter values depend on a user and none is connected yet; in that case the caller should skip the + * request — the next emission of [filterFlow], or the reload once a user connects, will re-trigger. * * In Predefined mode with an active channel search, falls back to a Standard request whose filter is * just [searchChannelFilter] (the predefined filter is server-owned and cannot be combined locally). @@ -1066,9 +1068,13 @@ public class ChannelListViewModel internal constructor( memberLimit = memberLimit, ) } else { + val filterValues = when (val buildFilterValues = mode.filterValuesForUser) { + null -> mode.filterValues + else -> buildFilterValues(user.value ?: return null) + } QueryChannelsRequest( predefinedFilter = mode.name, - filterValues = mode.filterValues, + filterValues = filterValues, sortValues = mode.sortValues, limit = channelLimit, messageLimit = messageLimit, @@ -1262,12 +1268,6 @@ public class ChannelListViewModel internal constructor( members.any { it.user.id != currentUser?.id && it.user.id in mutedUserIds } } - /** - * Builds the default channel filter, which represents "messaging" channels that the current user is a part of. - */ - private fun defaultChannelsFilter(): Flow = - chatClient.clientState.user.map(Filters::defaultChannelListFilter).filterNotNull() - @Deprecated( message = "Avoid using this search query as `member.user.name` is an expensive operation. " + "In the future, we should migrate to use optimizedChannelSearchFilter.", @@ -1299,6 +1299,10 @@ public class ChannelListViewModel internal constructor( } internal companion object { + private val DEFAULT_USER_FILTER: (User) -> FilterObject = { user -> + checkNotNull(Filters.defaultChannelListFilter(user)) + } + /** * Default value of number of channels to return when querying channels. */ 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..df6d55a8aecd 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 @@ -27,6 +27,7 @@ import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModel import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.User import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter @@ -35,9 +36,9 @@ 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, and filters or filter values + * built from the user, follow the connected user. A fixed filter or filter values that contain a user id apply to + * that user only. * * @param chatClient The client used to fetch data. * @param channelLimit How many channels we fetch per page. @@ -90,6 +91,35 @@ public class ChannelListViewModelFactory internal constructor( messageSearchSort = messageSearchSort, ) + /** + * Builds a factory for a [ChannelListViewModel] that queries channels by a filter built from the connected user. + * + * @param querySort The sorting order for channels. + * @param filters Builds the filter for the connected user, on the main thread. It's called again when another + * user connects. + */ + @JvmOverloads + public constructor( + chatClient: ChatClient = ChatClient.instance(), + querySort: QuerySorter = QuerySortByField.descByName("last_updated"), + filters: (User) -> FilterObject, + channelLimit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, + memberLimit: Int? = null, + messageLimit: Int? = null, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), + draftMessagesEnabled: Boolean = true, + messageSearchSort: QuerySorter? = null, + ) : this( + chatClient = chatClient, + mode = QueryMode.Standard(initialFilter = null, initialSort = querySort, filterForUser = filters), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + draftMessagesEnabled = draftMessagesEnabled, + messageSearchSort = messageSearchSort, + ) + /** * Builds a factory for a [ChannelListViewModel] that queries channels using a predefined filter * resolved by the server. @@ -125,6 +155,43 @@ public class ChannelListViewModelFactory internal constructor( messageSearchSort = messageSearchSort, ) + /** + * Builds a factory for a [ChannelListViewModel] that queries channels using a predefined filter + * resolved by the server, with filter values built from the connected user. + * + * @param predefinedFilterName The name of the predefined filter registered on the backend. + * @param filterValues Builds the values interpolated into the predefined filter template for the connected + * user, on the main thread. It's called for every request, so another user gets their own values. + * @param sortValues Optional values interpolated into the predefined sort template. + */ + @JvmOverloads + public constructor( + chatClient: ChatClient = ChatClient.instance(), + predefinedFilterName: String, + filterValues: (User) -> Map, + sortValues: Map? = null, + channelLimit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, + memberLimit: Int? = null, + messageLimit: Int? = null, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), + draftMessagesEnabled: Boolean = true, + messageSearchSort: QuerySorter? = null, + ) : this( + chatClient = chatClient, + mode = QueryMode.Predefined( + name = predefinedFilterName, + filterValues = null, + sortValues = sortValues, + filterValuesForUser = filterValues, + ), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + draftMessagesEnabled = draftMessagesEnabled, + messageSearchSort = messageSearchSort, + ) + /** * Grouped [ChannelListViewModel] factory. Wires the ViewModel to the state identified by * [groupKey] without firing a remote call; `queryGroupedChannels` responses populate it. 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..d7400443ce19 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,94 @@ internal class ChannelListViewModelTest { assertEquals(ChannelsState(), viewModel.channelsState) } + @Test + fun `Given filters built from the user When another user connects Should query with their filters`() = runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenFilterForUser { user -> Filters.`in`("members", listOf(user.id)) } + fixture.get(this) + + fixture.disconnectUser() + fixture.connectUser(otherUser) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + assertEquals(Filters.`in`("members", listOf(otherUser.id)), captor.lastValue.filter) + assertEquals(1, captor.allValues.count { it.filter == Filters.`in`("members", listOf(currentUserId)) }) + } + + @Test + fun `Given filters built from the user When the same user logs in again Should query with their filters`() = + runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenFilterForUser { user -> Filters.`in`("members", listOf(user.id)) } + 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.`in`("members", listOf(currentUserId)) }) + } + + @Test + fun `Given filters built from the user and filters set by the app When another user connects Should keep the app's filters`() = + runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenFilterForUser { user -> Filters.`in`("members", listOf(user.id)) } + 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 filter values built from the user When another user connects Should query with their values`() = + runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenPredefinedValuesForUser("vip_filter") { user -> mapOf("user_id" to user.id) } + fixture.get(this) + + fixture.disconnectUser() + fixture.connectUser(otherUser) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, times(2)).queryChannels(captor.capture()) + assertEquals(mapOf("user_id" to currentUserId), captor.firstValue.filterValues) + assertEquals(mapOf("user_id" to otherUser.id), captor.secondValue.filterValues) + } + @Test fun `Given channel list in content state When deleting a channel Should delete the channel`() = runTest { val chatClient: ChatClient = mock() @@ -1935,6 +2023,7 @@ internal class ChannelListViewModelTest { private var predefinedFilterValues: Map? = null private var predefinedSortValues: Map? = null private var searchDebounceMs: Long = ChannelListViewModel.SEARCH_DEBOUNCE_MS + private var queryMode: ChannelListViewModel.QueryMode? = null init { val statePlugin: StatePlugin = mock() @@ -2052,6 +2141,23 @@ internal class ChannelListViewModelTest { predefinedSortValues = sortValues } + fun givenFilterForUser(filterForUser: (User) -> FilterObject) = apply { + queryMode = ChannelListViewModel.QueryMode.Standard( + initialFilter = null, + initialSort = initialSort, + filterForUser = filterForUser, + ) + } + + fun givenPredefinedValuesForUser(name: String, filterValuesForUser: (User) -> Map) = apply { + queryMode = ChannelListViewModel.QueryMode.Predefined( + name = name, + filterValues = null, + sortValues = null, + filterValuesForUser = filterValuesForUser, + ) + } + fun givenSearchDebounceMs(searchDebounceMs: Long) = apply { this.searchDebounceMs = searchDebounceMs } @@ -2095,7 +2201,21 @@ internal class ChannelListViewModelTest { fun get(testScope: TestScope, groupKey: String? = null): ChannelListViewModel { val name = predefinedFilterName + val mode = queryMode val channelListViewModel = when { + mode != null -> ChannelListViewModel( + chatClient = chatClient, + mode = mode, + channelLimit = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, + memberLimit = null, + messageLimit = null, + chatEventHandlerFactory = ChatEventHandlerFactory(clientState), + searchDebounceMs = searchDebounceMs, + draftMessagesEnabled = false, + messageSearchSort = messageSearchSort, + globalState = MutableStateFlow(globalState), + ) + groupKey != null -> ChannelListViewModel( chatClient = chatClient, groupKey = groupKey, diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index 65aa70f58aff..39aba7027712 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -4487,6 +4487,22 @@ public final class io/getstream/chat/android/ui/viewmodel/channels/ChannelListVi public fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Z)V public fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;)V public synthetic fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/util/Map;)V + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/util/Map;I)V + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/util/Map;ILjava/lang/Integer;)V + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;)V + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Z)V + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;)V + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lkotlin/jvm/functions/Function1;)V + public fun (Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/models/querysort/QuerySorter;)V + public fun (Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/models/querysort/QuerySorter;I)V + public fun (Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Integer;)V + public fun (Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Integer;Ljava/lang/Integer;)V + public fun (Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Integer;Ljava/lang/Integer;Z)V + public fun (Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;)V + public synthetic fun (Lkotlin/jvm/functions/Function1;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; public fun create (Ljava/lang/Class;Landroidx/lifecycle/viewmodel/CreationExtras;)Landroidx/lifecycle/ViewModel; public fun create (Lkotlin/reflect/KClass;Landroidx/lifecycle/viewmodel/CreationExtras;)Landroidx/lifecycle/ViewModel; 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..eb145a58212b 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 @@ -40,6 +40,7 @@ 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.User 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 @@ -251,6 +252,13 @@ public class ChannelListViewModel internal constructor( */ private var isFilterSetByApp = false + /** + * Builds the filter for the connected user: the app's, or the default one when no filter was given. + */ + private val userFilter: ((User) -> FilterObject)? = (mode as? QueryMode.Standard)?.let { standard -> + standard.filterForUser ?: DEFAULT_USER_FILTER.takeIf { standard.initialFilter == null } + } + /** * The in-flight request for the next page, if any. */ @@ -259,10 +267,10 @@ public class ChannelListViewModel internal constructor( init { when (mode) { is QueryMode.Standard -> { - if (mode.initialFilter == null) { + userFilter?.let { buildFilter -> viewModelScope.launch { - val resolvedFilter = buildDefaultFilter().first() - if (!isFilterSetByApp) this@ChannelListViewModel.filterLiveData.value = resolvedFilter + val user = chatClient.clientState.user.filterNotNull().first() + if (!isFilterSetByApp) this@ChannelListViewModel.filterLiveData.value = buildFilter(user) } } stateMerger.addSource(filterLiveData) { filter -> @@ -283,9 +291,11 @@ public class ChannelListViewModel internal constructor( 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() + val buildFilter = userFilter.takeUnless { isFilterSetByApp } + val user = chatClient.clientState.user.value + if (buildFilter != null && user != null) { + // The filter is built from the current user, and setting it reloads. + filterLiveData.value = buildFilter(user) } else { initData() } @@ -293,19 +303,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 - - /** - * Builds the default channel filter, which represents "messaging" channels that the current user is a part of. - */ - private fun buildDefaultFilter(): Flow { - return chatClient.clientState.user.map(Filters::defaultChannelListFilter).filterNotNull() - } - /** * Initializes the data necessary for the screen. */ @@ -494,8 +491,8 @@ public class ChannelListViewModel internal constructor( } /** - * Builds a [QueryChannelsRequest] for the current [mode]. Returns `null` in Standard mode when the - * filter has not yet been resolved (e.g. before [buildDefaultFilter] completes). + * Builds a [QueryChannelsRequest] for the current [mode]. Returns `null` when the filter or filter values + * depend on a user and none is connected yet. */ private fun buildQueryChannelsRequest(): QueryChannelsRequest? = when (mode) { is QueryMode.Standard -> { @@ -508,14 +505,20 @@ public class ChannelListViewModel internal constructor( memberLimit = memberLimit, ) } - is QueryMode.Predefined -> QueryChannelsRequest( - predefinedFilter = mode.name, - filterValues = mode.filterValues, - sortValues = mode.sortValues, - limit = limit, - messageLimit = messageLimit, - memberLimit = memberLimit, - ) + is QueryMode.Predefined -> { + val filterValues = when (val buildFilterValues = mode.filterValuesForUser) { + null -> mode.filterValues + else -> buildFilterValues(chatClient.clientState.user.value ?: return null) + } + QueryChannelsRequest( + predefinedFilter = mode.name, + filterValues = filterValues, + sortValues = mode.sortValues, + limit = limit, + messageLimit = messageLimit, + memberLimit = memberLimit, + ) + } } /** @@ -613,17 +616,23 @@ public class ChannelListViewModel internal constructor( data class Standard( val initialFilter: FilterObject?, val initialSort: QuerySorter, + val filterForUser: ((User) -> FilterObject)? = null, ) : QueryMode data class Predefined( val name: String, val filterValues: Map?, val sortValues: Map?, + val filterValuesForUser: ((User) -> Map)? = null, ) : QueryMode } public companion object { + private val DEFAULT_USER_FILTER: (User) -> FilterObject = { user -> + checkNotNull(Filters.defaultChannelListFilter(user)) + } + /** * The default sorting option for queries. */ 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..2fa2cae7d716 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 @@ -24,6 +24,7 @@ import io.getstream.chat.android.client.api.state.globalStateFlow 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.User import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.ui.ChatUI import io.getstream.chat.android.ui.viewmodel.channels.ChannelListViewModel.QueryMode @@ -32,9 +33,9 @@ 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, and filters or filter values + * built from the user, follow the connected user. A fixed filter or filter values that contain a user id apply to + * that user only. * * @param limit How many channels to return. * @param memberLimit The number of members per channel. When `null`, the server-side default is used. @@ -81,6 +82,31 @@ private constructor( chatEventHandlerFactory = chatEventHandlerFactory, ) + /** + * Builds a factory for a [ChannelListViewModel] that queries channels by a filter built from the connected user. + * + * @param filter Builds the filter for the connected user, on the main thread. It's called again when another + * user connects. + * @param sort How to sort the channels, defaults to last_updated. + */ + @JvmOverloads + public constructor( + filter: (User) -> FilterObject, + sort: QuerySorter = ChannelListViewModel.DEFAULT_SORT, + limit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, + messageLimit: Int? = null, + memberLimit: Int? = null, + isDraftMessagesEnabled: Boolean = ChatUI.draftMessagesEnabled, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(), + ) : this( + mode = QueryMode.Standard(initialFilter = null, initialSort = sort, filterForUser = filter), + limit = limit, + messageLimit = messageLimit, + memberLimit = memberLimit, + isDraftMessagesEnabled = isDraftMessagesEnabled, + chatEventHandlerFactory = chatEventHandlerFactory, + ) + /** * Builds a factory for a [ChannelListViewModel] that queries channels using a predefined filter * resolved by the server. @@ -112,6 +138,39 @@ private constructor( chatEventHandlerFactory = chatEventHandlerFactory, ) + /** + * Builds a factory for a [ChannelListViewModel] that queries channels using a predefined filter + * resolved by the server, with filter values built from the connected user. + * + * @param predefinedFilterName The name of the predefined filter registered on the backend. + * @param filterValues Builds the values interpolated into the predefined filter template for the connected + * user, on the main thread. It's called for every request, so another user gets their own values. + * @param sortValues Optional values interpolated into the predefined sort template. + */ + @JvmOverloads + public constructor( + predefinedFilterName: String, + filterValues: (User) -> Map, + sortValues: Map? = null, + limit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, + messageLimit: Int? = null, + memberLimit: Int? = null, + isDraftMessagesEnabled: Boolean = ChatUI.draftMessagesEnabled, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(), + ) : this( + mode = QueryMode.Predefined( + name = predefinedFilterName, + filterValues = null, + sortValues = sortValues, + filterValuesForUser = filterValues, + ), + limit = limit, + messageLimit = messageLimit, + memberLimit = memberLimit, + isDraftMessagesEnabled = isDraftMessagesEnabled, + chatEventHandlerFactory = chatEventHandlerFactory, + ) + /** * Returns an instance of [ChannelListViewModel]. */ 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..351882dc1d21 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 @@ -317,6 +317,49 @@ internal class ChannelListViewModelTest { verify(nextPageCall).cancel() } + @Test + fun `Given filters built from the user When another user connects Should query with their filters`() = runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenFilterForUser { user -> Filters.`in`("members", listOf(user.id)) } + val viewModel = fixture.get() + viewModel.state.observeAll() + + fixture.disconnectUser() + fixture.connectUser(User(id = "other")) + + val captor = argumentCaptor() + verify(chatClient, atLeastOnce()).queryChannels(captor.capture()) + captor.lastValue.filter shouldBeEqualTo Filters.`in`("members", listOf("other")) + captor.allValues.count { it.filter == Filters.`in`("members", listOf("Jc")) } shouldBeEqualTo 1 + } + + @Test + fun `Given filter values built from the user When another user connects Should query with their values`() = + runTest { + val chatClient: ChatClient = mock() + val fixture = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenPredefinedValuesForUser("vip_filter") { user -> mapOf("user_id" to user.id) } + val viewModel = fixture.get() + viewModel.state.observeAll() + + fixture.disconnectUser() + fixture.connectUser(User(id = "other")) + + val captor = argumentCaptor() + verify(chatClient, times(2)).queryChannels(captor.capture()) + captor.firstValue.filterValues shouldBeEqualTo mapOf("user_id" to "Jc") + captor.secondValue.filterValues shouldBeEqualTo mapOf("user_id" to "other") + } + @Test fun `Given channel list in content state When leaving a channel Should leave the channel`() = runTest { val chatClient: ChatClient = mock() @@ -483,6 +526,7 @@ internal class ChannelListViewModelTest { private var predefinedFilterName: String? = null private var predefinedFilterValues: Map? = null private var predefinedSortValues: Map? = null + private var queryMode: ChannelListViewModel.QueryMode? = null init { whenever(chatClient.channel(any())) doReturn channelClient @@ -531,6 +575,23 @@ internal class ChannelListViewModelTest { user.value = null } + fun givenFilterForUser(filterForUser: (User) -> FilterObject) = apply { + queryMode = ChannelListViewModel.QueryMode.Standard( + initialFilter = null, + initialSort = initialSort, + filterForUser = filterForUser, + ) + } + + fun givenPredefinedValuesForUser(name: String, filterValuesForUser: (User) -> Map) = apply { + queryMode = ChannelListViewModel.QueryMode.Predefined( + name = name, + filterValues = null, + sortValues = null, + filterValuesForUser = filterValuesForUser, + ) + } + fun givenChannelMutes(channelMutes: List = emptyList()) = apply { whenever(globalState.channelMutes) doReturn MutableStateFlow(channelMutes) } @@ -578,6 +639,19 @@ internal class ChannelListViewModelTest { fun get(): ChannelListViewModel { val name = predefinedFilterName + val mode = queryMode + if (mode != null) { + return ChannelListViewModel( + mode = mode, + limit = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, + messageLimit = null, + memberLimit = null, + isDraftMessagesEnabled = false, + chatEventHandlerFactory = ChatEventHandlerFactory(clientState = clientState), + chatClient = chatClient, + globalState = MutableStateFlow(globalState), + ) + } return if (name != null) { ChannelListViewModel( chatClient = chatClient,