Skip to content

Commit 16072bd

Browse files
committed
Merge branch 'master' into feat/notification-min-display
2 parents f197654 + a422d4b commit 16072bd

6 files changed

Lines changed: 283 additions & 15 deletions

File tree

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103
"emoji-mart": "^5.4.0",
104104
"react": "^19.0.0 || ^18.0.0 || ^17.0.0",
105105
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0",
106-
"stream-chat": "^9.43.0",
106+
"stream-chat": "^9.44.2",
107107
"modern-normalize": "^3.0.1"
108108
},
109109
"peerDependenciesMeta": {
@@ -174,7 +174,7 @@
174174
"react-dom": "^19.0.0",
175175
"sass": "^1.97.2",
176176
"semantic-release": "^25.0.3",
177-
"stream-chat": "^9.43.0",
177+
"stream-chat": "^9.44.2",
178178
"typescript": "^5.4.5",
179179
"typescript-eslint": "^8.17.0",
180180
"vite": "^7.3.1",

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', () => {

0 commit comments

Comments
 (0)