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..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 @@ -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 @@ -94,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]. @@ -108,7 +110,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 +145,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 +193,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 +238,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 +810,9 @@ public class ChannelListViewModel internal constructor( } private fun handleSearchQuery(query: String) { - logger.d { "[handleSearchQuery] query: '$query'" } - searchDebouncer.submitSuspendable { + 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 ee309c0b061..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 @@ -57,11 +57,14 @@ 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.runCurrent import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -76,6 +79,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 +598,113 @@ 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(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 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 { + 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) + + verify(chatClient, never()).searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) + + runCurrent() + + 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(searchDebounceMs) + + 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 message search query Should search messages without offset or cursor`() = runTest { @@ -1270,6 +1381,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 +1468,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 +1511,7 @@ internal class ChannelListViewModelTest { groupKey != null -> ChannelListViewModel( chatClient = chatClient, groupKey = groupKey, + searchDebounceMs = searchDebounceMs, draftMessagesEnabled = false, messageSearchSort = messageSearchSort, globalState = MutableStateFlow(globalState), @@ -1405,6 +1522,7 @@ internal class ChannelListViewModelTest { predefinedFilterName = name, filterValues = predefinedFilterValues, sortValues = predefinedSortValues, + searchDebounceMs = searchDebounceMs, draftMessagesEnabled = false, chatEventHandlerFactory = ChatEventHandlerFactory(clientState), messageSearchSort = messageSearchSort, @@ -1415,6 +1533,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..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 @@ -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 the given period instead of the one 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 the given period instead of the one 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..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 @@ -123,6 +123,64 @@ 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(499) + // then + acc `should be equal to` 0 + // when + delay(1) + // 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(499) + // then + acc `should be equal to` 0 + // when + delay(1) + // 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(299) + // then + acc `should be equal to` 0 + // when + delay(1) + // 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..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 @@ -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.trim(), 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..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 @@ -18,28 +18,39 @@ 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 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.ExperimentalCoroutinesApi 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 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 +@OptIn(ExperimentalCoroutinesApi::class) internal class AddMembersViewControllerTest { @Test @@ -166,6 +177,63 @@ 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) + expectNoEvents() // Still debounced, no search started + + runCurrent() + assertTrue(awaitItem().isLoading) // Search triggered + assertEquals(users, awaitItem().searchResult) + } + } + + @Test + fun `QueryChanged with a padded short query waits for the short query debounce`() = runTest { + val users = listOf(randomUser()) + val fixture = Fixture() + .givenQueryUsers(users = emptyList()) // initial empty-query search + .givenQueryUsers(users = users) // search for "a " + val sut = fixture.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) + expectNoEvents() // Still debounced, no search started + + runCurrent() + 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 fun `QueryChanged search error clears loading state`() = runTest { val sut = Fixture() @@ -322,7 +390,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 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..4c9dc987c61 --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/SearchDebounceTest.kt @@ -0,0 +1,52 @@ +/* + * 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 org.amshove.kluent.shouldBeEqualTo +import org.junit.jupiter.api.Test + +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..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 @@ -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("") @@ -205,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 + } +}