From b8acc1ff3ff13c7c53244abf60b242c3bec92386 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 23 Feb 2026 15:59:33 +0100 Subject: [PATCH 1/4] Use cursor-based pagination for search messages Co-Authored-By: Claude --- .../channels/ChannelListViewModel.kt | 14 +- .../channels/ChannelListViewModelTest.kt | 184 ++++++++++++++--- .../ui/viewmodel/search/SearchViewModel.kt | 28 +-- .../viewmodels/search/SearchViewModelTest.kt | 195 ++++++++++++++++++ 4 files changed, 377 insertions(+), 44 deletions(-) create mode 100644 stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.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 fe22f7131e8..60e2f87fedc 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 @@ -367,14 +367,14 @@ public class ChannelListViewModel( currentState: SearchMessageState, channelFilter: FilterObject, ): SearchMessageState { - val offset = currentState.messages.size val limit = channelLimit - logger.v { "[searchMessages] #$src; query: '${currentState.query}', offset: $offset, limit: $limit" } + val next = currentState.next + logger.v { "[searchMessages] #$src; query: '${currentState.query}', next: $next, limit: $limit" } val result = chatClient.searchMessages( channelFilter = channelFilter, messageFilter = Filters.autocomplete("text", currentState.query), - offset = offset, limit = limit, + next = next, ).await() return when (result) { is io.getstream.result.Result.Success -> { @@ -383,7 +383,8 @@ public class ChannelListViewModel( messages = currentState.messages + result.value.messages, isLoading = false, isLoadingMore = false, - canLoadMore = result.value.messages.size >= limit, + canLoadMore = !result.value.next.isNullOrEmpty(), + next = result.value.next, ) } is io.getstream.result.Result.Failure -> { @@ -391,7 +392,6 @@ public class ChannelListViewModel( currentState.copy( isLoading = false, isLoadingMore = false, - canLoadMore = true, ) } } @@ -773,6 +773,7 @@ public class ChannelListViewModel( private data class SearchMessageState( val query: String = "", val canLoadMore: Boolean = true, + val next: String? = null, val messages: List = emptyList(), val isLoading: Boolean = false, val isLoadingMore: Boolean = false, @@ -784,7 +785,8 @@ public class ChannelListViewModel( "messages.size=${messages.size}, " + "isLoading=$isLoading, " + "isLoadingMore=$isLoadingMore, " + - "canLoadMore=$canLoadMore)" + "canLoadMore=$canLoadMore, " + + "next=$next)" } } } 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 ef8f80bb926..883bfec1c27 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 @@ -19,6 +19,7 @@ package io.getstream.chat.android.compose.viewmodel.channels import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.channel.ChannelClient +import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.compose.state.channels.list.ItemState import io.getstream.chat.android.compose.state.channels.list.SearchQuery @@ -30,10 +31,12 @@ import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.OrFilterObject +import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.models.TypingEvent import io.getstream.chat.android.models.User import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter +import io.getstream.chat.android.randomMessage import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory import io.getstream.chat.android.state.plugin.internal.StatePlugin import io.getstream.chat.android.state.plugin.state.StateRegistry @@ -48,10 +51,15 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.amshove.kluent.`should be equal to` +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq @@ -76,8 +84,8 @@ internal class ChannelListViewModelTest { .get(this) val channelsState = viewModel.channelsState - channelsState.channelItems.size `should be equal to` 0 - channelsState.isLoading `should be equal to` true + assertEquals(0, channelsState.channelItems.size) + assertTrue(channelsState.isLoading) } @Test @@ -95,8 +103,8 @@ internal class ChannelListViewModelTest { .get(this) val channelsState = viewModel.channelsState - channelsState.channelItems.size `should be equal to` 2 - channelsState.isLoading `should be equal to` false + assertEquals(2, channelsState.channelItems.size) + assertFalse(channelsState.isLoading) } @Test @@ -118,8 +126,8 @@ internal class ChannelListViewModelTest { viewModel.performChannelAction(DeleteConversation(channel1)) viewModel.deleteConversation(channel1) - viewModel.activeChannelAction `should be equal to` null - viewModel.selectedChannel.value `should be equal to` null + assertNull(viewModel.activeChannelAction) + assertNull(viewModel.selectedChannel.value) verify(channelClient).delete() } @@ -140,8 +148,8 @@ internal class ChannelListViewModelTest { viewModel.selectChannel(channel1) viewModel.muteChannel(channel1) - viewModel.activeChannelAction `should be equal to` null - viewModel.selectedChannel.value `should be equal to` null + assertNull(viewModel.activeChannelAction) + assertNull(viewModel.selectedChannel.value) verify(chatClient).muteChannel("messaging", "channel1", null) } @@ -171,9 +179,9 @@ internal class ChannelListViewModelTest { viewModel.selectChannel(channel1) viewModel.unmuteChannel(channel1) - (viewModel.channelsState.channelItems.first() as ItemState.ChannelItemState).isMuted `should be equal to` true - viewModel.activeChannelAction `should be equal to` null - viewModel.selectedChannel.value `should be equal to` null + assertTrue((viewModel.channelsState.channelItems.first() as ItemState.ChannelItemState).isMuted) + assertNull(viewModel.activeChannelAction) + assertNull(viewModel.selectedChannel.value) verify(chatClient).unmuteChannel("messaging", "channel1") } @@ -193,8 +201,8 @@ internal class ChannelListViewModelTest { viewModel.selectChannel(channel1) viewModel.dismissChannelAction() - viewModel.activeChannelAction `should be equal to` null - viewModel.selectedChannel.value `should be equal to` null + assertNull(viewModel.activeChannelAction) + assertNull(viewModel.selectedChannel.value) } @Test @@ -223,8 +231,8 @@ internal class ChannelListViewModelTest { val captor = argumentCaptor() verify(chatClient, times(2)).queryChannels(captor.capture()) - captor.firstValue.offset `should be equal to` 0 - captor.secondValue.offset `should be equal to` 30 + assertEquals(0, captor.firstValue.offset) + assertEquals(30, captor.secondValue.offset) } @Test @@ -246,7 +254,7 @@ internal class ChannelListViewModelTest { val captor = argumentCaptor() verify(chatClient, times(1)).queryChannels(captor.capture()) - captor.firstValue.offset `should be equal to` 0 + assertEquals(0, captor.firstValue.offset) } @Test @@ -271,8 +279,8 @@ internal class ChannelListViewModelTest { val andFilterObject = captor.secondValue.filter as AndFilterObject val orFilterObject = andFilterObject.filterObjects.last() as OrFilterObject val autoCompleteFilterObject = orFilterObject.filterObjects.last() as AutocompleteFilterObject - autoCompleteFilterObject.fieldName `should be equal to` "name" - autoCompleteFilterObject.value `should be equal to` "Search query" + assertEquals("name", autoCompleteFilterObject.fieldName) + assertEquals("Search query", autoCompleteFilterObject.value) } @Test @@ -301,8 +309,8 @@ internal class ChannelListViewModelTest { val andFilterObject = captor.secondValue.filter as AndFilterObject val orFilterObject = andFilterObject.filterObjects.last() as OrFilterObject val autoCompleteFilterObject = orFilterObject.filterObjects.last() as AutocompleteFilterObject - autoCompleteFilterObject.fieldName `should be equal to` "name" - autoCompleteFilterObject.value `should be equal to` "Search query" + assertEquals("name", autoCompleteFilterObject.fieldName) + assertEquals("Search query", autoCompleteFilterObject.value) } @Test @@ -333,9 +341,125 @@ internal class ChannelListViewModelTest { val captor = argumentCaptor() verify(chatClient, times(2)).queryChannels(captor.capture()) - captor.allValues.size `should be equal to` 2 - captor.firstValue.offset `should be equal to` 0 - captor.secondValue.offset `should be equal to` 30 + assertEquals(2, captor.allValues.size) + assertEquals(0, captor.firstValue.offset) + assertEquals(30, captor.secondValue.offset) + } + + @Test + fun `Given channel list When setting message search query Should search messages without offset or cursor`() = + runTest { + val chatClient: ChatClient = mock() + val messages = listOf(randomMessage(cid = "messaging:channel1")) + val searchResult = SearchMessagesResult(messages = messages, next = "cursor_page2") + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenSearchMessagesResult(searchResult) + .givenRepositorySelectChannels(listOf(channel1)) + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + verify(chatClient).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = eq(null), + limit = any(), + next = eq(null), + sort = eq(null), + ) + val items = viewModel.channelsState.channelItems + assertEquals(1, items.size) + assertInstanceOf(ItemState.SearchResultItemState::class.java, items.first()) + } + + @Test + fun `Given message search results with next cursor When loading more Should pass the cursor`() = + runTest { + val chatClient: ChatClient = mock() + val firstPageMessages = listOf(randomMessage(cid = "messaging:channel1")) + val firstPageResult = SearchMessagesResult(messages = firstPageMessages, next = "cursor_page2") + val secondPageMessages = listOf(randomMessage(cid = "messaging:channel1")) + val secondPageResult = SearchMessagesResult(messages = secondPageMessages, next = null) + + whenever( + chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()), + ).doReturn( + firstPageResult.asCall(), + secondPageResult.asCall(), + ) + + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenRepositorySelectChannels(listOf(channel1)) + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + viewModel.loadMore() + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, times(2)).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = captor.capture(), + sort = anyOrNull(), + ) + assertNull(captor.firstValue) + assertEquals("cursor_page2", captor.secondValue) + } + + @Test + fun `Given message search results without next cursor When loading more Should not load more`() = + runTest { + val chatClient: ChatClient = mock() + val messages = listOf(randomMessage(cid = "messaging:channel1")) + val searchResult = SearchMessagesResult(messages = messages, next = null) + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenSearchMessagesResult(searchResult) + .givenRepositorySelectChannels(listOf(channel1)) + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("hello")) + advanceUntilIdle() + + assertTrue(viewModel.channelsState.endOfChannels) + + viewModel.loadMore() + advanceUntilIdle() + + verify(chatClient, times(1)).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = anyOrNull(), + sort = anyOrNull(), + ) } private class Fixture( @@ -347,6 +471,7 @@ internal class ChannelListViewModelTest { private val clientState: ClientState = mock() private val stateRegistry: StateRegistry = mock() private val globalState: GlobalState = mock() + private val repositoryFacade: RepositoryFacade = mock() init { val statePlugin: StatePlugin = mock() @@ -357,6 +482,7 @@ internal class ChannelListViewModelTest { whenever(chatClient.channel(any())) doReturn channelClient whenever(chatClient.channel(any(), any())) doReturn channelClient whenever(chatClient.clientState) doReturn clientState + whenever(chatClient.repositoryFacade) doReturn repositoryFacade whenever(globalState.channelDraftMessages) doReturn MutableStateFlow(emptyMap()) } @@ -394,6 +520,16 @@ internal class ChannelListViewModelTest { whenever(chatClient.unmuteChannel(any(), any())) doReturn Unit.asCall() } + fun givenSearchMessagesResult(result: SearchMessagesResult) = apply { + whenever( + chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()), + ) doReturn result.asCall() + } + + suspend fun givenRepositorySelectChannels(channels: List = emptyList()) = apply { + whenever(repositoryFacade.selectChannels(any>())) doReturn channels + } + fun givenChannelsState( channelsStateData: ChannelsStateData = ChannelsStateData.Loading, channels: List? = null, diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt index 350bd4d5d1b..7136780573c 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/search/SearchViewModel.kt @@ -22,7 +22,7 @@ import androidx.lifecycle.ViewModel import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.Filters -import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.state.utils.Event import io.getstream.chat.android.ui.common.model.MessageResult import io.getstream.log.taggedLogger @@ -124,7 +124,7 @@ public class SearchViewModel( val currentState = _state.value!! val result = searchMessages( query = currentState.query, - offset = currentState.results.size, + next = currentState.next, ) when (result) { is Result.Success -> handleSearchMessageSuccess(result.value) @@ -136,12 +136,12 @@ public class SearchViewModel( /** * Notifies the UI about the search results and enables the pagination. */ - private suspend fun handleSearchMessageSuccess(messages: List) { - logger.d { "Found messages: ${messages.size}" } + private suspend fun handleSearchMessageSuccess(searchResult: SearchMessagesResult) { + logger.d { "Found messages: ${searchResult.messages.size}" } val currentState = _state.value!! - val channels = chatClient.repositoryFacade.selectChannels(messages.map { it.cid }) + val channels = chatClient.repositoryFacade.selectChannels(searchResult.messages.map { it.cid }) _state.value = currentState.copy( - results = currentState.results + messages.map { + results = currentState.results + searchResult.messages.map { MessageResult( message = it, channel = channels.firstOrNull { channel -> channel.cid == it.cid }, @@ -149,7 +149,8 @@ public class SearchViewModel( }, isLoading = false, isLoadingMore = false, - canLoadMore = messages.size == QUERY_LIMIT, + canLoadMore = !searchResult.next.isNullOrEmpty(), + next = searchResult.next, ) } @@ -161,7 +162,6 @@ public class SearchViewModel( _state.value = _state.value!!.copy( isLoading = false, isLoadingMore = false, - canLoadMore = true, ) _errorEvents.value = Event(Unit) } @@ -170,21 +170,19 @@ public class SearchViewModel( * Searches messages containing [query] text across channels where the current user is a member. * * @param query The search query. - * @param offset The pagination offset offset. + * @param next The cursor for the next page of results. */ - private suspend fun searchMessages(query: String, offset: Int): Result> { - logger.d { "Searching for \"$query\" with offset: $offset" } + private suspend fun searchMessages(query: String, next: String?): Result { + logger.d { "Searching for \"$query\" with next: $next" } val currentUser = requireNotNull(chatClient.clientState.user.value) - // TODO: use the pagination based on "limit" nad "next" params here return chatClient .searchMessages( channelFilter = Filters.`in`("members", listOf(currentUser.id)), messageFilter = Filters.autocomplete("text", query), - offset = offset, limit = QUERY_LIMIT, + next = next, ) .await() - .map { it.messages } } /** @@ -195,6 +193,7 @@ public class SearchViewModel( * @param canLoadMore If we've reached the end of messages, to stop triggering pagination. * @param isLoading If we're currently loading data (initial load). * @param isLoadingMore If we're loading more items (pagination). + * @param next The cursor for the next page of results. */ public data class State( val query: String = "", @@ -202,6 +201,7 @@ public class SearchViewModel( val results: List = emptyList(), val isLoading: Boolean = false, val isLoadingMore: Boolean = false, + val next: String? = null, ) private companion object { diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt new file mode 100644 index 00000000000..580ad271c41 --- /dev/null +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt @@ -0,0 +1,195 @@ +/* + * 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.viewmodels.search + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.persistance.repository.RepositoryFacade +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.SearchMessagesResult +import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.test.InstantTaskExecutorExtension +import io.getstream.chat.android.test.TestCoroutineExtension +import io.getstream.chat.android.test.asCall +import io.getstream.chat.android.test.observeAll +import io.getstream.chat.android.ui.viewmodel.search.SearchViewModel +import kotlinx.coroutines.flow.MutableStateFlow +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.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +internal class SearchViewModelTest { + + @Test + fun `Given empty query When setting query Should clear results`() = runTest { + val viewModel = Fixture() + .givenCurrentUser() + .givenSearchMessagesResult(SearchMessagesResult(messages = listOf(randomMessage(cid = "messaging:ch1")))) + .givenRepositorySelectChannels() + .get() + + viewModel.setQuery("hello") + viewModel.setQuery("") + + val states = viewModel.state.observeAll() + val lastState = states.last() + assertEquals(0, lastState.results.size) + assertFalse(lastState.isLoading) + assertFalse(lastState.canLoadMore) + assertNull(lastState.next) + } + + @Test + fun `Given search query When searching Should call searchMessages without offset or cursor`() = runTest { + val chatClient: ChatClient = mock() + val messages = listOf(randomMessage(cid = "messaging:ch1")) + val searchResult = SearchMessagesResult(messages = messages, next = "cursor_page2") + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenSearchMessagesResult(searchResult) + .givenRepositorySelectChannels() + .get() + + viewModel.setQuery("hello") + + verify(chatClient).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = eq(null), + limit = any(), + next = eq(null), + sort = eq(null), + ) + } + + @Test + fun `Given search results with next cursor When loading more Should pass the cursor`() = runTest { + val chatClient: ChatClient = mock() + val firstPageMessages = listOf(randomMessage(cid = "messaging:ch1")) + val firstPageResult = SearchMessagesResult(messages = firstPageMessages, next = "cursor_page2") + val secondPageMessages = listOf(randomMessage(cid = "messaging:ch1")) + val secondPageResult = SearchMessagesResult(messages = secondPageMessages, next = null) + + whenever( + chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()), + ).doReturn( + firstPageResult.asCall(), + secondPageResult.asCall(), + ) + + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenRepositorySelectChannels() + .get() + + viewModel.setQuery("hello") + viewModel.loadMore() + + val captor = argumentCaptor() + verify(chatClient, times(2)).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = captor.capture(), + sort = anyOrNull(), + ) + assertNull(captor.firstValue) + assertEquals("cursor_page2", captor.secondValue) + } + + @Test + fun `Given search results without next cursor When loading more Should not load more`() = runTest { + val chatClient: ChatClient = mock() + val messages = listOf(randomMessage(cid = "messaging:ch1")) + val searchResult = SearchMessagesResult(messages = messages, next = null) + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenSearchMessagesResult(searchResult) + .givenRepositorySelectChannels() + .get() + + viewModel.setQuery("hello") + + val states = viewModel.state.observeAll() + val lastState = states.last() + assertFalse(lastState.canLoadMore) + + viewModel.loadMore() + + verify(chatClient, times(1)).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = anyOrNull(), + sort = anyOrNull(), + ) + } + + private class Fixture( + private val chatClient: ChatClient = mock(), + ) { + private val clientState: ClientState = mock() + private val repositoryFacade: RepositoryFacade = mock() + + init { + whenever(chatClient.clientState) doReturn clientState + whenever(chatClient.repositoryFacade) doReturn repositoryFacade + } + + fun givenCurrentUser(currentUser: User = User(id = "Jc")) = apply { + whenever(clientState.user) doReturn MutableStateFlow(currentUser) + } + + fun givenSearchMessagesResult(result: SearchMessagesResult) = apply { + whenever( + chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()), + ) doReturn result.asCall() + } + + suspend fun givenRepositorySelectChannels(channels: List = emptyList()) = apply { + whenever(repositoryFacade.selectChannels(any>())) doReturn channels + } + + fun get(): SearchViewModel = SearchViewModel(chatClient = chatClient) + } + + companion object { + @JvmField + @RegisterExtension + val testCoroutines: TestCoroutineExtension = TestCoroutineExtension() + + @JvmField + @RegisterExtension + val instantExecutorExtension: InstantTaskExecutorExtension = InstantTaskExecutorExtension() + } +} From 4523874f88b8e1804da95721ce106726676231c2 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 23 Feb 2026 16:01:37 +0100 Subject: [PATCH 2/4] Fix detekt. --- .../api/stream-chat-android-ui-components.api | 10 ++++++---- .../ui/viewmodels/search/SearchViewModelTest.kt | 1 - 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index e432f6630a5..8edd9462047 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -5209,17 +5209,19 @@ public final class io/getstream/chat/android/ui/viewmodel/search/SearchViewModel public final class io/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State { public fun ()V - public fun (Ljava/lang/String;ZLjava/util/List;ZZ)V - public synthetic fun (Ljava/lang/String;ZLjava/util/List;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;ZLjava/util/List;ZZLjava/lang/String;)V + public synthetic fun (Ljava/lang/String;ZLjava/util/List;ZZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Z public final fun component3 ()Ljava/util/List; public final fun component4 ()Z public final fun component5 ()Z - public final fun copy (Ljava/lang/String;ZLjava/util/List;ZZ)Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State;Ljava/lang/String;ZLjava/util/List;ZZILjava/lang/Object;)Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State; + public final fun component6 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;ZLjava/util/List;ZZLjava/lang/String;)Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State;Ljava/lang/String;ZLjava/util/List;ZZLjava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State; public fun equals (Ljava/lang/Object;)Z public final fun getCanLoadMore ()Z + public final fun getNext ()Ljava/lang/String; public final fun getQuery ()Ljava/lang/String; public final fun getResults ()Ljava/util/List; public fun hashCode ()I diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt index 580ad271c41..2a78f15a05e 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/search/SearchViewModelTest.kt @@ -33,7 +33,6 @@ 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.assertNull -import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import org.mockito.kotlin.any From 95fa95351012c21ef1bc8e8852be25bedae1d267 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 25 Feb 2026 06:30:24 +0100 Subject: [PATCH 3/4] Add messageSearchSort option to ChannelListViewModel and ChannelViewModelFactory Co-Authored-By: Claude --- .../api/stream-chat-android-compose.api | 8 ++++---- .../compose/viewmodel/channels/ChannelListViewModel.kt | 3 +++ .../compose/viewmodel/channels/ChannelViewModelFactory.kt | 4 ++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 4c4e996a5d5..2142f3e5b26 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -4887,8 +4887,8 @@ public final class io/getstream/chat/android/compose/viewmodel/channel/ChannelIn public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel : androidx/lifecycle/ViewModel { public static final field $stable I - public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLkotlinx/coroutines/flow/Flow;)V - public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun archiveChannel (Lio/getstream/chat/android/models/Channel;)V public final fun deleteConversation (Lio/getstream/chat/android/models/Channel;)V public final fun dismissChannelAction ()V @@ -4920,8 +4920,8 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelL public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory : androidx/lifecycle/ViewModelProvider$Factory { public static final field $stable I public fun ()V - public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;Z)V - public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index fe22f7131e8..0510b4d90d0 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 @@ -89,6 +89,7 @@ import kotlin.coroutines.cancellation.CancellationException * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] used to create [ChatEventHandler]. * @param searchDebounceMs The debounce time for search queries. * @param isDraftMessageEnabled 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]. */ @OptIn(ExperimentalCoroutinesApi::class) @@ -103,6 +104,7 @@ public class ChannelListViewModel( private val chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, private val isDraftMessageEnabled: Boolean = false, + private val messageSearchSort: QuerySorter? = null, private val globalState: Flow = chatClient.globalStateFlow, ) : ViewModel() { @@ -375,6 +377,7 @@ public class ChannelListViewModel( messageFilter = Filters.autocomplete("text", currentState.query), offset = offset, limit = limit, + sort = messageSearchSort, ).await() return when (result) { is io.getstream.result.Result.Success -> { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt index fac41b7b87c..fbd2c9afd3c 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.ViewModelProvider import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler @@ -39,6 +40,7 @@ import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandl * @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 messageSearchSort Optional sorting for message search results. When `null`, the server-side default is used. */ public class ChannelViewModelFactory( private val chatClient: ChatClient = ChatClient.instance(), @@ -49,6 +51,7 @@ public class ChannelViewModelFactory( private val messageLimit: Int? = null, private val chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), private val isDraftMessageEnabled: Boolean = false, + private val messageSearchSort: QuerySorter? = null, ) : ViewModelProvider.Factory { /** @@ -68,6 +71,7 @@ public class ChannelViewModelFactory( memberLimit = memberLimit, chatEventHandlerFactory = chatEventHandlerFactory, isDraftMessageEnabled = isDraftMessageEnabled, + messageSearchSort = messageSearchSort, ) as T } } From 561e580363f8c1bd94bd639d129e23ab295ffdde Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 25 Feb 2026 06:53:50 +0100 Subject: [PATCH 4/4] Add tests for messageSearchSort in ChannelListViewModel Co-Authored-By: Claude --- .../channels/ChannelListViewModelTest.kt | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) 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 883bfec1c27..f9c54285f30 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 @@ -30,6 +30,7 @@ import io.getstream.chat.android.models.ChannelMute import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.InitializationState +import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.OrFilterObject import io.getstream.chat.android.models.SearchMessagesResult import io.getstream.chat.android.models.TypingEvent @@ -462,6 +463,70 @@ internal class ChannelListViewModelTest { ) } + @Test + fun `Given no messageSearchSort When searching messages Should pass null sort to searchMessages`() = + runTest { + val chatClient: ChatClient = mock() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + ) + .givenChannelMutes() + .givenSearchMessagesResult(SearchMessagesResult()) + .givenRepositorySelectChannels() + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("test")) + advanceUntilIdle() + + val sortCaptor = argumentCaptor>() + verify(chatClient).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = anyOrNull(), + sort = sortCaptor.capture(), + ) + assertNull(sortCaptor.firstValue) + } + + @Test + fun `Given messageSearchSort is set When searching messages Should pass the sort to searchMessages`() = + runTest { + val chatClient: ChatClient = mock() + val messageSearchSort = QuerySortByField.descByName("created_at") + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), + loading = false, + ) + .givenChannelMutes() + .givenMessageSearchSort(messageSearchSort) + .givenSearchMessagesResult(SearchMessagesResult()) + .givenRepositorySelectChannels() + .get(this) + + viewModel.setSearchQuery(SearchQuery.Messages("test")) + advanceUntilIdle() + + val sortCaptor = argumentCaptor>() + verify(chatClient).searchMessages( + channelFilter = any(), + messageFilter = any(), + offset = anyOrNull(), + limit = anyOrNull(), + next = anyOrNull(), + sort = sortCaptor.capture(), + ) + assertEquals(messageSearchSort, sortCaptor.firstValue) + } + private class Fixture( private val chatClient: ChatClient = mock(), private val channelClient: ChannelClient = mock(), @@ -472,6 +537,7 @@ internal class ChannelListViewModelTest { private val stateRegistry: StateRegistry = mock() private val globalState: GlobalState = mock() private val repositoryFacade: RepositoryFacade = mock() + private var messageSearchSort: QuerySorter? = null init { val statePlugin: StatePlugin = mock() @@ -520,6 +586,10 @@ internal class ChannelListViewModelTest { whenever(chatClient.unmuteChannel(any(), any())) doReturn Unit.asCall() } + fun givenMessageSearchSort(sort: QuerySorter?) = apply { + messageSearchSort = sort + } + fun givenSearchMessagesResult(result: SearchMessagesResult) = apply { whenever( chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()), @@ -556,6 +626,7 @@ internal class ChannelListViewModelTest { initialFilters = initialFilters, isDraftMessageEnabled = false, chatEventHandlerFactory = ChatEventHandlerFactory(clientState), + messageSearchSort = messageSearchSort, globalState = MutableStateFlow(globalState), ) testScope.advanceUntilIdle()