From bf7f1b7f3ff20820bb86fab21f8f0d40c5f72282 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:00:49 +0200 Subject: [PATCH 1/6] search: Debounce short search queries for longer Message search and the add-members user search have no minimum query length, so a one character query is sent as typed. Those queries match a large portion of the data set, which makes them the slowest ones to serve, while they are usually just a step towards the query the user is after. Debounce queries of 1-2 characters for at least 500ms instead of the configured 300ms, matching the iOS SDK. The thresholds live in SearchDebounce in ui-common, marked as internal API since they are not meant to be configured by integrators: the existing debounce still applies to regular queries, and wins for short ones too when it is longer than 500ms. Applied to the Compose message search in ChannelListViewModel, to the debounced input listener of the XML SearchInputView, and to the user search in AddMembersViewController. Channel search is left alone: it only queries from 3 characters on, so it never sends the short autocomplete queries this targets, and iOS likewise debounces only queries built around a text-search operator. Mention autocomplete is also left alone, as it queries the members of a single channel. SearchInputView.clear() now cancels a debounce still pending from the last keystroke, which would otherwise notify the listener with the query being cleared and re-run the search the user just dismissed. Debouncer gained internal submit/submitSuspendable overloads taking the debounce period for a single piece of work. The public API of every module is unchanged. AND-1409 Co-Authored-By: Claude Opus 5 (1M context) --- .../channels/ChannelListViewModel.kt | 17 ++-- .../channels/ChannelListViewModelTest.kt | 87 +++++++++++++++++++ .../chat/android/core/utils/Debouncer.kt | 29 +++++-- .../chat/android/core/utils/DebouncerTest.kt | 57 +++++++++++- .../channel/info/AddMembersViewController.kt | 3 +- .../android/ui/common/utils/SearchDebounce.kt | 42 +++++++++ .../info/AddMembersViewControllerTest.kt | 27 ++++++ .../ui/common/utils/SearchDebounceTest.kt | 54 ++++++++++++ .../ui/feature/search/SearchInputView.kt | 6 +- 9 files changed, 308 insertions(+), 14 deletions(-) create mode 100644 stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/SearchDebounce.kt create mode 100644 stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/SearchDebounceTest.kt diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index a78405d89de..6bac9ee0c4a 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 @@ -56,6 +56,7 @@ import io.getstream.chat.android.models.User import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.ui.common.state.channels.actions.ChannelAction +import io.getstream.chat.android.ui.common.utils.SearchDebounce import io.getstream.chat.android.ui.common.utils.extensions.defaultChannelListFilter import io.getstream.chat.android.ui.common.utils.extensions.isOneToOne import io.getstream.log.taggedLogger @@ -108,7 +109,7 @@ public class ChannelListViewModel internal constructor( private val memberLimit: Int?, private val messageLimit: Int?, private val chatEventHandlerFactory: ChatEventHandlerFactory, - searchDebounceMs: Long, + private val searchDebounceMs: Long, private val draftMessagesEnabled: Boolean, private val messageSearchSort: QuerySorter?, private val globalState: Flow, @@ -143,7 +144,8 @@ public class ChannelListViewModel internal constructor( * @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. + * @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 globalState A flow emitting the current [GlobalState]. @@ -190,7 +192,8 @@ public class ChannelListViewModel internal constructor( * @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. + * @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 globalState A flow emitting the current [GlobalState]. @@ -234,7 +237,8 @@ public class ChannelListViewModel internal constructor( * * @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. + * @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 globalState A flow emitting the current [GlobalState]. @@ -805,8 +809,9 @@ public class ChannelListViewModel internal constructor( } private fun handleSearchQuery(query: String) { - logger.d { "[handleSearchQuery] query: '$query'" } - searchDebouncer.submitSuspendable { + val debounceMs = SearchDebounce.debounceMsFor(query, searchDebounceMs) + logger.d { "[handleSearchQuery] query: '$query', debounceMs: $debounceMs" } + searchDebouncer.submitSuspendable(debounceMs) { searchMessagesForQuery(query) } } 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 ee309c0b061..785a0ad1bf4 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 @@ -57,10 +57,12 @@ import io.getstream.chat.android.randomMessage import io.getstream.chat.android.test.TestCoroutineExtension import io.getstream.chat.android.test.asCall import io.getstream.chat.android.ui.common.state.channels.actions.DeleteConversation +import io.getstream.chat.android.ui.common.utils.SearchDebounce import io.getstream.result.Error import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals @@ -76,6 +78,7 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -594,6 +597,82 @@ internal class ChannelListViewModelTest { assertEquals(30, captor.secondValue.offset) } + @Test + fun `Given channel list When setting a short message search query Should debounce it for longer`() = + runTest { + val chatClient: ChatClient = mock() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenSearchMessagesResult(SearchMessagesResult()) + .givenRepositorySelectChannels() + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("ab")) + advanceTimeBy(ChannelListViewModel.SEARCH_DEBOUNCE_MS + 50) + + verify(chatClient, never()).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + + advanceTimeBy(SearchDebounce.SHORT_QUERY_DEBOUNCE_MS) + + verify(chatClient).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + } + + @Test + fun `Given channel list When setting a regular message search query Should debounce it with the default debounce`() = + runTest { + val chatClient: ChatClient = mock() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenSearchMessagesResult(SearchMessagesResult()) + .givenRepositorySelectChannels() + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("abc")) + advanceTimeBy(ChannelListViewModel.SEARCH_DEBOUNCE_MS + 50) + + verify(chatClient).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + } + + @Test + fun `Given a debounce longer than the short query one When setting a short search query Should keep it`() = + runTest { + val searchDebounceMs = SearchDebounce.SHORT_QUERY_DEBOUNCE_MS + 300 + val chatClient: ChatClient = mock() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenSearchMessagesResult(SearchMessagesResult()) + .givenRepositorySelectChannels() + .givenSearchDebounceMs(searchDebounceMs) + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("ab")) + advanceTimeBy(SearchDebounce.SHORT_QUERY_DEBOUNCE_MS + 50) + + verify(chatClient, never()).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + + advanceTimeBy(searchDebounceMs) + + verify(chatClient).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + } + @Test fun `Given channel list When setting message search query Should search messages without offset or cursor`() = runTest { @@ -1270,6 +1349,7 @@ internal class ChannelListViewModelTest { private var predefinedFilterName: String? = null private var predefinedFilterValues: Map? = null private var predefinedSortValues: Map? = null + private var searchDebounceMs: Long = ChannelListViewModel.SEARCH_DEBOUNCE_MS init { val statePlugin: StatePlugin = mock() @@ -1356,6 +1436,10 @@ internal class ChannelListViewModelTest { predefinedSortValues = sortValues } + fun givenSearchDebounceMs(searchDebounceMs: Long) = apply { + this.searchDebounceMs = searchDebounceMs + } + fun givenSearchMessagesResult(result: SearchMessagesResult) = apply { whenever( chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()), @@ -1395,6 +1479,7 @@ internal class ChannelListViewModelTest { groupKey != null -> ChannelListViewModel( chatClient = chatClient, groupKey = groupKey, + searchDebounceMs = searchDebounceMs, draftMessagesEnabled = false, messageSearchSort = messageSearchSort, globalState = MutableStateFlow(globalState), @@ -1405,6 +1490,7 @@ internal class ChannelListViewModelTest { predefinedFilterName = name, filterValues = predefinedFilterValues, sortValues = predefinedSortValues, + searchDebounceMs = searchDebounceMs, draftMessagesEnabled = false, chatEventHandlerFactory = ChatEventHandlerFactory(clientState), messageSearchSort = messageSearchSort, @@ -1415,6 +1501,7 @@ internal class ChannelListViewModelTest { chatClient = chatClient, initialSort = initialSort, initialFilters = initialFilters, + searchDebounceMs = searchDebounceMs, draftMessagesEnabled = false, chatEventHandlerFactory = ChatEventHandlerFactory(clientState), messageSearchSort = messageSearchSort, diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/core/utils/Debouncer.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/core/utils/Debouncer.kt index 0b6ec929620..9ad3c07e1a5 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/core/utils/Debouncer.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/core/utils/Debouncer.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.core.utils +import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -41,11 +42,16 @@ public class Debouncer( * containing the new work. */ public fun submit(work: () -> Unit) { - job?.cancel() - job = scope.launch { - delay(debounceMs) - work() - } + submitInternal(debounceMs) { work() } + } + + /** + * Like [submit], but debounced by [debounceMs] instead of by the period this [Debouncer] was + * created with. + */ + @InternalStreamChatApi + public fun submit(debounceMs: Long, work: () -> Unit) { + submitInternal(debounceMs) { work() } } /** @@ -53,6 +59,19 @@ public class Debouncer( * containing the new suspendable work. */ public fun submitSuspendable(work: suspend () -> Unit) { + submitInternal(debounceMs, work) + } + + /** + * Like [submitSuspendable], but debounced by [debounceMs] instead of by the period this + * [Debouncer] was created with. + */ + @InternalStreamChatApi + public fun submitSuspendable(debounceMs: Long, work: suspend () -> Unit) { + submitInternal(debounceMs, work) + } + + private fun submitInternal(debounceMs: Long, work: suspend () -> Unit) { job?.cancel() job = scope.launch { delay(debounceMs) diff --git a/stream-chat-android-core/src/test/java/io/getstream/chat/android/core/utils/DebouncerTest.kt b/stream-chat-android-core/src/test/java/io/getstream/chat/android/core/utils/DebouncerTest.kt index 940b8d99e9b..25a09f86dc5 100644 --- a/stream-chat-android-core/src/test/java/io/getstream/chat/android/core/utils/DebouncerTest.kt +++ b/stream-chat-android-core/src/test/java/io/getstream/chat/android/core/utils/DebouncerTest.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.core.utils +import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -31,7 +32,7 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class, InternalStreamChatApi::class) internal class DebouncerTest { private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(TestCoroutineScheduler()) @@ -123,6 +124,60 @@ internal class DebouncerTest { acc `should be equal to` 2 } + @Test + fun testWorkWithCustomDebounceInterval() = runTest { + // given + val debouncer = Debouncer(200) + var acc = 0 + // when + debouncer.submit(debounceMs = 500) { + acc += 1 + } + delay(300) + // then + acc `should be equal to` 0 + // when + delay(300) + // then + acc `should be equal to` 1 + } + + @Test + fun testSuspendableWorkWithCustomDebounceInterval() = runTest { + // given + val debouncer = Debouncer(200) + var acc = 0 + // when + debouncer.submitSuspendable(debounceMs = 500) { + acc += 1 + } + delay(300) + // then + acc `should be equal to` 0 + // when + delay(300) + // then + acc `should be equal to` 1 + } + + @Test + fun testWorkWithCustomDebounceIntervalCancelsPendingWork() = runTest { + // given + val debouncer = Debouncer(200) + var acc = 0 + // when + debouncer.submit(debounceMs = 500) { + acc += 1 + } + delay(100) + debouncer.submit(debounceMs = 300) { + acc += 10 + } + delay(400) + // then + acc `should be equal to` 10 + } + @Test fun testCancelLastDebounce() = runTest { // given diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewController.kt index c02ae47cdf6..04ec7197c41 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewController.kt @@ -24,6 +24,7 @@ import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.ui.common.state.channel.info.AddMembersViewState +import io.getstream.chat.android.ui.common.utils.SearchDebounce import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -86,7 +87,7 @@ public class AddMembersViewController( // Re-run search whenever the query changes, with debounce. _state .map { it.query } - .debounce(TYPING_DEBOUNCE_TIMEOUT_MS) + .debounce { query -> SearchDebounce.debounceMsFor(query, TYPING_DEBOUNCE_TIMEOUT_MS) } .distinctUntilChanged() .onEach { query -> searchUsers(query) } .launchIn(scope) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/SearchDebounce.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/SearchDebounce.kt new file mode 100644 index 00000000000..7675812ddbd --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/SearchDebounce.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.ui.common.utils + +import io.getstream.chat.android.core.internal.InternalStreamChatApi + +/** + * Resolves how long a search query is debounced for, based on its length. + * + * Queries of one or two characters match a large portion of the data set, which makes them the + * slowest ones to serve, while they are usually just a step towards the query the user is after. + * The thresholds match the other Stream Chat SDKs. + */ +@InternalStreamChatApi +public object SearchDebounce { + + public const val SHORT_QUERY_MAX_LENGTH: Int = 2 + + public const val SHORT_QUERY_DEBOUNCE_MS: Long = 500L + + /** + * Returns the debounce period for [query], never shorter than [debounceMs]. + */ + public fun debounceMsFor(query: String, debounceMs: Long): Long = when { + query.isEmpty() || query.length > SHORT_QUERY_MAX_LENGTH -> debounceMs + else -> maxOf(debounceMs, SHORT_QUERY_DEBOUNCE_MS) + } +} diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt index d1b7f4ea869..7e71fa7de3e 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.ui.common.feature.channel.info import app.cash.turbine.test import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.channel.state.ChannelState +import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.User import io.getstream.chat.android.randomGenericError @@ -26,9 +27,11 @@ import io.getstream.chat.android.randomMembers import io.getstream.chat.android.randomUser import io.getstream.chat.android.test.asCall import io.getstream.chat.android.ui.common.state.channel.info.AddMembersViewState +import io.getstream.chat.android.ui.common.utils.SearchDebounce import io.getstream.result.Error import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -40,6 +43,7 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +@OptIn(InternalStreamChatApi::class) internal class AddMembersViewControllerTest { @Test @@ -166,6 +170,29 @@ internal class AddMembersViewControllerTest { } } + @Test + fun `QueryChanged with a short query waits for the short query debounce`() = runTest { + val users = listOf(randomUser()) + val sut = Fixture() + .givenQueryUsers(users = emptyList()) // initial empty-query search + .givenQueryUsers(users = users) // search for "Al" + .get(backgroundScope) + + sut.state.test { + skipItems(2) // Skip initial state and empty-query search result + + sut.onViewAction(AddMembersViewAction.QueryChanged("Al")) + skipItems(1) // Skip the query-only state update + + advanceTimeBy(SearchDebounce.SHORT_QUERY_DEBOUNCE_MS - 50) + expectNoEvents() // Still debounced, no search started + + advanceTimeBy(100) + assertTrue(awaitItem().isLoading) // Search triggered + assertEquals(users, awaitItem().searchResult) + } + } + @Test fun `QueryChanged search error clears loading state`() = runTest { val sut = Fixture() diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/SearchDebounceTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/SearchDebounceTest.kt new file mode 100644 index 00000000000..0805f4d3d8c --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/SearchDebounceTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.ui.common.utils + +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import org.amshove.kluent.shouldBeEqualTo +import org.junit.jupiter.api.Test + +@OptIn(InternalStreamChatApi::class) +internal class SearchDebounceTest { + + @Test + fun `Given a short query When resolving the debounce Should return the short query debounce`() { + SearchDebounce.debounceMsFor("a", DEBOUNCE_MS) shouldBeEqualTo SearchDebounce.SHORT_QUERY_DEBOUNCE_MS + SearchDebounce.debounceMsFor("ab", DEBOUNCE_MS) shouldBeEqualTo SearchDebounce.SHORT_QUERY_DEBOUNCE_MS + } + + @Test + fun `Given a regular query When resolving the debounce Should return the configured debounce`() { + SearchDebounce.debounceMsFor("abc", DEBOUNCE_MS) shouldBeEqualTo DEBOUNCE_MS + SearchDebounce.debounceMsFor("abcd", DEBOUNCE_MS) shouldBeEqualTo DEBOUNCE_MS + } + + @Test + fun `Given an empty query When resolving the debounce Should return the configured debounce`() { + SearchDebounce.debounceMsFor("", DEBOUNCE_MS) shouldBeEqualTo DEBOUNCE_MS + } + + @Test + fun `Given a debounce longer than the short query one When resolving the debounce Should keep it`() { + val debounceMs = SearchDebounce.SHORT_QUERY_DEBOUNCE_MS + 300 + + SearchDebounce.debounceMsFor("a", debounceMs) shouldBeEqualTo debounceMs + SearchDebounce.debounceMsFor("abc", debounceMs) shouldBeEqualTo debounceMs + } + + private companion object { + private const val DEBOUNCE_MS = 300L + } +} diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/search/SearchInputView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/search/SearchInputView.kt index 07ee8d4fead..9e9f09280d2 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/search/SearchInputView.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/search/SearchInputView.kt @@ -28,6 +28,7 @@ import androidx.core.widget.doAfterTextChanged import androidx.transition.Fade import androidx.transition.TransitionManager import io.getstream.chat.android.core.utils.Debouncer +import io.getstream.chat.android.ui.common.utils.SearchDebounce import io.getstream.chat.android.ui.databinding.StreamUiSearchViewBinding import io.getstream.chat.android.ui.utils.extensions.createStreamThemeWrapper import io.getstream.chat.android.ui.utils.extensions.focusAndShowKeyboard @@ -116,7 +117,7 @@ public class SearchInputView : FrameLayout { val newQuery = query continuousInputChangedListener?.onInputChanged(newQuery) - inputDebouncer.submit { + inputDebouncer.submit(SearchDebounce.debounceMsFor(newQuery, TYPING_DEBOUNCE_MS)) { debouncedInputChangedListener?.onInputChanged(newQuery) } } @@ -173,6 +174,9 @@ public class SearchInputView : FrameLayout { return false } + // A debounce pending from the last keystroke would notify with the query being cleared. + inputDebouncer.cancelLastDebounce() + withoutListenerNotifications { binding.inputField.setText("") From db8e6ddd7874f4948686a4ef2e8169262c4b4774 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:37:20 +0200 Subject: [PATCH 2/6] search: Debounce the user search on the query that is actually sent The add-members search trims the query before building the request, so a padded short query was debounced as a regular one while firing a one character queryUsers. Co-Authored-By: Claude Opus 5 (1M context) --- .../chat/android/core/utils/Debouncer.kt | 4 +-- .../channel/info/AddMembersViewController.kt | 2 +- .../info/AddMembersViewControllerTest.kt | 25 +++++++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/core/utils/Debouncer.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/core/utils/Debouncer.kt index 9ad3c07e1a5..d8b6dc0c26e 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/core/utils/Debouncer.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/core/utils/Debouncer.kt @@ -46,7 +46,7 @@ public class Debouncer( } /** - * Like [submit], but debounced by [debounceMs] instead of by the period this [Debouncer] was + * Like [submit], but debounced by the given period instead of the one this [Debouncer] was * created with. */ @InternalStreamChatApi @@ -63,7 +63,7 @@ public class Debouncer( } /** - * Like [submitSuspendable], but debounced by [debounceMs] instead of by the period this + * Like [submitSuspendable], but debounced by the given period instead of the one this * [Debouncer] was created with. */ @InternalStreamChatApi diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewController.kt index 04ec7197c41..6a42608fc22 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewController.kt @@ -87,7 +87,7 @@ public class AddMembersViewController( // Re-run search whenever the query changes, with debounce. _state .map { it.query } - .debounce { query -> SearchDebounce.debounceMsFor(query, TYPING_DEBOUNCE_TIMEOUT_MS) } + .debounce { query -> SearchDebounce.debounceMsFor(query.trim(), TYPING_DEBOUNCE_TIMEOUT_MS) } .distinctUntilChanged() .onEach { query -> searchUsers(query) } .launchIn(scope) diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt index 7e71fa7de3e..ca4b794ae24 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt @@ -193,6 +193,31 @@ internal class AddMembersViewControllerTest { } } + @Test + fun `QueryChanged with a padded short query waits for the short query debounce`() = runTest { + val users = listOf(randomUser()) + val sut = Fixture() + .givenQueryUsers(users = emptyList()) // initial empty-query search + .givenQueryUsers(users = users) // search for "a " + .get(backgroundScope) + + sut.state.test { + skipItems(2) // Skip initial state and empty-query search result + + // Whitespace is preserved in the query but trimmed away before searching, so this is a + // one character search. + sut.onViewAction(AddMembersViewAction.QueryChanged("a ")) + skipItems(1) // Skip the query-only state update + + advanceTimeBy(SearchDebounce.SHORT_QUERY_DEBOUNCE_MS - 50) + expectNoEvents() // Still debounced, no search started + + advanceTimeBy(100) + assertTrue(awaitItem().isLoading) // Search triggered + assertEquals(users, awaitItem().searchResult) + } + } + @Test fun `QueryChanged search error clears loading state`() = runTest { val sut = Fixture() From 003a88a445c06f1f0fdb90bf5773462442b4c3f1 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:50:27 +0200 Subject: [PATCH 3/6] search: Drop redundant opt-in annotations from the search debounce tests Every touched module already passes -opt-in=InternalStreamChatApi. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/io/getstream/chat/android/core/utils/DebouncerTest.kt | 3 +-- .../feature/channel/info/AddMembersViewControllerTest.kt | 2 -- .../chat/android/ui/common/utils/SearchDebounceTest.kt | 2 -- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/stream-chat-android-core/src/test/java/io/getstream/chat/android/core/utils/DebouncerTest.kt b/stream-chat-android-core/src/test/java/io/getstream/chat/android/core/utils/DebouncerTest.kt index 25a09f86dc5..715a5a6f70b 100644 --- a/stream-chat-android-core/src/test/java/io/getstream/chat/android/core/utils/DebouncerTest.kt +++ b/stream-chat-android-core/src/test/java/io/getstream/chat/android/core/utils/DebouncerTest.kt @@ -16,7 +16,6 @@ package io.getstream.chat.android.core.utils -import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -32,7 +31,7 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -@OptIn(ExperimentalCoroutinesApi::class, InternalStreamChatApi::class) +@OptIn(ExperimentalCoroutinesApi::class) internal class DebouncerTest { private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(TestCoroutineScheduler()) diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt index ca4b794ae24..f1a325e0c8f 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt @@ -19,7 +19,6 @@ package io.getstream.chat.android.ui.common.feature.channel.info import app.cash.turbine.test import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.channel.state.ChannelState -import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.User import io.getstream.chat.android.randomGenericError @@ -43,7 +42,6 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -@OptIn(InternalStreamChatApi::class) internal class AddMembersViewControllerTest { @Test diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/SearchDebounceTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/SearchDebounceTest.kt index 0805f4d3d8c..4c9dc987c61 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/SearchDebounceTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/SearchDebounceTest.kt @@ -16,11 +16,9 @@ package io.getstream.chat.android.ui.common.utils -import io.getstream.chat.android.core.internal.InternalStreamChatApi import org.amshove.kluent.shouldBeEqualTo import org.junit.jupiter.api.Test -@OptIn(InternalStreamChatApi::class) internal class SearchDebounceTest { @Test From 75bc0b6e91538b001fe96463f1e25f9e3fdd0860 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:30:20 +0200 Subject: [PATCH 4/6] search: Assert the debounce intervals at their boundary The tests advanced past the expected interval with a margin, so a shorter debounce passed them too. Advance to the interval, assert nothing ran, then run the work scheduled at it. Co-Authored-By: Claude Opus 5 (1M context) --- .../channels/ChannelListViewModelTest.kt | 15 ++++++++++----- .../chat/android/core/utils/DebouncerTest.kt | 14 +++++++++----- .../channel/info/AddMembersViewControllerTest.kt | 9 +++++---- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt index 785a0ad1bf4..0054a9df342 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 @@ -64,6 +64,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -614,11 +615,11 @@ internal class ChannelListViewModelTest { .get(this) viewModel.setSearchQuery(SearchQuery.Messages("ab")) - advanceTimeBy(ChannelListViewModel.SEARCH_DEBOUNCE_MS + 50) + advanceTimeBy(SearchDebounce.SHORT_QUERY_DEBOUNCE_MS) verify(chatClient, never()).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) - advanceTimeBy(SearchDebounce.SHORT_QUERY_DEBOUNCE_MS) + runCurrent() verify(chatClient).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } @@ -640,7 +641,11 @@ internal class ChannelListViewModelTest { .get(this) viewModel.setSearchQuery(SearchQuery.Messages("abc")) - advanceTimeBy(ChannelListViewModel.SEARCH_DEBOUNCE_MS + 50) + advanceTimeBy(ChannelListViewModel.SEARCH_DEBOUNCE_MS) + + verify(chatClient, never()).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + + runCurrent() verify(chatClient).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } @@ -664,11 +669,11 @@ internal class ChannelListViewModelTest { .get(this) viewModel.setSearchQuery(SearchQuery.Messages("ab")) - advanceTimeBy(SearchDebounce.SHORT_QUERY_DEBOUNCE_MS + 50) + advanceTimeBy(searchDebounceMs) verify(chatClient, never()).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) - advanceTimeBy(searchDebounceMs) + runCurrent() verify(chatClient).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } diff --git a/stream-chat-android-core/src/test/java/io/getstream/chat/android/core/utils/DebouncerTest.kt b/stream-chat-android-core/src/test/java/io/getstream/chat/android/core/utils/DebouncerTest.kt index 715a5a6f70b..074e5bf8057 100644 --- a/stream-chat-android-core/src/test/java/io/getstream/chat/android/core/utils/DebouncerTest.kt +++ b/stream-chat-android-core/src/test/java/io/getstream/chat/android/core/utils/DebouncerTest.kt @@ -132,11 +132,11 @@ internal class DebouncerTest { debouncer.submit(debounceMs = 500) { acc += 1 } - delay(300) + delay(499) // then acc `should be equal to` 0 // when - delay(300) + delay(1) // then acc `should be equal to` 1 } @@ -150,11 +150,11 @@ internal class DebouncerTest { debouncer.submitSuspendable(debounceMs = 500) { acc += 1 } - delay(300) + delay(499) // then acc `should be equal to` 0 // when - delay(300) + delay(1) // then acc `should be equal to` 1 } @@ -172,7 +172,11 @@ internal class DebouncerTest { debouncer.submit(debounceMs = 300) { acc += 10 } - delay(400) + delay(299) + // then + acc `should be equal to` 0 + // when + delay(1) // then acc `should be equal to` 10 } diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt index f1a325e0c8f..a7ee877fb5f 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt @@ -31,6 +31,7 @@ import io.getstream.result.Error import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -182,10 +183,10 @@ internal class AddMembersViewControllerTest { sut.onViewAction(AddMembersViewAction.QueryChanged("Al")) skipItems(1) // Skip the query-only state update - advanceTimeBy(SearchDebounce.SHORT_QUERY_DEBOUNCE_MS - 50) + advanceTimeBy(SearchDebounce.SHORT_QUERY_DEBOUNCE_MS) expectNoEvents() // Still debounced, no search started - advanceTimeBy(100) + runCurrent() assertTrue(awaitItem().isLoading) // Search triggered assertEquals(users, awaitItem().searchResult) } @@ -207,10 +208,10 @@ internal class AddMembersViewControllerTest { sut.onViewAction(AddMembersViewAction.QueryChanged("a ")) skipItems(1) // Skip the query-only state update - advanceTimeBy(SearchDebounce.SHORT_QUERY_DEBOUNCE_MS - 50) + advanceTimeBy(SearchDebounce.SHORT_QUERY_DEBOUNCE_MS) expectNoEvents() // Still debounced, no search started - advanceTimeBy(100) + runCurrent() assertTrue(awaitItem().isLoading) // Search triggered assertEquals(users, awaitItem().searchResult) } From f5c32b4c493d30548da8eae4ba826dff0f0bf01c Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:44:47 +0200 Subject: [PATCH 5/6] search: Assert the trimmed query reaches the user search request The padded-query test pinned only the debounce period, so dropping the trim on the outbound request went unnoticed. Co-Authored-By: Claude Opus 5 (1M context) --- .../info/AddMembersViewControllerTest.kt | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt index a7ee877fb5f..a32d198f90c 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt @@ -18,8 +18,11 @@ package io.getstream.chat.android.ui.common.feature.channel.info import app.cash.turbine.test import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.api.models.QueryUsersRequest import io.getstream.chat.android.client.channel.state.ChannelState +import io.getstream.chat.android.models.AutocompleteFilterObject import io.getstream.chat.android.models.Member +import io.getstream.chat.android.models.OrFilterObject import io.getstream.chat.android.models.User import io.getstream.chat.android.randomGenericError import io.getstream.chat.android.randomMembers @@ -38,9 +41,12 @@ import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever internal class AddMembersViewControllerTest { @@ -195,10 +201,10 @@ internal class AddMembersViewControllerTest { @Test fun `QueryChanged with a padded short query waits for the short query debounce`() = runTest { val users = listOf(randomUser()) - val sut = Fixture() + val fixture = Fixture() .givenQueryUsers(users = emptyList()) // initial empty-query search .givenQueryUsers(users = users) // search for "a " - .get(backgroundScope) + val sut = fixture.get(backgroundScope) sut.state.test { skipItems(2) // Skip initial state and empty-query search result @@ -215,6 +221,15 @@ internal class AddMembersViewControllerTest { assertTrue(awaitItem().isLoading) // Search triggered assertEquals(users, awaitItem().searchResult) } + + // The debounce is resolved on the trimmed query because that is what gets sent. + val requestCaptor = argumentCaptor() + verify(fixture.chatClient, times(2)).queryUsers(requestCaptor.capture()) + val filter = requestCaptor.secondValue.filter as OrFilterObject + assertEquals( + setOf("a"), + filter.filterObjects.map { (it as AutocompleteFilterObject).value }.toSet(), + ) } @Test @@ -373,7 +388,7 @@ internal class AddMembersViewControllerTest { private val channelState: ChannelState = mock { on { members } doReturn channelMembers } - private val chatClient: ChatClient = mock() + val chatClient: ChatClient = mock() private val queryUsersResults = mutableListOf?, Error?>>() private var callCount = 0 From 29c09b3b9d67ebb70467e09c41a232d93966bed8 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:13:34 +0200 Subject: [PATCH 6/6] search: Cover SearchInputView with the Paparazzi harness, trim the message search query Adds view tests for the debounced listener, including the pending debounce being dropped when the input is cleared, which had no coverage. The message search debounce now resolves on the trimmed query, like the user search already did, so a padded short query no longer takes the regular path. Also documents the longer wait for short input on setDebouncedInputChangedListener and on the primary constructor's searchDebounceMs, and opts the add-members test in to the experimental test scheduler API it uses. Co-Authored-By: Claude Opus 5 (1M context) --- .../channels/ChannelListViewModel.kt | 5 +- .../channels/ChannelListViewModelTest.kt | 27 +++++ .../info/AddMembersViewControllerTest.kt | 2 + .../ui/feature/search/SearchInputView.kt | 3 + .../ui/feature/search/SearchInputViewTest.kt | 99 +++++++++++++++++++ 5 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/feature/search/SearchInputViewTest.kt diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index 6bac9ee0c4a..736acc3be56 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 @@ -95,7 +95,8 @@ import kotlin.coroutines.cancellation.CancellationException * @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. + * @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 globalState A flow emitting the current [GlobalState]. @@ -809,7 +810,7 @@ public class ChannelListViewModel internal constructor( } private fun handleSearchQuery(query: String) { - val debounceMs = SearchDebounce.debounceMsFor(query, searchDebounceMs) + val debounceMs = SearchDebounce.debounceMsFor(query.trim(), searchDebounceMs) logger.d { "[handleSearchQuery] query: '$query', debounceMs: $debounceMs" } searchDebouncer.submitSuspendable(debounceMs) { searchMessagesForQuery(query) 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 0054a9df342..95d690d1f94 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 @@ -624,6 +624,33 @@ internal class ChannelListViewModelTest { verify(chatClient).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } + @Test + fun `Given channel list When setting a padded short message search query Should debounce it for longer`() = + runTest { + val chatClient: ChatClient = mock() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenSearchMessagesResult(SearchMessagesResult()) + .givenRepositorySelectChannels() + .get(this) + + // Whitespace makes this three characters long, while the term searched for is one. + viewModel.setSearchQuery(SearchQuery.Messages("a ")) + advanceTimeBy(SearchDebounce.SHORT_QUERY_DEBOUNCE_MS) + + verify(chatClient, never()).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + + runCurrent() + + verify(chatClient).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + } + @Test fun `Given channel list When setting a regular message search query Should debounce it with the default debounce`() = runTest { diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt index a32d198f90c..28d3537315c 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/channel/info/AddMembersViewControllerTest.kt @@ -32,6 +32,7 @@ import io.getstream.chat.android.ui.common.state.channel.info.AddMembersViewStat import io.getstream.chat.android.ui.common.utils.SearchDebounce import io.getstream.result.Error import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent @@ -49,6 +50,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +@OptIn(ExperimentalCoroutinesApi::class) internal class AddMembersViewControllerTest { @Test diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/search/SearchInputView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/search/SearchInputView.kt index 9e9f09280d2..68e5bd692d7 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/search/SearchInputView.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/search/SearchInputView.kt @@ -209,6 +209,9 @@ public class SearchInputView : FrameLayout { /** * Sets a listener for debounced input events. Quick changes to the input will not be passed to * this listener, it will only be invoked when the input has been stable for a short while. + * + * Input of 1-2 characters is held for longer than that, as such queries are the most expensive + * ones to search for. */ public fun setDebouncedInputChangedListener(inputChangedListener: InputChangedListener?) { this.debouncedInputChangedListener = inputChangedListener diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/feature/search/SearchInputViewTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/feature/search/SearchInputViewTest.kt new file mode 100644 index 00000000000..029fa631abd --- /dev/null +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/feature/search/SearchInputViewTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.ui.feature.search + +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider +import io.getstream.chat.android.ui.PaparazziViewTest +import io.getstream.chat.android.ui.common.utils.SearchDebounce +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class SearchInputViewTest : PaparazziViewTest() { + + override val deviceConfig = DeviceConfig.PIXEL_2 + + override val paparazzi = Paparazzi(deviceConfig = deviceConfig) + + private val testDispatcher = StandardTestDispatcher() + + init { + DispatcherProvider.set(mainDispatcher = testDispatcher, ioDispatcher = testDispatcher) + } + + @After + fun resetDispatchers() { + DispatcherProvider.reset() + } + + @Test + fun `debounced listener is notified once the input is stable`() = runTest(testDispatcher) { + val queries = mutableListOf() + val searchInputView = searchInputView { queries += it } + + searchInputView.setQuery("abc") + advanceTimeBy(DEFAULT_DEBOUNCE_MS) + assertEquals(emptyList(), queries) + + runCurrent() + assertEquals(listOf("abc"), queries) + } + + @Test + fun `short input is held for longer than regular input`() = runTest(testDispatcher) { + val queries = mutableListOf() + val searchInputView = searchInputView { queries += it } + + searchInputView.setQuery("ab") + advanceTimeBy(SearchDebounce.SHORT_QUERY_DEBOUNCE_MS) + assertEquals(emptyList(), queries) + + runCurrent() + assertEquals(listOf("ab"), queries) + } + + @Test + fun `clearing the input drops the debounce pending from the last keystroke`() = runTest(testDispatcher) { + val queries = mutableListOf() + val searchInputView = searchInputView { queries += it } + searchInputView.setQuery("abc") + + searchInputView.clear() + advanceUntilIdle() + + // Without dropping it, the pending debounce notifies "abc" after the cleared query. + assertEquals(listOf(""), queries) + } + + private fun searchInputView(onInputChanged: (String) -> Unit) = + SearchInputView(paparazzi.context).apply { + setDebouncedInputChangedListener(onInputChanged) + } + + private companion object { + private const val DEFAULT_DEBOUNCE_MS = 300L + } +}