Skip to content

Commit e8e97bf

Browse files
VelikovPetarclaude
andauthored
Use cursor-based pagination for search messages (#6179)
* Use cursor-based pagination for search messages Co-Authored-By: Claude <noreply@anthropic.com> * Fix detekt. * Make next internal --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3a3ce49 commit e8e97bf

5 files changed

Lines changed: 380 additions & 48 deletions

File tree

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -367,14 +367,14 @@ public class ChannelListViewModel(
367367
currentState: SearchMessageState,
368368
channelFilter: FilterObject,
369369
): SearchMessageState {
370-
val offset = currentState.messages.size
371370
val limit = channelLimit
372-
logger.v { "[searchMessages] #$src; query: '${currentState.query}', offset: $offset, limit: $limit" }
371+
val next = currentState.next
372+
logger.v { "[searchMessages] #$src; query: '${currentState.query}', next: $next, limit: $limit" }
373373
val result = chatClient.searchMessages(
374374
channelFilter = channelFilter,
375375
messageFilter = Filters.autocomplete("text", currentState.query),
376-
offset = offset,
377376
limit = limit,
377+
next = next,
378378
).await()
379379
return when (result) {
380380
is io.getstream.result.Result.Success -> {
@@ -383,15 +383,15 @@ public class ChannelListViewModel(
383383
messages = currentState.messages + result.value.messages,
384384
isLoading = false,
385385
isLoadingMore = false,
386-
canLoadMore = result.value.messages.size >= limit,
386+
canLoadMore = !result.value.next.isNullOrEmpty(),
387+
next = result.value.next,
387388
)
388389
}
389390
is io.getstream.result.Result.Failure -> {
390391
logger.e { "[searchMessages] #$src; failed: ${result.value}" }
391392
currentState.copy(
392393
isLoading = false,
393394
isLoadingMore = false,
394-
canLoadMore = true,
395395
)
396396
}
397397
}
@@ -773,6 +773,7 @@ public class ChannelListViewModel(
773773
private data class SearchMessageState(
774774
val query: String = "",
775775
val canLoadMore: Boolean = true,
776+
val next: String? = null,
776777
val messages: List<Message> = emptyList(),
777778
val isLoading: Boolean = false,
778779
val isLoadingMore: Boolean = false,
@@ -784,7 +785,8 @@ public class ChannelListViewModel(
784785
"messages.size=${messages.size}, " +
785786
"isLoading=$isLoading, " +
786787
"isLoadingMore=$isLoadingMore, " +
787-
"canLoadMore=$canLoadMore)"
788+
"canLoadMore=$canLoadMore, " +
789+
"next=$next)"
788790
}
789791
}
790792
}

stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt

Lines changed: 160 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package io.getstream.chat.android.compose.viewmodel.channels
1919
import io.getstream.chat.android.client.ChatClient
2020
import io.getstream.chat.android.client.api.models.QueryChannelsRequest
2121
import io.getstream.chat.android.client.channel.ChannelClient
22+
import io.getstream.chat.android.client.persistance.repository.RepositoryFacade
2223
import io.getstream.chat.android.client.setup.state.ClientState
2324
import io.getstream.chat.android.compose.state.channels.list.ItemState
2425
import io.getstream.chat.android.compose.state.channels.list.SearchQuery
@@ -30,10 +31,12 @@ import io.getstream.chat.android.models.FilterObject
3031
import io.getstream.chat.android.models.Filters
3132
import io.getstream.chat.android.models.InitializationState
3233
import io.getstream.chat.android.models.OrFilterObject
34+
import io.getstream.chat.android.models.SearchMessagesResult
3335
import io.getstream.chat.android.models.TypingEvent
3436
import io.getstream.chat.android.models.User
3537
import io.getstream.chat.android.models.querysort.QuerySortByField
3638
import io.getstream.chat.android.models.querysort.QuerySorter
39+
import io.getstream.chat.android.randomMessage
3740
import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory
3841
import io.getstream.chat.android.state.plugin.internal.StatePlugin
3942
import io.getstream.chat.android.state.plugin.state.StateRegistry
@@ -48,10 +51,15 @@ import kotlinx.coroutines.flow.MutableStateFlow
4851
import kotlinx.coroutines.test.TestScope
4952
import kotlinx.coroutines.test.advanceUntilIdle
5053
import kotlinx.coroutines.test.runTest
51-
import org.amshove.kluent.`should be equal to`
54+
import org.junit.jupiter.api.Assertions.assertEquals
55+
import org.junit.jupiter.api.Assertions.assertFalse
56+
import org.junit.jupiter.api.Assertions.assertInstanceOf
57+
import org.junit.jupiter.api.Assertions.assertNull
58+
import org.junit.jupiter.api.Assertions.assertTrue
5259
import org.junit.jupiter.api.Test
5360
import org.junit.jupiter.api.extension.ExtendWith
5461
import org.mockito.kotlin.any
62+
import org.mockito.kotlin.anyOrNull
5563
import org.mockito.kotlin.argumentCaptor
5664
import org.mockito.kotlin.doReturn
5765
import org.mockito.kotlin.eq
@@ -76,8 +84,8 @@ internal class ChannelListViewModelTest {
7684
.get(this)
7785

7886
val channelsState = viewModel.channelsState
79-
channelsState.channelItems.size `should be equal to` 0
80-
channelsState.isLoading `should be equal to` true
87+
assertEquals(0, channelsState.channelItems.size)
88+
assertTrue(channelsState.isLoading)
8189
}
8290

8391
@Test
@@ -95,8 +103,8 @@ internal class ChannelListViewModelTest {
95103
.get(this)
96104

97105
val channelsState = viewModel.channelsState
98-
channelsState.channelItems.size `should be equal to` 2
99-
channelsState.isLoading `should be equal to` false
106+
assertEquals(2, channelsState.channelItems.size)
107+
assertFalse(channelsState.isLoading)
100108
}
101109

102110
@Test
@@ -118,8 +126,8 @@ internal class ChannelListViewModelTest {
118126
viewModel.performChannelAction(DeleteConversation(channel1))
119127
viewModel.deleteConversation(channel1)
120128

121-
viewModel.activeChannelAction `should be equal to` null
122-
viewModel.selectedChannel.value `should be equal to` null
129+
assertNull(viewModel.activeChannelAction)
130+
assertNull(viewModel.selectedChannel.value)
123131
verify(channelClient).delete()
124132
}
125133

@@ -140,8 +148,8 @@ internal class ChannelListViewModelTest {
140148
viewModel.selectChannel(channel1)
141149
viewModel.muteChannel(channel1)
142150

143-
viewModel.activeChannelAction `should be equal to` null
144-
viewModel.selectedChannel.value `should be equal to` null
151+
assertNull(viewModel.activeChannelAction)
152+
assertNull(viewModel.selectedChannel.value)
145153
verify(chatClient).muteChannel("messaging", "channel1", null)
146154
}
147155

@@ -171,9 +179,9 @@ internal class ChannelListViewModelTest {
171179
viewModel.selectChannel(channel1)
172180
viewModel.unmuteChannel(channel1)
173181

174-
(viewModel.channelsState.channelItems.first() as ItemState.ChannelItemState).isMuted `should be equal to` true
175-
viewModel.activeChannelAction `should be equal to` null
176-
viewModel.selectedChannel.value `should be equal to` null
182+
assertTrue((viewModel.channelsState.channelItems.first() as ItemState.ChannelItemState).isMuted)
183+
assertNull(viewModel.activeChannelAction)
184+
assertNull(viewModel.selectedChannel.value)
177185
verify(chatClient).unmuteChannel("messaging", "channel1")
178186
}
179187

@@ -193,8 +201,8 @@ internal class ChannelListViewModelTest {
193201
viewModel.selectChannel(channel1)
194202
viewModel.dismissChannelAction()
195203

196-
viewModel.activeChannelAction `should be equal to` null
197-
viewModel.selectedChannel.value `should be equal to` null
204+
assertNull(viewModel.activeChannelAction)
205+
assertNull(viewModel.selectedChannel.value)
198206
}
199207

200208
@Test
@@ -223,8 +231,8 @@ internal class ChannelListViewModelTest {
223231

224232
val captor = argumentCaptor<QueryChannelsRequest>()
225233
verify(chatClient, times(2)).queryChannels(captor.capture())
226-
captor.firstValue.offset `should be equal to` 0
227-
captor.secondValue.offset `should be equal to` 30
234+
assertEquals(0, captor.firstValue.offset)
235+
assertEquals(30, captor.secondValue.offset)
228236
}
229237

230238
@Test
@@ -246,7 +254,7 @@ internal class ChannelListViewModelTest {
246254

247255
val captor = argumentCaptor<QueryChannelsRequest>()
248256
verify(chatClient, times(1)).queryChannels(captor.capture())
249-
captor.firstValue.offset `should be equal to` 0
257+
assertEquals(0, captor.firstValue.offset)
250258
}
251259

252260
@Test
@@ -271,8 +279,8 @@ internal class ChannelListViewModelTest {
271279
val andFilterObject = captor.secondValue.filter as AndFilterObject
272280
val orFilterObject = andFilterObject.filterObjects.last() as OrFilterObject
273281
val autoCompleteFilterObject = orFilterObject.filterObjects.last() as AutocompleteFilterObject
274-
autoCompleteFilterObject.fieldName `should be equal to` "name"
275-
autoCompleteFilterObject.value `should be equal to` "Search query"
282+
assertEquals("name", autoCompleteFilterObject.fieldName)
283+
assertEquals("Search query", autoCompleteFilterObject.value)
276284
}
277285

278286
@Test
@@ -301,8 +309,8 @@ internal class ChannelListViewModelTest {
301309
val andFilterObject = captor.secondValue.filter as AndFilterObject
302310
val orFilterObject = andFilterObject.filterObjects.last() as OrFilterObject
303311
val autoCompleteFilterObject = orFilterObject.filterObjects.last() as AutocompleteFilterObject
304-
autoCompleteFilterObject.fieldName `should be equal to` "name"
305-
autoCompleteFilterObject.value `should be equal to` "Search query"
312+
assertEquals("name", autoCompleteFilterObject.fieldName)
313+
assertEquals("Search query", autoCompleteFilterObject.value)
306314
}
307315

308316
@Test
@@ -333,9 +341,125 @@ internal class ChannelListViewModelTest {
333341

334342
val captor = argumentCaptor<QueryChannelsRequest>()
335343
verify(chatClient, times(2)).queryChannels(captor.capture())
336-
captor.allValues.size `should be equal to` 2
337-
captor.firstValue.offset `should be equal to` 0
338-
captor.secondValue.offset `should be equal to` 30
344+
assertEquals(2, captor.allValues.size)
345+
assertEquals(0, captor.firstValue.offset)
346+
assertEquals(30, captor.secondValue.offset)
347+
}
348+
349+
@Test
350+
fun `Given channel list When setting message search query Should search messages without offset or cursor`() =
351+
runTest {
352+
val chatClient: ChatClient = mock()
353+
val messages = listOf(randomMessage(cid = "messaging:channel1"))
354+
val searchResult = SearchMessagesResult(messages = messages, next = "cursor_page2")
355+
val viewModel = Fixture(chatClient)
356+
.givenCurrentUser()
357+
.givenChannelsQuery()
358+
.givenChannelsState(
359+
channelsStateData = ChannelsStateData.Result(listOf(channel1)),
360+
loading = false,
361+
)
362+
.givenChannelMutes()
363+
.givenSearchMessagesResult(searchResult)
364+
.givenRepositorySelectChannels(listOf(channel1))
365+
.get(this)
366+
367+
viewModel.setSearchQuery(SearchQuery.Messages("hello"))
368+
advanceUntilIdle()
369+
370+
verify(chatClient).searchMessages(
371+
channelFilter = any(),
372+
messageFilter = any(),
373+
offset = eq(null),
374+
limit = any(),
375+
next = eq(null),
376+
sort = eq(null),
377+
)
378+
val items = viewModel.channelsState.channelItems
379+
assertEquals(1, items.size)
380+
assertInstanceOf(ItemState.SearchResultItemState::class.java, items.first())
381+
}
382+
383+
@Test
384+
fun `Given message search results with next cursor When loading more Should pass the cursor`() =
385+
runTest {
386+
val chatClient: ChatClient = mock()
387+
val firstPageMessages = listOf(randomMessage(cid = "messaging:channel1"))
388+
val firstPageResult = SearchMessagesResult(messages = firstPageMessages, next = "cursor_page2")
389+
val secondPageMessages = listOf(randomMessage(cid = "messaging:channel1"))
390+
val secondPageResult = SearchMessagesResult(messages = secondPageMessages, next = null)
391+
392+
whenever(
393+
chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()),
394+
).doReturn(
395+
firstPageResult.asCall(),
396+
secondPageResult.asCall(),
397+
)
398+
399+
val viewModel = Fixture(chatClient)
400+
.givenCurrentUser()
401+
.givenChannelsQuery()
402+
.givenChannelsState(
403+
channelsStateData = ChannelsStateData.Result(listOf(channel1)),
404+
loading = false,
405+
)
406+
.givenChannelMutes()
407+
.givenRepositorySelectChannels(listOf(channel1))
408+
.get(this)
409+
410+
viewModel.setSearchQuery(SearchQuery.Messages("hello"))
411+
advanceUntilIdle()
412+
413+
viewModel.loadMore()
414+
advanceUntilIdle()
415+
416+
val captor = argumentCaptor<String>()
417+
verify(chatClient, times(2)).searchMessages(
418+
channelFilter = any(),
419+
messageFilter = any(),
420+
offset = anyOrNull(),
421+
limit = anyOrNull(),
422+
next = captor.capture(),
423+
sort = anyOrNull(),
424+
)
425+
assertNull(captor.firstValue)
426+
assertEquals("cursor_page2", captor.secondValue)
427+
}
428+
429+
@Test
430+
fun `Given message search results without next cursor When loading more Should not load more`() =
431+
runTest {
432+
val chatClient: ChatClient = mock()
433+
val messages = listOf(randomMessage(cid = "messaging:channel1"))
434+
val searchResult = SearchMessagesResult(messages = messages, next = null)
435+
val viewModel = Fixture(chatClient)
436+
.givenCurrentUser()
437+
.givenChannelsQuery()
438+
.givenChannelsState(
439+
channelsStateData = ChannelsStateData.Result(listOf(channel1)),
440+
loading = false,
441+
)
442+
.givenChannelMutes()
443+
.givenSearchMessagesResult(searchResult)
444+
.givenRepositorySelectChannels(listOf(channel1))
445+
.get(this)
446+
447+
viewModel.setSearchQuery(SearchQuery.Messages("hello"))
448+
advanceUntilIdle()
449+
450+
assertTrue(viewModel.channelsState.endOfChannels)
451+
452+
viewModel.loadMore()
453+
advanceUntilIdle()
454+
455+
verify(chatClient, times(1)).searchMessages(
456+
channelFilter = any(),
457+
messageFilter = any(),
458+
offset = anyOrNull(),
459+
limit = anyOrNull(),
460+
next = anyOrNull(),
461+
sort = anyOrNull(),
462+
)
339463
}
340464

341465
private class Fixture(
@@ -347,6 +471,7 @@ internal class ChannelListViewModelTest {
347471
private val clientState: ClientState = mock()
348472
private val stateRegistry: StateRegistry = mock()
349473
private val globalState: GlobalState = mock()
474+
private val repositoryFacade: RepositoryFacade = mock()
350475

351476
init {
352477
val statePlugin: StatePlugin = mock()
@@ -357,6 +482,7 @@ internal class ChannelListViewModelTest {
357482
whenever(chatClient.channel(any())) doReturn channelClient
358483
whenever(chatClient.channel(any(), any())) doReturn channelClient
359484
whenever(chatClient.clientState) doReturn clientState
485+
whenever(chatClient.repositoryFacade) doReturn repositoryFacade
360486
whenever(globalState.channelDraftMessages) doReturn MutableStateFlow(emptyMap())
361487
}
362488

@@ -394,6 +520,16 @@ internal class ChannelListViewModelTest {
394520
whenever(chatClient.unmuteChannel(any(), any())) doReturn Unit.asCall()
395521
}
396522

523+
fun givenSearchMessagesResult(result: SearchMessagesResult) = apply {
524+
whenever(
525+
chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()),
526+
) doReturn result.asCall()
527+
}
528+
529+
suspend fun givenRepositorySelectChannels(channels: List<Channel> = emptyList()) = apply {
530+
whenever(repositoryFacade.selectChannels(any<List<String>>())) doReturn channels
531+
}
532+
397533
fun givenChannelsState(
398534
channelsStateData: ChannelsStateData = ChannelsStateData.Loading,
399535
channels: List<Channel>? = null,

stream-chat-android-ui-components/api/stream-chat-android-ui-components.api

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5209,15 +5209,15 @@ public final class io/getstream/chat/android/ui/viewmodel/search/SearchViewModel
52095209

52105210
public final class io/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State {
52115211
public fun <init> ()V
5212-
public fun <init> (Ljava/lang/String;ZLjava/util/List;ZZ)V
5213-
public synthetic fun <init> (Ljava/lang/String;ZLjava/util/List;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
5212+
public fun <init> (Ljava/lang/String;ZLjava/util/List;ZZLjava/lang/String;)V
5213+
public synthetic fun <init> (Ljava/lang/String;ZLjava/util/List;ZZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
52145214
public final fun component1 ()Ljava/lang/String;
52155215
public final fun component2 ()Z
52165216
public final fun component3 ()Ljava/util/List;
52175217
public final fun component4 ()Z
52185218
public final fun component5 ()Z
5219-
public final fun copy (Ljava/lang/String;ZLjava/util/List;ZZ)Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State;
5220-
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;
5219+
public final fun copy (Ljava/lang/String;ZLjava/util/List;ZZLjava/lang/String;)Lio/getstream/chat/android/ui/viewmodel/search/SearchViewModel$State;
5220+
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;
52215221
public fun equals (Ljava/lang/Object;)Z
52225222
public final fun getCanLoadMore ()Z
52235223
public final fun getQuery ()Ljava/lang/String;

0 commit comments

Comments
 (0)