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