From e51876d5880a1862c352cfa36be85464e6f3cbaf Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 5 Aug 2026 15:32:06 +0200 Subject: [PATCH 1/4] feat: rename ChannelPaginatorsOrchestrator to ChannelManager --- ai-docs/ai-migration-v14-v15.md | 2 +- .../src/2-core-component-setup/App.tsx | 11 ++++-- examples/tutorial/src/3-channel-list/App.tsx | 21 ++++------ .../src/4-custom-ui-components/App.tsx | 28 ++++++++------ .../src/5-custom-attachment-type/App.tsx | 38 ++++++++++++------- examples/tutorial/src/6-emoji-picker/App.tsx | 11 ++++-- examples/tutorial/src/7-livestream/App.tsx | 11 ++++-- examples/vite/src/App.tsx | 18 ++++----- .../AppSettings/tabs/General/GeneralTab.tsx | 11 ++---- .../SwitchableChannelNavigation.tsx | 13 +++---- .../vite/src/ChatLayout/WorkspaceUrlSync.tsx | 21 ++++------ .../src/SingleChannel/SingleChannelApp.tsx | 15 +++----- src/components/ChannelList/ChannelList.tsx | 11 ++---- src/components/ChannelList/ChannelLists.tsx | 17 ++++----- src/components/Chat/Chat.tsx | 16 ++++---- .../Chat/hooks/useCreateChatContext.ts | 6 +-- .../InfiniteScrollWithComponents.tsx | 2 +- ...essageAlsoSentInChannelNavigation.test.tsx | 2 +- .../useMessageAlsoSentInChannelNavigation.ts | 4 +- .../Search/SearchResults/SearchResultItem.tsx | 34 +++++------------ .../__tests__/SearchResultItem.test.tsx | 7 ++-- src/context/ChannelListContext.tsx | 2 +- src/context/ChatContext.tsx | 6 +-- .../ChannelMemberActions.defaults.tsx | 6 +-- 24 files changed, 150 insertions(+), 163 deletions(-) diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 6e1bc9878..09c62e95c 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -59,7 +59,7 @@ There is no `setActiveChannel` on `ChatContext`. Bind a channel by: - passing it directly as the `channel` prop: `` (the `Channel` component takes `channel` as a prop; it no longer reads it from context), and/or - opening it in a `ChatView` layout slot — e.g. `open({ key: channel.cid, kind: 'channel', source: channel })` — the mechanism `ChannelListItemUI` uses on selection. -To ingest an ad-hoc channel (e.g. navigating to a DM or search result) into the paginators, use `channelPaginatorsOrchestrator.ingestChannel(channel)`. Confirm the exact `open()` / orchestrator signatures against the installed source. +To ingest an ad-hoc channel (e.g. navigating to a DM or search result) into the paginators, use `channelManager.ingestChannel(channel)`. Confirm the exact `open()` / orchestrator signatures against the installed source. ### `ChatContext.channelsQueryState` → removed diff --git a/examples/tutorial/src/2-core-component-setup/App.tsx b/examples/tutorial/src/2-core-component-setup/App.tsx index 34f159342..652dc793c 100644 --- a/examples/tutorial/src/2-core-component-setup/App.tsx +++ b/examples/tutorial/src/2-core-component-setup/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import type { Channel as StreamChannel } from 'stream-chat'; -import { type User } from 'stream-chat'; +import { type ClientUser } from 'stream-chat'; import { Channel, ChannelHeader, @@ -15,7 +15,7 @@ import 'stream-chat-react/dist/css/index.css'; import './layout.css'; import { apiKey, tokenProvider, userId, userName } from '../1-client-setup/credentials'; -const user: User = { +const user: ClientUser = { id: userId, name: userName, image: `https://getstream.io/random_png/?name=${userName}`, @@ -33,9 +33,12 @@ const App = () => { if (!client) return; const channel = client.channel('messaging', 'custom_channel_id', { - image: 'https://getstream.io/random_png/?name=react', - name: 'Talk about React', members: [userId], + // custom channel fields live under `custom` since v10 + custom: { + image: 'https://getstream.io/random_png/?name=react', + name: 'Talk about React', + }, }); setChannel(channel); diff --git a/examples/tutorial/src/3-channel-list/App.tsx b/examples/tutorial/src/3-channel-list/App.tsx index 2f4655780..e2a896191 100644 --- a/examples/tutorial/src/3-channel-list/App.tsx +++ b/examples/tutorial/src/3-channel-list/App.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; -import type { ChannelFilters, ChannelSort, User } from 'stream-chat'; -import { ChannelPaginator, ChannelPaginatorsOrchestrator } from 'stream-chat'; +import type { ChannelFilters, ChannelSort, ClientUser } from 'stream-chat'; +import { ChannelManager, ChannelPaginator } from 'stream-chat'; import { Channel, ChannelHeader, @@ -16,7 +16,7 @@ import { ChatView, useSlotChannels } from 'stream-chat-react/slot-layout'; import './layout.css'; import { apiKey, tokenProvider, userId, userName } from '../1-client-setup/credentials'; -const user: User = { +const user: ClientUser = { id: userId, name: userName, image: `https://getstream.io/random_png/?name=${userName}`, @@ -61,11 +61,11 @@ const App = () => { }); // Channel-list query config (filters/sort) now lives on a `ChannelPaginator`, - // coordinated by the `ChannelPaginatorsOrchestrator` passed to ``. - const channelPaginatorsOrchestrator = useMemo( + // coordinated by the `ChannelManager` passed to ``. + const channelManager = useMemo( () => client && - new ChannelPaginatorsOrchestrator({ + new ChannelManager({ client, paginators: [ new ChannelPaginator({ client, filters, id: 'channels:default', sort }), @@ -74,15 +74,10 @@ const App = () => { [client], ); - if (!client || !channelPaginatorsOrchestrator) - return
Setting up client & connection...
; + if (!client || !channelManager) return
Setting up client & connection...
; return ( - + }} /> ); diff --git a/examples/tutorial/src/4-custom-ui-components/App.tsx b/examples/tutorial/src/4-custom-ui-components/App.tsx index 46274e525..2d4f726a2 100644 --- a/examples/tutorial/src/4-custom-ui-components/App.tsx +++ b/examples/tutorial/src/4-custom-ui-components/App.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState } from 'react'; -import type { User } from 'stream-chat'; +import { useEffect, useState } from 'react'; +import type { ClientUser } from 'stream-chat'; import { Channel, ChannelAvatar, @@ -9,6 +9,7 @@ import { Chat, MessageComposer, MessageList, + SummarizedMessagePreview, Thread, useCreateChatClient, useMessageContext, @@ -23,7 +24,7 @@ import { import './layout.css'; import { apiKey, tokenProvider, userId, userName } from '../1-client-setup/credentials'; -const user: User = { +const user: ClientUser = { id: userId, name: userName, image: `https://getstream.io/random_png/?name=${userName}`, @@ -34,7 +35,7 @@ const CustomChannelListItem = ({ channel, displayImage, displayTitle, - latestMessagePreview, + previewedMessage, }: ChannelListItemUIProps) => { // Selection is one navigation model: open the channel into a layout slot. const { open } = useChatViewNavigation(); @@ -59,14 +60,16 @@ const CustomChannelListItem = ({ type='button' >
-
{displayTitle ?? channel.data?.name ?? 'Unnamed Channel'}
- {latestMessagePreview ? ( -
{latestMessagePreview}
+
{displayTitle ?? channel.data?.custom?.name ?? 'Unnamed Channel'}
+ {previewedMessage ? ( +
+ +
) : null}
@@ -137,9 +140,12 @@ const App = () => { const initChannel = async () => { const channel = client.channel('messaging', 'react-tutorial', { - image: 'https://getstream.io/random_png/?name=react-v14', - name: 'Talk about React', members: [userId], + // custom channel fields live under `custom` since v10 + custom: { + image: 'https://getstream.io/random_png/?name=react-v14', + name: 'Talk about React', + }, }); await channel.watch(); diff --git a/examples/tutorial/src/5-custom-attachment-type/App.tsx b/examples/tutorial/src/5-custom-attachment-type/App.tsx index f69598573..6e14b9a3e 100644 --- a/examples/tutorial/src/5-custom-attachment-type/App.tsx +++ b/examples/tutorial/src/5-custom-attachment-type/App.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react'; import type { Attachment as AttachmentType, + ClientUser, Channel as StreamChannel, - User, } from 'stream-chat'; import { Attachment, @@ -20,7 +20,7 @@ import { import './layout.css'; import { apiKey, tokenProvider, userId, userName } from '../1-client-setup/credentials'; -const user: User = { +const user: ClientUser = { id: userId, name: userName, image: `https://getstream.io/random_png/?name=${userName}`, @@ -28,10 +28,14 @@ const user: User = { const attachments: AttachmentType[] = [ { - image: 'https://images-na.ssl-images-amazon.com/images/I/71k0cry-ceL._SL1500_.jpg', - name: 'iPhone', type: 'product', - url: 'https://goo.gl/ppFmcR', + // fields that are not part of the Attachment API go under `custom` — this example declares them + // through module augmentation in ./stream-chat.d.ts + custom: { + image: 'https://images-na.ssl-images-amazon.com/images/I/71k0cry-ceL._SL1500_.jpg', + name: 'iPhone', + url: 'https://goo.gl/ppFmcR', + }, }, ]; @@ -55,14 +59,16 @@ const CustomAttachment = (props: AttachmentProps) => {
Product recommendation
- + custom-attachment -
{attachment.name}
+
+ {attachment.custom?.name} +
); @@ -84,21 +90,27 @@ const App = () => { const initChannel = async () => { const channel = client.channel('messaging', 'react-tutorial-products', { - image: 'https://getstream.io/random_png/?name=products', - name: 'Product recommendations', members: [userId], + custom: { + image: 'https://getstream.io/random_png/?name=products', + name: 'Product recommendations', + }, }); await channel.watch(); - const hasProductMessage = channel.state.messages.some((message) => + // messages are no longer kept on channel.state — the paginator owns the list + const hasProductMessage = (channel.messagePaginator.items ?? []).some((message) => message.attachments?.some(isProductAttachment), ); if (!hasProductMessage) { + // the message payload is nested under `message` since v10 await channel.sendMessage({ - text: 'Your selected product is out of stock, would you like to select one of these alternatives?', - attachments, + message: { + text: 'Your selected product is out of stock, would you like to select one of these alternatives?', + attachments, + }, }); } diff --git a/examples/tutorial/src/6-emoji-picker/App.tsx b/examples/tutorial/src/6-emoji-picker/App.tsx index d8df79a70..ba69e011e 100644 --- a/examples/tutorial/src/6-emoji-picker/App.tsx +++ b/examples/tutorial/src/6-emoji-picker/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import type { User } from 'stream-chat'; +import type { ClientUser } from 'stream-chat'; import { Channel, ChannelHeader, @@ -20,7 +20,7 @@ import data from '@emoji-mart/data'; import './layout.css'; import { apiKey, tokenProvider, userId, userName } from '../1-client-setup/credentials'; -const user: User = { +const user: ClientUser = { id: userId, name: userName, image: `https://getstream.io/random_png/?name=${userName}`, @@ -62,9 +62,12 @@ const App = () => { const initChannel = async () => { const channel = client.channel('messaging', 'react-tutorial', { - image: 'https://getstream.io/random_png/?name=react-v14', - name: 'Talk about React', members: [userId], + // custom channel fields live under `custom` since v10 + custom: { + image: 'https://getstream.io/random_png/?name=react-v14', + name: 'Talk about React', + }, }); await channel.watch(); diff --git a/examples/tutorial/src/7-livestream/App.tsx b/examples/tutorial/src/7-livestream/App.tsx index 740aefcc7..7e4200a4d 100644 --- a/examples/tutorial/src/7-livestream/App.tsx +++ b/examples/tutorial/src/7-livestream/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import type { Channel as StreamChannel, User } from 'stream-chat'; +import type { Channel as StreamChannel, ClientUser } from 'stream-chat'; import { Channel, ChannelHeader, @@ -12,7 +12,7 @@ import { import './layout.css'; import { apiKey, tokenProvider, userId, userName } from '../1-client-setup/credentials'; -const user: User = { +const user: ClientUser = { id: userId, name: userName, image: `https://getstream.io/random_png/?name=${userName}`, @@ -31,8 +31,11 @@ const App = () => { const initChannel = async () => { const spaceChannel = chatClient.channel('livestream', 'spacex', { - image: 'https://goo.gl/Zefkbx', - name: 'SpaceX launch discussion', + // custom channel fields live under `custom` since v10 + custom: { + image: 'https://goo.gl/Zefkbx', + name: 'SpaceX launch discussion', + }, }); await spaceChannel.watch(); diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 24794d22a..0f077f1bc 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -14,8 +14,8 @@ import type { TextComposerMiddleware, } from 'stream-chat'; import { + ChannelManager, ChannelPaginator, - ChannelPaginatorsOrchestrator, ChannelSearchSource, createActiveCommandGuardMiddleware, createCommandInjectionMiddleware, @@ -379,7 +379,7 @@ const App = () => { // (archived > muted > default > opened), so e.g. an archived channel stays out of the main list. // `orchestrator.ingestChannel` (search/DM open, and the mute handler below) re-evaluates a // channel against every list and routes it accordingly. - const channelPaginatorsOrchestrator = useMemo(() => { + const channelManager = useMemo(() => { if (!chatClient) return undefined; const main = new ChannelPaginator({ client: chatClient, @@ -413,24 +413,24 @@ const App = () => { // between lists on its own. Enrich the default handlers: when the user's channel mutes change, // re-route every loaded channel (ingestChannel re-evaluates ownership per channel, so a newly // muted channel leaves the main list for the muted one and an unmuted channel returns). - const eventHandlers = ChannelPaginatorsOrchestrator.getDefaultHandlers(); + const eventHandlers = ChannelManager.getDefaultHandlers(); eventHandlers['notification.channel_mutes_updated'] = [ { id: 'example:channel-mutes-updated', - handle: ({ ctx: { orchestrator } }) => { + handle: ({ ctx: { channelManager } }) => { const seen = new Set(); - orchestrator.paginators.forEach((paginator) => { + channelManager.paginators.forEach((paginator) => { (paginator.items ?? []).forEach((channel) => { if (seen.has(channel.cid)) return; seen.add(channel.cid); - orchestrator.ingestChannel(channel); + channelManager.ingestChannel(channel); }); }); }, }, ]; - return new ChannelPaginatorsOrchestrator({ + return new ChannelManager({ client: chatClient, eventHandlers, ownershipResolver: [ @@ -567,7 +567,7 @@ const App = () => { > { channel={resolveSingleChannel({ channelKey: singleChannelCid, client: chatClient, - orchestrator: channelPaginatorsOrchestrator, + orchestrator: channelManager, })} referenceElement={singleChannelAnchor} /> diff --git a/examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx b/examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx index 1cff346a8..362e8e5f9 100644 --- a/examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx +++ b/examples/vite/src/AppSettings/tabs/General/GeneralTab.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react'; -import type { ChannelPaginatorsOrchestratorState } from 'stream-chat'; +import type { ChannelManagerState } from 'stream-chat'; import { Button, useChatContext, useStateStore } from 'stream-chat-react'; import { appSettingsStore, useAppSettingsState } from '../../state'; import { SearchableSelect, type SearchableSelectOption } from '../../SearchableSelect'; @@ -12,7 +12,7 @@ type GeneralTabProps = { close: () => void; }; -const paginatorsSelector = (state: ChannelPaginatorsOrchestratorState) => ({ +const paginatorsSelector = (state: ChannelManagerState) => ({ paginators: state.paginators, }); @@ -27,11 +27,8 @@ export const GeneralTab = ({ close }: GeneralTabProps) => { // `layout.channelCid`. Setting it directly would open the modal behind the settings dialog. const [draftChannelCid, setDraftChannelCid] = useState(''); - const { channelPaginatorsOrchestrator } = useChatContext(); - const { paginators } = useStateStore( - channelPaginatorsOrchestrator.state, - paginatorsSelector, - ); + const { channelManager } = useChatContext(); + const { paginators } = useStateStore(channelManager.state, paginatorsSelector); // Options for the single-channel selector: a placeholder entry plus every channel the paginators // have already loaded (deduped by cid). Memoized so its identity is stable — SearchableSelect // derives its trigger from the options, and a fresh array each render would remount the trigger diff --git a/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx b/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx index 0abea39bf..402a8e9ae 100644 --- a/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx +++ b/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { Channel, ChannelPaginator, - ChannelPaginatorsOrchestratorState, + ChannelManagerState, ChannelPaginatorState, PaginatorIntervalViews, SearchControllerState, @@ -47,7 +47,7 @@ const itemCountSelector = (state: ChannelPaginatorState) => ({ count: state.items?.length ?? 0, }); -const paginatorsSelector = (state: ChannelPaginatorsOrchestratorState) => ({ +const paginatorsSelector = (state: ChannelManagerState) => ({ paginators: state.paginators, }); const searchControllerStateSelector = (state: SearchControllerState) => ({ @@ -190,7 +190,7 @@ const SideloadedChannels = ({ paginator }: { paginator: ChannelPaginator }) => { /** * Example channel navigation that shows exactly ONE channel list at a time plus a menu to - * switch between the lists held by the `ChannelPaginatorsOrchestrator`. It mirrors the SDK's + * switch between the lists held by the `ChannelManager`. It mirrors the SDK's * `ChannelNavigation` (header, search, notifications) but replaces the SDK's stacked * `ChannelLists` (one `` per paginator, empty ones included) with a switcher + * the active list. This keeps the empty "Opened" fallback from rendering below the primary @@ -199,11 +199,8 @@ const SideloadedChannels = ({ paginator }: { paginator: ChannelPaginator }) => { export const SwitchableChannelNavigation = () => { const { NotificationList = DefaultNotificationList, Search = DefaultSearch } = useComponentContext(); - const { channelPaginatorsOrchestrator, searchController } = useChatContext(); - const { paginators } = useStateStore( - channelPaginatorsOrchestrator.state, - paginatorsSelector, - ); + const { channelManager, searchController } = useChatContext(); + const { paginators } = useStateStore(channelManager.state, paginatorsSelector); const { isActive } = useStateStore( searchController.state, searchControllerStateSelector, diff --git a/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx b/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx index 8bc2abb79..9ee8b08df 100644 --- a/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx +++ b/examples/vite/src/ChatLayout/WorkspaceUrlSync.tsx @@ -10,12 +10,7 @@ import { useChatViewContext, useChatViewNavigation, } from 'stream-chat-react/slot-layout'; -import type { - Channel, - ChannelPaginatorsOrchestrator, - StreamChat, - Thread, -} from 'stream-chat'; +import type { Channel, ChannelManager, StreamChat, Thread } from 'stream-chat'; /** * Full-workspace URL sync for the vite example. @@ -284,7 +279,7 @@ const waitForState = ( }); /** Wait for the channel-list paginator(s) to load their first page (so listed channels are watched). */ -const waitForChannelList = async (orchestrator: ChannelPaginatorsOrchestrator) => { +const waitForChannelList = async (orchestrator: ChannelManager) => { await waitForState(orchestrator.state, (s) => s.paginators.length > 0); const paginator = orchestrator.paginators[0]; if (!paginator) return; @@ -312,7 +307,7 @@ const workspaceEncodedSelector = (state: ChatViewLayoutState) => ({ * Afterwards it keeps the `?workspace=` param in sync with every layout change. */ export const WorkspaceUrlSync = () => { - const { channelPaginatorsOrchestrator, client } = useChatContext(); + const { channelManager, client } = useChatContext(); const { layoutController } = useChatViewContext(); const { openView } = useChatViewNavigation(); @@ -357,9 +352,7 @@ export const WorkspaceUrlSync = () => { .flatMap((s) => [s.base.kind, ...s.layers.map((l) => l.kind)]), ); await Promise.all([ - activeKinds.has('channel') - ? waitForChannelList(channelPaginatorsOrchestrator) - : undefined, + activeKinds.has('channel') ? waitForChannelList(channelManager) : undefined, target.activeView === 'threads' && activeKinds.has('thread') ? waitForThreadList(client) : undefined, @@ -370,12 +363,12 @@ export const WorkspaceUrlSync = () => { target.slots.map(async (entry) => { const base = await resolveBinding(client, entry.base); if (!base) return undefined; - if (base.channel) channelPaginatorsOrchestrator.ingestChannel(base.channel); + if (base.channel) channelManager.ingestChannel(base.channel); const layers: ChatViewEntityBinding[] = []; for (const layerToken of entry.layers) { const layer = await resolveBinding(client, layerToken); if (!layer) continue; - if (layer.channel) channelPaginatorsOrchestrator.ingestChannel(layer.channel); + if (layer.channel) channelManager.ingestChannel(layer.channel); layers.push(layer.binding); } return { base: base.binding, layers, slot: entry.slot, view: entry.view }; @@ -433,7 +426,7 @@ export const WorkspaceUrlSync = () => { return { ...current, activeView: target.activeView, layouts }; }); }, - [channelPaginatorsOrchestrator, client, layoutController], + [channelManager, client, layoutController], ); // (1)+(2) Go straight to the active view before the browser paints — the channels view never shows. diff --git a/examples/vite/src/SingleChannel/SingleChannelApp.tsx b/examples/vite/src/SingleChannel/SingleChannelApp.tsx index cdd95513f..e4d290ae3 100644 --- a/examples/vite/src/SingleChannel/SingleChannelApp.tsx +++ b/examples/vite/src/SingleChannel/SingleChannelApp.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef } from 'react'; import type { - ChannelPaginatorsOrchestrator, - ChannelPaginatorsOrchestratorState, + ChannelManager, + ChannelManagerState, Channel as StreamChannel, StreamChat, } from 'stream-chat'; @@ -40,7 +40,7 @@ export const resolveSingleChannel = ({ }: { channelKey?: string; client: StreamChat; - orchestrator?: ChannelPaginatorsOrchestrator; + orchestrator?: ChannelManager; }): StreamChannel => { if (channelKey) { const separatorIndex = channelKey.indexOf(':'); @@ -67,7 +67,7 @@ const setSingleChannel = (channelCid: string | undefined) => layout: { ...appSettingsStore.getLatestValue().layout, channelCid }, }); -const paginatorsSelector = (state: ChannelPaginatorsOrchestratorState) => ({ +const paginatorsSelector = (state: ChannelManagerState) => ({ paginators: state.paginators, }); @@ -77,11 +77,8 @@ const paginatorsSelector = (state: ChannelPaginatorsOrchestratorState) => ({ * are the channels the paginators have loaded, with the current channel always present. */ const SingleChannelTitle = ({ channel }: { channel: StreamChannel }) => { - const { channelPaginatorsOrchestrator } = useChatContext(); - const { paginators } = useStateStore( - channelPaginatorsOrchestrator.state, - paginatorsSelector, - ); + const { channelManager } = useChatContext(); + const { paginators } = useStateStore(channelManager.state, paginatorsSelector); const options = useMemo[]>(() => { const loaded = Array.from( diff --git a/src/components/ChannelList/ChannelList.tsx b/src/components/ChannelList/ChannelList.tsx index fb3ad6c15..f51eaf54f 100644 --- a/src/components/ChannelList/ChannelList.tsx +++ b/src/components/ChannelList/ChannelList.tsx @@ -25,7 +25,7 @@ const channelPaginatorStateSelector = (state: ChannelPaginatorState) => ({ /** * Channel list driven by a single `ChannelPaginator`. The paginator is created + - * coordinated by the `ChannelPaginatorsOrchestrator` on `ChatContext`; this component + * coordinated by the `ChannelManager` on `ChatContext`; this component * only renders its reactive `state` and drives pagination. Selection is not this * component's concern — the `ChannelListItem` default `ListItem` opens the channel via * ChatView navigation. @@ -35,8 +35,8 @@ export const ChannelList = ({ loadMoreThresholdPx, paginator, }: ChannelListProps) => { - const { channelPaginatorsOrchestrator, client } = useChatContext('ChannelList'); - const { t } = useTranslationContext('ChannelList'); + const { channelManager, client } = useChatContext(); + const { t } = useTranslationContext(); const { lastQueryError } = useStateStore( paginator.state, channelPaginatorStateSelector, @@ -60,10 +60,7 @@ export const ChannelList = ({ const { onClickCapture, onKeyDown } = useChannelListKeyboardNavigation(listboxRef); // Ref-counted: safe whether called here, from , or from . - useEffect( - () => channelPaginatorsOrchestrator.registerSubscriptions(), - [channelPaginatorsOrchestrator], - ); + useEffect(() => channelManager.registerSubscriptions(), [channelManager]); useEffect(() => { if (paginator.items) return; diff --git a/src/components/ChannelList/ChannelLists.tsx b/src/components/ChannelList/ChannelLists.tsx index eeebf5b5e..f5b7797ef 100644 --- a/src/components/ChannelList/ChannelLists.tsx +++ b/src/components/ChannelList/ChannelLists.tsx @@ -1,29 +1,26 @@ import React from 'react'; -import type { ChannelPaginatorsOrchestratorState } from 'stream-chat'; +import type { ChannelManagerState } from 'stream-chat'; import { ChannelListContextProvider } from '../../context/ChannelListContext'; import { useChatContext } from '../../context/ChatContext'; import { useStateStore } from '../../store'; import { ChannelList } from './ChannelList'; -const paginatorsSelector = (state: ChannelPaginatorsOrchestratorState) => ({ +const paginatorsSelector = (state: ChannelManagerState) => ({ paginators: state.paginators, }); /** - * Renders one `` per paginator held by the `ChannelPaginatorsOrchestrator` on - * `ChatContext` — i.e. its data source is the orchestrator (the whole set of lists). Each child + * Renders one `` per paginator held by the `ChannelManager` on + * `ChatContext` — i.e. its data source is the `ChannelManager` (the whole set of lists). Each child * `ChannelList` registers the (ref-counted) WS subscriptions. The primary (`paginators[0]`) * paginator is exposed through `ChannelListContext` so descendants (search results, member * actions, notification targeting) can read/mutate the loaded list without knowing about the - * orchestrator. + * channel manager. */ export const ChannelLists = () => { - const { channelPaginatorsOrchestrator } = useChatContext('ChannelLists'); - const { paginators } = useStateStore( - channelPaginatorsOrchestrator.state, - paginatorsSelector, - ); + const { channelManager } = useChatContext(); + const { paginators } = useStateStore(channelManager.state, paginatorsSelector); const lists = paginators.map((paginator) => ( diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 66e13a875..ac413f256 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -2,8 +2,8 @@ import type { PropsWithChildren } from 'react'; import React, { useMemo } from 'react'; import type { StreamChat } from 'stream-chat'; import { + ChannelManager, ChannelPaginator, - ChannelPaginatorsOrchestrator, ChannelSearchSource, MessageSearchSource, SearchController, @@ -95,7 +95,7 @@ export type ChatProps = { * ownership). Defaults to a single `channels:default` paginator over the current * user's channels. */ - channelPaginatorsOrchestrator?: ChannelPaginatorsOrchestrator; + channelManager?: ChannelManager; /** Object containing custom CSS classnames to override the library's default container CSS */ customClasses?: CustomClasses; /** Sets the default fallback language for UI component translation, defaults to 'en' for English */ @@ -124,7 +124,7 @@ export type ChatProps = { */ export const Chat = (props: PropsWithChildren) => { const { - channelPaginatorsOrchestrator: customChannelPaginatorsOrchestrator, + channelManager: customChannelManager, children, client, customClasses, @@ -156,10 +156,10 @@ export const Chat = (props: PropsWithChildren) => { [client, customChannelSearchController], ); - const channelPaginatorsOrchestrator = useMemo( + const channelManager = useMemo( () => - customChannelPaginatorsOrchestrator ?? - new ChannelPaginatorsOrchestrator({ + customChannelManager ?? + new ChannelManager({ client, paginators: [ new ChannelPaginator({ @@ -174,11 +174,11 @@ export const Chat = (props: PropsWithChildren) => { }), ], }), - [client, customChannelPaginatorsOrchestrator], + [client, customChannelManager], ); const chatContextValue = useCreateChatContext({ - channelPaginatorsOrchestrator, + channelManager, client, customClasses, getAppSettings, diff --git a/src/components/Chat/hooks/useCreateChatContext.ts b/src/components/Chat/hooks/useCreateChatContext.ts index 3e9b7bf7c..4e19a731d 100644 --- a/src/components/Chat/hooks/useCreateChatContext.ts +++ b/src/components/Chat/hooks/useCreateChatContext.ts @@ -4,7 +4,7 @@ import type { ChatContextValue } from '../../../context/ChatContext'; export const useCreateChatContext = (value: ChatContextValue) => { const { - channelPaginatorsOrchestrator, + channelManager, client, customClasses, getAppSettings, @@ -24,7 +24,7 @@ export const useCreateChatContext = (value: ChatContextValue) => { const chatContext: ChatContextValue = useMemo( () => ({ - channelPaginatorsOrchestrator, + channelManager, client, customClasses, getAppSettings, @@ -37,7 +37,7 @@ export const useCreateChatContext = (value: ChatContextValue) => { }), // eslint-disable-next-line react-hooks/exhaustive-deps [ - channelPaginatorsOrchestrator, + channelManager, clientValues, getAppSettings, searchController, diff --git a/src/components/InfiniteScrollPaginator/InfiniteScrollWithComponents.tsx b/src/components/InfiniteScrollPaginator/InfiniteScrollWithComponents.tsx index 0444caff1..15f12aa68 100644 --- a/src/components/InfiniteScrollPaginator/InfiniteScrollWithComponents.tsx +++ b/src/components/InfiniteScrollPaginator/InfiniteScrollWithComponents.tsx @@ -35,7 +35,7 @@ type InfiniteScrollWithComponentsComponent = ( /** * Renders any paginator-backed list with pluggable indicator/item components, - * driven by the paginator's reactive `state`. Used by the orchestrator-driven + * driven by the paginator's reactive `state`. Used by the ChannelManager-driven * channel list. `forwardRef` so callers can put the scroll root's DOM node to use * (e.g. the channel list marks it `role="listbox"` and drives keyboard roving off it). */ diff --git a/src/components/Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx b/src/components/Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx index 191fd82bb..305ed1c7d 100644 --- a/src/components/Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx +++ b/src/components/Message/hooks/__tests__/useMessageAlsoSentInChannelNavigation.test.tsx @@ -27,7 +27,7 @@ vi.mock('../../../../context', () => ({ query: mocks.query, }), useChatContext: () => ({ - channelPaginatorsOrchestrator: { ingestChannel: mocks.ingestChannel }, + channelManager: { ingestChannel: mocks.ingestChannel }, client: { getThread: vi.fn(), notifications: { addError: vi.fn() }, diff --git a/src/components/Message/hooks/useMessageAlsoSentInChannelNavigation.ts b/src/components/Message/hooks/useMessageAlsoSentInChannelNavigation.ts index 8281d7bb4..463724051 100644 --- a/src/components/Message/hooks/useMessageAlsoSentInChannelNavigation.ts +++ b/src/components/Message/hooks/useMessageAlsoSentInChannelNavigation.ts @@ -30,7 +30,7 @@ export type MessageAlsoSentInChannelNavigation = { */ export const useMessageAlsoSentInChannelNavigation = (): MessageAlsoSentInChannelNavigation => { - const { channelPaginatorsOrchestrator, client } = useChatContext(); + const { channelManager, client } = useChatContext(); const { t } = useTranslationContext(); const channel = useChannel(); const { isChannelActive, openChannel, openThread } = useWorkspaceNavigation(); @@ -62,7 +62,7 @@ export const useMessageAlsoSentInChannelNavigation = await channel.messagePaginator.jumpToMessage(messageId); if (needsNavigation) { - channelPaginatorsOrchestrator.ingestChannel(channel); + channelManager.ingestChannel(channel); } }; diff --git a/src/components/Search/SearchResults/SearchResultItem.tsx b/src/components/Search/SearchResults/SearchResultItem.tsx index 8ff807c71..f4d77a194 100644 --- a/src/components/Search/SearchResults/SearchResultItem.tsx +++ b/src/components/Search/SearchResults/SearchResultItem.tsx @@ -34,7 +34,7 @@ export const ChannelSearchResultItem = ({ onSelect, }: ChannelSearchResultItemProps) => { const { openChannel } = useWorkspaceNavigation(); - const { channelPaginatorsOrchestrator } = useChatContext(); + const { channelManager } = useChatContext(); const handleSelect = useCallback( (event: React.MouseEvent) => { @@ -45,11 +45,11 @@ export const ChannelSearchResultItem = ({ // Default: open the channel in the workspace, forwarding the event so a consumer overriding // `openChannel` (e.g. via ChatView's `deriveWorkspaceNavigation`) can honor ⌘/ctrl-click. openChannel(item, { event }); - // Route the channel into the list(s) that should own it (the orchestrator dedupes by cid, + // Route the channel into the list(s) that should own it (the channel manager dedupes by cid, // inserts in sort order, and honors ownership/filters) so it appears without a re-query. - channelPaginatorsOrchestrator.ingestChannel(item); + channelManager.ingestChannel(item); }, - [item, openChannel, channelPaginatorsOrchestrator, onSelect], + [item, openChannel, channelManager, onSelect], ); return ( @@ -72,7 +72,7 @@ export const MessageSearchResultItem = ({ item, onSelect, }: ChannelByMessageSearchResultItemProps) => { - const { channelPaginatorsOrchestrator, client, searchController } = useChatContext(); + const { channelManager, client, searchController } = useChatContext(); const { isChannelActive, openChannel } = useWorkspaceNavigation(); const channel = useMemo(() => { @@ -98,16 +98,9 @@ export const MessageSearchResultItem = ({ // window around the target). No manual channel.state preload is needed here. searchController._internalState.partialNext({ focusedMessage: item }); openChannel(channel, { event }); - channelPaginatorsOrchestrator.ingestChannel(channel); + channelManager.ingestChannel(channel); }, - [ - channel, - item, - openChannel, - searchController, - channelPaginatorsOrchestrator, - onSelect, - ], + [channel, item, openChannel, searchController, channelManager, onSelect], ); // Preview the matched message itself (not the channel's latest) by overriding `previewedMessage`. @@ -137,7 +130,7 @@ export type UserSearchResultItemProps = { }; export const UserSearchResultItem = ({ item, onSelect }: UserSearchResultItemProps) => { - const { channelPaginatorsOrchestrator, client } = useChatContext(); + const { channelManager, client } = useChatContext(); const { openChannel } = useWorkspaceNavigation(); const { directMessagingChannelType } = useSearchContext(); const { t } = useTranslationContext(); @@ -157,16 +150,9 @@ export const UserSearchResultItem = ({ item, onSelect }: UserSearchResultItemPro // Default: open the DM channel in the workspace, forwarding the event so a consumer overriding // `openChannel` can honor ⌘/ctrl-click. openChannel(newChannel, { event }); - channelPaginatorsOrchestrator.ingestChannel(newChannel); + channelManager.ingestChannel(newChannel); }, - [ - client, - item, - openChannel, - channelPaginatorsOrchestrator, - directMessagingChannelType, - onSelect, - ], + [client, item, openChannel, channelManager, directMessagingChannelType, onSelect], ); return ( diff --git a/src/components/Search/__tests__/SearchResultItem.test.tsx b/src/components/Search/__tests__/SearchResultItem.test.tsx index 965d1590a..b51bb32fb 100644 --- a/src/components/Search/__tests__/SearchResultItem.test.tsx +++ b/src/components/Search/__tests__/SearchResultItem.test.tsx @@ -28,7 +28,7 @@ const CHANNEL_PREVIEW_BUTTON_TEST_ID = 'channel-list-item-button'; const mockOpenChannel = vi.fn(); const mockIngestChannel = vi.fn(); -const mockOrchestrator = { ingestChannel: mockIngestChannel }; +const mockChannelManager = { ingestChannel: mockIngestChannel }; const directMessagingChannelType = 'X'; // Selection opens the channel in the workspace (one navigation model); the item's @@ -91,7 +91,7 @@ const renderComponent = async ({ { await renderComponent({ SearchResultItemComponent, userData: user }); expect(screen.getByTestId('avatar')).toBeInTheDocument(); - expect(screen.getByText(user.name)).toBeInTheDocument(); + // `generateUser` always sets a name, but `UserResponse.name` is optional in v10 types + expect(screen.getByText(String(user.name))).toBeInTheDocument(); }); it('handles user selection', async () => { diff --git a/src/context/ChannelListContext.tsx b/src/context/ChannelListContext.tsx index d0aa623c5..88e9b94ab 100644 --- a/src/context/ChannelListContext.tsx +++ b/src/context/ChannelListContext.tsx @@ -5,7 +5,7 @@ import type { ChannelPaginator } from 'stream-chat'; export type ChannelListContextValue = { /** - * The primary channel paginator held by the `ChannelPaginatorsOrchestrator` on `ChatContext`. + * The primary channel paginator held by the `ChannelManager` on `ChatContext`. * Read the loaded channels reactively with `useStateStore(paginator.state, …)`, load the next * page with `paginator.next()`, and mutate the loaded list (e.g. prepend a just-opened channel) * via `paginator.setItems({ valueOrFactory })`. Undefined when rendered outside a channel list. diff --git a/src/context/ChatContext.tsx b/src/context/ChatContext.tsx index be65bdaca..c00df3bb7 100644 --- a/src/context/ChatContext.tsx +++ b/src/context/ChatContext.tsx @@ -1,7 +1,7 @@ import React, { useContext } from 'react'; import type { PropsWithChildren } from 'react'; import type { - ChannelPaginatorsOrchestrator, + ChannelManager, SearchController, StreamChat, UserMuteResponse, @@ -27,10 +27,10 @@ type ChannelConfId = string; // e.g.: "messaging:general" export type ChatContextValue = { /** - * `ChannelPaginatorsOrchestrator` used to query and manage channels across one or + * `ChannelManager` used to query and manage channels across one or * more channel lists (the channel-list data source + cross-list ownership). */ - channelPaginatorsOrchestrator: ChannelPaginatorsOrchestrator; + channelManager: ChannelManager; getAppSettings: () => ReturnType | null; latestMessageDatesByChannels: Record; mutes: Array; diff --git a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx index b959f6f38..9abb42894 100644 --- a/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx +++ b/src/plugins/ChannelDetail/Views/ChannelMemberDetailView/ChannelMemberActions.defaults.tsx @@ -206,7 +206,7 @@ export const useBaseChannelMemberActionSetFilter = ( }; const SendDirectMessageAction = () => { - const { channelPaginatorsOrchestrator, client } = useChatContext(); + const { channelManager, client } = useChatContext(); const { open } = useChatViewNavigation(); const { close } = useModalContext(); const { channel } = useChannelDetailContext(); @@ -231,7 +231,7 @@ const SendDirectMessageAction = () => { kind: 'channel', source: directMessageChannel, }); - channelPaginatorsOrchestrator.ingestChannel(directMessageChannel); + channelManager.ingestChannel(directMessageChannel); close(); } catch (error) { addNotification({ @@ -250,7 +250,7 @@ const SendDirectMessageAction = () => { channel, client, close, - channelPaginatorsOrchestrator, + channelManager, isSending, open, t, From 801cc3f959c693054417a27a4c73d930311c6bbf Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 7 Aug 2026 14:01:00 +0200 Subject: [PATCH 2/4] feat(Chat)!: read the channel manager from the client Chat no longer constructs a ChannelManager or a default paginator; it exposes `client.channelManager` on ChatContext and leaves list registration to the application. BREAKING CHANGE: the `channelManager` prop is removed and no `channels:default` paginator is created. Register lists with `client.channelManager.insertPaginator({ paginator })`. --- .../__tests__/ChannelLists.test.tsx | 117 ++++++++++++++++++ src/components/Chat/Chat.tsx | 32 +---- src/components/Chat/__tests__/Chat.test.tsx | 95 ++++++++++++++ src/context/ChatContext.tsx | 6 +- 4 files changed, 217 insertions(+), 33 deletions(-) create mode 100644 src/components/ChannelList/__tests__/ChannelLists.test.tsx diff --git a/src/components/ChannelList/__tests__/ChannelLists.test.tsx b/src/components/ChannelList/__tests__/ChannelLists.test.tsx new file mode 100644 index 000000000..1f8f20dc2 --- /dev/null +++ b/src/components/ChannelList/__tests__/ChannelLists.test.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { + Channel, + ChannelPaginator as ChannelPaginatorType, + StreamChat, +} from 'stream-chat'; +import { ChannelPaginator } from 'stream-chat'; + +import { Chat } from '../../Chat'; +import { ChannelLists } from '../ChannelLists'; +import { useChannelListContext } from '../../../context/ChannelListContext'; +import { ComponentProvider } from '../../../context/ComponentContext'; +import { getTestClientWithUser } from '../../../mock-builders'; + +/** + * Keeps the assertions on the list composition rather than on the default channel preview. Also + * reports the primary paginator `ChannelLists` provides to the list subtree. + */ +const ListItem = ({ item }: { item: Channel }) => ( +
+ {item.cid} +
+); + +const setupClient = async () => { + const client = await getTestClientWithUser({ id: 'user_x' }); + // no list in these tests should reach the network — they are seeded instead + vi.spyOn(client, 'queryChannelsAndHydrate').mockRejectedValue( + new Error('unexpected channel query'), + ); + return client; +}; + +/** A paginator with a single loaded page, so it renders a list without querying. */ +const seededPaginator = (client: StreamChat, id: string) => { + const paginator = new ChannelPaginator({ client, id }); + paginator.setItems({ + isLastPage: true, + valueOrFactory: [client.channel('messaging', id.replace(':', '-'))], + }); + return paginator; +}; + +const renderLists = (client: StreamChat, paginators: ChannelPaginatorType[]) => { + paginators.forEach((paginator) => client.channelManager.insertPaginator({ paginator })); + return render( + + + + + , + ); +}; + +describe('ChannelLists', () => { + afterEach(cleanup); + + it('renders one list per paginator registered on the client channel manager', async () => { + const client = await setupClient(); + + renderLists(client, [ + seededPaginator(client, 'channels:a'), + seededPaginator(client, 'channels:b'), + ]); + + await waitFor(() => expect(screen.getAllByRole('listbox')).toHaveLength(2)); + }); + + it('renders a list for a paginator inserted after mount and drops it on removal', async () => { + const client = await setupClient(); + + renderLists(client, [seededPaginator(client, 'channels:a')]); + await waitFor(() => expect(screen.getAllByRole('listbox')).toHaveLength(1)); + + const added = seededPaginator(client, 'channels:b'); + act(() => { + client.channelManager.insertPaginator({ paginator: added }); + }); + await waitFor(() => expect(screen.getAllByRole('listbox')).toHaveLength(2)); + + act(() => { + client.channelManager.removePaginator(added); + }); + await waitFor(() => expect(screen.getAllByRole('listbox')).toHaveLength(1)); + }); + + it('exposes the first registered paginator to the lists as the primary one', async () => { + const client = await setupClient(); + const first = seededPaginator(client, 'channels:a'); + const second = seededPaginator(client, 'channels:b'); + + renderLists(client, [first, second]); + + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(2)); + expect(client.channelManager.paginators[0]).toBe(first); + // every list — not just the first — sees `paginators[0]` as the primary paginator + screen.getAllByRole('option').forEach((option) => { + expect(option).toHaveAttribute('data-primary-paginator', first.id); + }); + }); + + it('registers the channel manager subscriptions while a list is mounted', async () => { + const client = await setupClient(); + + const { unmount } = renderLists(client, [seededPaginator(client, 'channels:a')]); + + await waitFor(() => expect(client.channelManager.hasSubscriptions).toBe(true)); + + act(() => { + unmount(); + }); + + expect(client.channelManager.hasSubscriptions).toBe(false); + }); +}); diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index ac413f256..ba13c4037 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -2,8 +2,6 @@ import type { PropsWithChildren } from 'react'; import React, { useMemo } from 'react'; import type { StreamChat } from 'stream-chat'; import { - ChannelManager, - ChannelPaginator, ChannelSearchSource, MessageSearchSource, SearchController, @@ -90,12 +88,6 @@ const ModalNotificationConfiguration = ({ export type ChatProps = { /** The StreamChat client object */ client: StreamChat; - /** - * Orchestrator managing the channel-list paginators (data source + cross-list - * ownership). Defaults to a single `channels:default` paginator over the current - * user's channels. - */ - channelManager?: ChannelManager; /** Object containing custom CSS classnames to override the library's default container CSS */ customClasses?: CustomClasses; /** Sets the default fallback language for UI component translation, defaults to 'en' for English */ @@ -124,7 +116,6 @@ export type ChatProps = { */ export const Chat = (props: PropsWithChildren) => { const { - channelManager: customChannelManager, children, client, customClasses, @@ -156,29 +147,8 @@ export const Chat = (props: PropsWithChildren) => { [client, customChannelSearchController], ); - const channelManager = useMemo( - () => - customChannelManager ?? - new ChannelManager({ - client, - paginators: [ - new ChannelPaginator({ - client, - filters: client.user?.id ? { members: { $in: [client.user.id] } } : {}, - id: 'channels:default', - sort: [ - { direction: -1, field: 'last_message_at' }, - { direction: 1, field: 'pinned_at' }, - { direction: -1, field: 'updated_at' }, - ], - }), - ], - }), - [client, customChannelManager], - ); - const chatContextValue = useCreateChatContext({ - channelManager, + channelManager: client.channelManager, client, customClasses, getAppSettings, diff --git a/src/components/Chat/__tests__/Chat.test.tsx b/src/components/Chat/__tests__/Chat.test.tsx index c25832c4c..459bc7ccd 100644 --- a/src/components/Chat/__tests__/Chat.test.tsx +++ b/src/components/Chat/__tests__/Chat.test.tsx @@ -2,6 +2,7 @@ import React, { useContext } from 'react'; import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import type { OwnUserResponse } from 'stream-chat'; +import { ChannelPaginator } from 'stream-chat'; import { Chat } from '..'; @@ -222,6 +223,100 @@ describe('Chat', () => { }); }); + describe('channel manager', () => { + it('exposes the client channel manager on the context', async () => { + const client = getTestClient(); + let context: ChatContextValue; + + await act(() => { + render( + + { + context = ctx; + }} + /> + , + ); + }); + + await waitFor(() => expect(context.channelManager).toBe(client.channelManager)); + }); + + it('does not register any channel list of its own', async () => { + const client = await getTestClientWithUser({ id: 'user_x' }); + + await act(() => { + render( + +
+ , + ); + }); + + await waitFor(() => expect(screen.getByTestId('children')).toBeInTheDocument()); + expect(client.channelManager.paginators).toEqual([]); + }); + + it('leaves the lists registered on the manager untouched', async () => { + const client = getTestClient(); + const paginator = new ChannelPaginator({ client, id: 'channels:app-owned' }); + client.channelManager.insertPaginator({ paginator }); + + let unmount: () => void; + await act(() => { + ({ unmount } = render( + +
+ , + )); + }); + + await waitFor(() => { + expect(client.channelManager.paginators).toStrictEqual([paginator]); + }); + + await act(() => { + unmount(); + }); + + // the app owns its lists — unmounting Chat must not drop them + expect(client.channelManager.paginators).toStrictEqual([paginator]); + }); + + it('keeps exposing the same manager when the client changes', async () => { + const client = getTestClient(); + const nextClient = getTestClient(); + let context: ChatContextValue; + + const { rerender } = render( + + { + context = ctx; + }} + /> + , + ); + + await waitFor(() => expect(context.channelManager).toBe(client.channelManager)); + + await act(() => { + rerender( + + { + context = ctx; + }} + /> + , + ); + }); + + await waitFor(() => expect(context.channelManager).toBe(nextClient.channelManager)); + }); + }); + describe('mutes', () => { it('init the mute state with client data', async () => { const chatClientWithUser = await getTestClientWithUser({ id: 'user_x' }); diff --git a/src/context/ChatContext.tsx b/src/context/ChatContext.tsx index c00df3bb7..6930c3757 100644 --- a/src/context/ChatContext.tsx +++ b/src/context/ChatContext.tsx @@ -27,8 +27,10 @@ type ChannelConfId = string; // e.g.: "messaging:general" export type ChatContextValue = { /** - * `ChannelManager` used to query and manage channels across one or - * more channel lists (the channel-list data source + cross-list ownership). + * The client's `ChannelManager` (`client.channelManager`) — used to query and manage channels + * across one or more channel lists (the channel-list data source + cross-list ownership). The + * lists themselves are registered on it by the application + * (`client.channelManager.insertPaginator({ paginator })`); the SDK creates none. */ channelManager: ChannelManager; getAppSettings: () => ReturnType | null; From 892e64286a4a1ad06c122f48f9ec20318d2c0524 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 7 Aug 2026 14:01:16 +0200 Subject: [PATCH 3/4] fix(ChannelList): query whenever the list has no loaded page The paginator outlives the component now, so querying only on mount left a list reset by a disconnect permanently empty. Drive the query off the reactive "never queried" state instead; a loaded-but-empty page still does not auto-query. --- src/components/ChannelList/ChannelList.tsx | 15 ++-- .../__tests__/ChannelList.test.tsx | 70 +++++++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 src/components/ChannelList/__tests__/ChannelList.test.tsx diff --git a/src/components/ChannelList/ChannelList.tsx b/src/components/ChannelList/ChannelList.tsx index f51eaf54f..7cebea174 100644 --- a/src/components/ChannelList/ChannelList.tsx +++ b/src/components/ChannelList/ChannelList.tsx @@ -20,6 +20,10 @@ export type ChannelListProps = { }; const channelPaginatorStateSelector = (state: ChannelPaginatorState) => ({ + // `items === undefined` means "never queried" — the state a paginator starts in and returns to + // when its data is discarded (e.g. `client.disconnectUser` resets every registered list). An + // empty array is a loaded empty page and must not trigger a query. + isUnloaded: state.items === undefined, lastQueryError: state.lastQueryError, }); @@ -37,7 +41,7 @@ export const ChannelList = ({ }: ChannelListProps) => { const { channelManager, client } = useChatContext(); const { t } = useTranslationContext(); - const { lastQueryError } = useStateStore( + const { isUnloaded, lastQueryError } = useStateStore( paginator.state, channelPaginatorStateSelector, ); @@ -62,10 +66,13 @@ export const ChannelList = ({ // Ref-counted: safe whether called here, from , or from . useEffect(() => channelManager.registerSubscriptions(), [channelManager]); + // Loads the first page, and reloads it whenever the list is emptied back to "never queried" — + // the paginator outlives this component (it is registered on `client.channelManager`), so + // querying only on mount would leave a list reset after a disconnect/reconnect permanently empty. useEffect(() => { - if (paginator.items) return; - paginator.nextDebounced(); - }, [paginator]); + if (!isUnloaded) return; + paginator.toTailDebounced(); + }, [isUnloaded, paginator]); useEffect(() => { if (!lastQueryError) return; diff --git a/src/components/ChannelList/__tests__/ChannelList.test.tsx b/src/components/ChannelList/__tests__/ChannelList.test.tsx new file mode 100644 index 000000000..10e0f2207 --- /dev/null +++ b/src/components/ChannelList/__tests__/ChannelList.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { act, cleanup, render, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { StreamChat } from 'stream-chat'; +import { ChannelPaginator } from 'stream-chat'; + +import { Chat } from '../../Chat'; +import { ChannelList } from '../ChannelList'; +import { getTestClientWithUser } from '../../../mock-builders'; + +const setupClient = async () => { + const client = await getTestClientWithUser({ id: 'user_x' }); + const queryChannels = vi + .spyOn(client, 'queryChannelsAndHydrate') + .mockResolvedValue({ channels: [] } as never); + return { client, queryChannels }; +}; + +const renderList = (client: StreamChat, paginator: ChannelPaginator) => { + client.channelManager.insertPaginator({ paginator }); + return render( + + + , + ); +}; + +describe('ChannelList', () => { + afterEach(cleanup); + + it('queries the first page of a list that has never been queried', async () => { + const { client, queryChannels } = await setupClient(); + const paginator = new ChannelPaginator({ client, id: 'channels:a' }); + + renderList(client, paginator); + + await waitFor(() => expect(queryChannels).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(paginator.items).toEqual([])); + }); + + it('does not query a list that already has a loaded page', async () => { + const { client, queryChannels } = await setupClient(); + const paginator = new ChannelPaginator({ client, id: 'channels:a' }); + // a loaded but empty page — e.g. a catch-all list seeded so it never auto-queries + paginator.setItems({ isLastPage: true, valueOrFactory: [] }); + + renderList(client, paginator); + + await waitFor(() => expect(paginator.items).toEqual([])); + expect(queryChannels).not.toHaveBeenCalled(); + }); + + it('re-queries when the list data is discarded while it stays mounted', async () => { + const { client, queryChannels } = await setupClient(); + const paginator = new ChannelPaginator({ client, id: 'channels:a' }); + + renderList(client, paginator); + await waitFor(() => expect(queryChannels).toHaveBeenCalledTimes(1)); + + // what `client.disconnectUser()` does to every registered list: the loaded channels belong to + // the user that is going away. The paginator outlives the component, so a mount-only query + // would leave the list empty forever. + act(() => { + client.channelManager.resetPaginatorStates(); + }); + + await waitFor(() => expect(queryChannels).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(paginator.items).toEqual([])); + }); +}); From ec1eac83abfcfaa4caee7e7f184d200931191955 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 7 Aug 2026 14:01:53 +0200 Subject: [PATCH 4/4] docs(examples): register channel lists on client.channelManager Both examples register their paginators on the client-owned manager instead of passing a ChannelManager to . The vite example sets its ownership priority there too and drops its custom mute handler, now a default in the LLC, and the remaining `orchestrator` naming follows the rename. --- examples/tutorial/src/3-channel-list/App.tsx | 38 +++--- examples/vite/src/App.tsx | 120 +++++++----------- .../SwitchableChannelNavigation.tsx | 4 +- .../src/SingleChannel/SingleChannelApp.tsx | 6 +- 4 files changed, 73 insertions(+), 95 deletions(-) diff --git a/examples/tutorial/src/3-channel-list/App.tsx b/examples/tutorial/src/3-channel-list/App.tsx index e2a896191..c3969057e 100644 --- a/examples/tutorial/src/3-channel-list/App.tsx +++ b/examples/tutorial/src/3-channel-list/App.tsx @@ -1,6 +1,6 @@ -import { useMemo } from 'react'; +import { useEffect } from 'react'; import type { ChannelFilters, ChannelSort, ClientUser } from 'stream-chat'; -import { ChannelManager, ChannelPaginator } from 'stream-chat'; +import { ChannelPaginator } from 'stream-chat'; import { Channel, ChannelHeader, @@ -60,24 +60,28 @@ const App = () => { userData: user, }); - // Channel-list query config (filters/sort) now lives on a `ChannelPaginator`, - // coordinated by the `ChannelManager` passed to ``. - const channelManager = useMemo( - () => - client && - new ChannelManager({ - client, - paginators: [ - new ChannelPaginator({ client, filters, id: 'channels:default', sort }), - ], - }), - [client], - ); + // Channel-list query config (filters/sort) lives on a `ChannelPaginator`. The list is registered + // on `client.channelManager` — the orchestrator instantiated together with the client, which + // keeps every registered list in sync with WS events. `` renders one list per + // registered paginator. + useEffect(() => { + if (!client) return; + const paginator = new ChannelPaginator({ + client, + filters, + id: 'channels:default', + sort, + }); + client.channelManager.insertPaginator({ paginator }); + return () => { + client.channelManager.removePaginator(paginator); + }; + }, [client]); - if (!client || !channelManager) return
Setting up client & connection...
; + if (!client) return
Setting up client & connection...
; return ( - + }} /> ); diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 0f077f1bc..4e7f68474 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -14,7 +14,6 @@ import type { TextComposerMiddleware, } from 'stream-chat'; import { - ChannelManager, ChannelPaginator, ChannelSearchSource, createActiveCommandGuardMiddleware, @@ -130,6 +129,7 @@ const requestOptions: ChannelPaginatorRequestOptions = { }; const sort: ChannelSort = [ + { direction: -1, field: 'pinned_at' }, { direction: -1, field: 'last_message_at' }, { direction: -1, field: 'updated_at' }, ]; @@ -342,8 +342,11 @@ const App = () => { }); }, [chatClient]); - const filters: ChannelFilters = useMemo( - () => ({ + useEffect(() => { + if (!chatClient) return; + const { channelManager } = chatClient; + + const filters: ChannelFilters = { $or: [ { members: { $in: [userId] }, @@ -365,42 +368,8 @@ const App = () => { ], }, ], - }), - [userId], - ); + }; - // Four channel lists driven by one orchestrator: - // - `channels:default` (main): the app `filters`, minus archived and muted channels. - // - `channels:archived`: the user's archived channels. - // - `channels:muted`: the user's muted channels. - // - `channels:opened` (fallback): empty filter, seeded empty so it never auto-queries — holds - // channels opened from search that don't match any of the above. - // The priority ownership resolver decides where a channel that matches several lists lands - // (archived > muted > default > opened), so e.g. an archived channel stays out of the main list. - // `orchestrator.ingestChannel` (search/DM open, and the mute handler below) re-evaluates a - // channel against every list and routes it accordingly. - const channelManager = useMemo(() => { - if (!chatClient) return undefined; - const main = new ChannelPaginator({ - client: chatClient, - filters: { ...filters, archived: false, muted: false }, - id: 'channels:default', - paginatorOptions: { pageSize: CHANNELS_PAGE_SIZE }, - requestOptions, - sort, - }); - const archived = new ChannelPaginator({ - client: chatClient, - filters: { ...filters, archived: true }, - id: 'channels:archived', - sort, - }); - const muted = new ChannelPaginator({ - client: chatClient, - filters: { ...filters, muted: true }, - id: 'channels:muted', - sort, - }); const fallback = new ChannelPaginator({ client: chatClient, filters: {}, @@ -409,39 +378,45 @@ const App = () => { // Seed an empty loaded page so the catch-all list doesn't auto-query on mount. fallback.setItems({ isLastPage: true, valueOrFactory: [] }); - // The orchestrator has no built-in mute handler, so muting/unmuting wouldn't move a channel - // between lists on its own. Enrich the default handlers: when the user's channel mutes change, - // re-route every loaded channel (ingestChannel re-evaluates ownership per channel, so a newly - // muted channel leaves the main list for the muted one and an unmuted channel returns). - const eventHandlers = ChannelManager.getDefaultHandlers(); - eventHandlers['notification.channel_mutes_updated'] = [ - { - id: 'example:channel-mutes-updated', - handle: ({ ctx: { channelManager } }) => { - const seen = new Set(); - channelManager.paginators.forEach((paginator) => { - (paginator.items ?? []).forEach((channel) => { - if (seen.has(channel.cid)) return; - seen.add(channel.cid); - channelManager.ingestChannel(channel); - }); - }); - }, - }, - ]; - - return new ChannelManager({ - client: chatClient, - eventHandlers, - ownershipResolver: [ - 'channels:archived', - 'channels:muted', - 'channels:default', - 'channels:opened', - ], - paginators: [main, archived, muted, fallback], - }); - }, [chatClient, filters]); + // One state update for the whole set — inserting them one by one would publish (and re-render) + // four times. + channelManager.setPaginators([ + new ChannelPaginator({ + client: chatClient, + filters: { ...filters, archived: false, muted: false }, + id: 'channels:default', + paginatorOptions: { pageSize: CHANNELS_PAGE_SIZE }, + requestOptions, + sort, + }), + new ChannelPaginator({ + client: chatClient, + filters: { ...filters, archived: true }, + id: 'channels:archived', + sort, + }), + new ChannelPaginator({ + client: chatClient, + filters: { ...filters, muted: true }, + id: 'channels:muted', + sort, + }), + fallback, + ]); + + channelManager.setOwnershipResolver([ + 'channels:archived', + 'channels:muted', + 'channels:default', + 'channels:opened', + ]); + + return () => { + // this app is the only one registering lists on the manager, so it can drop them all at once + channelManager.clearPaginators(); + channelManager.setOwnershipResolver(); + }; + }, [chatClient, userId]); useEffect(() => { if (!chatClient) return; @@ -567,7 +542,6 @@ const App = () => { > { channel={resolveSingleChannel({ channelKey: singleChannelCid, client: chatClient, - orchestrator: channelManager, + channelManager: chatClient.channelManager, })} referenceElement={singleChannelAnchor} /> diff --git a/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx b/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx index 402a8e9ae..3f07aaf14 100644 --- a/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx +++ b/examples/vite/src/ChatLayout/SwitchableChannelNavigation.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import type { Channel, - ChannelPaginator, ChannelManagerState, + ChannelPaginator, ChannelPaginatorState, PaginatorIntervalViews, SearchControllerState, @@ -162,7 +162,7 @@ const ChannelListSwitcher = ({ }; // Channels ingested into the active paginator out of pagination order (surfaced via -// `orchestrator.ingestChannel` on a deep-link restore, search result, or new DM) land in one of the +// `channelManager.ingestChannel` on a deep-link restore, search result, or new DM) land in one of the // paginator's logical intervals when their sort position falls outside the loaded pages: the logical // HEAD when newer than the loaded window, the logical TAIL when older (e.g. deep-linking a channel // far down the list). Neither shows in the paginated `ChannelList` (which follows the active diff --git a/examples/vite/src/SingleChannel/SingleChannelApp.tsx b/examples/vite/src/SingleChannel/SingleChannelApp.tsx index e4d290ae3..86d947b6b 100644 --- a/examples/vite/src/SingleChannel/SingleChannelApp.tsx +++ b/examples/vite/src/SingleChannel/SingleChannelApp.tsx @@ -36,11 +36,11 @@ const SINGLE_CHANNEL_DIALOG_ID = 'app-single-channel-modal'; export const resolveSingleChannel = ({ channelKey, client, - orchestrator, + channelManager, }: { channelKey?: string; client: StreamChat; - orchestrator?: ChannelManager; + channelManager?: ChannelManager; }): StreamChannel => { if (channelKey) { const separatorIndex = channelKey.indexOf(':'); @@ -51,7 +51,7 @@ export const resolveSingleChannel = ({ return client.channel(type, id); } - const loadedChannel = orchestrator?.paginators.flatMap( + const loadedChannel = channelManager?.paginators.flatMap( (paginator) => paginator.items ?? [], )[0]; if (loadedChannel) return loadedChannel;