diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelSearchTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelSearchTests.kt index 4e2c0335380a..4b8eeda59e02 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelSearchTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/ChannelSearchTests.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.compose.tests import io.getstream.chat.android.compose.robots.assertChannelCount import io.getstream.chat.android.compose.robots.assertChannelWithName import io.getstream.chat.android.compose.sample.ui.InitTestActivity +import io.getstream.chat.android.e2e.test.robots.ParticipantRobot import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId import org.junit.Test @@ -57,4 +58,24 @@ class ChannelSearchTests : StreamTestCase() { .assertChannelWithName(channelName) } } + + @AllureId("12487") + @Test + fun test_userSearchesForMemberName_noChannelIsFound() { + step("GIVEN channels and a direct message channel with the participant exist") { + backendRobot.generateChannels(channelsCount = 3, withDirectMessageChannel = true) + } + step("AND user logs in") { + userRobot + .login() + .waitForChannelListToLoad() + .assertChannelCount(4) + } + step("WHEN user searches for the participant's name") { + userRobot.search(ParticipantRobot.name.substringBefore(" ")) + } + step("THEN no channel is shown, because the default search matches channel names only") { + userRobot.assertChannelCount(0) + } + } } diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/MemberNameSearchTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/MemberNameSearchTests.kt new file mode 100644 index 000000000000..d0cfd8563de6 --- /dev/null +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/MemberNameSearchTests.kt @@ -0,0 +1,92 @@ +/* + * 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.compose.tests + +import io.getstream.chat.android.compose.robots.assertChannelCount +import io.getstream.chat.android.compose.robots.assertChannelWithName +import io.getstream.chat.android.compose.sample.ui.InitTestActivity +import io.getstream.chat.android.e2e.test.robots.ParticipantRobot +import io.qameta.allure.kotlin.Allure.step +import io.qameta.allure.kotlin.AllureId +import org.junit.Test + +/** + * Covers a channel search customized through `ChannelListSearchOptions`. It needs the app started with the channel + * search and member name search flags, which make the sample also match channels by the name of a member. The + * default channel search is covered by [ChannelSearchTests]. + */ +class MemberNameSearchTests : StreamTestCase() { + + override fun initTestActivity() = InitTestActivity.UserLogin + override var useChannelSearch = true + override var useMemberNameSearch = true + + @AllureId("12485") + @Test + fun test_userSearchesForChannelsByMemberName() { + step("GIVEN group channels with a third member and a direct message channel without them exist") { + backendRobot.generateChannels(channelsCount = 3, withDirectMessageChannel = true) + } + step("AND user logs in") { + userRobot + .login() + .waitForChannelListToLoad() + .assertChannelCount(4) + } + step("WHEN user searches for the third member's name") { + userRobot.search(MEMBER_NAME_QUERY) + } + step("THEN only the channels with that member are shown") { + userRobot + .assertChannelCount(3) + .assertChannelWithName(ParticipantRobot.name, isDisplayed = false) + } + } + + @AllureId("12486") + @Test + fun test_userSearchesForChannelByNameWithMemberNameSearch() { + step("GIVEN channels exist, one with a searchable name") { + backendRobot.generateChannels( + channelsCount = 3, + channelNames = listOf(CHANNEL_NAME), + withDirectMessageChannel = true, + ) + } + step("AND user logs in") { + userRobot + .login() + .waitForChannelListToLoad() + .assertChannelCount(4) + } + step("WHEN user searches for the channel by its name") { + userRobot.search(CHANNEL_NAME_QUERY) + } + step("THEN only the matching channel is shown") { + userRobot + .assertChannelCount(1) + .assertChannelWithName(CHANNEL_NAME) + } + } + + private companion object { + /** A member of the seeded group channels only: not the app user, and not part of the direct message channel. */ + const val MEMBER_NAME_QUERY = "Lando" + const val CHANNEL_NAME = "Blue Team" + const val CHANNEL_NAME_QUERY = "Blue" + } +} diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/StreamTestCase.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/StreamTestCase.kt index 87073085a1fd..ea7dfe2ceef2 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/StreamTestCase.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/StreamTestCase.kt @@ -46,6 +46,9 @@ abstract class StreamTestCase { /** Makes the channel list header search for channels instead of messages. */ open var useChannelSearch = false + + /** Makes the channel search also match channels by the name of a member. */ + open var useMemberNameSearch = false val userRobot = UserRobot() lateinit var backendRobot: BackendRobot lateinit var participantRobot: ParticipantRobot @@ -105,6 +108,7 @@ abstract class StreamTestCase { it.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) it.putExtra("InitTestActivity", initTestActivity()) it.putExtra("CHANNEL_SEARCH", useChannelSearch) + it.putExtra("MEMBER_NAME_SEARCH", useMemberNameSearch) if (useMockServer) { it.putExtra("BASE_URL", mockServer.url) it.putExtra("USE_V2_WEBSOCKET", apiVersion == "v2") diff --git a/stream-chat-android-compose-sample/src/e2e/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt b/stream-chat-android-compose-sample/src/e2e/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt index fd00fa966dee..54131164ca71 100644 --- a/stream-chat-android-compose-sample/src/e2e/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt +++ b/stream-chat-android-compose-sample/src/e2e/java/io/getstream/chat/android/compose/sample/ui/StartupActivity.kt @@ -60,6 +60,9 @@ class StartupActivity : AppCompatActivity() { if (intent.hasExtra("CHANNEL_SEARCH")) { customSettings().isChannelSearchEnabled = intent.getBooleanExtra("CHANNEL_SEARCH", false) } + if (intent.hasExtra("MEMBER_NAME_SEARCH")) { + customSettings().isMemberNameSearchEnabled = intent.getBooleanExtra("MEMBER_NAME_SEARCH", false) + } val initTestActivity = intent.getSerializableExtra("InitTestActivity") as? InitTestActivity if (initTestActivity != null) { diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt index a247c91081df..cf8eacec2c33 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt @@ -40,6 +40,7 @@ class CustomSettings(private val context: Context) { var isLocationSharingEnabled: Boolean by booleanPref(LocationSharing) var isLocalUnreadCountEnabled: Boolean by booleanPref(LocalUnreadCount) var isChannelSearchEnabled: Boolean by booleanPref(ChannelSearch) + var isMemberNameSearchEnabled: Boolean by booleanPref(MemberNameSearch) var isMarkdownEnabled: Boolean by booleanPref(Markdown) private fun booleanPref(key: String, default: Boolean = false) = @@ -60,6 +61,7 @@ private const val SystemAttachmentPicker = "system_attachment_picker" private const val LocationSharing = "location_sharing" private const val LocalUnreadCount = "local_unread_count" private const val ChannelSearch = "channel_search" +private const val MemberNameSearch = "member_name_search" private const val Markdown = "markdown" fun Context.customSettings(): CustomSettings = CustomSettings(this) diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/SampleChannelListViewModelFactory.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/SampleChannelListViewModelFactory.kt index 033847f30de8..058c1f417ca7 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/SampleChannelListViewModelFactory.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/SampleChannelListViewModelFactory.kt @@ -18,8 +18,10 @@ package io.getstream.chat.android.compose.sample.feature.channel.list import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.compose.sample.data.CustomSettings +import io.getstream.chat.android.compose.viewmodel.channels.ChannelListSearchOptions import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModelFactory 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.querysort.QuerySortByField @@ -30,10 +32,17 @@ import io.getstream.chat.android.models.querysort.QuerySortByField * to make the feature testable. Otherwise, the predefined server-side filter is used, which resolves * to: messaging channels the current user is a member of, without a draft, sorted by "pinned_at" and * "last_updated" descending. + * + * When member name search is enabled, the channel search also matches channels by the name of a member. */ fun sampleChannelListViewModelFactory(settings: CustomSettings): ChannelListViewModelFactory { val chatClient = ChatClient.instance() val currentUserId = chatClient.getCurrentUser()?.id.orEmpty() + val searchOptions = if (settings.isMemberNameSearchEnabled) { + MemberNameSearchOptions() + } else { + ChannelListSearchOptions() + } return if (settings.isLocalUnreadCountEnabled) { ChannelListViewModelFactory( chatClient = chatClient, @@ -44,6 +53,7 @@ fun sampleChannelListViewModelFactory(settings: CustomSettings): ChannelListView Filters.or(Filters.notExists("draft"), Filters.eq("draft", false)), ), chatEventHandlerFactory = CustomChatEventHandlerFactory(), + searchOptions = searchOptions, ) } else { ChannelListViewModelFactory( @@ -54,6 +64,24 @@ fun sampleChannelListViewModelFactory(settings: CustomSettings): ChannelListView "user_id" to currentUserId, ), chatEventHandlerFactory = CustomChatEventHandlerFactory(), + searchOptions = searchOptions, + ) + } +} + +/** + * Searches channels by their name or by the name of one of their members, among the channels the current user is a + * member of. + */ +private class MemberNameSearchOptions : ChannelListSearchOptions() { + override fun channelFilter(params: ChannelSearchParams): FilterObject { + val searchFilter = Filters.and( + Filters.or( + Filters.autocomplete("name", params.query), + Filters.autocomplete("member.user.name", params.query), + ), + Filters.`in`("members", listOfNotNull(params.currentUser?.id)), ) + return params.listFilter?.let { Filters.and(it, searchFilter) } ?: searchFilter } } diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt index e58ea294c169..15c384a17445 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt @@ -149,6 +149,9 @@ class CustomLoginActivity : AppCompatActivity() { var isChannelSearchEnabled by remember { mutableStateOf(settings.isChannelSearchEnabled) } + var isMemberNameSearchEnabled by remember { + mutableStateOf(settings.isMemberNameSearchEnabled) + } var isMarkdownEnabled by remember { mutableStateOf(settings.isMarkdownEnabled) } @@ -236,6 +239,15 @@ class CustomLoginActivity : AppCompatActivity() { settings.isChannelSearchEnabled = it }, ), + FeatureFlag( + label = stringResource(R.string.custom_login_flag_member_name_search_label), + description = stringResource(R.string.custom_login_flag_member_name_search_description), + value = isMemberNameSearchEnabled, + onValueChange = { + isMemberNameSearchEnabled = it + settings.isMemberNameSearchEnabled = it + }, + ), FeatureFlag( label = stringResource(R.string.custom_login_flag_markdown_label), description = stringResource(R.string.custom_login_flag_markdown_description), diff --git a/stream-chat-android-compose-sample/src/main/res/values/strings.xml b/stream-chat-android-compose-sample/src/main/res/values/strings.xml index d2a07d1dc450..302ff5c55953 100644 --- a/stream-chat-android-compose-sample/src/main/res/values/strings.xml +++ b/stream-chat-android-compose-sample/src/main/res/values/strings.xml @@ -49,6 +49,8 @@ Track the unread count locally for channels with read events disabled. Also shows livestream channels in the channel list Channel search Search for channels instead of messages from the channel list header + Member name search + Also find channels by the name of a member when searching for channels Markdown Render message text as markdown 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..c241d9519258 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -6960,14 +6960,64 @@ public final class io/getstream/chat/android/compose/viewmodel/channel/ChannelIn public fun create (Lkotlin/reflect/KClass;Landroidx/lifecycle/viewmodel/CreationExtras;)Landroidx/lifecycle/ViewModel; } +public class io/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions { + public static final field $stable I + public fun ()V + public fun channelFilter (Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions$ChannelSearchParams;)Lio/getstream/chat/android/models/FilterObject; + public fun channelSort (Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions$ChannelSearchParams;)Lio/getstream/chat/android/models/querysort/QuerySorter; + public fun messageChannelFilter (Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions$MessageSearchParams;)Lio/getstream/chat/android/models/FilterObject; + public fun messageFilter (Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions$MessageSearchParams;)Lio/getstream/chat/android/models/FilterObject; + public fun messageSort (Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions$MessageSearchParams;)Lio/getstream/chat/android/models/querysort/QuerySorter; +} + +public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions$ChannelSearchParams { + public static final field $stable I + public fun (Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Lio/getstream/chat/android/models/User; + public final fun component3 ()Lio/getstream/chat/android/models/FilterObject; + public final fun component4 ()Lio/getstream/chat/android/models/querysort/QuerySorter; + public final fun copy (Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;)Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions$ChannelSearchParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions$ChannelSearchParams;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Object;)Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions$ChannelSearchParams; + public fun equals (Ljava/lang/Object;)Z + public final fun getCurrentUser ()Lio/getstream/chat/android/models/User; + public final fun getListFilter ()Lio/getstream/chat/android/models/FilterObject; + public final fun getListSort ()Lio/getstream/chat/android/models/querysort/QuerySorter; + public final fun getQuery ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions$MessageSearchParams { + public static final field $stable I + public fun (Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/FilterObject;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Lio/getstream/chat/android/models/User; + public final fun component3 ()Lio/getstream/chat/android/models/FilterObject; + public final fun copy (Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/FilterObject;)Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions$MessageSearchParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions$MessageSearchParams;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Object;)Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions$MessageSearchParams; + public fun equals (Ljava/lang/Object;)Z + public final fun getCurrentUser ()Lio/getstream/chat/android/models/User; + public final fun getListFilter ()Lio/getstream/chat/android/models/FilterObject; + public final fun getQuery ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel : androidx/lifecycle/ViewModel { public static final field $stable I 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;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)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;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)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;JZLkotlinx/coroutines/flow/Flow;Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions;)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;JZLkotlinx/coroutines/flow/Flow;Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions;ILkotlin/jvm/internal/DefaultConstructorMarker;)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;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)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;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)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;JZLkotlinx/coroutines/flow/Flow;Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions;)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;JZLkotlinx/coroutines/flow/Flow;Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)V public synthetic fun (Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;JZLkotlinx/coroutines/flow/Flow;Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions;)V + public synthetic fun (Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;JZLkotlinx/coroutines/flow/Flow;Lio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun archiveChannel (Lio/getstream/chat/android/models/Channel;)V public final fun blockUser (Ljava/lang/String;)V public final fun confirmPendingAction ()V @@ -7013,6 +7063,8 @@ 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;)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;)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;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/compose/viewmodel/channels/ChannelListSearchOptions;)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/compose/viewmodel/channels/ChannelListSearchOptions;ILkotlin/jvm/internal/DefaultConstructorMarker;)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;Ljava/lang/String;)V @@ -7023,9 +7075,13 @@ 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;)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;)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;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/compose/viewmodel/channels/ChannelListSearchOptions;)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/compose/viewmodel/channels/ChannelListSearchOptions;ILkotlin/jvm/internal/DefaultConstructorMarker;)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 (Ljava/lang/String;)V + public fun (Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;ZLio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions;)V + public synthetic fun (Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;ZLio/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions;ILkotlin/jvm/internal/DefaultConstructorMarker;)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 create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions.kt new file mode 100644 index 000000000000..3f40f1fbe1e0 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptions.kt @@ -0,0 +1,140 @@ +/* + * 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.compose.viewmodel.channels + +import io.getstream.chat.android.models.AndFilterObject +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.Message +import io.getstream.chat.android.models.User +import io.getstream.chat.android.models.querysort.QuerySortByField +import io.getstream.chat.android.models.querysort.QuerySorter + +/** + * Builds the filters and sort that [ChannelListViewModel] uses to search channels and messages. + * + * Extend this class and override the functions you want to change. Each default implementation returns the + * filter or sort the view model uses when no options are provided. + * + * The functions are called on the main thread when a search request is built, so they must return quickly and must + * not block. They are called again for the next page of a message search, and for the next page of a channel search + * in a channel list created with a filter. In a channel list that uses a predefined filter or a channel group, the + * next page of a channel search reuses the filter and sort of the first page. + */ +public open class ChannelListSearchOptions { + + /** + * Returns the filter used to query channels while a channel search is active. + * + * By default, it matches channels whose `name` matches the query with an `autocomplete` filter, and that the + * current user is a member of. + * When the channel list has its own filter ([ChannelSearchParams.listFilter]), the default also applies it. + * + * @param params The current channel search. + * @return The full filter sent with the channel query. + */ + @Suppress("SpreadOperator") + public open fun channelFilter(params: ChannelSearchParams): FilterObject { + val searchFilter = Filters.and( + Filters.autocomplete("name", params.query), + Filters.`in`("members", params.currentUser?.id.orEmpty()), + ) + return when (val listFilter = params.listFilter) { + null -> searchFilter + is AndFilterObject -> Filters.and(*(listFilter.filterObjects + searchFilter).toTypedArray()) + else -> Filters.and(listFilter, searchFilter) + } + } + + /** + * Returns the sort used to query channels while a channel search is active. + * + * By default, it is the sort of the channel list ([ChannelSearchParams.listSort]), or the server-side default + * when the channel list has no sort of its own. + * + * @param params The current channel search. + * @return The sort sent with the channel query. + */ + public open fun channelSort(params: ChannelSearchParams): QuerySorter = + params.listSort ?: QuerySortByField() + + /** + * Returns the filter that selects the channels in which messages are searched. + * + * By default, it is the filter of the channel list ([MessageSearchParams.listFilter]). When the channel list has + * no filter of its own, the default matches the channels the current user is a member of. + * + * @param params The current message search. + * @return The channel filter sent with the message search, or `null` to skip the search. + */ + public open fun messageChannelFilter(params: MessageSearchParams): FilterObject? = + params.listFilter ?: params.currentUser?.id?.let { userId -> Filters.`in`("members", listOf(userId)) } + + /** + * Returns the filter that messages must match to be part of the message search results. + * + * By default, it matches messages whose `text` matches the query with an `autocomplete` filter. + * + * @param params The current message search. + * @return The message filter sent with the message search. + */ + public open fun messageFilter(params: MessageSearchParams): FilterObject = + Filters.autocomplete("text", params.query) + + /** + * Returns the sort of the message search results. + * + * By default, it is `null`, so the server-side default is used. + * + * @param params The current message search. + * @return The sort sent with the message search, or `null` to use the server-side default. + */ + public open fun messageSort(params: MessageSearchParams): QuerySorter? = null + + /** + * The state of a channel search. + * + * @param query The text the user searches for. + * @param currentUser The connected user, or `null` when no user is connected. + * @param listFilter The filter of the channel list. It is `null` for a channel list that uses a predefined + * filter or a channel group, because the server owns their filter. + * @param listSort The sort of the channel list. It is `null` for a channel list that uses a predefined filter + * or a channel group, because the server owns their sort. + */ + public data class ChannelSearchParams( + val query: String, + val currentUser: User?, + val listFilter: FilterObject?, + val listSort: QuerySorter?, + ) + + /** + * The state of a message search. + * + * @param query The text the user searches for. + * @param currentUser The connected user, or `null` when no user is connected. + * @param listFilter The filter of the channel list, or the default filter when the app did not set one. It is + * `null` for a channel list that uses a predefined filter or a channel group, because the server owns their + * filter. + */ + public data class MessageSearchParams( + val query: String, + val currentUser: User?, + val listFilter: FilterObject?, + ) +} 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 620cc9bcbe7a..733769035681 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 @@ -42,7 +42,6 @@ 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.compose.util.extensions.asState import io.getstream.chat.android.core.utils.Debouncer -import io.getstream.chat.android.models.AndFilterObject import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelMute import io.getstream.chat.android.models.ConnectionState @@ -103,8 +102,9 @@ import kotlin.coroutines.cancellation.CancellationException * @param searchDebounceMs The debounce time for search queries. Message search queries of 1-2 characters * are debounced for at least 500ms. * @param draftMessagesEnabled If the draft message feature is enabled. - * @param messageSearchSort Sorting for message search results. When `null`, the server-side default is used. + * @param messageSearchSort Sorting for message search results, used when [searchOptions] returns no message sort. * @param globalState A flow emitting the current [GlobalState]. + * @param searchOptions Builds the filters and sort used to search channels and messages. */ @OptIn(ExperimentalCoroutinesApi::class) @Suppress("TooManyFunctions", "LongParameterList", "LargeClass") @@ -119,6 +119,7 @@ public class ChannelListViewModel internal constructor( private val draftMessagesEnabled: Boolean, private val messageSearchSort: QuerySorter?, private val globalState: Flow, + private val searchOptions: ChannelListSearchOptions, ) : ViewModel() { /** Internal discriminator for the query modes supported by this ViewModel. */ @@ -137,6 +138,51 @@ public class ChannelListViewModel internal constructor( data class Grouped(val groupKey: String) : QueryMode } + /** + * Creates a view model that queries channels by an explicit filter and sort. + * + * @param chatClient The prepared [ChatClient] instance required for fetching the data. + * @param initialSort The initial sort used for [Channel]s. Can be changed at runtime via [setQuerySort]. + * @param initialFilters The data filter. When `null`, a default filter scoped to "messaging" channels the current + * user is a member of is used. Can be changed at runtime via [setFilters]. + * @param channelLimit How many channels we fetch per page. + * @param memberLimit How many members are fetched for each channel item when loading channels. + * When `null`, the server-side default is used. + * @param messageLimit How many messages are fetched for each channel item when loading channels. + * When `null`, the server-side default is used. + * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] used to create [ChatEventHandler]. + * @param searchDebounceMs The debounce time for search queries. Message search queries of 1-2 characters + * are debounced for at least 500ms. + * @param draftMessagesEnabled If the draft message feature is enabled. + * @param globalState A flow emitting the current [GlobalState]. + * @param searchOptions Builds the filters and sort used to search channels and messages. + */ + public constructor( + chatClient: ChatClient, + initialSort: QuerySorter = QuerySortByField.descByName("last_updated"), + initialFilters: FilterObject? = null, + channelLimit: Int = DEFAULT_CHANNEL_LIMIT, + memberLimit: Int? = null, + messageLimit: Int? = null, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), + searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, + draftMessagesEnabled: Boolean = true, + globalState: Flow = chatClient.globalStateFlow, + searchOptions: ChannelListSearchOptions = ChannelListSearchOptions(), + ) : this( + chatClient = chatClient, + mode = QueryMode.Standard(initialFilter = initialFilters, initialSort = initialSort), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + searchDebounceMs = searchDebounceMs, + draftMessagesEnabled = draftMessagesEnabled, + messageSearchSort = null, + globalState = globalState, + searchOptions = searchOptions, + ) + /** * Creates a view model that queries channels by an explicit filter and sort. * @@ -156,6 +202,10 @@ public class ChannelListViewModel internal constructor( * @param messageSearchSort Sorting for message search results. When `null`, the server-side default is used. * @param globalState A flow emitting the current [GlobalState]. */ + @Deprecated( + message = "Set the message search sort by overriding ChannelListSearchOptions.messageSort, " + + "and use the constructor without messageSearchSort.", + ) public constructor( chatClient: ChatClient, initialSort: QuerySorter = QuerySortByField.descByName("last_updated"), @@ -166,7 +216,7 @@ public class ChannelListViewModel internal constructor( chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, draftMessagesEnabled: Boolean = true, - messageSearchSort: QuerySorter? = null, + messageSearchSort: QuerySorter?, globalState: Flow = chatClient.globalStateFlow, ) : this( chatClient = chatClient, @@ -179,6 +229,61 @@ public class ChannelListViewModel internal constructor( draftMessagesEnabled = draftMessagesEnabled, messageSearchSort = messageSearchSort, globalState = globalState, + searchOptions = ChannelListSearchOptions(), + ) + + /** + * Creates a view model that queries channels using a predefined filter resolved by the server. + * + * The filter and sort are identified by [predefinedFilterName] and resolved server-side; + * [filterValues] and [sortValues] interpolate into the predefined template. [setFilters] and + * [setQuerySort] do not affect a view model created this way. + * + * @param chatClient The prepared [ChatClient] instance required for fetching the data. + * @param predefinedFilterName The name of the predefined filter registered on the backend. + * @param filterValues Optional values interpolated into the predefined filter template. + * @param sortValues Optional values interpolated into the predefined sort template. + * @param channelLimit How many channels we fetch per page. + * @param memberLimit How many members are fetched for each channel item when loading channels. + * When `null`, the server-side default is used. + * @param messageLimit How many messages are fetched for each channel item when loading channels. + * When `null`, the server-side default is used. + * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] used to create [ChatEventHandler]. + * @param searchDebounceMs The debounce time for search queries. Message search queries of 1-2 characters + * are debounced for at least 500ms. + * @param draftMessagesEnabled If the draft message feature is enabled. + * @param globalState A flow emitting the current [GlobalState]. + * @param searchOptions Builds the filters and sort used to search channels and messages. + */ + public constructor( + chatClient: ChatClient, + predefinedFilterName: String, + filterValues: Map? = null, + sortValues: Map? = null, + channelLimit: Int = DEFAULT_CHANNEL_LIMIT, + memberLimit: Int? = null, + messageLimit: Int? = null, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), + searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, + draftMessagesEnabled: Boolean = true, + globalState: Flow = chatClient.globalStateFlow, + searchOptions: ChannelListSearchOptions = ChannelListSearchOptions(), + ) : this( + chatClient = chatClient, + mode = QueryMode.Predefined( + name = predefinedFilterName, + filterValues = filterValues, + sortValues = sortValues, + ), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + searchDebounceMs = searchDebounceMs, + draftMessagesEnabled = draftMessagesEnabled, + messageSearchSort = null, + globalState = globalState, + searchOptions = searchOptions, ) /** @@ -204,6 +309,10 @@ public class ChannelListViewModel internal constructor( * @param messageSearchSort Sorting for message search results. When `null`, the server-side default is used. * @param globalState A flow emitting the current [GlobalState]. */ + @Deprecated( + message = "Set the message search sort by overriding ChannelListSearchOptions.messageSort, " + + "and use the constructor without messageSearchSort.", + ) public constructor( chatClient: ChatClient, predefinedFilterName: String, @@ -215,7 +324,7 @@ public class ChannelListViewModel internal constructor( chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, draftMessagesEnabled: Boolean = true, - messageSearchSort: QuerySorter? = null, + messageSearchSort: QuerySorter?, globalState: Flow = chatClient.globalStateFlow, ) : this( chatClient = chatClient, @@ -232,6 +341,46 @@ public class ChannelListViewModel internal constructor( draftMessagesEnabled = draftMessagesEnabled, messageSearchSort = messageSearchSort, globalState = globalState, + searchOptions = ChannelListSearchOptions(), + ) + + /** + * Grouped channel list constructor. Subscribes to the state identified by [groupKey] without + * issuing a remote call; the state is populated externally by `queryGroupedChannels` responses. + * + * **IMPORTANT: This is an enterprise feature and is disabled by default. For more info, reach out to our + * Contact & Support.** + * + * @param groupKey The name of the channels group. + * @param chatClient The prepared [ChatClient] instance required for fetching the data. + * @param searchDebounceMs The debounce time for search queries. Message search queries of 1-2 characters + * are debounced for at least 500ms. + * @param draftMessagesEnabled If the draft message feature is enabled. + * @param globalState A flow emitting the current [GlobalState]. + * @param searchOptions Builds the filters and sort used to search channels and messages. + */ + public constructor( + groupKey: String, + chatClient: ChatClient = ChatClient.instance(), + searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, + draftMessagesEnabled: Boolean = true, + globalState: Flow = chatClient.globalStateFlow, + searchOptions: ChannelListSearchOptions = ChannelListSearchOptions(), + ) : this( + chatClient = chatClient, + mode = QueryMode.Grouped(groupKey), + channelLimit = DEFAULT_CHANNEL_LIMIT, + memberLimit = null, + messageLimit = null, + chatEventHandlerFactory = groupAwareChatEventHandlerFactory( + groupKey = groupKey, + clientState = chatClient.clientState, + ), + searchDebounceMs = searchDebounceMs, + draftMessagesEnabled = draftMessagesEnabled, + messageSearchSort = null, + globalState = globalState, + searchOptions = searchOptions, ) /** @@ -249,12 +398,16 @@ public class ChannelListViewModel internal constructor( * @param messageSearchSort Sorting for message search results. When `null`, the server-side default is used. * @param globalState A flow emitting the current [GlobalState]. */ + @Deprecated( + message = "Set the message search sort by overriding ChannelListSearchOptions.messageSort, " + + "and use the constructor without messageSearchSort.", + ) public constructor( groupKey: String, chatClient: ChatClient = ChatClient.instance(), searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, draftMessagesEnabled: Boolean = true, - messageSearchSort: QuerySorter? = null, + messageSearchSort: QuerySorter?, globalState: Flow = chatClient.globalStateFlow, ) : this( chatClient = chatClient, @@ -270,6 +423,7 @@ public class ChannelListViewModel internal constructor( draftMessagesEnabled = draftMessagesEnabled, messageSearchSort = messageSearchSort, globalState = globalState, + searchOptions = ChannelListSearchOptions(), ) private val logger by taggedLogger("Chat:ChannelListVM") @@ -846,23 +1000,20 @@ public class ChannelListViewModel internal constructor( private suspend fun searchMessagesForQuery(query: String) { logger.d { "[searchMessagesForQuery] query: '$query'" } - val channelFilter = messageSearchChannelFilter() ?: run { + val params = messageSearchParams(query) + val channelFilter = searchOptions.messageChannelFilter(params) ?: run { logger.v { "[searchMessagesForQuery] rejected (no channel filter)" } return } val newState = SearchMessageState(query = query, isLoading = true) searchMessageState.value = newState - searchMessageState.value = searchMessages(src = "new", newState, channelFilter).also { + searchMessageState.value = searchMessages(src = "new", newState, params, channelFilter).also { logger.v { "[searchMessagesForQuery] completed('$query'): ${it.messages.size}" } } } private suspend fun loadMoreQueryMessages() { logger.d { "[loadMoreQueryMessages] no args" } - val channelFilter = messageSearchChannelFilter() ?: run { - logger.v { "[loadMoreQueryMessages] rejected (no channel filter)" } - return - } val currentState = searchMessageState.value ?: run { logger.v { "[loadMoreQueryMessages] rejected (no current state)" } return @@ -881,9 +1032,14 @@ public class ChannelListViewModel internal constructor( } val query = currentState.query logger.v { "[loadMoreQueryMessages] query: 'query'" } + val params = messageSearchParams(query) + val channelFilter = searchOptions.messageChannelFilter(params) ?: run { + logger.v { "[loadMoreQueryMessages] rejected (no channel filter)" } + return + } val newState = currentState.copy(isLoadingMore = true) searchMessageState.value = newState - searchMessageState.value = searchMessages(src = "more", newState, channelFilter).also { + searchMessageState.value = searchMessages(src = "more", newState, params, channelFilter).also { logger.v { "[loadMoreQueryMessages] completed('$query'): ${it.messages.size}" } } } @@ -894,18 +1050,20 @@ public class ChannelListViewModel internal constructor( private suspend fun searchMessages( src: String, currentState: SearchMessageState, + params: ChannelListSearchOptions.MessageSearchParams, channelFilter: FilterObject, ): SearchMessageState { val limit = channelLimit val next = currentState.next + val sort = searchOptions.messageSort(params) ?: messageSearchSort logger.v { - "[searchMessages] #$src; query: '${currentState.query}', sort: $messageSearchSort, next: $next, " + + "[searchMessages] #$src; query: '${currentState.query}', sort: $sort, next: $next, " + "limit: $limit" } val result = chatClient.searchMessages( channelFilter = channelFilter, - messageFilter = Filters.autocomplete("text", currentState.query), - sort = messageSearchSort, + messageFilter = searchOptions.messageFilter(params), + sort = sort, limit = limit, next = next, ).await() @@ -1043,27 +1201,21 @@ public class ChannelListViewModel internal constructor( * 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. * - * In Predefined mode with an active channel search, falls back to a Standard request whose filter is - * just [channelSearchFilter] (the predefined filter is server-owned and cannot be combined locally). + * In Predefined mode with an active channel search, falls back to [channelSearchRequest] (the predefined + * filter is server-owned and cannot be combined locally). */ private fun buildQueryChannelsRequest(searchQuery: String): QueryChannelsRequest? = when (val mode = mode) { is QueryMode.Standard -> { val baseFilter = filterFlow.value ?: return null QueryChannelsRequest( - filter = createQueryChannelsFilter(baseFilter, searchQuery), - querySort = querySortFlow.value, + filter = baseFilter, limit = channelLimit, messageLimit = messageLimit, memberLimit = memberLimit, - ) + ).withStandardFilterAndSort(baseFilter, searchQuery) } is QueryMode.Predefined -> if (searchQuery.length >= MIN_CHANNEL_SEARCH_QUERY_LENGTH) { - QueryChannelsRequest( - filter = channelSearchFilter(searchQuery), - limit = channelLimit, - messageLimit = messageLimit, - memberLimit = memberLimit, - ) + channelSearchRequest(searchQuery) } else { QueryChannelsRequest( predefinedFilter = mode.name, @@ -1075,8 +1227,18 @@ public class ChannelListViewModel internal constructor( ) } // When in Grouped mode, this is reached only when search is active - is QueryMode.Grouped -> QueryChannelsRequest( - filter = channelSearchFilter(searchQuery), + is QueryMode.Grouped -> channelSearchRequest(searchQuery) + } + + /** + * Builds the channel search request for the modes whose filter and sort are server-owned, so the search + * replaces them instead of extending them. + */ + private fun channelSearchRequest(searchQuery: String): QueryChannelsRequest { + val params = channelSearchParams(searchQuery, listFilter = null, listSort = null) + return QueryChannelsRequest( + filter = searchOptions.channelFilter(params), + querySort = searchOptions.channelSort(params), limit = channelLimit, messageLimit = messageLimit, memberLimit = memberLimit, @@ -1084,32 +1246,19 @@ public class ChannelListViewModel internal constructor( } /** - * Creates a filter that is used to query channels. - * - * If the [searchQuery] is shorter than 3 characters, then returns the original [filter] provided by the user. - * Otherwise, returns a wrapped [filter] that also checks that the channel name matches the - * [searchQuery] and that the current user is a member of the channel. - * - * @param filter The filter that was passed by the user. - * @param searchQuery The search query used to filter the channels. - * - * @return The filter that will be used to query channels. + * Sets the Standard mode filter and sort: the ones of the channel list, or the channel search ones from + * [searchOptions] when [searchQuery] is long enough to search. */ - @Suppress("SpreadOperator") - private fun createQueryChannelsFilter(filter: FilterObject, searchQuery: String): FilterObject { - return if (searchQuery.length >= MIN_CHANNEL_SEARCH_QUERY_LENGTH) { - if (filter is AndFilterObject) { - // If the base filter is `AND`, extend it with the search query filter. - val filters = filter.filterObjects - val extendedFilters = filters + channelSearchFilter(searchQuery) - Filters.and(*extendedFilters.toTypedArray()) - } else { - // If the base filter is not `AND`, wrap it in an `AND` with the search query filter. - Filters.and(filter, channelSearchFilter(searchQuery)) - } - } else { - filter + private fun QueryChannelsRequest.withStandardFilterAndSort( + listFilter: FilterObject, + searchQuery: String, + ): QueryChannelsRequest { + val listSort = querySortFlow.value + if (searchQuery.length < MIN_CHANNEL_SEARCH_QUERY_LENGTH) { + return copy(filter = listFilter, querySort = listSort) } + val params = channelSearchParams(searchQuery, listFilter, listSort) + return copy(filter = searchOptions.channelFilter(params), querySort = searchOptions.channelSort(params)) } private suspend fun loadMoreQueryChannels() { @@ -1142,10 +1291,7 @@ public class ChannelListViewModel internal constructor( logger.v { "[loadMoreQueryChannels] rejected (no current filter)" } return } - currentQuery.copy( - filter = createQueryChannelsFilter(currentFilter, _searchQuery.value.query), - querySort = querySortFlow.value, - ) + currentQuery.withStandardFilterAndSort(currentFilter, _searchQuery.value.query) } is QueryMode.Predefined -> currentQuery is QueryMode.Grouped -> currentQuery @@ -1267,23 +1413,28 @@ public class ChannelListViewModel internal constructor( private fun defaultChannelsFilter(): Flow = chatClient.clientState.user.map(Filters::defaultChannelListFilter).filterNotNull() - private fun channelSearchFilter(text: String): FilterObject = - Filters.and( - Filters.autocomplete("name", text), - Filters.`in`("members", user.value?.id.orEmpty()), - ) + private fun channelSearchParams( + searchQuery: String, + listFilter: FilterObject?, + listSort: QuerySorter?, + ) = ChannelListSearchOptions.ChannelSearchParams( + query = searchQuery, + currentUser = user.value, + listFilter = listFilter, + listSort = listSort, + ) - private fun messageSearchChannelFilter(): FilterObject? = when (mode) { - // Standard mode: Use the initial filters (backwards compatible) - is QueryMode.Standard -> filterFlow.value ?: Filters.defaultChannelListFilter(user.value) - // Predefined and Grouped modes: Use simple membership filter (aligned with other platforms); - is QueryMode.Predefined, - is QueryMode.Grouped, - -> when (val userId = user.value?.id) { - null -> null - else -> Filters.`in`("members", listOf(userId)) - } - } + private fun messageSearchParams(query: String) = ChannelListSearchOptions.MessageSearchParams( + query = query, + currentUser = user.value, + listFilter = when (mode) { + is QueryMode.Standard -> filterFlow.value ?: Filters.defaultChannelListFilter(user.value) + // The filter of Predefined and Grouped modes is server-owned. + is QueryMode.Predefined, + is QueryMode.Grouped, + -> null + }, + ) internal companion object { /** 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 4026adad9414..e55c9dc04da2 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 @@ -48,7 +48,9 @@ import io.getstream.chat.android.models.querysort.QuerySorter * When `null`, the server-side default is used. * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] used to create [ChatEventHandler]. * @param draftMessagesEnabled If the draft message feature is enabled. - * @param messageSearchSort Optional sorting for message search results. When `null`, the server-side default is used. + * @param messageSearchSort Optional sorting for message search results, used when [searchOptions] returns no message + * sort. + * @param searchOptions Builds the filters and sort used to search channels and messages. */ @Suppress("LongParameterList") public class ChannelListViewModelFactory internal constructor( @@ -60,6 +62,7 @@ public class ChannelListViewModelFactory internal constructor( private val chatEventHandlerFactory: ChatEventHandlerFactory, private val draftMessagesEnabled: Boolean, private val messageSearchSort: QuerySorter?, + private val searchOptions: ChannelListSearchOptions, ) : ViewModelProvider.Factory { /** @@ -79,7 +82,40 @@ public class ChannelListViewModelFactory internal constructor( messageLimit: Int? = null, chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), draftMessagesEnabled: Boolean = true, - messageSearchSort: QuerySorter? = null, + searchOptions: ChannelListSearchOptions = ChannelListSearchOptions(), + ) : this( + chatClient = chatClient, + mode = QueryMode.Standard(initialFilter = filters, initialSort = querySort), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + draftMessagesEnabled = draftMessagesEnabled, + messageSearchSort = null, + searchOptions = searchOptions, + ) + + /** + * Builds a factory for a [ChannelListViewModel] that queries channels by an explicit filter and sort. + * + * @param querySort The sorting order for channels. + * @param filters The base filters used to filter out channels. When `null`, a default filter scoped + * to messaging channels the current user is a member of is used. + */ + @Deprecated( + message = "Set the message search sort by overriding ChannelListSearchOptions.messageSort, " + + "and use the constructor without messageSearchSort.", + ) + public constructor( + chatClient: ChatClient = ChatClient.instance(), + querySort: QuerySorter = QuerySortByField.descByName("last_updated"), + filters: FilterObject? = null, + channelLimit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, + memberLimit: Int? = null, + messageLimit: Int? = null, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), + draftMessagesEnabled: Boolean = true, + messageSearchSort: QuerySorter?, ) : this( chatClient = chatClient, mode = QueryMode.Standard(initialFilter = filters, initialSort = querySort), @@ -89,6 +125,7 @@ public class ChannelListViewModelFactory internal constructor( chatEventHandlerFactory = chatEventHandlerFactory, draftMessagesEnabled = draftMessagesEnabled, messageSearchSort = messageSearchSort, + searchOptions = ChannelListSearchOptions(), ) /** @@ -110,7 +147,46 @@ public class ChannelListViewModelFactory internal constructor( messageLimit: Int? = null, chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), draftMessagesEnabled: Boolean = true, - messageSearchSort: QuerySorter? = null, + searchOptions: ChannelListSearchOptions = ChannelListSearchOptions(), + ) : this( + chatClient = chatClient, + mode = QueryMode.Predefined( + name = predefinedFilterName, + filterValues = filterValues, + sortValues = sortValues, + ), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + draftMessagesEnabled = draftMessagesEnabled, + messageSearchSort = null, + searchOptions = searchOptions, + ) + + /** + * Builds a factory for a [ChannelListViewModel] that queries channels using a predefined filter + * resolved by the server. + * + * @param predefinedFilterName The name of the predefined filter registered on the backend. + * @param filterValues Optional values interpolated into the predefined filter template. + * @param sortValues Optional values interpolated into the predefined sort template. + */ + @Deprecated( + message = "Set the message search sort by overriding ChannelListSearchOptions.messageSort, " + + "and use the constructor without messageSearchSort.", + ) + public constructor( + chatClient: ChatClient = ChatClient.instance(), + predefinedFilterName: String, + filterValues: Map? = null, + 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?, ) : this( chatClient = chatClient, mode = QueryMode.Predefined( @@ -124,6 +200,39 @@ public class ChannelListViewModelFactory internal constructor( chatEventHandlerFactory = chatEventHandlerFactory, draftMessagesEnabled = draftMessagesEnabled, messageSearchSort = messageSearchSort, + searchOptions = ChannelListSearchOptions(), + ) + + /** + * Grouped [ChannelListViewModel] factory. Wires the ViewModel to the state identified by + * [groupKey] without firing a remote call; `queryGroupedChannels` responses populate it. + * + * **IMPORTANT: This is an enterprise feature and is disabled by default. For more info, reach + * out to our Contact & Support.** + * + * @param groupKey The name of the channels group. + * @param chatClient The prepared [ChatClient] instance required for fetching the data. + * @param draftMessagesEnabled If the draft message feature is enabled. + * @param searchOptions Builds the filters and sort used to search channels and messages. + */ + public constructor( + groupKey: String, + chatClient: ChatClient = ChatClient.instance(), + draftMessagesEnabled: Boolean = true, + searchOptions: ChannelListSearchOptions = ChannelListSearchOptions(), + ) : this( + chatClient = chatClient, + mode = QueryMode.Grouped(groupKey), + channelLimit = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, + memberLimit = null, + messageLimit = null, + chatEventHandlerFactory = groupAwareChatEventHandlerFactory( + groupKey = groupKey, + clientState = chatClient.clientState, + ), + draftMessagesEnabled = draftMessagesEnabled, + messageSearchSort = null, + searchOptions = searchOptions, ) /** @@ -138,11 +247,15 @@ public class ChannelListViewModelFactory internal constructor( * @param draftMessagesEnabled If the draft message feature is enabled. * @param messageSearchSort Optional sorting for message search results. */ + @Deprecated( + message = "Set the message search sort by overriding ChannelListSearchOptions.messageSort, " + + "and use the constructor without messageSearchSort.", + ) public constructor( groupKey: String, chatClient: ChatClient = ChatClient.instance(), draftMessagesEnabled: Boolean = true, - messageSearchSort: QuerySorter? = null, + messageSearchSort: QuerySorter?, ) : this( chatClient = chatClient, mode = QueryMode.Grouped(groupKey), @@ -155,6 +268,7 @@ public class ChannelListViewModelFactory internal constructor( ), draftMessagesEnabled = draftMessagesEnabled, messageSearchSort = messageSearchSort, + searchOptions = ChannelListSearchOptions(), ) /** @@ -176,6 +290,7 @@ public class ChannelListViewModelFactory internal constructor( draftMessagesEnabled = draftMessagesEnabled, messageSearchSort = messageSearchSort, globalState = chatClient.globalStateFlow, + searchOptions = searchOptions, ) as T } } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptionsTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptionsTest.kt new file mode 100644 index 000000000000..fa059a34f0b7 --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListSearchOptionsTest.kt @@ -0,0 +1,140 @@ +/* + * 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.compose.viewmodel.channels + +import io.getstream.chat.android.compose.viewmodel.channels.ChannelListSearchOptions.ChannelSearchParams +import io.getstream.chat.android.compose.viewmodel.channels.ChannelListSearchOptions.MessageSearchParams +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.QuerySortByField +import io.getstream.chat.android.randomString +import io.getstream.chat.android.randomUser +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +internal class ChannelListSearchOptionsTest { + + private val options = ChannelListSearchOptions() + private val query = randomString() + private val currentUser = randomUser() + + @Test + fun `Given no list filter When building the channel filter Should match name and membership`() { + val filter = options.channelFilter(channelParams(listFilter = null)) + + assertEquals(nameAndMembership(), filter) + } + + @Test + fun `Given an AND list filter When building the channel filter Should add the search to its conditions`() { + val typeFilter = Filters.eq("type", "messaging") + val frozenFilter = Filters.eq("frozen", false) + + val filter = options.channelFilter(channelParams(listFilter = Filters.and(typeFilter, frozenFilter))) + + assertEquals(Filters.and(typeFilter, frozenFilter, nameAndMembership()), filter) + } + + @Test + fun `Given a list filter that is not AND When building the channel filter Should combine both with AND`() { + val listFilter = Filters.eq("type", "messaging") + + val filter = options.channelFilter(channelParams(listFilter = listFilter)) + + assertEquals(Filters.and(listFilter, nameAndMembership()), filter) + } + + @Test + fun `Given no current user When building the channel filter Should match an empty member id`() { + val filter = options.channelFilter(channelParams(listFilter = null, currentUser = null)) + + val expected = Filters.and(Filters.autocomplete("name", query), Filters.`in`("members", "")) + assertEquals(expected, filter) + } + + @Test + fun `Given a list sort When building the channel sort Should use it`() { + val listSort = QuerySortByField.descByName("last_updated") + + val sort = options.channelSort(channelParams(listSort = listSort)) + + assertEquals(listSort, sort) + } + + @Test + fun `Given no list sort When building the channel sort Should use an empty sort`() { + val sort = options.channelSort(channelParams(listSort = null)) + + assertEquals(QuerySortByField(), sort) + } + + @Test + fun `Given a list filter When building the message channel filter Should use it`() { + val listFilter = Filters.eq("type", "messaging") + + val filter = options.messageChannelFilter(messageParams(listFilter = listFilter)) + + assertEquals(listFilter, filter) + } + + @Test + fun `Given no list filter When building the message channel filter Should match membership`() { + val filter = options.messageChannelFilter(messageParams(listFilter = null)) + + assertEquals(Filters.`in`("members", listOf(currentUser.id)), filter) + } + + @Test + fun `Given no list filter and no current user When building the message channel filter Should skip the search`() { + val filter = options.messageChannelFilter(messageParams(listFilter = null, currentUser = null)) + + assertNull(filter) + } + + @Test + fun `When building the message filter Should autocomplete the text`() { + val filter = options.messageFilter(messageParams(listFilter = null)) + + assertEquals(Filters.autocomplete("text", query), filter) + } + + @Test + fun `When building the message sort Should use the server-side default`() { + val sort = options.messageSort(messageParams(listFilter = null)) + + assertNull(sort) + } + + private fun nameAndMembership() = Filters.and( + Filters.autocomplete("name", query), + Filters.`in`("members", currentUser.id), + ) + + private fun channelParams( + listFilter: FilterObject? = null, + listSort: QuerySortByField? = null, + currentUser: User? = this.currentUser, + ) = ChannelSearchParams(query = query, currentUser = currentUser, listFilter = listFilter, listSort = listSort) + + private fun messageParams( + listFilter: FilterObject?, + currentUser: User? = this.currentUser, + ) = MessageSearchParams(query = query, currentUser = currentUser, listFilter = listFilter) +} diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelFactoryTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelFactoryTest.kt index 8c17e436c20a..80a50d7db097 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelFactoryTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelFactoryTest.kt @@ -18,6 +18,8 @@ package io.getstream.chat.android.compose.viewmodel.channels import androidx.lifecycle.ViewModel import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.querysort.QuerySortByField import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Test @@ -50,14 +52,39 @@ internal class ChannelListViewModelFactoryTest { ) } + @Test + fun `create should return an instance for every constructor`() { + val chatClient = Fixture().chatClient + val messageSearchSort = QuerySortByField.descByName("created_at") + val factories = listOf( + ChannelListViewModelFactory(chatClient = chatClient, searchOptions = ChannelListSearchOptions()), + ChannelListViewModelFactory(chatClient = chatClient, predefinedFilterName = "filter"), + ChannelListViewModelFactory(groupKey = "group", chatClient = chatClient), + @Suppress("DEPRECATION") + ChannelListViewModelFactory(chatClient = chatClient, messageSearchSort = messageSearchSort), + @Suppress("DEPRECATION") + ChannelListViewModelFactory( + chatClient = chatClient, + predefinedFilterName = "filter", + messageSearchSort = messageSearchSort, + ), + @Suppress("DEPRECATION") + ChannelListViewModelFactory(groupKey = "group", chatClient = chatClient, messageSearchSort = messageSearchSort), + ) + + factories.forEach { factory -> + assertInstanceOf(factory.create(ChannelListViewModel::class.java)) + } + } + private class Fixture { - private val mockChatClient = mock { + val chatClient = mock { on { clientState } doReturn mock() } fun get(): ChannelListViewModelFactory { return ChannelListViewModelFactory( - chatClient = mockChatClient, + chatClient = chatClient, ) } } 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 3651d5cfab51..14ee6bede84a 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 @@ -1171,7 +1171,7 @@ internal class ChannelListViewModelTest { } @Test - fun `Given messageSearchSort is set When searching messages Should pass the sort to searchMessages`() = + fun `Given the deprecated messageSearchSort is set When searching messages Should pass the sort to searchMessages`() = runTest { val chatClient: ChatClient = mock() val messageSearchSort = QuerySortByField.descByName("created_at") @@ -1696,6 +1696,402 @@ internal class ChannelListViewModelTest { assertEquals(queryFilter, channelFilterCaptor.firstValue) } + @Test + fun `Given search options When searching channels Should query with their filter and sort`() = runTest { + val chatClient: ChatClient = mock() + val searchOptions = RecordingSearchOptions() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenSearchOptions(searchOptions) + .get(this) + + viewModel.setSearchQuery(SearchQuery.Channels("Search query")) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, times(2)).queryChannels(captor.capture()) + assertEquals(RecordingSearchOptions.channelFilter, captor.secondValue.filter) + assertEquals(RecordingSearchOptions.channelSort, captor.secondValue.querySort) + val expectedParams = ChannelListSearchOptions.ChannelSearchParams( + query = "Search query", + currentUser = User(id = currentUserId), + listFilter = queryFilter, + listSort = querySort, + ) + assertEquals(expectedParams, searchOptions.channelParams.last()) + } + + @Test + fun `Given search options When the channel search query is too short Should not use them`() = runTest { + val chatClient: ChatClient = mock() + val searchOptions = RecordingSearchOptions() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenSearchOptions(searchOptions) + .get(this) + + viewModel.setSearchQuery(SearchQuery.Channels("Se")) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, times(2)).queryChannels(captor.capture()) + assertEquals(queryFilter, captor.secondValue.filter) + assertEquals(querySort, captor.secondValue.querySort) + assertTrue(searchOptions.channelParams.isEmpty()) + } + + @Test + fun `Given search options and an active channel search When loading more Should query with their filter and sort`() = + runTest { + val chatClient: ChatClient = mock() + val searchOptions = RecordingSearchOptions() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + nextPageRequest = QueryChannelsRequest(filter = queryFilter, offset = 30, limit = 30), + ) + .givenChannelMutes() + .givenIsOffline(false) + .givenSearchOptions(searchOptions) + .get(this) + viewModel.setSearchQuery(SearchQuery.Channels("Search query")) + advanceUntilIdle() + + viewModel.loadMore() + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, times(3)).queryChannels(captor.capture()) + assertEquals(RecordingSearchOptions.channelFilter, captor.thirdValue.filter) + assertEquals(RecordingSearchOptions.channelSort, captor.thirdValue.querySort) + assertEquals(30, captor.thirdValue.offset) + } + + @Test + fun `Given predefined filter and search options When searching channels Should pass no list filter or sort`() = + runTest { + val chatClient: ChatClient = mock() + val searchOptions = RecordingSearchOptions() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenPredefined(name = "vip_filter") + .givenSearchOptions(searchOptions) + .get(this) + + viewModel.setSearchQuery(SearchQuery.Channels("Search query")) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, times(2)).queryChannels(captor.capture()) + assertNull(captor.secondValue.predefinedFilter) + assertEquals(RecordingSearchOptions.channelFilter, captor.secondValue.filter) + assertEquals(RecordingSearchOptions.channelSort, captor.secondValue.querySort) + val params = searchOptions.channelParams.last() + assertNull(params.listFilter) + assertNull(params.listSort) + } + + @Test + fun `Given grouped view model and search options When searching channels Should pass no list filter or sort`() = + runTest { + val chatClient: ChatClient = mock() + val searchOptions = RecordingSearchOptions() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenSearchOptions(searchOptions) + .get(this, groupKey = "team-a") + + viewModel.setSearchQuery(SearchQuery.Channels("Search query")) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient).queryChannels(captor.capture()) + assertEquals(RecordingSearchOptions.channelFilter, captor.firstValue.filter) + assertEquals(RecordingSearchOptions.channelSort, captor.firstValue.querySort) + val params = searchOptions.channelParams.last() + assertNull(params.listFilter) + assertNull(params.listSort) + } + + @Test + fun `Given search options When searching messages Should search with their filters`() = runTest { + val chatClient: ChatClient = mock() + val searchOptions = RecordingSearchOptions() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenSearchMessagesResult(SearchMessagesResult()) + .givenRepositorySelectChannels() + .givenSearchOptions(searchOptions) + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + verify(chatClient).searchMessages( + channelFilter = eq(RecordingSearchOptions.messageChannelFilter), + messageFilter = eq(RecordingSearchOptions.messageFilter), + offset = anyOrNull(), + limit = anyOrNull(), + next = anyOrNull(), + sort = eq(RecordingSearchOptions.messageSort), + ) + val expectedParams = ChannelListSearchOptions.MessageSearchParams( + query = "hello", + currentUser = User(id = currentUserId), + listFilter = queryFilter, + ) + assertEquals(listOf(expectedParams), searchOptions.messageParams.distinct()) + } + + @Test + fun `Given search options and no initial filter When searching messages Should pass the default filter`() = + runTest { + val chatClient: ChatClient = mock() + val searchOptions = RecordingSearchOptions() + val viewModel = Fixture(chatClient, initialFilters = null) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenSearchMessagesResult(SearchMessagesResult()) + .givenRepositorySelectChannels() + .givenSearchOptions(searchOptions) + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + val expectedFilter = Filters.defaultChannelListFilter(User(id = currentUserId)) + assertEquals(expectedFilter, searchOptions.messageParams.last().listFilter) + } + + @Test + fun `Given predefined filter and search options When searching messages Should pass no list filter`() = + runTest { + val chatClient: ChatClient = mock() + val searchOptions = RecordingSearchOptions() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenPredefined(name = "vip_filter") + .givenSearchMessagesResult(SearchMessagesResult()) + .givenRepositorySelectChannels() + .givenSearchOptions(searchOptions) + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + verify(chatClient).searchMessages( + channelFilter = eq(RecordingSearchOptions.messageChannelFilter), + messageFilter = eq(RecordingSearchOptions.messageFilter), + offset = anyOrNull(), + limit = anyOrNull(), + next = anyOrNull(), + sort = anyOrNull(), + ) + assertNull(searchOptions.messageParams.last().listFilter) + } + + @Test + fun `Given search options that skip the message search When searching messages Should not search`() = runTest { + val chatClient: ChatClient = mock() + val searchOptions = RecordingSearchOptions(skipMessageSearch = true) + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenSearchOptions(searchOptions) + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + verify(chatClient, never()).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + } + + @Test + fun `Given search options and message search results When loading more Should search with their filters`() = + runTest { + val chatClient: ChatClient = mock() + val searchOptions = RecordingSearchOptions() + val firstPage = SearchMessagesResult( + messages = listOf(randomMessage(cid = "messaging:channel1")), + next = "cursor_page2", + ) + whenever( + chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()), + ).doReturn(firstPage.asCall(), SearchMessagesResult().asCall()) + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenRepositorySelectChannels(listOf(channel1)) + .givenSearchOptions(searchOptions) + .get(this) + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + viewModel.loadMore() + advanceUntilIdle() + + verify(chatClient, times(2)).searchMessages( + channelFilter = eq(RecordingSearchOptions.messageChannelFilter), + messageFilter = eq(RecordingSearchOptions.messageFilter), + offset = anyOrNull(), + limit = anyOrNull(), + next = anyOrNull(), + sort = anyOrNull(), + ) + assertTrue(searchOptions.messageParams.all { it.query == "hello" }) + } + + @Test + fun `Given predefined filter and the deprecated messageSearchSort When searching messages Should pass the sort`() = + runTest { + val chatClient: ChatClient = mock() + val messageSearchSort = QuerySortByField.descByName("created_at") + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenPredefined(name = "vip_filter") + .givenMessageSearchSort(messageSearchSort) + .givenSearchMessagesResult(SearchMessagesResult()) + .givenRepositorySelectChannels() + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + verify(chatClient).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = anyOrNull(), + sort = eq(messageSearchSort), + ) + } + + @Test + fun `Given grouped view model and the deprecated messageSearchSort When searching messages Should pass the sort`() = + runTest { + val chatClient: ChatClient = mock() + val messageSearchSort = QuerySortByField.descByName("created_at") + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenMessageSearchSort(messageSearchSort) + .givenSearchMessagesResult(SearchMessagesResult()) + .givenRepositorySelectChannels() + .get(this, groupKey = "team-a") + + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + verify(chatClient).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = anyOrNull(), + sort = eq(messageSearchSort), + ) + } + + @Test + fun `Given search options that skip the next message page When loading more Should not search again`() = + runTest { + val chatClient: ChatClient = mock() + val searchOptions = RecordingSearchOptions() + val firstPage = SearchMessagesResult( + messages = listOf(randomMessage(cid = "messaging:channel1")), + next = "cursor_page2", + ) + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Result(listOf(channel1))) + .givenChannelMutes() + .givenSearchMessagesResult(firstPage) + .givenRepositorySelectChannels(listOf(channel1)) + .givenSearchOptions(searchOptions) + .get(this) + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + searchOptions.skipMessageSearch = true + + viewModel.loadMore() + advanceUntilIdle() + + verify(chatClient, times(1)).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + } + + /** Returns fixed filters and sort, and records the params it is called with. */ + private class RecordingSearchOptions( + var skipMessageSearch: Boolean = false, + ) : ChannelListSearchOptions() { + val channelParams = mutableListOf() + val messageParams = mutableListOf() + + override fun channelFilter(params: ChannelSearchParams): FilterObject { + channelParams += params + return channelFilter + } + + override fun channelSort(params: ChannelSearchParams): QuerySorter { + channelParams += params + return channelSort + } + + override fun messageChannelFilter(params: MessageSearchParams): FilterObject? { + messageParams += params + return messageChannelFilter.takeUnless { skipMessageSearch } + } + + override fun messageFilter(params: MessageSearchParams): FilterObject { + messageParams += params + return messageFilter + } + + override fun messageSort(params: MessageSearchParams): QuerySorter { + messageParams += params + return messageSort + } + + companion object { + val channelFilter = Filters.autocomplete("member.user.name", "custom") + val channelSort = QuerySortByField.ascByName("name") + val messageChannelFilter = Filters.eq("type", "team") + val messageFilter = Filters.eq("pinned", true) + val messageSort = QuerySortByField.descByName("created_at") + } + } + @Test fun `Given a channel action fails When muting Should emit an action error event`() = runTest { val chatClient: ChatClient = mock() @@ -1890,6 +2286,7 @@ internal class ChannelListViewModelTest { private var predefinedFilterValues: Map? = null private var predefinedSortValues: Map? = null private var searchDebounceMs: Long = ChannelListViewModel.SEARCH_DEBOUNCE_MS + private var searchOptions: ChannelListSearchOptions = ChannelListSearchOptions() init { val statePlugin: StatePlugin = mock() @@ -2004,6 +2401,10 @@ internal class ChannelListViewModelTest { this.searchDebounceMs = searchDebounceMs } + fun givenSearchOptions(searchOptions: ChannelListSearchOptions) = apply { + this.searchOptions = searchOptions + } + fun givenSearchMessagesResult(result: SearchMessagesResult) = apply { whenever( chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()), @@ -2044,41 +2445,86 @@ internal class ChannelListViewModelTest { fun get(testScope: TestScope, groupKey: String? = null): ChannelListViewModel { val name = predefinedFilterName val channelListViewModel = when { - groupKey != null -> ChannelListViewModel( - chatClient = chatClient, - groupKey = groupKey, - searchDebounceMs = searchDebounceMs, - draftMessagesEnabled = false, - messageSearchSort = messageSearchSort, - globalState = MutableStateFlow(globalState), - ) - - name != null -> ChannelListViewModel( - chatClient = chatClient, - predefinedFilterName = name, - filterValues = predefinedFilterValues, - sortValues = predefinedSortValues, - searchDebounceMs = searchDebounceMs, - draftMessagesEnabled = false, - chatEventHandlerFactory = ChatEventHandlerFactory(clientState), - messageSearchSort = messageSearchSort, - globalState = MutableStateFlow(globalState), - ) - - else -> ChannelListViewModel( - chatClient = chatClient, - initialSort = initialSort, - initialFilters = initialFilters, - searchDebounceMs = searchDebounceMs, - draftMessagesEnabled = false, - chatEventHandlerFactory = ChatEventHandlerFactory(clientState), - messageSearchSort = messageSearchSort, - globalState = MutableStateFlow(globalState), - ) + groupKey != null -> groupedViewModel(groupKey) + name != null -> predefinedViewModel(name) + else -> standardViewModel() } testScope.advanceUntilIdle() return channelListViewModel } + + private fun groupedViewModel(groupKey: String): ChannelListViewModel = if (messageSearchSort != null) { + @Suppress("DEPRECATION") + ChannelListViewModel( + chatClient = chatClient, + groupKey = groupKey, + searchDebounceMs = searchDebounceMs, + draftMessagesEnabled = false, + messageSearchSort = messageSearchSort, + globalState = MutableStateFlow(globalState), + ) + } else { + ChannelListViewModel( + chatClient = chatClient, + groupKey = groupKey, + searchDebounceMs = searchDebounceMs, + draftMessagesEnabled = false, + globalState = MutableStateFlow(globalState), + searchOptions = searchOptions, + ) + } + + private fun predefinedViewModel(name: String): ChannelListViewModel = if (messageSearchSort != null) { + @Suppress("DEPRECATION") + ChannelListViewModel( + chatClient = chatClient, + predefinedFilterName = name, + filterValues = predefinedFilterValues, + sortValues = predefinedSortValues, + searchDebounceMs = searchDebounceMs, + draftMessagesEnabled = false, + chatEventHandlerFactory = ChatEventHandlerFactory(clientState), + messageSearchSort = messageSearchSort, + globalState = MutableStateFlow(globalState), + ) + } else { + ChannelListViewModel( + chatClient = chatClient, + predefinedFilterName = name, + filterValues = predefinedFilterValues, + sortValues = predefinedSortValues, + searchDebounceMs = searchDebounceMs, + draftMessagesEnabled = false, + chatEventHandlerFactory = ChatEventHandlerFactory(clientState), + globalState = MutableStateFlow(globalState), + searchOptions = searchOptions, + ) + } + + private fun standardViewModel(): ChannelListViewModel = if (messageSearchSort != null) { + @Suppress("DEPRECATION") + ChannelListViewModel( + chatClient = chatClient, + initialSort = initialSort, + initialFilters = initialFilters, + searchDebounceMs = searchDebounceMs, + draftMessagesEnabled = false, + chatEventHandlerFactory = ChatEventHandlerFactory(clientState), + messageSearchSort = messageSearchSort, + globalState = MutableStateFlow(globalState), + ) + } else { + ChannelListViewModel( + chatClient = chatClient, + initialSort = initialSort, + initialFilters = initialFilters, + searchDebounceMs = searchDebounceMs, + draftMessagesEnabled = false, + chatEventHandlerFactory = ChatEventHandlerFactory(clientState), + globalState = MutableStateFlow(globalState), + searchOptions = searchOptions, + ) + } } companion object {