Skip to content

Commit cb19a66

Browse files
committed
feat: reflect predefined filters in the channel list ordering
1 parent 237b139 commit cb19a66

3 files changed

Lines changed: 273 additions & 9 deletions

File tree

src/components/ChannelList/ChannelList.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import type {
1111
} from 'stream-chat';
1212

1313
import { useConnectionRecoveredListener } from './hooks/useConnectionRecoveredListener';
14-
import type { CustomQueryChannelsFn } from './hooks/usePaginatedChannels';
14+
import type {
15+
CustomQueryChannelsFn,
16+
EffectiveQueryParams,
17+
} from './hooks/usePaginatedChannels';
1518
import { usePaginatedChannels } from './hooks/usePaginatedChannels';
1619
import {
1720
useChannelListShape,
@@ -211,6 +214,7 @@ const UnMemoizedChannelList = (props: ChannelListProps) => {
211214
const activeChannelHandler = async (
212215
channels: Array<Channel>,
213216
setChannels: React.Dispatch<React.SetStateAction<Array<Channel>>>,
217+
effectiveQueryParams: EffectiveQueryParams,
214218
) => {
215219
if (!channels.length) {
216220
return;
@@ -234,7 +238,7 @@ const UnMemoizedChannelList = (props: ChannelListProps) => {
234238
const newChannels = moveChannelUpwards({
235239
channels,
236240
channelToMove: customActiveChannelObject,
237-
sort,
241+
sort: effectiveQueryParams.sort,
238242
});
239243

240244
setChannels(newChannels);
@@ -253,7 +257,14 @@ const UnMemoizedChannelList = (props: ChannelListProps) => {
253257
*/
254258
const forceUpdate = useCallback(() => setChannelUpdateCount((count) => count + 1), []);
255259

256-
const { channels, hasNextPage, loadNextPage, setChannels } = usePaginatedChannels(
260+
const {
261+
channels,
262+
effectiveFilters,
263+
effectiveSort,
264+
hasNextPage,
265+
loadNextPage,
266+
setChannels,
267+
} = usePaginatedChannels(
257268
client,
258269
filters || DEFAULT_FILTERS,
259270
sort || DEFAULT_SORT,
@@ -269,7 +280,11 @@ const UnMemoizedChannelList = (props: ChannelListProps) => {
269280

270281
const { customHandler, defaultHandler } = usePrepareShapeHandlers({
271282
allowNewMessagesFromUnfilteredChannels,
272-
filters,
283+
// `effectiveFilters`/`effectiveSort` reflect the backend-resolved
284+
// `predefined_filter` metadata when `options.predefined_filter` is in use.
285+
// For non-predefined queries they fall back to the caller-supplied
286+
// `filters`/`sort` props so behavior is unchanged.
287+
filters: effectiveFilters,
273288
lockChannelOrder,
274289
onAddedToChannel,
275290
onChannelDeleted,
@@ -281,7 +296,7 @@ const UnMemoizedChannelList = (props: ChannelListProps) => {
281296
onMessageNewHandler,
282297
onRemovedFromChannel,
283298
setChannels,
284-
sort,
299+
sort: effectiveSort,
285300
// TODO: implement
286301
// customHandleChannelListShape
287302
});

src/components/ChannelList/__tests__/ChannelList.test.tsx

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1876,6 +1876,192 @@ describe('ChannelList', () => {
18761876
expect(results).toHaveNoViolations();
18771877
});
18781878
});
1879+
1880+
describe('predefined_filter response metadata', () => {
1881+
// Resolved `predefined_filter` from queryChannels response overrides the
1882+
// caller-supplied `filters`/`sort` props for local WS-driven channel list
1883+
// mutation decisions. See stream-chat PR #1747 for the underlying SDK
1884+
// change.
1885+
1886+
const mockQueryChannelsResponseWithPredefinedFilter = ({
1887+
channels,
1888+
filter,
1889+
sort,
1890+
}: {
1891+
channels: ChannelAPIResponse[];
1892+
filter: Record<string, unknown>;
1893+
sort?: { direction?: 1 | -1; field: string }[];
1894+
}) => {
1895+
(vi.spyOn(chatClient.axiosInstance, 'post') as unknown as Mock).mockResolvedValue(
1896+
{
1897+
data: {
1898+
channels,
1899+
duration: '0.01ms',
1900+
predefined_filter: {
1901+
filter,
1902+
name: 'messaging_channels',
1903+
sort,
1904+
},
1905+
},
1906+
status: 200,
1907+
},
1908+
);
1909+
};
1910+
1911+
const channelListProps = {
1912+
filters: {},
1913+
options: {
1914+
limit: 25,
1915+
message_limit: 25,
1916+
predefined_filter: 'messaging_channels',
1917+
},
1918+
};
1919+
1920+
it('does not promote an archived channel when resolved filter excludes archived', async () => {
1921+
mockQueryChannelsResponseWithPredefinedFilter({
1922+
channels: [testChannel1, testChannel2, testChannel3],
1923+
filter: { archived: false },
1924+
});
1925+
1926+
const { getAllByRole, getByRole } = await render(
1927+
<Chat client={chatClient}>
1928+
<WithComponents
1929+
overrides={{
1930+
ChannelListItemUI: ChannelPreviewComponent,
1931+
ChannelListUI: ChannelListComponent,
1932+
}}
1933+
>
1934+
<ChannelList {...channelListProps} />
1935+
</WithComponents>
1936+
</Chat>,
1937+
);
1938+
1939+
await waitFor(() => {
1940+
expect(getByRole('list')).toBeInTheDocument();
1941+
});
1942+
1943+
// Mark target channel as archived after it has been loaded into the
1944+
// list so the response is still "non-archived" but local state for the
1945+
// channel is archived.
1946+
const targetChannel = chatClient.activeChannels[testChannel3.channel.cid];
1947+
targetChannel.state.membership = {
1948+
archived_at: '2024-01-15T10:30:00Z',
1949+
};
1950+
1951+
const itemsBefore = getAllByRole('listitem').map((el) =>
1952+
el.getAttribute('data-testid'),
1953+
);
1954+
1955+
await act(() => {
1956+
dispatchMessageNewEvent(
1957+
chatClient,
1958+
generateMessage({ user: generateUser() }),
1959+
testChannel3.channel,
1960+
);
1961+
});
1962+
1963+
const itemsAfter = getAllByRole('listitem').map((el) =>
1964+
el.getAttribute('data-testid'),
1965+
);
1966+
1967+
expect(itemsAfter).toStrictEqual(itemsBefore);
1968+
});
1969+
1970+
it('does not move a pinned channel when resolved sort considers pinned_at', async () => {
1971+
mockQueryChannelsResponseWithPredefinedFilter({
1972+
channels: [testChannel1, testChannel2, testChannel3],
1973+
filter: {},
1974+
sort: [{ direction: -1, field: 'pinned_at' }],
1975+
});
1976+
1977+
const { getAllByRole, getByRole } = await render(
1978+
<Chat client={chatClient}>
1979+
<WithComponents
1980+
overrides={{
1981+
ChannelListItemUI: ChannelPreviewComponent,
1982+
ChannelListUI: ChannelListComponent,
1983+
}}
1984+
>
1985+
<ChannelList {...channelListProps} />
1986+
</WithComponents>
1987+
</Chat>,
1988+
);
1989+
1990+
await waitFor(() => {
1991+
expect(getByRole('list')).toBeInTheDocument();
1992+
});
1993+
1994+
// Mark target channel as pinned in local state after it has been
1995+
// loaded.
1996+
const targetChannel = chatClient.activeChannels[testChannel3.channel.cid];
1997+
targetChannel.state.membership = {
1998+
pinned_at: '2024-01-15T10:30:00Z',
1999+
};
2000+
2001+
const itemsBefore = getAllByRole('listitem').map((el) =>
2002+
el.getAttribute('data-testid'),
2003+
);
2004+
2005+
await act(() => {
2006+
dispatchMessageNewEvent(
2007+
chatClient,
2008+
generateMessage({ user: generateUser() }),
2009+
testChannel3.channel,
2010+
);
2011+
});
2012+
2013+
const itemsAfter = getAllByRole('listitem').map((el) =>
2014+
el.getAttribute('data-testid'),
2015+
);
2016+
2017+
expect(itemsAfter).toStrictEqual(itemsBefore);
2018+
});
2019+
2020+
it('falls back to caller filters/sort when response has no predefined_filter', async () => {
2021+
useMockedApis(chatClient, [
2022+
queryChannelsApi([testChannel1, testChannel2, testChannel3]),
2023+
]);
2024+
2025+
const { getAllByRole, getByRole, getByText } = await render(
2026+
<Chat client={chatClient}>
2027+
<WithComponents
2028+
overrides={{
2029+
ChannelListItemUI: ChannelPreviewComponent,
2030+
ChannelListUI: ChannelListComponent,
2031+
}}
2032+
>
2033+
<ChannelList filters={{}} options={{ limit: 25, message_limit: 25 }} />
2034+
</WithComponents>
2035+
</Chat>,
2036+
);
2037+
2038+
await waitFor(() => {
2039+
expect(getByRole('list')).toBeInTheDocument();
2040+
});
2041+
2042+
// Even with archived local membership, the absence of an effective
2043+
// `archived` filter means the channel should still be promoted.
2044+
const targetChannel = chatClient.activeChannels[testChannel3.channel.cid];
2045+
targetChannel.state.membership = {
2046+
archived_at: '2024-01-15T10:30:00Z',
2047+
};
2048+
2049+
const newMessage = generateMessage({ user: generateUser() });
2050+
await act(() => {
2051+
dispatchMessageNewEvent(chatClient, newMessage, testChannel3.channel);
2052+
});
2053+
2054+
await waitFor(() => {
2055+
expect(getByText(newMessage.text)).toBeInTheDocument();
2056+
});
2057+
2058+
const items = getAllByRole('listitem');
2059+
const channelPreview = getByText(newMessage.text).closest(
2060+
ROLE_LIST_ITEM_SELECTOR,
2061+
);
2062+
expect(channelPreview?.isEqualNode(items[0])).toBe(true);
2063+
});
2064+
});
18792065
});
18802066

18812067
describe('on connection recovery', () => {

src/components/ChannelList/hooks/usePaginatedChannels.ts

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
ChannelOptions,
99
ChannelSort,
1010
ErrorFromResponse,
11+
ParsedPredefinedFilterResponse,
1112
StreamChat,
1213
} from 'stream-chat';
1314

@@ -34,6 +35,27 @@ export type CustomQueryChannelParams = {
3435

3536
export type CustomQueryChannelsFn = (params: CustomQueryChannelParams) => Promise<void>;
3637

38+
/**
39+
* Filters and sort effectively used by the channel list. When `options.predefined_filter`
40+
* is set, these reflect the backend-resolved values from `predefined_filter` response
41+
* metadata; otherwise they fall back to the caller-supplied `filters`/`sort`.
42+
*/
43+
export type EffectiveQueryParams = {
44+
filters: ChannelFilters;
45+
sort: ChannelSort;
46+
};
47+
48+
/**
49+
* The `predefined_filter` response carries sort as
50+
* `[{ field, direction }]`. `ChannelSort` expects `[{ field: direction }]`.
51+
*/
52+
const mapPredefinedFilterSortToChannelSort = (
53+
sort: NonNullable<ParsedPredefinedFilterResponse['sort']>,
54+
): ChannelSort =>
55+
sort.map(({ direction = 1, field }) => ({
56+
[field]: direction,
57+
})) as ChannelSort;
58+
3759
export const usePaginatedChannels = (
3860
client: StreamChat,
3961
filters: ChannelFilters,
@@ -42,6 +64,7 @@ export const usePaginatedChannels = (
4264
activeChannelHandler: (
4365
channels: Array<Channel>,
4466
setChannels: React.Dispatch<React.SetStateAction<Array<Channel>>>,
67+
effectiveQueryParams: EffectiveQueryParams,
4568
) => void,
4669
recoveryThrottleIntervalMs: number = RECOVER_LOADED_CHANNELS_THROTTLE_INTERVAL_IN_MS,
4770
customQueryChannels?: CustomQueryChannelsFn,
@@ -53,6 +76,14 @@ export const usePaginatedChannels = (
5376
const { t } = useTranslationContext();
5477
const [channels, setChannels] = useState<Array<Channel>>([]);
5578
const [hasNextPage, setHasNextPage] = useState(true);
79+
// Backend-resolved filter/sort from `predefined_filter` response metadata.
80+
// Used to override the caller `filters`/`sort` for local WS-driven list
81+
// mutation decisions (archiving, pinning) when a predefined filter is in
82+
// use. Stays `undefined` for non-predefined queries.
83+
const [responseFilters, setResponseFilters] = useState<ChannelFilters | undefined>(
84+
undefined,
85+
);
86+
const [responseSort, setResponseSort] = useState<ChannelSort | undefined>(undefined);
5687
const lastRecoveryTimestamp = useRef<number | undefined>(undefined);
5788

5889
const recoveryThrottleInterval =
@@ -82,6 +113,10 @@ export const usePaginatedChannels = (
82113
setChannels,
83114
setHasNextPage,
84115
});
116+
// `customQueryChannels` bypasses the SDK response so any previously
117+
// resolved predefined-filter metadata is no longer trustworthy.
118+
setResponseFilters(undefined);
119+
setResponseSort(undefined);
85120
} else {
86121
const newOptions = {
87122
offset,
@@ -92,19 +127,38 @@ export const usePaginatedChannels = (
92127
filters,
93128
sort || {},
94129
newOptions,
130+
{ withResponse: true },
95131
);
96132

97133
const newChannels =
98134
queryType === 'reload'
99-
? channelQueryResponse
100-
: uniqBy([...channels, ...channelQueryResponse], 'cid');
135+
? channelQueryResponse.channels
136+
: uniqBy([...channels, ...channelQueryResponse.channels], 'cid');
101137

102138
setChannels(newChannels);
103-
setHasNextPage(channelQueryResponse.length >= (newOptions.limit ?? 1));
139+
setHasNextPage(channelQueryResponse.channels.length >= (newOptions.limit ?? 1));
140+
141+
// Pull backend-resolved filter/sort from `predefined_filter` metadata so
142+
// WS-driven list mutations use the effective semantics. Always reset
143+
// first; non-predefined queries do not return this metadata and keeping
144+
// stale values would silently change list behavior.
145+
const predefinedFilter = channelQueryResponse.predefined_filter;
146+
const nextResponseFilters = predefinedFilter
147+
? (predefinedFilter.filter as ChannelFilters)
148+
: undefined;
149+
const nextResponseSort = predefinedFilter?.sort
150+
? mapPredefinedFilterSortToChannelSort(predefinedFilter.sort)
151+
: undefined;
152+
153+
setResponseFilters(nextResponseFilters);
154+
setResponseSort(nextResponseSort);
104155

105156
// Set active channel only on load of first page
106157
if (!offset && activeChannelHandler) {
107-
activeChannelHandler(newChannels, setChannels);
158+
activeChannelHandler(newChannels, setChannels, {
159+
filters: nextResponseFilters ?? filters,
160+
sort: nextResponseSort ?? sort,
161+
});
108162
}
109163
}
110164
} catch (error) {
@@ -165,8 +219,17 @@ export const usePaginatedChannels = (
165219
// eslint-disable-next-line react-hooks/exhaustive-deps
166220
}, [filterString, sortString]);
167221

222+
// Effective filters/sort: response-derived values take precedence over
223+
// caller-supplied props when a predefined filter is in use. Falls back to
224+
// the caller props for non-predefined queries and during the initial load
225+
// before the first response.
226+
const effectiveFilters = responseFilters ?? filters;
227+
const effectiveSort = responseSort ?? sort;
228+
168229
return {
169230
channels,
231+
effectiveFilters,
232+
effectiveSort,
170233
hasNextPage,
171234
loadNextPage,
172235
setChannels,

0 commit comments

Comments
 (0)