diff --git a/examples/E2ETestApp/package.json b/examples/E2ETestApp/package.json index 84093f57a0..0d3aff83e9 100644 --- a/examples/E2ETestApp/package.json +++ b/examples/E2ETestApp/package.json @@ -23,7 +23,7 @@ "react-native-svg": "^15.15.5", "react-native-teleport": "^1.2.2", "react-native-worklets": "^0.13.0", - "stream-chat": "^9.54.2", + "stream-chat": "^9.54.4", "stream-chat-react-native": "workspace:^" }, "devDependencies": { diff --git a/examples/ExpoChatGPTSample/package.json b/examples/ExpoChatGPTSample/package.json index 73a01a0e80..7d6dc5c668 100644 --- a/examples/ExpoChatGPTSample/package.json +++ b/examples/ExpoChatGPTSample/package.json @@ -29,7 +29,7 @@ "react-native-svg": "15.15.5", "react-native-teleport": "^1.2.2", "react-native-worklets": "0.13.0", - "stream-chat": "^9.54.2", + "stream-chat": "^9.54.4", "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 7ea24ba04c..e134125af5 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": "^9.54.2", + "stream-chat": "^9.54.4", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 6b230be81d..38db2a0178 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -64,7 +64,7 @@ "react-native-teleport": "^1.2.2", "react-native-video": "^6.19.3", "react-native-worklets": "^0.13.0", - "stream-chat": "^9.54.2", + "stream-chat": "^9.54.4", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/package/package.json b/package/package.json index 673f176a05..85a4db05d1 100644 --- a/package/package.json +++ b/package/package.json @@ -84,7 +84,7 @@ "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", "react-syntax-highlighter": "^16.1.0", - "stream-chat": "^9.54.2", + "stream-chat": "^9.54.4", "use-sync-external-store": "^1.5.0", "zod": "^4.1.12" }, diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index f3658c10b2..80a1a89edb 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -4,6 +4,7 @@ import { LayoutChangeEvent, ScrollViewProps, StyleSheet, View, useColorScheme } import Animated from 'react-native-reanimated'; import type { FlashListProps, FlashListRef } from '@shopify/flash-list'; +import debounce from 'lodash/debounce'; import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; import { useMessageList } from './hooks/useMessageList'; @@ -147,7 +148,7 @@ type MessageFlashListPropsWithContext = Pick< > & Pick< ThreadContextValue, - 'loadMoreRecentThread' | 'loadMoreThread' | 'thread' | 'threadInstance' + 'loadMoreRecentThread' | 'loadMoreThread' | 'thread' | 'threadHasMore' | 'threadInstance' > & { /** * Besides existing (default) UX behavior of underlying FlatList of MessageList component, if you want @@ -346,6 +347,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => hasPendingInitialTargetLoad, targetedMessage, thread, + threadHasMore, threadInstance, threadList = false, } = props; @@ -835,6 +837,16 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => * once per content length. */ + /** + * How many messages the list pages through are loaded: the thread's replies in a thread list, the + * channel's messages otherwise. + */ + const getLoadedMessageCount = useStableCallback(() => + threadList && thread?.id + ? (channel?.state.threads[thread.id]?.length ?? 0) + : (channel?.state.messages.length ?? 0), + ); + /** * 1. Makes a call to `loadMore` function, which queries more older messages. * 2. Ensures that we call `loadMore`, once per content length @@ -853,8 +865,11 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => onStartReachedTracker.current[processedMessageList.length] = true; } - const callback = () => { + const callback = (loadedMessageCount: number) => { onStartReachedInPromise.current = null; + if (getLoadedMessageCount() === loadedMessageCount) { + onError(); + } return Promise.resolve(); }; @@ -869,12 +884,13 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => if (onEndReachedInPromise.current) { await onEndReachedInPromise.current; } + const loadedMessageCount = getLoadedMessageCount(); onStartReachedInPromise.current = ( threadList && !!threadInstance && loadMoreRecentThread ? loadMoreRecentThread({}) : loadMoreRecent() ) - .then(callback) + .then(() => callback(loadedMessageCount)) .catch(onError); }); @@ -885,7 +901,10 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => */ const maybeCallOnEndReached = useStableCallback(async () => { // If onStartReached has already been called for given data length, then ignore. - if (processedMessageList?.length && onEndReachedTracker.current[processedMessageList.length]) { + if ( + (processedMessageList?.length && onEndReachedTracker.current[processedMessageList.length]) || + (threadList && !threadHasMore) + ) { return; } @@ -893,9 +912,11 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => onEndReachedTracker.current[processedMessageList.length] = true; } - const callback = () => { + const callback = (loadedMessageCount: number) => { onEndReachedInPromise.current = null; - + if (getLoadedMessageCount() === loadedMessageCount) { + onError(); + } return Promise.resolve(); }; @@ -911,8 +932,9 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => await onStartReachedInPromise.current; } + const loadedMessageCount = getLoadedMessageCount(); onEndReachedInPromise.current = (threadList ? loadMoreThread() : loadMore()) - .then(callback) + .then(() => callback(loadedMessageCount)) .catch(onError); }); @@ -1100,6 +1122,31 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => currentListHeightRef.current = height; }); + const debouncedPrefillMessages = useMemo( + () => + debounce( + (viewportHeight: number, contentHeight: number) => { + if (viewportHeight >= contentHeight) { + maybeCallOnEndReached(); + } + }, + 500, + { + leading: false, + trailing: true, + }, + ), + [maybeCallOnEndReached], + ); + + const onContentSizeChange = useStableCallback((width: number, height: number) => { + if (additionalFlashListProps?.onContentSizeChange) { + additionalFlashListProps.onContentSizeChange(width, height); + } + + debouncedPrefillMessages(currentListHeightRef.current ?? 0, height); + }); + const ListFooterComponent = useCallback(() => { if (FooterComponent) { return ; @@ -1172,6 +1219,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => {...additionalFlashListPropsExcludingStyle} accessibilityActions={messageListAccessibilityActions} onAccessibilityAction={messageListOnAccessibilityAction} + onContentSizeChange={onContentSizeChange} /> )} @@ -1313,7 +1361,8 @@ export const MessageFlashList = (props: MessageFlashListProps) => { const { disableTypingIndicator, FlatList, myMessageTheme, shouldShowUnreadUnderlay } = useMessagesContext(); const { loadMore, loadMoreRecent } = usePaginatedMessageListContext(); - const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext(); + const { loadMoreRecentThread, loadMoreThread, thread, threadHasMore, threadInstance } = + useThreadContext(); const { readEvents } = useOwnCapabilitiesContext(); const { allowSendBeforeAttachmentsUpload, messageInputFloating, messageInputHeightStore } = useMessageInputContext(); @@ -1355,6 +1404,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { shouldShowUnreadUnderlay, targetedMessage, thread, + threadHasMore, threadInstance, threadList, }} diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index baf66fb98c..d2d9269cf6 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -932,6 +932,16 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { * once per content length. */ + /** + * How many messages the list pages through are loaded: the thread's replies in a thread list, the + * channel's messages otherwise. + */ + const getLoadedMessageCount = useStableCallback(() => + threadList && thread?.id + ? (channel?.state.threads[thread.id]?.length ?? 0) + : (channel?.state.messages.length ?? 0), + ); + /** * 1. Makes a call to `loadMoreRecent` function, which queries more recent messages. * 2. Ensures that we call `loadMoreRecent`, once per content length @@ -950,9 +960,11 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { onStartReachedTracker.current[processedMessageList.length] = true; } - const callback = () => { + const callback = (loadedMessageCount: number) => { onStartReachedInPromise.current = null; - + if (getLoadedMessageCount() === loadedMessageCount) { + onError(); + } return Promise.resolve(); }; @@ -967,12 +979,13 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { if (onEndReachedInPromise.current) { await onEndReachedInPromise.current; } + const loadedMessageCount = getLoadedMessageCount(); onStartReachedInPromise.current = ( threadList && !!threadInstance && loadMoreRecentThread ? loadMoreRecentThread({}) : loadMoreRecent() ) - .then(callback) + .then(() => callback(loadedMessageCount)) .catch(onError); }); @@ -995,8 +1008,11 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { onEndReachedTracker.current[processedMessageList.length] = true; } - const callback = () => { + const callback = (loadedMessageCount: number) => { onEndReachedInPromise.current = null; + if (getLoadedMessageCount() === loadedMessageCount) { + onError(); + } return Promise.resolve(); }; @@ -1011,8 +1027,9 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { if (onStartReachedInPromise.current) { await onStartReachedInPromise.current; } + const loadedMessageCount = getLoadedMessageCount(); onEndReachedInPromise.current = (threadList ? loadMoreThread() : loadMore()) - .then(callback) + .then(() => callback(loadedMessageCount)) .catch(onError); }); diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index bfdee3cb8d..438140fad7 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -650,6 +650,57 @@ describe('MessageList pagination', () => { }); }); + it('tries loading older messages again after a load that loaded nothing', async () => { + const user1 = generateUser(); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user: user1 })], + messages: Array.from({ length: 100 }, (_, i) => generateMessage({ text: `message-${i}` })), + }); + + const chatClient = await getTestClientWithUser({ id: 'testID' }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + // resolves without loading anything, as `loadMore` does when its request fails, e.g. offline + const loadMore = jest.fn(() => Promise.resolve()); + mockedHook({ loadMore }); + + const { getByTestId } = render( + + + + + + + , + ); + + const scrollToEnd = () => + act(() => { + fireEvent(getByTestId('message-flat-list'), 'momentumScrollEnd', { + nativeEvent: { + contentOffset: { y: 1900 }, + contentSize: { height: 2000, width: 200 }, + layoutMeasurement: { height: 400, width: 200 }, + }, + }); + }); + + scrollToEnd(); + await waitFor(() => { + expect(loadMore).toHaveBeenCalledTimes(1); + }); + + // released after the same 2 second backoff as a rejected load + await act(() => new Promise((resolve) => setTimeout(resolve, 2100))); + scrollToEnd(); + + await waitFor(() => { + expect(loadMore).toHaveBeenCalledTimes(2); + }); + }); + it('should call load latest messages when the scroll to bottom button is pressed', async () => { const user1 = generateUser(); const messages = Array.from({ length: 10 }, (_, i) => diff --git a/package/src/store/__tests__/selectMessagesForChannels.test.ts b/package/src/store/__tests__/selectMessagesForChannels.test.ts new file mode 100644 index 0000000000..1d7ac87b76 --- /dev/null +++ b/package/src/store/__tests__/selectMessagesForChannels.test.ts @@ -0,0 +1,63 @@ +import type { ChannelAPIResponse } from 'stream-chat'; + +import { generateChannelResponse } from '../../mock-builders/generator/channel'; +import { generateMessage } from '../../mock-builders/generator/message'; +import { BetterSqlite } from '../../test-utils/BetterSqlite'; +import { getChannels } from '../apis/getChannels'; +import { selectMessagesForChannels } from '../apis/queries/selectMessagesForChannels'; +import { upsertChannels } from '../apis/upsertChannels'; +import { SqliteClient } from '../SqliteClient'; + +/** + * The client reads the cached page as a channel list page of `message_limit` messages, so a page of + * a different size makes it guess wrongly whether older messages exist, for example that a channel + * with a long history starts at its cached messages. + */ +describe('selectMessagesForChannels', () => { + let cid: string; + let messages: ReturnType[]; + + beforeEach(async () => { + await SqliteClient.initializeDatabase(); + await BetterSqlite.openDB(); + + const channelResponse = generateChannelResponse({ members: [], messages: [] }); + cid = channelResponse.channel.cid as string; + messages = Array.from({ length: 30 }, (_, i) => + generateMessage({ cid, timestamp: new Date(Date.UTC(2020, 0, 1, 0, i)) }), + ); + channelResponse.messages = messages as unknown as typeof channelResponse.messages; + await upsertChannels({ + channels: [channelResponse] as unknown as ChannelAPIResponse[], + isLatestMessagesSet: true, + }); + }); + + afterEach(() => { + BetterSqlite.dropAllTables(); + BetterSqlite.closeDB(); + }); + + const ids = (rows: { id: string }[]) => rows.map((row) => row.id); + + it("returns a channel's newest 25 messages, oldest first, by default", async () => { + const rows = await selectMessagesForChannels([cid]); + + expect(ids(rows)).toEqual(ids(messages.slice(-25))); + }); + + it("returns a channel's newest `messageLimit` messages", async () => { + expect(ids(await selectMessagesForChannels([cid], 10))).toEqual(ids(messages.slice(-10))); + expect(ids(await selectMessagesForChannels([cid], 50))).toEqual(ids(messages)); + }); + + it('applies the limit that getChannels is given', async () => { + const [channel] = await getChannels({ + channelIds: [cid], + currentUserId: 'some-user', + messageLimit: 10, + }); + + expect(ids(channel.messages)).toEqual(ids(messages.slice(-10))); + }); +}); diff --git a/package/src/store/apis/getChannelMessages.ts b/package/src/store/apis/getChannelMessages.ts index 87ae9641ed..1b7fed52fb 100644 --- a/package/src/store/apis/getChannelMessages.ts +++ b/package/src/store/apis/getChannelMessages.ts @@ -12,15 +12,18 @@ import type { TableRow, TableRowJoinedUser } from '../types'; export const getChannelMessages = async ({ channelIds, currentUserId, + messageLimit, }: { channelIds: string[]; currentUserId: string; + messageLimit?: number; }) => { SqliteClient.logger?.('info', 'getChannelMessages', { channelIds, currentUserId, + messageLimit, }); - const messageRows = await selectMessagesForChannels(channelIds); + const messageRows = await selectMessagesForChannels(channelIds, messageLimit); const messageIds = messageRows.map(({ id }) => id); // Populate the message reactions. diff --git a/package/src/store/apis/getChannels.ts b/package/src/store/apis/getChannels.ts index 094981c2aa..c7f9e89743 100644 --- a/package/src/store/apis/getChannels.ts +++ b/package/src/store/apis/getChannels.ts @@ -16,17 +16,20 @@ import { SqliteClient } from '../SqliteClient'; * @param {Object} param * @param {Array} param.channelIds List of channel ids to fetch. * @param {Array} param.currentUserId Id of the current logged in user. + * @param {number} param.messageLimit How many of each channel's newest messages to return, 25 by default. * * @returns {Array} Channels with enriched state. */ export const getChannels = async ({ channelIds, currentUserId, + messageLimit, }: { channelIds: string[]; currentUserId: string; + messageLimit?: number; }): Promise[]> => { - SqliteClient.logger?.('info', 'getChannels', { channelIds, currentUserId }); + SqliteClient.logger?.('info', 'getChannels', { channelIds, currentUserId, messageLimit }); const [channels, cidVsDraft, cidVsMembers, cidVsReads, cidVsMessages, cidVsActiveLocations] = await Promise.all([ @@ -37,6 +40,7 @@ export const getChannels = async ({ getChannelMessages({ channelIds, currentUserId, + messageLimit, }), getChannelActiveLocations({ channelIds }), ]); diff --git a/package/src/store/apis/getChannelsForFilterSort.ts b/package/src/store/apis/getChannelsForFilterSort.ts index 6ecd35d3b3..f029e68037 100644 --- a/package/src/store/apis/getChannelsForFilterSort.ts +++ b/package/src/store/apis/getChannelsForFilterSort.ts @@ -48,5 +48,6 @@ export const getChannelsForFilterSort = async ({ return await getChannels({ channelIds, currentUserId, + messageLimit: options?.message_limit, }); }; diff --git a/package/src/store/apis/queries/selectMessagesForChannels.ts b/package/src/store/apis/queries/selectMessagesForChannels.ts index c7c62074f5..c6257bf039 100644 --- a/package/src/store/apis/queries/selectMessagesForChannels.ts +++ b/package/src/store/apis/queries/selectMessagesForChannels.ts @@ -2,8 +2,12 @@ import { tables } from '../../schema'; import { SqliteClient } from '../../SqliteClient'; import type { TableRowJoinedUser } from '../../types'; +/** The `message_limit` the client assumes for a channel list query that sets none. */ +export const DEFAULT_CHANNEL_LIST_MESSAGE_LIMIT = 25; + export const selectMessagesForChannels = async ( cids: string[], + messageLimit: number = DEFAULT_CHANNEL_LIST_MESSAGE_LIMIT, ): Promise[]> => { const questionMarks = Array(cids.length).fill('?').join(','); const messagesColumnNames = Object.keys(tables.messages.columns) @@ -15,6 +19,7 @@ export const selectMessagesForChannels = async ( SqliteClient.logger?.('info', 'selectMessagesForChannels', { cids, + messageLimit, }); const result = await SqliteClient.executeSql( @@ -38,9 +43,9 @@ export const selectMessagesForChannels = async ( LEFT JOIN users b ON b.id = a.userId - WHERE RowNum < 25 + WHERE RowNum <= ? ORDER BY a.createdAt ASC`, - cids, + [...cids, messageLimit], ); return result.map((r) => JSON.parse(r.value)); diff --git a/yarn.lock b/yarn.lock index 44741c2181..ab453e7250 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7344,7 +7344,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:^9.54.2" + stream-chat: "npm:^9.54.4" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -10040,7 +10040,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:^9.54.2" + stream-chat: "npm:^9.54.4" stream-chat-react-native: "workspace:^" typescript: "npm:6.0.3" languageName: unknown @@ -11432,7 +11432,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:^9.54.2" + stream-chat: "npm:^9.54.4" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -19352,7 +19352,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:^9.54.2" + stream-chat: "npm:^9.54.4" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -20173,7 +20173,7 @@ __metadata: react-syntax-highlighter: "npm:^16.1.0" react-test-renderer: "npm:19.3.0" rimraf: "npm:^6.0.1" - stream-chat: "npm:^9.54.2" + stream-chat: "npm:^9.54.4" typescript: "npm:6.0.3" use-sync-external-store: "npm:^1.5.0" uuid: "npm:^11.1.0" @@ -20261,9 +20261,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.54.2": - version: 9.54.2 - resolution: "stream-chat@npm:9.54.2" +"stream-chat@npm:^9.54.4": + version: 9.54.4 + resolution: "stream-chat@npm:9.54.4" dependencies: "@types/jsonwebtoken": "npm:^9.0.8" "@types/ws": "npm:^8.18.1" @@ -20279,7 +20279,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/ebe3835165ad942c1eb7c3c1e4d5b2d24f9638ce6b849918efe945cf4a41a856c3287b12e9f0ab166a128276f36a74f4482da0c505c3e5a339b47f90b800b65f + checksum: 10c0/52f399b7f62a40e05025412e24a3a052e5cfeadaf84c8e71b46c7ac7273326be50d8c271b9e3ba90363c6d8ae54ea38bf0d91dee9aa0d74bcb5bf86750d8e866 languageName: node linkType: hard