Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Message>?,
private val globalState: Flow<GlobalState>,
Expand Down Expand Up @@ -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
Comment thread
andremion marked this conversation as resolved.
* 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].
Expand Down Expand Up @@ -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].
Expand Down Expand Up @@ -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].
Expand Down Expand Up @@ -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)
Comment thread
andremion marked this conversation as resolved.
Outdated
logger.d { "[handleSearchQuery] query: '$query', debounceMs: $debounceMs" }
searchDebouncer.submitSuspendable(debounceMs) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
searchMessagesForQuery(query)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -594,6 +598,86 @@ 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 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())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

@Test
fun `Given channel list When setting message search query Should search messages without offset or cursor`() =
runTest {
Expand Down Expand Up @@ -1270,6 +1354,7 @@ internal class ChannelListViewModelTest {
private var predefinedFilterName: String? = null
private var predefinedFilterValues: Map<String, Any>? = null
private var predefinedSortValues: Map<String, Any>? = null
private var searchDebounceMs: Long = ChannelListViewModel.SEARCH_DEBOUNCE_MS

init {
val statePlugin: StatePlugin = mock()
Expand Down Expand Up @@ -1356,6 +1441,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()),
Expand Down Expand Up @@ -1395,6 +1484,7 @@ internal class ChannelListViewModelTest {
groupKey != null -> ChannelListViewModel(
chatClient = chatClient,
groupKey = groupKey,
searchDebounceMs = searchDebounceMs,
draftMessagesEnabled = false,
messageSearchSort = messageSearchSort,
globalState = MutableStateFlow(globalState),
Expand All @@ -1405,6 +1495,7 @@ internal class ChannelListViewModelTest {
predefinedFilterName = name,
filterValues = predefinedFilterValues,
sortValues = predefinedSortValues,
searchDebounceMs = searchDebounceMs,
draftMessagesEnabled = false,
chatEventHandlerFactory = ChatEventHandlerFactory(clientState),
messageSearchSort = messageSearchSort,
Expand All @@ -1415,6 +1506,7 @@ internal class ChannelListViewModelTest {
chatClient = chatClient,
initialSort = initialSort,
initialFilters = initialFilters,
searchDebounceMs = searchDebounceMs,
draftMessagesEnabled = false,
chatEventHandlerFactory = ChatEventHandlerFactory(clientState),
messageSearchSort = messageSearchSort,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -41,18 +42,36 @@ 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() }
}

/**
* Cancels the previous work and launches a new coroutine
* 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading