Skip to content

Commit 5dfa493

Browse files
authored
feat: allow to pass custom function customQueryChannels to ChannelList to query channels (#2260)
1 parent 0c32ce5 commit 5dfa493

File tree

5 files changed

+214
-28
lines changed

5 files changed

+214
-28
lines changed

docusaurus/docs/React/components/core-components/channel-list.mdx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,103 @@ Set a channel (with this ID) to active and force it to move to the top of the li
219219
| ------ |
220220
| string |
221221

222+
### customQueryChannels
223+
224+
Custom function that handles the channel pagination.
225+
226+
Takes parameters:
227+
228+
| Parameter | Description |
229+
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
230+
| `currentChannels` | The state of loaded `Channel` objects queried thus far. Has to be set with `setChannels` (see below). |
231+
| `queryType` | A string indicating, whether the channels state has to be reset to the first page ('reload') or newly queried channels should be appended to the `currentChannels`. |
232+
| `setChannels` | Function that allows us to set the channels state reflected in `currentChannels`. |
233+
| `setHasNextPage` | Flag indicating whether there are more items to be loaded from the API. Should be infered from the comparison of the query result length and the query options limit. |
234+
235+
The function has to:
236+
1. build / provide own query filters, sort and options parameters
237+
2. query and append channels to the current channels state
238+
3. update the `hasNext` pagination flag after each query with `setChannels` function
239+
240+
An example below implements a custom query function that uses different filters sequentially once a preceding filter is exhausted:
241+
242+
```ts
243+
import uniqBy from "lodash.uniqby";
244+
import throttle from 'lodash.throttle';
245+
import {useCallback, useRef} from 'react';
246+
import {ChannelFilters, ChannelOptions, ChannelSort, StreamChat} from 'stream-chat';
247+
import {
248+
CustomQueryChannelParams,
249+
useChatContext,
250+
} from 'stream-chat-react';
251+
252+
const DEFAULT_PAGE_SIZE = 30 as const;
253+
254+
export const useCustomQueryChannels = () => {
255+
const { client } = useChatContext();
256+
const filters1: ChannelFilters = {
257+
member_count: { $gt: 10 },
258+
members: { $in: [client.user?.id || ''] },
259+
type: 'messaging',
260+
};
261+
const filters2: ChannelFilters = { members: { $in: [client.user?.id || ''] }, type: 'messaging' };
262+
const options: ChannelOptions = { limit: 10, presence: true, state: true };
263+
const sort: ChannelSort = { last_message_at: -1, updated_at: -1 };
264+
265+
const filtersArray = [filters1, filters2];
266+
const appliedFilterIndex = useRef(0);
267+
268+
const customQueryChannels = useCallback(
269+
throttle(
270+
async ({
271+
currentChannels,
272+
queryType,
273+
setChannels,
274+
setHasNextPage,
275+
}: CustomQueryChannelParams) => {
276+
const offset = queryType === 'reload' ? 0 : currentChannels.length;
277+
278+
const newOptions = {
279+
limit: options.limit ?? DEFAULT_PAGE_SIZE,
280+
offset,
281+
...options,
282+
};
283+
284+
const filters = filtersArray[appliedFilterIndex.current];
285+
const channelQueryResponse = await client.queryChannels(filters, sort || {}, newOptions);
286+
287+
const newChannels =
288+
queryType === 'reload'
289+
? channelQueryResponse
290+
: uniqBy([...currentChannels, ...channelQueryResponse], 'cid');
291+
292+
setChannels(newChannels);
293+
294+
const lastPageForCurrentFilter = channelQueryResponse.length < newOptions.limit;
295+
const isLastPageForAllFilters =
296+
lastPageForCurrentFilter && appliedFilterIndex.current === filtersArray.length - 1;
297+
298+
setHasNextPage(!isLastPageForAllFilters);
299+
if (lastPageForCurrentFilter) {
300+
appliedFilterIndex.current += 1;
301+
}
302+
},
303+
500,
304+
{ leading: true, trailing: false },
305+
),
306+
[client, filtersArray],
307+
);
308+
309+
return customQueryChannels;
310+
};
311+
```
312+
313+
It is recommended to control for duplicate requests by throttling the custom function calls.
314+
315+
| Type |
316+
|---------------------------------------------------------------------------------------------------|
317+
| <GHComponentLink text='CustomQueryChannelsFn' path='/ChannelList/hooks/usePaginatedChannels.ts'/> |
318+
222319
### EmptyStateIndicator
223320

224321
Custom UI component for rendering an empty list.

src/components/ChannelList/ChannelList.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { useMobileNavigation } from './hooks/useMobileNavigation';
1313
import { useNotificationAddedToChannelListener } from './hooks/useNotificationAddedToChannelListener';
1414
import { useNotificationMessageNewListener } from './hooks/useNotificationMessageNewListener';
1515
import { useNotificationRemovedFromChannelListener } from './hooks/useNotificationRemovedFromChannelListener';
16-
import { usePaginatedChannels } from './hooks/usePaginatedChannels';
16+
import { CustomQueryChannelsFn, usePaginatedChannels } from './hooks/usePaginatedChannels';
1717
import { useUserPresenceChangedListener } from './hooks/useUserPresenceChangedListener';
1818
import { MAX_QUERY_CHANNELS_LIMIT, moveChannelUp } from './utils';
1919

@@ -64,6 +64,8 @@ export type ChannelListProps<
6464
ChannelSearch?: React.ComponentType<ChannelSearchProps<StreamChatGenerics>>;
6565
/** Set a channel (with this ID) to active and manually move it to the top of the list */
6666
customActiveChannel?: string;
67+
/** Custom function that handles the channel pagination. Has to build query filters, sort and options and query and append channels to the current channels state and update the hasNext pagination flag after each query. */
68+
customQueryChannels?: CustomQueryChannelsFn<StreamChatGenerics>;
6769
/** Custom UI component for rendering an empty list, defaults to and accepts same props as: [EmptyStateIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/EmptyStateIndicator/EmptyStateIndicator.tsx) */
6870
EmptyStateIndicator?: React.ComponentType<EmptyStateIndicatorProps>;
6971
/** An object containing channel query filters */
@@ -163,6 +165,7 @@ const UnMemoizedChannelList = <
163165
channelRenderFilterFn,
164166
ChannelSearch = DefaultChannelSearch,
165167
customActiveChannel,
168+
customQueryChannels,
166169
EmptyStateIndicator = DefaultEmptyStateIndicator,
167170
filters,
168171
LoadingErrorIndicator = ChatDown,
@@ -274,6 +277,7 @@ const UnMemoizedChannelList = <
274277
options || DEFAULT_OPTIONS,
275278
activeChannelHandler,
276279
recoveryThrottleIntervalMs,
280+
customQueryChannels,
277281
);
278282

279283
const loadedChannels = channelRenderFilterFn ? channelRenderFilterFn(channels) : channels;

src/components/ChannelList/__tests__/ChannelList.test.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838

3939
import { ChatContext, useChannelListContext, useChatContext } from '../../../context';
4040
import { ChannelListMessenger } from '../ChannelListMessenger';
41+
import { initClientWithChannels } from '../../../mock-builders';
4142

4243
expect.extend(toHaveNoViolations);
4344

@@ -203,6 +204,66 @@ describe('ChannelList', () => {
203204
expect(results).toHaveNoViolations();
204205
});
205206

207+
it('should use custom query channels function instead of default channels query', async () => {
208+
const { channels, client } = await initClientWithChannels({
209+
channelsData: [testChannel1],
210+
});
211+
const props = {
212+
customQueryChannels: jest
213+
.fn()
214+
.mockImplementationOnce(({ currentChannels, setChannels, setHasNextPage }) => {
215+
if (!currentChannels.length) setChannels([channels[0]]);
216+
setHasNextPage(true);
217+
}),
218+
filters: {},
219+
};
220+
const queryChannelsMock = jest.spyOn(client, 'queryChannels').mockImplementationOnce();
221+
222+
let rerender;
223+
await act(async () => {
224+
const result = await render(
225+
<Chat client={client}>
226+
<ChannelList {...props} />
227+
</Chat>,
228+
);
229+
rerender = result.rerender;
230+
});
231+
232+
expect(queryChannelsMock).toHaveBeenCalledTimes(0);
233+
expect(props.customQueryChannels).toHaveBeenCalledTimes(1);
234+
expect(props.customQueryChannels).toHaveBeenCalledWith(
235+
expect.objectContaining({
236+
currentChannels: [],
237+
queryType: 'reload',
238+
setChannels: expect.any(Function),
239+
setHasNextPage: expect.any(Function),
240+
}),
241+
);
242+
243+
await act(async () => {
244+
await rerender(
245+
<Chat client={client}>
246+
<ChannelList {...props} />
247+
</Chat>,
248+
);
249+
});
250+
await act(() => {
251+
fireEvent.click(screen.getByTestId('load-more-button'));
252+
});
253+
254+
expect(queryChannelsMock).toHaveBeenCalledTimes(0);
255+
expect(props.customQueryChannels).toHaveBeenCalledTimes(2);
256+
expect(props.customQueryChannels).toHaveBeenCalledWith(
257+
expect.objectContaining({
258+
currentChannels: [channels[0]],
259+
queryType: 'load-more',
260+
setChannels: expect.any(Function),
261+
setHasNextPage: expect.any(Function),
262+
}),
263+
);
264+
queryChannelsMock.mockRestore();
265+
});
266+
206267
it('should only show filtered channels when a filter function prop is provided', async () => {
207268
const filteredChannel = generateChannel({ channel: { type: 'filtered' } });
208269

src/components/ChannelList/hooks/usePaginatedChannels.ts

Lines changed: 49 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,26 @@ import type { Channel, ChannelFilters, ChannelOptions, ChannelSort, StreamChat }
88
import { useChatContext } from '../../../context/ChatContext';
99

1010
import type { DefaultStreamChatGenerics } from '../../../types/types';
11+
import type { ChannelsQueryState } from '../../Chat/hooks/useChannelsQueryState';
1112

1213
const RECOVER_LOADED_CHANNELS_THROTTLE_INTERVAL_IN_MS = 5000;
1314
const MIN_RECOVER_LOADED_CHANNELS_THROTTLE_INTERVAL_IN_MS = 2000;
1415

16+
type AllowedQueryType = Extract<ChannelsQueryState['queryInProgress'], 'reload' | 'load-more'>;
17+
18+
export type CustomQueryChannelParams<
19+
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
20+
> = {
21+
currentChannels: Array<Channel<StreamChatGenerics>>;
22+
queryType: AllowedQueryType;
23+
setChannels: React.Dispatch<React.SetStateAction<Array<Channel<StreamChatGenerics>>>>;
24+
setHasNextPage: React.Dispatch<React.SetStateAction<boolean>>;
25+
};
26+
27+
export type CustomQueryChannelsFn<
28+
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
29+
> = (params: CustomQueryChannelParams<StreamChatGenerics>) => Promise<void>;
30+
1531
export const usePaginatedChannels = <
1632
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics
1733
>(
@@ -24,6 +40,7 @@ export const usePaginatedChannels = <
2440
setChannels: React.Dispatch<React.SetStateAction<Array<Channel<StreamChatGenerics>>>>,
2541
) => void,
2642
recoveryThrottleIntervalMs: number = RECOVER_LOADED_CHANNELS_THROTTLE_INTERVAL_IN_MS,
43+
customQueryChannels?: CustomQueryChannelsFn<StreamChatGenerics>,
2744
) => {
2845
const {
2946
channelsQueryState: { error, setError, setQueryInProgress },
@@ -43,38 +60,45 @@ export const usePaginatedChannels = <
4360
const sortString = useMemo(() => JSON.stringify(sort), [sort]);
4461

4562
// eslint-disable-next-line react-hooks/exhaustive-deps
46-
const queryChannels = async (queryType?: string) => {
63+
const queryChannels = async (queryType = 'load-more') => {
4764
setError(null);
4865

4966
if (queryType === 'reload') {
5067
setChannels([]);
51-
setQueryInProgress('reload');
52-
} else {
53-
setQueryInProgress('load-more');
5468
}
55-
56-
const offset = queryType === 'reload' ? 0 : channels.length;
57-
58-
const newOptions = {
59-
limit: options?.limit ?? MAX_QUERY_CHANNELS_LIMIT,
60-
offset,
61-
...options,
62-
};
69+
setQueryInProgress(queryType as AllowedQueryType);
6370

6471
try {
65-
const channelQueryResponse = await client.queryChannels(filters, sort || {}, newOptions);
66-
67-
const newChannels =
68-
queryType === 'reload'
69-
? channelQueryResponse
70-
: uniqBy([...channels, ...channelQueryResponse], 'cid');
71-
72-
setChannels(newChannels);
73-
setHasNextPage(channelQueryResponse.length >= newOptions.limit);
74-
75-
// Set active channel only on load of first page
76-
if (!offset && activeChannelHandler) {
77-
activeChannelHandler(newChannels, setChannels);
72+
if (customQueryChannels) {
73+
await customQueryChannels({
74+
currentChannels: channels,
75+
queryType: queryType as AllowedQueryType,
76+
setChannels,
77+
setHasNextPage,
78+
});
79+
} else {
80+
const offset = queryType === 'reload' ? 0 : channels.length;
81+
82+
const newOptions = {
83+
limit: options?.limit ?? MAX_QUERY_CHANNELS_LIMIT,
84+
offset,
85+
...options,
86+
};
87+
88+
const channelQueryResponse = await client.queryChannels(filters, sort || {}, newOptions);
89+
90+
const newChannels =
91+
queryType === 'reload'
92+
? channelQueryResponse
93+
: uniqBy([...channels, ...channelQueryResponse], 'cid');
94+
95+
setChannels(newChannels);
96+
setHasNextPage(channelQueryResponse.length >= newOptions.limit);
97+
98+
// Set active channel only on load of first page
99+
if (!offset && activeChannelHandler) {
100+
activeChannelHandler(newChannels, setChannels);
101+
}
78102
}
79103
} catch (err) {
80104
console.warn(err);

src/components/Chat/hooks/useChannelsQueryState.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ type ChannelQueryState =
99

1010
export interface ChannelsQueryState {
1111
error: ErrorFromResponse<APIErrorResponse> | null;
12-
queryInProgress: ChannelQueryState | null;
12+
queryInProgress: ChannelQueryState;
1313
setError: Dispatch<SetStateAction<ErrorFromResponse<APIErrorResponse> | null>>;
14-
setQueryInProgress: Dispatch<SetStateAction<ChannelQueryState | null>>;
14+
setQueryInProgress: Dispatch<SetStateAction<ChannelQueryState>>;
1515
}
1616

1717
export const useChannelsQueryState = (): ChannelsQueryState => {

0 commit comments

Comments
 (0)