From 58ba6b5f5c61f473c28ebbe1d2c4dc4b81bd2028 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 23 Oct 2026 02:41:02 +0200 Subject: [PATCH] feat: paginator refactor compatibility --- ai-docs/ai-migration-v9-to-v10.md | 13 ++++--- ai-docs/v10-breaking-changes.md | 5 +-- examples/ExpoChatGPTSample/package.json | 2 +- examples/ExpoMessaging/package.json | 2 +- examples/SampleApp/package.json | 2 +- package/package.json | 2 +- .../offline-support/offline-feature.tsx | 11 ++++-- .../offline-support/optimistic-update.tsx | 36 +++++++++-------- .../Channel/__tests__/Channel.test.tsx | 6 +-- .../__tests__/ChannelList.test.tsx | 4 +- .../hooks/useChannelPreviewData.ts | 2 +- .../MessageList/MessageFlashList.tsx | 2 +- .../components/MessageList/MessageList.tsx | 2 +- .../__tests__/MessageList.test.tsx | 2 + .../MessageList/hooks/useMarkRead.ts | 6 +-- .../components/ThreadList/ThreadListItem.tsx | 2 +- .../selectMessagesForChannels.test.ts | 39 +++++++++++++++++++ .../apis/queries/selectMessagesForChannels.ts | 2 +- yarn.lock | 16 ++++---- 19 files changed, 101 insertions(+), 55 deletions(-) create mode 100644 package/src/store/__tests__/selectMessagesForChannels.test.ts diff --git a/ai-docs/ai-migration-v9-to-v10.md b/ai-docs/ai-migration-v9-to-v10.md index 87e1cdda33..5450049742 100644 --- a/ai-docs/ai-migration-v9-to-v10.md +++ b/ai-docs/ai-migration-v9-to-v10.md @@ -263,7 +263,7 @@ means changed. Details in the linked section. | `useMessageContext().members` | `useStateStore(channel.state, (s) => ({ members: s.members }))` | §4 | | `` | the `ChannelPaginator` owns paging: `requestOptions` takes neither; page size is `paginatorOptions.pageSize` | §R.6 | | `` | `new ChannelPaginator({ client, filters: { 'custom.myField': x } })` | §R.6 | -| `channel.state.messages` / `channel.lastMessage()` | `channel.messagePaginator.state` (`items`) / `channel.messagePaginator.lastMessage` | §R.7 | +| `channel.state.messages` / `channel.lastMessage()` | `channel.messagePaginator.state` (`items`) / `channel.messagePaginator.latestMessage` | §R.7 | | `client.activeChannels[cid]` | `client.channelManager.get(cid)` | §R.7 | | `client.threads.state` `threads` / `client.threads.loadNextPage()` | `client.threads.paginator.state` `items` / `client.threads.paginator.toTail()` | §R.7 | | `client.addDevice(token, provider)` / `getDevices()` / `removeDevice(id)` | `client.createDevice({ id: token, push_provider })` / `listDevices()` / `deleteDevice({ id })` | §R.8 | @@ -3138,8 +3138,8 @@ v9's `` props. v9 → v10: - `channel.state.messages` → `useStateStore(channel.messagePaginator.state, (s) => ({ items: s.items }))`. -- `channel.state.latestMessages` / `channel.lastMessage()` → `channel.messagePaginator.lastMessage` (reactive through - `messagePaginator.aggregateState`). +- `channel.state.latestMessages` / `channel.lastMessage()` → `channel.messagePaginator.latestMessage` (reactive through + `messagePaginator.aggregateState`; `null` when there is none). - `channel.state.threads[id]` → `client.threads.get(id)?.messagePaginator`. - `channel.state.findMessage(id)` → `channel.messagePaginator.getItem(id)`, or `client.messageStore` for any loaded message. @@ -3163,9 +3163,10 @@ v9 → v10: Two request behaviours: -- **`channel.query(…)` no longer updates the message list.** It returns the page; only the latest-page paths - (`watch()`, `create()`) seed the list. Page with `channel.messagePaginator.toTail()` / `toHead()` instead of - `channel.query({ messages: { id_lt } })`. +- **`channel.query(…)` no longer updates the message list.** It returns the page; only `watch()` places the page it + fetches (`create()` places no messages). Page with `channel.messagePaginator.toTail()` / `toHead()` instead of + `channel.query({ messages: { id_lt } })`. `watch()` and the paginator's own page requests do not go through + `channel.query()`, so a spy on or an override of it does not see them; listen to `channels.queried` instead. - **A request with `watch` or `presence` waits for a WebSocket connection id** — `channel.watch()`, `queryChannels*`, `queryUsers({ payload: { presence } })`. v9 silently downgraded it to a non-watching request. With no socket open or opening it throws `No connection id is available…`, so call `connectUser()` (or `openConnection()` after a diff --git a/ai-docs/v10-breaking-changes.md b/ai-docs/v10-breaking-changes.md index 29ac719037..da23cdf479 100644 --- a/ai-docs/v10-breaking-changes.md +++ b/ai-docs/v10-breaking-changes.md @@ -7,8 +7,7 @@ update. Each entry says what changed and how to migrate in one line, and links t - **`§…`** → [`ai-migration-v9-to-v10.md`](./ai-migration-v9-to-v10.md), the agent migration ledger (Before/After code, detection greps). `§R.x` sections were added by the audit that produced this file. - **i18n** → [`i18n-v10-migration.md`](./i18n-v10-migration.md) and [`i18n-v10-key-map.json`](./i18n-v10-key-map.json). -- **LLC** → `stream-chat` v10's own list: `stream-chat-js` `ai-docs/v10-breaking-changes.md` (branch - `claude/sdk-v10-breaking-changes-f64baf`) and the `v9-to-v10-migration-guide-*.md` files on `release-v10`. +- **LLC** → `stream-chat` v10's own list: `stream-chat-js` `ai-docs/v10-breaking-changes.md` on `release-v10`. - **#NNNN** → the `GetStream/stream-chat-react-native` pull request that made the change. Docs impact (which getstream.io pages change) is in [`v10-docs-impact.md`](./v10-docs-impact.md). @@ -471,7 +470,7 @@ apps' own code. - Message storage moved off `channel.state` (`messages`, `latestMessages`, `threads`, `pinnedMessages`, `isUpToDate`, `findMessage`, `addMessageSorted`, `loadMessageIntoState`, …) → `channel.messagePaginator`, `channel.pinnedMessagesPaginator`, `client.messageStore`, `client.threads.get(id)`. -- `channel.lastMessage()` → `channel.messagePaginator.lastMessage`. +- `channel.lastMessage()` → `channel.messagePaginator.latestMessage ?? undefined`. - `channel.state.*Store` handles (pre-release) → `channel.state`; `mutedUsersStore` → `client.mutedUsersStore`. — §K.1 · §K.2 - `channel.disconnected` → `channel.pendingDisposal`; `` keeps rendering a deleted channel. — §K.8 · P.15 diff --git a/examples/ExpoChatGPTSample/package.json b/examples/ExpoChatGPTSample/package.json index 8dafb643f1..8132cda399 100644 --- a/examples/ExpoChatGPTSample/package.json +++ b/examples/ExpoChatGPTSample/package.json @@ -28,7 +28,7 @@ "react-native-svg": "15.15.5", "react-native-teleport": "^1.2.2", "react-native-worklets": "0.13.0", - "stream-chat": "^10.0.0-rc.23", + "stream-chat": "^10.0.0-rc.24", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^", "victory-native": "^42.0.1" diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index a43c62b637..a547b04188 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -51,7 +51,7 @@ "react-native-teleport": "^1.2.2", "react-native-web": "~0.21.4", "react-native-worklets": "0.13.0", - "stream-chat": "^10.0.0-rc.23", + "stream-chat": "^10.0.0-rc.24", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 9e973b015e..243f7647e6 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -65,7 +65,7 @@ "react-native-teleport": "^1.2.2", "react-native-video": "^6.19.3", "react-native-worklets": "^0.13.0", - "stream-chat": "^10.0.0-rc.23", + "stream-chat": "^10.0.0-rc.24", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/package/package.json b/package/package.json index 7404fa98cb..9547eafb27 100644 --- a/package/package.json +++ b/package/package.json @@ -85,7 +85,7 @@ "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", "react-syntax-highlighter": "^16.1.0", - "stream-chat": "^10.0.0-rc.23", + "stream-chat": "^10.0.0-rc.24", "use-sync-external-store": "^1.7.0", "zod": "^4.1.12" }, diff --git a/package/src/__tests__/offline-support/offline-feature.tsx b/package/src/__tests__/offline-support/offline-feature.tsx index a7e370aa75..aa83f1a3ea 100644 --- a/package/src/__tests__/offline-support/offline-feature.tsx +++ b/package/src/__tests__/offline-support/offline-feature.tsx @@ -83,7 +83,7 @@ import { BetterSqlite } from '../../test-utils/BetterSqlite'; const ChannelPreviewComponent = ({ channel }: { channel: ChannelLLC }) => ( {channel.data?.custom?.name} - {channel.messagePaginator?.headItems?.[0]?.text} + {channel.messagePaginator?.anchoredHeadItems?.[0]?.text} ); @@ -796,7 +796,8 @@ export const Generic = () => { expect(matchingRows.length).toBe(1); expect(matchingRows[0].hidden).toBeTruthy(); expect(matchingMessagesRows.length).toBe( - chatClient.channelManager.get(hiddenChannel.cid)?.messagePaginator.headItems.length, + chatClient.channelManager.get(hiddenChannel.cid)?.messagePaginator.anchoredHeadItems + .length, ); }); }); @@ -831,7 +832,8 @@ export const Generic = () => { expect(matchingRows.length).toBe(1); expect(matchingRows[0].hidden).toBeTruthy(); expect(matchingMessagesRows.length).toBe( - chatClient.channelManager.get(hiddenChannel.cid)?.messagePaginator.headItems.length, + chatClient.channelManager.get(hiddenChannel.cid)?.messagePaginator.anchoredHeadItems + .length, ); }); @@ -858,7 +860,8 @@ export const Generic = () => { expect(matchingRows.length).toBe(1); expect(matchingRows[0].hidden).toBeFalsy(); expect(matchingMessagesRows.length).toBe( - chatClient.channelManager.get(hiddenChannel.cid)?.messagePaginator.headItems.length, + chatClient.channelManager.get(hiddenChannel.cid)?.messagePaginator.anchoredHeadItems + .length, ); }); }); diff --git a/package/src/__tests__/offline-support/optimistic-update.tsx b/package/src/__tests__/offline-support/optimistic-update.tsx index 067059267d..eddcf7a322 100644 --- a/package/src/__tests__/offline-support/optimistic-update.tsx +++ b/package/src/__tests__/offline-support/optimistic-update.tsx @@ -380,7 +380,7 @@ export const OptimisticUpdates = () => { // is intentionally NOT queued. Force the offline case. See the delete-message variant for detail. it('pending task should exist if sendReaction request fails', async () => { const reaction = generateReaction(); - const targetMessage = channel.messagePaginator.headItems[0]; + const targetMessage = channel.messagePaginator.anchoredHeadItems[0]; render( @@ -415,7 +415,7 @@ export const OptimisticUpdates = () => { it('pending task should be cleared if sendReaction request is successful', async () => { const reaction = generateReaction(); - const targetMessage = channel.messagePaginator.headItems[0]; + const targetMessage = channel.messagePaginator.anchoredHeadItems[0]; render( @@ -516,7 +516,7 @@ export const OptimisticUpdates = () => { // is intentionally NOT queued. Force the offline case. See the delete-message variant for detail. it('pending task should exist if deleteReaction request fails', async () => { const reaction = generateReaction(); - const targetMessage = channel.messagePaginator.headItems[0]; + const targetMessage = channel.messagePaginator.anchoredHeadItems[0]; render( @@ -549,7 +549,7 @@ export const OptimisticUpdates = () => { it('pending task should be cleared if deleteReaction request is successful', async () => { const reaction = generateReaction(); - const targetMessage = channel.messagePaginator.headItems[0]; + const targetMessage = channel.messagePaginator.anchoredHeadItems[0]; render( @@ -576,7 +576,7 @@ export const OptimisticUpdates = () => { describe('edit message', () => { it('should keep the optimistic edit in state and DB if the LLC queues the edit', async () => { - const message = channel.messagePaginator.headItems[0]; + const message = channel.messagePaginator.anchoredHeadItems[0]; const editedText = 'edited while offline'; // Registered declaratively — the `` prop is gone. The LLC @@ -666,7 +666,7 @@ export const OptimisticUpdates = () => { }); it('should keep the optimistic edit if the request fails', async () => { - const message = channel.messagePaginator.headItems[0]; + const message = channel.messagePaginator.anchoredHeadItems[0]; const editedText = 'should stay optimistic'; // Registered declaratively — the `` prop is gone. The LLC @@ -738,7 +738,7 @@ export const OptimisticUpdates = () => { // failed message carries no message_text_updated_at. generateMessage always sets one, so strip // it here; the test then verifies the optimistic edit path does not add one back. const { message_text_updated_at: _stripped, ...headItem } = channel.messagePaginator - .headItems[0] as LocalMessage & { message_text_updated_at?: Date }; + .anchoredHeadItems[0] as LocalMessage & { message_text_updated_at?: Date }; const message = headItem as LocalMessage; const optimisticStateSpy = jest.fn(); @@ -795,7 +795,7 @@ export const OptimisticUpdates = () => { }); it('should keep the optimistic edit for attachment updates without auto-queueing', async () => { - const message = channel.messagePaginator.headItems[0]; + const message = channel.messagePaginator.anchoredHeadItems[0]; const editedText = 'edited attachment message'; const localUri = 'file://edited-attachment.png'; const editedAttachments = [ @@ -899,7 +899,7 @@ export const OptimisticUpdates = () => { // failing until the default optimistic offline update/delete lands — the red is the signal. describe('default offline update/delete (no custom handler)', () => { it('should optimistically apply + persist an offline edit without a custom doUpdateMessageRequest', async () => { - const message = channel.messagePaginator.headItems[0]; + const message = channel.messagePaginator.anchoredHeadItems[0]; const editedText = 'edited offline via default path'; render( @@ -953,7 +953,7 @@ export const OptimisticUpdates = () => { }); it('should optimistically apply + persist an offline delete without a custom doDeleteMessageRequest', async () => { - const message = channel.messagePaginator.headItems[0]; + const message = channel.messagePaginator.anchoredHeadItems[0]; render( @@ -1121,7 +1121,7 @@ export const OptimisticUpdates = () => { describe('optimistic edit without offline support', () => { it('keeps the optimistic edit AND surfaces the failure when there is no offline DB', async () => { - const message = channel.messagePaginator.headItems[0]; + const message = channel.messagePaginator.anchoredHeadItems[0]; const editedText = 'edited with no offline support'; chatClient.config.set({ @@ -1180,7 +1180,7 @@ export const OptimisticUpdates = () => { jest.setTimeout(20_000); it('pending task should be executed after connection is recovered', async () => { - const message = channel.messagePaginator.headItems[0]; + const message = channel.messagePaginator.anchoredHeadItems[0]; const reaction = generateReaction(); render( @@ -1291,7 +1291,7 @@ export const OptimisticUpdates = () => { it('should not re-add a failed local message after reconnect when its pending send task was resolved', async () => { const localMessage = generateMessage({ // The channel paginator only ingests messages whose cid matches (matchesFilter), so the - // optimistic message must carry the channel cid to appear in the paginator/headItems. + // optimistic message must carry the channel cid to appear in the paginator/anchoredHeadItems. cid: channel.cid, status: MessageStatusTypes.SENDING, text: 'offline resend', @@ -1344,7 +1344,9 @@ export const OptimisticUpdates = () => { }); expect( - channel.messagePaginator.headItems.some((message) => message.id === localMessage.id), + channel.messagePaginator.anchoredHeadItems.some( + (message) => message.id === localMessage.id, + ), ).toBe(true); jest @@ -1380,7 +1382,7 @@ export const OptimisticUpdates = () => { }); await waitFor(() => { - const matchingMessages = channel.messagePaginator.headItems.filter( + const matchingMessages = channel.messagePaginator.anchoredHeadItems.filter( (message) => message.text === localMessage.text, ); @@ -1399,7 +1401,7 @@ export const OptimisticUpdates = () => { it('should re-add a failed local message after reconnect when fresh state still does not contain it', async () => { const localMessage = generateMessage({ // The channel paginator only ingests messages whose cid matches (matchesFilter), so the - // optimistic message must carry the channel cid to appear in the paginator/headItems. + // optimistic message must carry the channel cid to appear in the paginator/anchoredHeadItems. cid: channel.cid, status: MessageStatusTypes.SENDING, text: 'offline resend unresolved', @@ -1476,7 +1478,7 @@ export const OptimisticUpdates = () => { }); await waitFor(() => { - const matchingMessages = channel.messagePaginator.headItems.filter( + const matchingMessages = channel.messagePaginator.anchoredHeadItems.filter( (message) => message.id === localMessage.id, ); diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index d3e95cd9bb..c72485f758 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -236,8 +236,8 @@ describe('Channel', () => { }), ); - it('should call the channel query method to load more messages', async () => { - const channelQuerySpy = jest.spyOn(channel, 'query'); + it('should request the channel state to load more messages', async () => { + const channelQuerySpy = jest.spyOn(channel, 'requestChannelState'); const newMessages = [generateMessage()]; @@ -1008,7 +1008,7 @@ describe('Channel initial load useEffect', () => { // the 10 originals + 10 failed remain. await waitFor(() => { expect(reloadSpy).toHaveBeenCalled(); - expect(channel.messagePaginator.headItems.length).toBe(20); + expect(channel.messagePaginator.anchoredHeadItems.length).toBe(20); }); }); diff --git a/package/src/components/ChannelList/__tests__/ChannelList.test.tsx b/package/src/components/ChannelList/__tests__/ChannelList.test.tsx index 38c22f9a17..af96a43ba6 100644 --- a/package/src/components/ChannelList/__tests__/ChannelList.test.tsx +++ b/package/src/components/ChannelList/__tests__/ChannelList.test.tsx @@ -62,7 +62,7 @@ const ChannelPreviewComponent = ({ channel }: { channel: ChannelType }) => { return ( {name} - {channel.messagePaginator.headItems[0]?.text} + {channel.messagePaginator.latestMessage?.text} ); }; @@ -513,7 +513,7 @@ describe('ChannelList', () => { describe('message.new', () => { // The message must carry the target channel's `cid`: v10's `MessageIntervalPaginator` filters // ingested messages by `cid` (`buildMatchFilters`), so a message without one is dropped and never - // reaches the channel's `messagePaginator.headItems` that the preview reads. + // reaches the channel's `messagePaginator.anchoredHeadItems` that the preview reads. const sendNewMessageOnChannel3 = () => { const newMessage = generateMessage({ cid: testChannel3.channel.cid, diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts index b97f39947c..c9bc87520f 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts @@ -18,7 +18,7 @@ export type LastMessageType = LocalMessage | MessageResponse; // The latest message comes from the paginator's `aggregateState`, which stays current when a new // message lands in the head interval while an older window is active. const lastMessageSelector = (state: MessagePaginatorAggregateState) => ({ - lastMessage: state.lastMessage ?? undefined, + lastMessage: state.latestMessage ?? undefined, }); export const useChannelPreviewData = (channel: Channel, client: StreamChat) => { diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 2a6fbaa4b8..7230f22772 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -432,7 +432,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } if (paginator.hasMoreHead) { - latestNonCurrentMessageBeforeUpdateRef.current = paginator.lastMessage ?? undefined; + latestNonCurrentMessageBeforeUpdateRef.current = paginator.latestMessage ?? undefined; setAutoscrollToRecent(false); setScrollToBottomButtonVisible(true); return; diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 74bb9b5037..a2713c0734 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -442,7 +442,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { } if (paginator.hasMoreHead) { - latestNonCurrentMessageBeforeUpdateRef.current = paginator.lastMessage ?? undefined; + latestNonCurrentMessageBeforeUpdateRef.current = paginator.latestMessage ?? undefined; setAutoscrollToRecent(false); setScrollToBottomButtonVisible(true); return; diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index 79b7864dc9..b01e7daeac 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -1078,6 +1078,8 @@ describe('MessageList pagination', () => { ), }); channel.messagePaginator.state.partialNext({ + // an older window than the newest messages, so "go to latest" has to jump + hasMoreHead: true, items: Array.from({ length: 10 }, (_, i) => generateMessage({ id: String(i) })), }); diff --git a/package/src/components/MessageList/hooks/useMarkRead.ts b/package/src/components/MessageList/hooks/useMarkRead.ts index ab4fb1dd5b..924ceca3c8 100644 --- a/package/src/components/MessageList/hooks/useMarkRead.ts +++ b/package/src/components/MessageList/hooks/useMarkRead.ts @@ -59,12 +59,12 @@ export const useMarkRead = (channel: Channel) => { // with unreads keeps its unread separator until the user catches up. const { updateChannelUnreadState = true } = options ?? {}; if (updateChannelUnreadState) { - const { lastMessage, lastMessageAt, unreadStateSnapshot } = channel.messagePaginator; + const { latestMessage, latestMessageAt, unreadStateSnapshot } = channel.messagePaginator; const previous = unreadStateSnapshot.getLatestValue(); unreadStateSnapshot.next({ firstUnreadMessageId: null, - lastReadAt: lastMessageAt ?? nowNs(), - lastReadMessageId: lastMessage?.id ?? previous.lastReadMessageId, + lastReadAt: latestMessageAt ?? nowNs(), + lastReadMessageId: latestMessage?.id ?? previous.lastReadMessageId, unreadCount: 0, }); } diff --git a/package/src/components/ThreadList/ThreadListItem.tsx b/package/src/components/ThreadList/ThreadListItem.tsx index 5df81a7273..f318dbf075 100644 --- a/package/src/components/ThreadList/ThreadListItem.tsx +++ b/package/src/components/ThreadList/ThreadListItem.tsx @@ -50,7 +50,7 @@ const attachmentManagerStateSelector = (state: AttachmentManagerState) => ({ // The newest reply, also when the loaded page of replies is not the newest one. const lastReplySelector = (state: MessagePaginatorAggregateState) => ({ - lastReply: state.lastMessage ?? undefined, + lastReply: state.latestMessage ?? undefined, }); export const ThreadListItemComponent = () => { diff --git a/package/src/store/__tests__/selectMessagesForChannels.test.ts b/package/src/store/__tests__/selectMessagesForChannels.test.ts new file mode 100644 index 0000000000..d2fc875d0f --- /dev/null +++ b/package/src/store/__tests__/selectMessagesForChannels.test.ts @@ -0,0 +1,39 @@ +import { generateChannelResponse } from '../../mock-builders/generator/channel'; +import { generateMessage } from '../../mock-builders/generator/message'; +import { BetterSqlite } from '../../test-utils/BetterSqlite'; +import { selectMessagesForChannels } from '../apis/queries/selectMessagesForChannels'; +import { upsertChannels } from '../apis/upsertChannels'; +import { SqliteClient } from '../SqliteClient'; + +/** + * The client treats the cached page as a `queryChannels` page of 25 messages, so a shorter one reads + * as the start of the channel and older messages never load from the server. + */ +describe('selectMessagesForChannels', () => { + beforeEach(async () => { + await SqliteClient.initializeDatabase(); + await BetterSqlite.openDB(); + }); + + afterEach(() => { + BetterSqlite.dropAllTables(); + BetterSqlite.closeDB(); + }); + + it("returns a channel's newest 25 messages, oldest first", async () => { + const channelResponse = generateChannelResponse({ members: [], messages: [] }); + const cid = channelResponse.channel.cid as string; + const messages = Array.from({ length: 30 }, (_, i) => + generateMessage({ cid, timestamp: Date.UTC(2020, 0, 1, 0, i) }), + ); + channelResponse.messages = messages as unknown as typeof channelResponse.messages; + await upsertChannels({ + channels: [channelResponse] as unknown as Parameters[0]['channels'], + isLatestMessagesSet: true, + }); + + const rows = await selectMessagesForChannels([cid]); + + expect(rows.map((row) => row.id)).toEqual(messages.slice(-25).map((message) => message.id)); + }); +}); diff --git a/package/src/store/apis/queries/selectMessagesForChannels.ts b/package/src/store/apis/queries/selectMessagesForChannels.ts index c7c62074f5..6d556a1a7d 100644 --- a/package/src/store/apis/queries/selectMessagesForChannels.ts +++ b/package/src/store/apis/queries/selectMessagesForChannels.ts @@ -38,7 +38,7 @@ export const selectMessagesForChannels = async ( LEFT JOIN users b ON b.id = a.userId - WHERE RowNum < 25 + WHERE RowNum <= 25 ORDER BY a.createdAt ASC`, cids, ); diff --git a/yarn.lock b/yarn.lock index 8a0778878a..dc913903e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6570,7 +6570,7 @@ __metadata: react-native-teleport: "npm:^1.2.2" react-native-web: "npm:~0.21.4" react-native-worklets: "npm:0.13.0" - stream-chat: "npm:^10.0.0-rc.23" + stream-chat: "npm:^10.0.0-rc.24" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -10276,7 +10276,7 @@ __metadata: react-native-svg: "npm:15.15.5" react-native-teleport: "npm:^1.2.2" react-native-worklets: "npm:0.13.0" - stream-chat: "npm:^10.0.0-rc.23" + stream-chat: "npm:^10.0.0-rc.24" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -17468,7 +17468,7 @@ __metadata: react-native-teleport: "npm:^1.2.2" react-native-video: "npm:^6.19.3" react-native-worklets: "npm:^0.13.0" - stream-chat: "npm:^10.0.0-rc.23" + stream-chat: "npm:^10.0.0-rc.24" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -18233,7 +18233,7 @@ __metadata: react-syntax-highlighter: "npm:^16.1.0" react-test-renderer: "npm:19.3.0" rimraf: "npm:^6.0.1" - stream-chat: "npm:^10.0.0-rc.23" + stream-chat: "npm:^10.0.0-rc.24" typescript: "npm:6.0.3" use-sync-external-store: "npm:^1.7.0" uuid: "npm:^11.1.0" @@ -18321,9 +18321,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^10.0.0-rc.23": - version: 10.0.0-rc.23 - resolution: "stream-chat@npm:10.0.0-rc.23" +"stream-chat@npm:^10.0.0-rc.24": + version: 10.0.0-rc.24 + resolution: "stream-chat@npm:10.0.0-rc.24" dependencies: "@stream-io/logger": "npm:^2.0.0" "@stream-io/state-store": "npm:^1.1.6" @@ -18334,7 +18334,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/5af08e7e64e48e2cb0bb3b440204a0b2e5972f7cc295b8dd6917c8958ad79ebe3856b96d1fdad8cf29237d460b877e400cf866e5d37aae47ec5138cfbc142c68 + checksum: 10c0/111dd28f50b4a331d1faccdc34460d49fe11987f9230984d6c9de0fab0fbbb7972a9afc4c1dfb7296aff46ca5f345a74fefa19ba68fa78df7470f6f0bfbd46a4 languageName: node linkType: hard