>(
channelReducer,
- // channel.initialized === false if client.channels() was not called, e.g. ChannelList is not used
+ // channel.initialized === false if client.channel().query() was not called, e.g. ChannelList is not used
// => Channel will call channel.watch() in useLayoutEffect => state.loading is used to signal the watch() call state
{ ...initialState, loading: !channel.initialized },
);
@@ -473,6 +490,7 @@ const ChannelInner = <
/**
* As the channel state is not normalized we re-fetch the channel data. Thus, we avoid having to search for user references in the channel state.
*/
+ // FIXME: we should use channelQueryOptions if they are available
await channel.query({
messages: { id_lt: oldestID, limit: DEFAULT_NEXT_CHANNEL_PAGE_SIZE },
watchers: { limit: DEFAULT_NEXT_CHANNEL_PAGE_SIZE },
@@ -512,7 +530,7 @@ const ChannelInner = <
}
}
}
- await getChannel({ channel, client, members });
+ await getChannel({ channel, client, members, options: channelQueryOptions });
const config = channel.getConfig();
setChannelConfig(config);
} catch (e) {
@@ -525,7 +543,14 @@ const ChannelInner = <
originalTitle.current = document.title;
if (!errored) {
- dispatch({ channel, type: 'initStateFromChannel' });
+ dispatch({
+ channel,
+ hasMore: hasMoreMessagesProbably(
+ channel.state.messages.length,
+ channelQueryOptions?.messages?.limit ?? DEFAULT_INITIAL_CHANNEL_PAGE_SIZE,
+ ),
+ type: 'initStateFromChannel',
+ });
if (channel.countUnread() > 0) markRead();
// The more complex sync logic is done in Chat
document.addEventListener('visibilitychange', onVisibilityChange);
@@ -547,7 +572,13 @@ const ChannelInner = <
client.off('user.deleted', handleEvent);
notificationTimeouts.forEach(clearTimeout);
};
- }, [channel.cid, doMarkReadRequest, channelConfig?.read_events, initializeOnMount]);
+ }, [
+ channel.cid,
+ channelQueryOptions,
+ doMarkReadRequest,
+ channelConfig?.read_events,
+ initializeOnMount,
+ ]);
useEffect(() => {
if (!state.thread) return;
@@ -575,7 +606,7 @@ const ChannelInner = <
);
const loadMore = async (limit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE) => {
- if (!online.current || !window.navigator.onLine) return 0;
+ if (!online.current || !window.navigator.onLine || !state.hasMore) return 0;
// prevent duplicate loading events...
const oldestMessage = state?.messages?.[0];
@@ -584,16 +615,6 @@ const ChannelInner = <
return 0;
}
- // initial state loads with up to 25 messages, so if less than 25 no need for additional query
- const notHasMore = hasNotMoreMessages(
- channel.state.messages.length,
- DEFAULT_INITIAL_CHANNEL_PAGE_SIZE,
- );
- if (notHasMore) {
- loadMoreFinished(false, channel.state.messages);
- return channel.state.messages.length;
- }
-
dispatch({ loadingMore: true, type: 'setLoadingMore' });
const oldestID = oldestMessage?.id;
@@ -678,6 +699,7 @@ const ChannelInner = <
const jumpToLatestMessage = async () => {
await channel.state.loadMessageIntoState('latest');
+ // FIXME: we cannot rely on constant value 25 as the page size can be customized by integrators
const hasMoreOlder = channel.state.messages.length >= 25;
loadMoreFinished(hasMoreOlder, channel.state.messages);
dispatch({
@@ -890,6 +912,7 @@ const ChannelInner = <
);
const loadMoreThread = async (limit: number = DEFAULT_THREAD_PAGE_SIZE) => {
+ // FIXME: should prevent loading more, if state.thread.reply_count === channel.state.threads[parentID].length
if (state.threadLoadingMore || !state.thread) return;
dispatch({ type: 'startLoadingThread' });
diff --git a/src/components/Channel/__tests__/Channel.test.js b/src/components/Channel/__tests__/Channel.test.js
index 38219cb87c..92a2d56780 100644
--- a/src/components/Channel/__tests__/Channel.test.js
+++ b/src/components/Channel/__tests__/Channel.test.js
@@ -33,6 +33,19 @@ jest.mock('../../Loading', () => ({
LoadingIndicator: jest.fn(() => loading
),
}));
+const queryChannelWithNewMessages = (newMessages, channel) =>
+ // generate new channel mock from existing channel with new messages added
+ getOrCreateChannelApi(
+ generateChannel({
+ channel: {
+ config: channel.getConfig(),
+ id: channel.id,
+ type: channel.type,
+ },
+ messages: newMessages,
+ }),
+ );
+
const MockAvatar = ({ name }) => (
{name}
@@ -272,7 +285,103 @@ describe('Channel', () => {
renderComponent({ channel, chatClient });
});
- await waitFor(() => expect(watchSpy).toHaveBeenCalledTimes(1));
+ await waitFor(() => {
+ expect(watchSpy).toHaveBeenCalledTimes(1);
+ expect(watchSpy).toHaveBeenCalledWith(undefined);
+ });
+ });
+
+ it('should apply channelQueryOptions to channel watch call', async () => {
+ const { channel, chatClient } = await initClient();
+ const watchSpy = jest.spyOn(channel, 'watch');
+ const channelQueryOptions = {
+ messages: { limit: 20 },
+ };
+ await act(() => {
+ renderComponent({ channel, channelQueryOptions, chatClient });
+ });
+
+ await waitFor(() => {
+ expect(watchSpy).toHaveBeenCalledTimes(1);
+ expect(watchSpy).toHaveBeenCalledWith(channelQueryOptions);
+ });
+ });
+
+ it('should set hasMore state to false if the initial channel query returns less messages than the default initial page size', async () => {
+ const { channel, chatClient } = await initClient();
+ useMockedApis(chatClient, [queryChannelWithNewMessages([generateMessage()], channel)]);
+ let hasMore;
+ await act(() => {
+ renderComponent({ channel, chatClient }, ({ hasMore: contextHasMore }) => {
+ hasMore = contextHasMore;
+ });
+ });
+
+ await waitFor(() => {
+ expect(hasMore).toBe(false);
+ });
+ });
+
+ it('should set hasMore state to true if the initial channel query returns count of messages equal to the default initial page size', async () => {
+ const { channel, chatClient } = await initClient();
+ useMockedApis(chatClient, [
+ queryChannelWithNewMessages(Array.from({ length: 25 }, generateMessage), channel),
+ ]);
+ let hasMore;
+ await act(() => {
+ renderComponent({ channel, chatClient }, ({ hasMore: contextHasMore }) => {
+ hasMore = contextHasMore;
+ });
+ });
+
+ await waitFor(() => {
+ expect(hasMore).toBe(true);
+ });
+ });
+
+ it('should set hasMore state to false if the initial channel query returns less messages than the custom query channels options message limit', async () => {
+ const { channel, chatClient } = await initClient();
+ useMockedApis(chatClient, [queryChannelWithNewMessages([generateMessage()], channel)]);
+ let hasMore;
+ const channelQueryOptions = {
+ messages: { limit: 10 },
+ };
+ await act(() => {
+ renderComponent(
+ { channel, channelQueryOptions, chatClient },
+ ({ hasMore: contextHasMore }) => {
+ hasMore = contextHasMore;
+ },
+ );
+ });
+
+ await waitFor(() => {
+ expect(hasMore).toBe(false);
+ });
+ });
+
+ it('should set hasMore state to true if the initial channel query returns count of messages equal custom query channels options message limit', async () => {
+ const { channel, chatClient } = await initClient();
+ const equalCount = 10;
+ useMockedApis(chatClient, [
+ queryChannelWithNewMessages(Array.from({ length: equalCount }, generateMessage), channel),
+ ]);
+ let hasMore;
+ const channelQueryOptions = {
+ messages: { limit: equalCount },
+ };
+ await act(() => {
+ renderComponent(
+ { channel, channelQueryOptions, chatClient },
+ ({ hasMore: contextHasMore }) => {
+ hasMore = contextHasMore;
+ },
+ );
+ });
+
+ await waitFor(() => {
+ expect(hasMore).toBe(true);
+ });
});
it('should not call watch the current channel on mount if channel is initialized', async () => {
@@ -375,7 +484,7 @@ describe('Channel', () => {
// first, wait for the effect in which the channel is watched,
// so we know the event listener is added to the document.
- await waitFor(() => expect(watchSpy).toHaveBeenCalledWith());
+ await waitFor(() => expect(watchSpy).toHaveBeenCalledWith(undefined));
setTimeout(() => fireEvent(document, new Event('visibilitychange')), 0);
await waitFor(() => expect(markReadSpy).toHaveBeenCalledWith());
@@ -586,19 +695,6 @@ describe('Channel', () => {
});
describe('loading more messages', () => {
- const queryChannelWithNewMessages = (newMessages, channel) =>
- // generate new channel mock from existing channel with new messages added
- getOrCreateChannelApi(
- generateChannel({
- channel: {
- config: channel.getConfig(),
- id: channel.id,
- type: channel.type,
- },
- messages: newMessages,
- }),
- );
-
const limit = 10;
it('should be able to load more messages', async () => {
const { channel, chatClient } = await initClient();
@@ -646,7 +742,7 @@ describe('Channel', () => {
useMockedApis(chatClient, [queryChannelWithNewMessages(newMessages, channel)]);
loadMore(limit);
} else {
- // If message has been added, set our checker variable so we can verify if hasMore is false.
+ // If message has been added, set our checker variable, so we can verify if hasMore is false.
channelHasMore = hasMore;
}
},
@@ -694,6 +790,151 @@ describe('Channel', () => {
});
await waitFor(() => expect(isLoadingMore).toBe(true));
});
+
+ it('should not load the second page, if the previous query has returned less then default limit messages', async () => {
+ const { channel, chatClient } = await initClient();
+ const firstPageOfMessages = [generateMessage()];
+ useMockedApis(chatClient, [queryChannelWithNewMessages(firstPageOfMessages, channel)]);
+ let queryNextPageSpy;
+ let contextMessageCount;
+ await act(() => {
+ renderComponent({ channel, chatClient }, ({ loadMore, messages: contextMessages }) => {
+ queryNextPageSpy = jest.spyOn(channel, 'query');
+ contextMessageCount = contextMessages.length;
+ loadMore();
+ });
+ });
+
+ await waitFor(() => {
+ expect(queryNextPageSpy).not.toHaveBeenCalled();
+ expect(chatClient.axiosInstance.post).toHaveBeenCalledTimes(1);
+ expect(chatClient.axiosInstance.post.mock.calls[0][1]).toMatchObject(
+ expect.objectContaining({ data: {}, presence: false, state: true, watch: false }),
+ );
+ expect(contextMessageCount).toBe(firstPageOfMessages.length);
+ });
+ });
+ it('should load the second page, if the previous query has returned message count equal default messages limit', async () => {
+ const { channel, chatClient } = await initClient();
+ const firstPageMessages = Array.from({ length: 25 }, generateMessage);
+ const secondPageMessages = Array.from({ length: 15 }, generateMessage);
+ useMockedApis(chatClient, [queryChannelWithNewMessages(firstPageMessages, channel)]);
+ let queryNextPageSpy;
+ let contextMessageCount;
+ await act(() => {
+ renderComponent({ channel, chatClient }, ({ loadMore, messages: contextMessages }) => {
+ queryNextPageSpy = jest.spyOn(channel, 'query');
+ contextMessageCount = contextMessages.length;
+ useMockedApis(chatClient, [queryChannelWithNewMessages(secondPageMessages, channel)]);
+ loadMore();
+ });
+ });
+
+ await waitFor(() => {
+ expect(queryNextPageSpy).toHaveBeenCalledTimes(1);
+ expect(chatClient.axiosInstance.post).toHaveBeenCalledTimes(2);
+ expect(chatClient.axiosInstance.post.mock.calls[0][1]).toMatchObject({
+ data: {},
+ presence: false,
+ state: true,
+ watch: false,
+ });
+ expect(chatClient.axiosInstance.post.mock.calls[1][1]).toMatchObject(
+ expect.objectContaining({
+ data: {},
+ messages: { id_lt: firstPageMessages[0].id, limit: 100 },
+ state: true,
+ watchers: { limit: 100 },
+ }),
+ );
+ expect(contextMessageCount).toBe(firstPageMessages.length + secondPageMessages.length);
+ });
+ });
+ it('should not load the second page, if the previous query has returned less then custom limit messages', async () => {
+ const { channel, chatClient } = await initClient();
+ const channelQueryOptions = {
+ messages: { limit: 10 },
+ };
+ const firstPageOfMessages = [generateMessage()];
+ useMockedApis(chatClient, [queryChannelWithNewMessages(firstPageOfMessages, channel)]);
+ let queryNextPageSpy;
+ let contextMessageCount;
+ await act(() => {
+ renderComponent(
+ { channel, channelQueryOptions, chatClient },
+ ({ loadMore, messages: contextMessages }) => {
+ queryNextPageSpy = jest.spyOn(channel, 'query');
+ contextMessageCount = contextMessages.length;
+ loadMore(channelQueryOptions.messages.limit);
+ },
+ );
+ });
+
+ await waitFor(() => {
+ expect(queryNextPageSpy).not.toHaveBeenCalled();
+ expect(chatClient.axiosInstance.post).toHaveBeenCalledTimes(1);
+ expect(chatClient.axiosInstance.post.mock.calls[0][1]).toMatchObject({
+ data: {},
+ messages: {
+ limit: channelQueryOptions.messages.limit,
+ },
+ presence: false,
+ state: true,
+ watch: false,
+ });
+ expect(contextMessageCount).toBe(firstPageOfMessages.length);
+ });
+ });
+ it('should load the second page, if the previous query has returned message count equal custom messages limit', async () => {
+ const { channel, chatClient } = await initClient();
+ const equalCount = 10;
+ const channelQueryOptions = {
+ messages: { limit: equalCount },
+ };
+ const firstPageMessages = Array.from({ length: equalCount }, generateMessage);
+ const secondPageMessages = Array.from({ length: equalCount - 1 }, generateMessage);
+ useMockedApis(chatClient, [queryChannelWithNewMessages(firstPageMessages, channel)]);
+ let queryNextPageSpy;
+ let contextMessageCount;
+
+ await act(() => {
+ renderComponent(
+ { channel, channelQueryOptions, chatClient },
+ ({ loadMore, messages: contextMessages }) => {
+ queryNextPageSpy = jest.spyOn(channel, 'query');
+ contextMessageCount = contextMessages.length;
+ useMockedApis(chatClient, [queryChannelWithNewMessages(secondPageMessages, channel)]);
+ loadMore(channelQueryOptions.messages.limit);
+ },
+ );
+ });
+
+ await waitFor(() => {
+ expect(queryNextPageSpy).toHaveBeenCalledTimes(1);
+ expect(chatClient.axiosInstance.post).toHaveBeenCalledTimes(2);
+ expect(chatClient.axiosInstance.post.mock.calls[0][1]).toMatchObject({
+ data: {},
+ messages: {
+ limit: channelQueryOptions.messages.limit,
+ },
+ presence: false,
+ state: true,
+ watch: false,
+ });
+ expect(chatClient.axiosInstance.post.mock.calls[1][1]).toMatchObject(
+ expect.objectContaining({
+ data: {},
+ messages: {
+ id_lt: firstPageMessages[0].id,
+ limit: channelQueryOptions.messages.limit,
+ },
+ state: true,
+ watchers: { limit: channelQueryOptions.messages.limit },
+ }),
+ );
+ expect(contextMessageCount).toBe(firstPageMessages.length + secondPageMessages.length);
+ });
+ });
});
describe('Sending/removing/updating messages', () => {
diff --git a/src/components/Channel/channelState.ts b/src/components/Channel/channelState.ts
index 415d200ebc..44cd430fd1 100644
--- a/src/components/Channel/channelState.ts
+++ b/src/components/Channel/channelState.ts
@@ -30,6 +30,7 @@ export type ChannelStateReducerAction<
}
| {
channel: Channel;
+ hasMore: boolean;
type: 'initStateFromChannel';
}
| {
@@ -132,9 +133,10 @@ export const channelReducer = <
}
case 'initStateFromChannel': {
- const { channel } = action;
+ const { channel, hasMore } = action;
return {
...state,
+ hasMore,
loading: false,
members: { ...channel.state.members },
messages: [...channel.state.messages],
diff --git a/src/components/InfiniteScrollPaginator/InfiniteScroll.tsx b/src/components/InfiniteScrollPaginator/InfiniteScroll.tsx
index 445474d612..aad3c278f7 100644
--- a/src/components/InfiniteScrollPaginator/InfiniteScroll.tsx
+++ b/src/components/InfiniteScrollPaginator/InfiniteScroll.tsx
@@ -91,7 +91,7 @@ export const InfiniteScroll = (props: PropsWithChildren) =>
}
if (isLoading) return;
-
+ // FIXME: this triggers loadMore call when a user types messages in thread and the scroll container container expands
if (
reverseOffset < Number(threshold) &&
typeof loadPreviousPageFn === 'function' &&
diff --git a/src/components/MML/__tests__/MML.test.js b/src/components/MML/__tests__/MML.test.js
index ad41d2cd1f..932c36db55 100644
--- a/src/components/MML/__tests__/MML.test.js
+++ b/src/components/MML/__tests__/MML.test.js
@@ -41,7 +41,7 @@ describe('MML', () => {
/>
`);
- });
+ }, 10000);
it('should render a basic mml', async () => {
const tree = await renderComponent({
diff --git a/src/components/MessageInput/index.ts b/src/components/MessageInput/index.ts
index 2a7cd048ee..bdb11a9f16 100644
--- a/src/components/MessageInput/index.ts
+++ b/src/components/MessageInput/index.ts
@@ -5,6 +5,7 @@ export * from './EditMessageForm';
export * from './EmojiPicker';
export * from './hooks';
export * from './icons';
+export * from './LinkPreviewList';
export * from './MessageInput';
export * from './MessageInputFlat';
export * from './MessageInputSmall';
diff --git a/src/components/MessageList/CustomNotification.tsx b/src/components/MessageList/CustomNotification.tsx
index 6b6a58b21e..5afe9c809c 100644
--- a/src/components/MessageList/CustomNotification.tsx
+++ b/src/components/MessageList/CustomNotification.tsx
@@ -18,6 +18,7 @@ const UnMemoizedCustomNotification = (props: PropsWithChildren {
expect(tree).toMatchInlineSnapshot(`
children
diff --git a/src/components/MessageList/index.ts b/src/components/MessageList/index.ts
index 81712a1eb0..065d3818a2 100644
--- a/src/components/MessageList/index.ts
+++ b/src/components/MessageList/index.ts
@@ -1,6 +1,7 @@
export * from './ConnectionStatus'; // TODO: export this under its own folder
export * from './GiphyPreviewMessage';
export * from './MessageList';
+export * from './MessageListNotifications';
export * from './MessageNotification';
export * from './ScrollToBottomButton';
export * from './VirtualizedMessageList';
diff --git a/src/components/MessageList/utils.ts b/src/components/MessageList/utils.ts
index 90d361fcb8..95cdc54b3c 100644
--- a/src/components/MessageList/utils.ts
+++ b/src/components/MessageList/utils.ts
@@ -318,5 +318,6 @@ export const getGroupStyles = <
export const hasMoreMessagesProbably = (returnedCountMessages: number, limit: number) =>
returnedCountMessages === limit;
+// @deprecated
export const hasNotMoreMessages = (returnedCountMessages: number, limit: number) =>
returnedCountMessages < limit;
diff --git a/src/components/Thread/Thread.tsx b/src/components/Thread/Thread.tsx
index ba9e7f67e5..2fe58fdeb0 100644
--- a/src/components/Thread/Thread.tsx
+++ b/src/components/Thread/Thread.tsx
@@ -125,6 +125,7 @@ const ThreadInner = <
useEffect(() => {
if (thread?.id && thread?.reply_count) {
+ // FIXME: integrators can customize channel query options but cannot customize channel.getReplies() options
loadMoreThread();
}
}, []);
diff --git a/src/utils/getChannel.ts b/src/utils/getChannel.ts
index 0a388f6bbc..00962c5711 100644
--- a/src/utils/getChannel.ts
+++ b/src/utils/getChannel.ts
@@ -1,4 +1,9 @@
-import type { Channel, QueryChannelAPIResponse, StreamChat } from 'stream-chat';
+import type {
+ Channel,
+ ChannelQueryOptions,
+ QueryChannelAPIResponse,
+ StreamChat,
+} from 'stream-chat';
import type { DefaultStreamChatGenerics } from '../types/types';
/**
@@ -17,12 +22,14 @@ type GetChannelParams<
channel?: Channel;
id?: string;
members?: string[];
+ options?: ChannelQueryOptions;
type?: string;
};
/**
* Calls channel.watch() if it was not already recently called. Waits for watch promise to resolve even if it was invoked previously.
* @param client
* @param members
+ * @param options
* @param type
* @param id
* @param channel
@@ -34,6 +41,7 @@ export const getChannel = async <
client,
id,
members,
+ options,
type,
}: GetChannelParams) => {
if (!channel && !type) {
@@ -60,7 +68,7 @@ export const getChannel = async <
if (queryPromise) {
await queryPromise;
} else {
- WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid] = theChannel.watch();
+ WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid] = theChannel.watch(options);
await WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid];
delete WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid];
}
diff --git a/yarn.lock b/yarn.lock
index c01091fba2..5f50534b94 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2571,10 +2571,10 @@
crypto-browserify "^3.11.0"
process-es6 "^0.11.2"
-"@stream-io/stream-chat-css@^3.13.0":
- version "3.13.0"
- resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-3.13.0.tgz#3c49d17baeddf9d48b4fea377387eca23663b5fe"
- integrity sha512-dF0VauSvAVeq+71z9zIru2Jpaj9D3yMK5S2+o1RGApYvGXkl07nS3vcPXv9btZ6c1RFskoVnzG/2xb42P0nleA==
+"@stream-io/stream-chat-css@^3.14.2":
+ version "3.14.2"
+ resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-3.14.2.tgz#11674178b538e2a7630038a420a642d411ce03de"
+ integrity sha512-S54qkFRJ4GwQ0uR36z+m3uv1aYD+Ut/tNG0i/9FpjE/mcqQLcpV7GrTYTfZh0hr5w4m3xJ5N+Mruz/A3BHe5mw==
"@stream-io/transliterate@^1.5.5":
version "1.5.5"
@@ -13799,9 +13799,9 @@ stream-browserify@^2.0.1:
readable-stream "^2.0.2"
stream-chat@^8.13.1:
- version "8.14.0"
- resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-8.14.0.tgz#ba96badaaf6c2d3025f31a6d5c0c5545d883c691"
- integrity sha512-WEAssYcY/qSJXVK4B39JZJjyBzLSE4Wn+Gliywm8Nc2cmM0+fJF0853H5jZNy6AEeZhzxzRfxwq71r0FfZKudQ==
+ version "8.14.3"
+ resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-8.14.3.tgz#165402d2ed6fc4085f0cc0121b28c664159f8976"
+ integrity sha512-GYYf4bfpSdl4Itaw981D7R3OUiSBWUUOQypvUd6tvhs20O76Pu+gR/eOUkpl40jBfYSAFVkbhd/CnDFxJJafug==
dependencies:
"@babel/runtime" "^7.16.3"
"@types/jsonwebtoken" "~9.0.0"