Skip to content

Commit 9fb334c

Browse files
authored
chore: predefined filter support for offline mode (#3595)
## 🎯 Goal This PR introduces missing support for offline handling of predefined filters. ## πŸ›  Implementation details <!-- Provide a description of the implementation --> ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## πŸ§ͺ Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## β˜‘οΈ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
1 parent 669655e commit 9fb334c

13 files changed

Lines changed: 280 additions & 54 deletions

File tree

β€Žexamples/ExpoMessaging/app/index.tsxβ€Ž

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@ import { Alert, Image, Pressable, StyleSheet, View } from 'react-native';
33
import { ChannelList, SqliteClient } from 'stream-chat-expo';
44
import { useCallback, useContext, useMemo } from 'react';
55
import { Stack, useRouter } from 'expo-router';
6-
import { ChannelSort } from 'stream-chat';
76
import { AppContext } from '../context/AppContext';
87
import { useUserContext } from '@/context/UserContext';
98
import { getInitialsOfName } from '@/utils/getInitialsOfName';
109

11-
const sort: ChannelSort = { last_updated: -1 };
12-
const options = {
10+
const baseOptions = {
11+
predefined_filter: 'basic_channel_list_filter',
1312
state: true,
1413
watch: true,
1514
};
@@ -46,12 +45,15 @@ const LogoutButton = () => {
4645

4746
export default function ChannelListScreen() {
4847
const { user } = useUserContext();
49-
const filters = useMemo(
48+
const userId = user?.id || '';
49+
const options = useMemo(
5050
() => ({
51-
members: { $in: [user?.id as string] },
52-
type: 'messaging',
51+
...baseOptions,
52+
filter_values: {
53+
user_id: userId,
54+
},
5355
}),
54-
[user?.id],
56+
[userId],
5557
);
5658
const router = useRouter();
5759
const { setChannel } = useContext(AppContext);
@@ -63,13 +65,11 @@ export default function ChannelListScreen() {
6365
/>
6466

6567
<ChannelList
66-
filters={filters}
6768
onSelect={(channel) => {
6869
setChannel(channel);
6970
router.push(`/channel/${channel.cid}`);
7071
}}
7172
options={options}
72-
sort={sort}
7373
/>
7474
</View>
7575
);

β€Žexamples/SampleApp/src/screens/ChannelListScreen.tsxβ€Ž

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import { MessageSearchList } from '../components/MessageSearch/MessageSearchList
1717
import { useAppContext } from '../context/AppContext';
1818
import { usePaginatedSearchedMessages } from '../hooks/usePaginatedSearchedMessages';
1919

20-
import type { ChannelSort } from 'stream-chat';
2120
import { useStreamChatContext } from '../context/StreamChatContext';
2221
import { Search } from '../icons/Search';
2322
import { ChannelInfo } from '../icons/ChannelInfo.tsx';
@@ -60,18 +59,12 @@ const styles = StyleSheet.create({
6059
},
6160
});
6261

63-
const baseFilters = {
64-
archived: false,
65-
type: 'messaging',
66-
};
67-
68-
const sort: ChannelSort = [{ pinned_at: -1 }, { last_message_at: -1 }, { updated_at: -1 }];
69-
70-
const options = {
62+
const baseOptions = {
7163
presence: true,
7264
state: true,
7365
watch: true,
7466
message_limit: 25,
67+
predefined_filter: 'basic_channel_list_filter',
7568
};
7669

7770
export const ChannelListScreen: React.FC = () => {
@@ -91,15 +84,12 @@ export const ChannelListScreen: React.FC = () => {
9184
usePaginatedSearchedMessages(searchQuery);
9285

9386
const chatClientUserId = chatClient?.user?.id || '';
94-
const filters = useMemo(
95-
() => ({
96-
...baseFilters,
97-
members: {
98-
$in: [chatClientUserId],
99-
},
100-
}),
101-
[chatClientUserId],
102-
);
87+
const options = useMemo(() => ({
88+
...baseOptions,
89+
filter_values: {
90+
user_id: chatClientUserId,
91+
}
92+
}), [chatClientUserId])
10393

10494
useScrollToTop(scrollRef as RefObject<FlatList<Channel>>);
10595

@@ -248,13 +238,11 @@ export const ChannelListScreen: React.FC = () => {
248238
<View style={[styles.channelListContainer, { opacity: searchQuery ? 0 : 1 }]}>
249239
<ChannelList
250240
additionalFlatListProps={additionalFlatListProps}
251-
filters={filters}
252241
maxUnreadCount={99}
253242
onSelect={onSelect}
254243
options={options}
255244
setFlatListRef={setScrollRef}
256245
getChannelActionItems={getChannelActionItems}
257-
sort={sort}
258246
/>
259247
</View>
260248
</View>

β€Žpackage/package.jsonβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@
8383
"path": "0.12.7",
8484
"react-native-markdown-package": "1.8.2",
8585
"react-native-url-polyfill": "^2.0.0",
86-
"stream-chat": "^9.43.2",
86+
"stream-chat": "^9.44.1",
8787
"use-sync-external-store": "^1.5.0"
8888
},
8989
"peerDependencies": {

β€Žpackage/src/components/ChannelList/__tests__/ChannelList.test.tsxβ€Ž

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,74 @@ describe('ChannelList', () => {
184184
});
185185
});
186186

187+
it('should re-query channels when predefined filter options change', async () => {
188+
const queryChannelsSpy = jest.spyOn(chatClient, 'queryChannels');
189+
useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);
190+
191+
render(
192+
<Chat client={chatClient}>
193+
<WithComponents overrides={{ ChannelPreview: ChannelPreviewComponent }}>
194+
<ChannelList
195+
{...props}
196+
options={{
197+
filter_values: { user_id: 'dan' },
198+
predefined_filter: 'user_messaging',
199+
}}
200+
/>
201+
</WithComponents>
202+
</Chat>,
203+
);
204+
205+
await waitFor(() => {
206+
expect(screen.getByTestId(testChannel1.channel.id)).toBeTruthy();
207+
});
208+
209+
expect(queryChannelsSpy).toHaveBeenNthCalledWith(
210+
1,
211+
{},
212+
expect.anything(),
213+
expect.objectContaining({
214+
filter_values: { user_id: 'dan' },
215+
offset: 0,
216+
predefined_filter: 'user_messaging',
217+
}),
218+
expect.anything(),
219+
);
220+
221+
useMockedApis(chatClient, [queryChannelsApi([testChannel2])]);
222+
223+
screen.rerender(
224+
<Chat client={chatClient}>
225+
<WithComponents overrides={{ ChannelPreview: ChannelPreviewComponent }}>
226+
<ChannelList
227+
{...props}
228+
options={{
229+
filter_values: { user_id: 'sara' },
230+
predefined_filter: 'user_messaging',
231+
}}
232+
/>
233+
</WithComponents>
234+
</Chat>,
235+
);
236+
237+
await waitFor(() => {
238+
expect(queryChannelsSpy).toHaveBeenCalledTimes(2);
239+
expect(screen.getByTestId(testChannel2.channel.id)).toBeTruthy();
240+
});
241+
242+
expect(queryChannelsSpy).toHaveBeenNthCalledWith(
243+
2,
244+
{},
245+
expect.anything(),
246+
expect.objectContaining({
247+
filter_values: { user_id: 'sara' },
248+
offset: 0,
249+
predefined_filter: 'user_messaging',
250+
}),
251+
expect.anything(),
252+
);
253+
});
254+
187255
it('should update if filters are updated while awaiting api call', async () => {
188256
const deferredCallForStaleFilter = new DeferredPromise();
189257
const deferredCallForFreshFilter = new DeferredPromise();

β€Žpackage/src/components/ChannelList/hooks/usePaginatedChannels.tsβ€Ž

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export const usePaginatedChannels = ({
5353
const hasNextPage = pagination?.hasNext;
5454

5555
const filtersRef = useRef<typeof filters | null>(null);
56+
const optionsRef = useRef<typeof options | null>(null);
5657
const sortRef = useRef<typeof sort | null>(null);
5758
const activeRequestId = useRef<number>(0);
5859
const isQueryingRef = useRef(false);
@@ -69,10 +70,9 @@ export const usePaginatedChannels = ({
6970
queryType === 'loadChannels' ||
7071
queryType === 'refresh' ||
7172
queryType === 'backgroundRefresh' ||
72-
[
73-
JSON.stringify(filtersRef.current) !== JSON.stringify(filters),
74-
JSON.stringify(sortRef.current) !== JSON.stringify(sort),
75-
].some(Boolean);
73+
JSON.stringify(filtersRef.current) !== JSON.stringify(filters) ||
74+
JSON.stringify(optionsRef.current) !== JSON.stringify(options) ||
75+
JSON.stringify(sortRef.current) !== JSON.stringify(sort);
7676

7777
const isQueryStale = () => !isMountedRef || activeRequestId.current !== currentRequestId;
7878

@@ -87,6 +87,7 @@ export const usePaginatedChannels = ({
8787
}
8888

8989
filtersRef.current = filters;
90+
optionsRef.current = options;
9091
sortRef.current = sort;
9192
isQueryingRef.current = true;
9293
activeRequestId.current++;
@@ -146,7 +147,7 @@ export const usePaginatedChannels = ({
146147
};
147148

148149
/**
149-
* Equality check using stringified filters/sort ensure that we don't make un-necessary queryChannels api calls
150+
* Equality check using stringified filters/options/sort ensure that we don't make un-necessary queryChannels api calls
150151
* for the scenario:
151152
*
152153
* <ChannelList
@@ -161,6 +162,7 @@ export const usePaginatedChannels = ({
161162
* in return will trigger useEffect. To avoid this, we can add a value check.
162163
*/
163164
const filterStr = useMemo(() => JSON.stringify(filters), [filters]);
165+
const optionsStr = useMemo(() => JSON.stringify(options), [options]);
164166
const sortStr = useMemo(() => JSON.stringify(sort), [sort]);
165167

166168
useEffect(() => {
@@ -178,7 +180,7 @@ export const usePaginatedChannels = ({
178180

179181
return () => listener?.unsubscribe?.();
180182
// eslint-disable-next-line react-hooks/exhaustive-deps
181-
}, [filterStr, sortStr, channelManager]);
183+
}, [filterStr, optionsStr, sortStr, channelManager]);
182184

183185
return {
184186
channelListInitialized,

β€Žpackage/src/store/OfflineDB.tsβ€Ž

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ export class OfflineDB extends AbstractOfflineDB {
5151
api.getChannels({ channelIds: cids, currentUserId: userId });
5252

5353
// TODO: Rename currentUserId -> userId in the next major version as it is technically breaking.
54-
getChannelsForQuery = ({ userId, filters, sort }: DBGetChannelsForQueryType) =>
55-
api.getChannelsForFilterSort({ currentUserId: userId, filters, sort });
54+
getChannelsForQuery = ({ userId, filters, options, sort }: DBGetChannelsForQueryType) =>
55+
api.getChannelsForFilterSort({ currentUserId: userId, filters, options, sort });
5656

5757
getAllChannelCids = api.getAllChannelIds;
5858

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { BetterSqlite } from '../../../test-utils/BetterSqlite';
2+
import { SqliteClient } from '../../SqliteClient';
3+
import { selectChannelIdsForFilterSort } from '../queries/selectChannelIdsForFilterSort';
4+
import { upsertCidsForQuery } from '../upsertCidsForQuery';
5+
6+
describe('channel query cids', () => {
7+
beforeEach(async () => {
8+
await SqliteClient.initializeDatabase();
9+
await BetterSqlite.openDB();
10+
});
11+
12+
afterEach(() => {
13+
BetterSqlite.dropAllTables();
14+
BetterSqlite.closeDB();
15+
jest.clearAllMocks();
16+
});
17+
18+
it('stores separate cid lists for predefined filter queries with the same filters and sort', async () => {
19+
await upsertCidsForQuery({
20+
cids: ['messaging:channel-1'],
21+
filters: {},
22+
options: {
23+
predefined_filter: 'user_messaging',
24+
},
25+
sort: {},
26+
});
27+
await upsertCidsForQuery({
28+
cids: ['messaging:channel-2'],
29+
filters: {},
30+
options: {
31+
predefined_filter: 'team_channels',
32+
},
33+
sort: {},
34+
});
35+
36+
await expect(
37+
selectChannelIdsForFilterSort({
38+
filters: {},
39+
options: {
40+
predefined_filter: 'user_messaging',
41+
},
42+
sort: {},
43+
}),
44+
).resolves.toEqual(['messaging:channel-1']);
45+
await expect(
46+
selectChannelIdsForFilterSort({
47+
filters: {},
48+
options: {
49+
predefined_filter: 'team_channels',
50+
},
51+
sort: {},
52+
}),
53+
).resolves.toEqual(['messaging:channel-2']);
54+
});
55+
});

β€Žpackage/src/store/apis/getChannelsForFilterSort.tsβ€Ž

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ChannelAPIResponse, ChannelFilters, ChannelSort } from 'stream-chat';
1+
import type { ChannelAPIResponse, ChannelFilters, ChannelOptions, ChannelSort } from 'stream-chat';
22

33
import { getChannels } from './getChannels';
44
import { selectChannelIdsForFilterSort } from './queries/selectChannelIdsForFilterSort';
@@ -18,20 +18,24 @@ import { SqliteClient } from '../SqliteClient';
1818
export const getChannelsForFilterSort = async ({
1919
currentUserId,
2020
filters,
21+
options,
2122
sort,
2223
}: {
2324
currentUserId: string;
2425
filters?: ChannelFilters;
26+
options?: ChannelOptions;
2527
sort?: ChannelSort;
2628
}): Promise<Omit<ChannelAPIResponse, 'duration'>[] | null> => {
27-
if (!filters && !sort) {
28-
console.warn('Please provide the query (filters/sort) to fetch channels from DB');
29+
if (!filters && !sort && !options?.predefined_filter) {
30+
console.warn(
31+
'Please provide the query (filters/sort/options.predefined_filter) to fetch channels from the DB.',
32+
);
2933
return null;
3034
}
3135

32-
SqliteClient.logger?.('info', 'getChannelsForFilterSort', { filters, sort });
36+
SqliteClient.logger?.('info', 'getChannelsForFilterSort', { filters, options, sort });
3337

34-
const channelIds = await selectChannelIdsForFilterSort({ filters, sort });
38+
const channelIds = await selectChannelIdsForFilterSort({ filters, options, sort });
3539

3640
if (!channelIds) {
3741
return null;

β€Žpackage/src/store/apis/queries/selectChannelIdsForFilterSort.tsβ€Ž

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ChannelFilters, ChannelSort } from 'stream-chat';
1+
import type { ChannelFilters, ChannelOptions, ChannelSort } from 'stream-chat';
22

33
import { createSelectQuery } from '../../sqlite-utils/createSelectQuery';
44
import { SqliteClient } from '../../SqliteClient';
@@ -17,12 +17,14 @@ import { convertFilterSortToQuery } from '../utils/convertFilterSortToQuery';
1717

1818
export const selectChannelIdsForFilterSort = async ({
1919
filters,
20+
options,
2021
sort,
2122
}: {
2223
filters?: ChannelFilters;
24+
options?: ChannelOptions;
2325
sort?: ChannelSort;
2426
}): Promise<string[] | null> => {
25-
const query = convertFilterSortToQuery({ filters, sort });
27+
const query = convertFilterSortToQuery({ filters, options, sort });
2628

2729
SqliteClient.logger?.('info', 'selectChannelIdsForFilterSort', {
2830
query,

0 commit comments

Comments
Β (0)