From e51876d5880a1862c352cfa36be85464e6f3cbaf Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 5 Aug 2026 15:32:06 +0200 Subject: [PATCH 1/3] 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 9c5d8c6a45149dc143d96481fe851ab0a82f66f7 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 18 Aug 2026 17:47:44 +0200 Subject: [PATCH 2/3] feat: use configuration service API as a single source of truth --- AI.md | 19 + ai-docs/ai-migration-v14-v15.md | 110 ++- ai-docs/instance-configuration.md | 255 +++++++ examples/vite/src/App.tsx | 3 +- .../vite/src/AppSettings/AppSettings.scss | 282 +++++++- examples/vite/src/AppSettings/AppSettings.tsx | 41 +- examples/vite/src/AppSettings/fullscreen.tsx | 20 + .../tabs/Configuration/ConfigurationTab.tsx | 674 ++++++++++++++++++ .../Configuration/ReferenceValueEditor.tsx | 117 +++ .../tabs/Configuration/configurationTree.ts | 546 ++++++++++++++ .../AppSettings/tabs/Configuration/index.ts | 1 + .../AppSettings/tabs/General/GeneralTab.tsx | 33 +- .../tabs/SettingsTabLayoutComponents.tsx | 48 +- examples/vite/src/icons.tsx | 36 + examples/vite/vite.config.ts | 22 +- src/components/Channel/Channel.tsx | 53 +- .../Channel/__tests__/Channel.test.tsx | 65 +- .../hooks/__tests__/useChannelConfig.test.tsx | 89 +++ .../useChannelRequestHandlers.test.ts | 92 --- .../Channel/hooks/useChannelConfig.ts | 45 +- .../hooks/useChannelRequestHandlers.ts | 122 ---- .../Channel/hooks/useEditMessageHandler.ts | 37 - src/components/Channel/index.ts | 1 - .../ChannelHeader/ChannelHeader.tsx | 3 +- src/components/Chat/Chat.tsx | 1 + .../Message/__tests__/Message.test.tsx | 4 +- .../Message/__tests__/utils.test.ts | 6 +- src/components/Message/utils.tsx | 8 +- .../hooks/useBaseMessageActionSetFilter.ts | 4 +- .../AttachmentSelector/AttachmentSelector.tsx | 17 +- .../__tests__/AttachmentSelector.test.tsx | 159 ++++- .../__tests__/MessageInput.test.tsx | 33 +- .../__tests__/ThreadMessageInput.test.tsx | 45 +- .../useAttachmentManagerState.test.tsx | 98 +++ .../useMessageComposerCommands.test.tsx | 10 +- .../hooks/useAttachmentManagerState.ts | 28 +- .../hooks/useMessageComposerCommands.ts | 4 +- .../MessageList/hooks/useMarkRead.ts | 11 +- src/components/Thread/Thread.tsx | 53 +- src/components/Thread/ThreadHeader.tsx | 3 +- .../Thread/__tests__/Thread.test.tsx | 4 +- .../useThreadRequestHandlers.test.ts | 102 --- .../Thread/hooks/useThreadRequestHandlers.ts | 192 ----- .../TypingIndicator/TypingIndicator.tsx | 2 +- .../TypingIndicator/TypingIndicatorHeader.tsx | 4 +- .../__tests__/TypingIndicator.test.tsx | 9 +- src/mock-builders/event/utils.ts | 4 +- src/mock-builders/utils.ts | 14 +- src/styling/dark.scss | 33 + 49 files changed, 2792 insertions(+), 770 deletions(-) create mode 100644 ai-docs/instance-configuration.md create mode 100644 examples/vite/src/AppSettings/fullscreen.tsx create mode 100644 examples/vite/src/AppSettings/tabs/Configuration/ConfigurationTab.tsx create mode 100644 examples/vite/src/AppSettings/tabs/Configuration/ReferenceValueEditor.tsx create mode 100644 examples/vite/src/AppSettings/tabs/Configuration/configurationTree.ts create mode 100644 examples/vite/src/AppSettings/tabs/Configuration/index.ts create mode 100644 src/components/Channel/hooks/__tests__/useChannelConfig.test.tsx delete mode 100644 src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts delete mode 100644 src/components/Channel/hooks/useChannelRequestHandlers.ts delete mode 100644 src/components/Channel/hooks/useEditMessageHandler.ts create mode 100644 src/components/MessageComposer/__tests__/useAttachmentManagerState.test.tsx delete mode 100644 src/components/Thread/hooks/__tests__/useThreadRequestHandlers.test.ts delete mode 100644 src/components/Thread/hooks/useThreadRequestHandlers.ts diff --git a/AI.md b/AI.md index 0fdb9ed29..3c685be92 100644 --- a/AI.md +++ b/AI.md @@ -369,8 +369,27 @@ body { - Initialize with `init({ data })` before rendering - For React 19, add package.json overrides if needed +## Configuring SDK-created instances + +Message-list page size, thread reply page size, list render throttling, composer feature flags, +notification durations and reminder offsets are configured on the client rather than through props, +because the SDK creates those instances for you: + +```ts +chatClient.config.set({ + channel: { messagePaginator: { pageSize: 50, stateThrottleMs: 250 } }, + thread: { messagePaginator: { pageSize: 25 } }, + messageComposer: { drafts: { enabled: true } }, +}); +``` + +Register it where you create the client, at module scope — not in an effect. See +[Instance configuration in React](./ai-docs/instance-configuration.md) for where it goes, how it +interacts with `` / `` request-handler props, and why there is no `` prop for it. + ## Resources +- **Instance configuration**: [`ai-docs/instance-configuration.md`](./ai-docs/instance-configuration.md) - **Official Tutorial**: https://getstream.io/chat/react-chat/tutorial/ - **Tutorial Source**: https://raw.githubusercontent.com/GetStream/getstream.io-tutorials/refs/heads/main/chat/tutorials/react-tutorial.mdx - **Component Docs**: https://getstream.io/chat/docs/sdk/react/ diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index 09c62e95c..a107017ee 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -43,13 +43,13 @@ The single largest v15 change: the React SDK no longer owns channel message stat - `loadMore` / `loadMoreNewer` → `channel.messagePaginator.prev()` / `.next()` (and `.toHead()` / `.toTail()`) - `jumpToMessage` → `channel.messagePaginator.jumpToMessage(id)` -These `*WithLocalUpdate` methods delegate to `channel.messageOperations`, which honours per-request overrides registered through the `Channel` props (see below). +These `*WithLocalUpdate` methods delegate to `channel.messageOperations`, which honours request handlers registered through `client.config` (see below). ### `MessageComposer` `overrideSubmitHandler` prop → removed `MessageComposer` (formerly `MessageInput`) now owns the submission flow (`messageComposer.compose()` → `channel.sendMessageWithLocalUpdate()`), so the `overrideSubmitHandler` prop is gone. To customise sending: -- **Intercept the outgoing request** → pass `Channel`'s `doSendMessageRequest` (also `doUpdateMessageRequest` / `doDeleteMessageRequest` / `doMarkReadRequest`). These are wired into `channel.messageOperations` and used instead of the default request. +- **Intercept the outgoing request** → register a `sendMessageRequest` handler on `client.config` (also `updateMessageRequest` / `deleteMessageRequest` / `markReadRequest`). `channel.messageOperations` uses it instead of the default request. The `do*Request` props that did this in v14 are removed — see "Per-component request-handler props removed" below. - **Transform the composed message** → register composition middleware on `messageComposer`. ### `ChatContext.setActiveChannel` → removed @@ -76,3 +76,109 @@ To ingest an ad-hoc channel (e.g. navigating to a DM or search result) into the - **Customize how the preview renders** → provide a `SummarizedMessagePreview` component override (via `ComponentProvider`, or the `` / `` component props). It receives `SummarizedMessagePreviewProps` (`{ latestMessage, messageDeliveryStatus, participantCount }`). - **Preview a specific message** rather than the channel's latest (e.g. a search result previewing the matched message) → pass the new `previewedMessage?: LocalMessage` prop to `ChannelListItem`. It defaults to the channel's reactive latest, `channel.messagePaginator.aggregateState.lastMessage`. - **Behavior note:** the preview now honors the channel's `skip_last_msg_update_for_system_msgs` config (a system message no longer becomes the previewed / last message), so the preview and the channel's sort position agree. + +### Attachment/poll availability now reads the composer's resolved config, not raw server flags + +`AttachmentSelector` used to decide which actions to offer by reading the channel type's raw server flags +(`channel.getConfig()?.uploads` / `.polls` / `.shared_locations`). It now reads the composer's **resolved** +configuration, which is those server flags already reconciled with whatever the integrator registered +through `client.config`. No React API changed — no prop, override key, or hook signature — but two +behaviours differ. + +- **Declarative configuration now reaches the UI.** `client.config.set({ messageComposer: { attachments: { enabled: false } } })` (and the same for `polls` / `location`) hides the corresponding action. Previously only the server flag was consulted, so the menu offered actions the composer would refuse to compose. Either side can switch a feature off; neither can widen — see the LLC's `docs/instance-configuration.md`. +- **A custom `doUploadRequest` no longer implies a custom upload destination.** If yours uploads to storage Stream does not host, you must now declare it, or the File action disappears for users without the `upload-file` capability: + + ```ts + client.config.set({ + messageComposer: { attachments: { customCdn: true } }, + }); + ``` + + If your custom upload function still posts to Stream (a wrapper adding retries or headers, a proxy + through your own backend), leave `customCdn` alone — Stream's capability correctly applies again. + +`useAttachmentManagerState` additionally subscribes to the composer's configuration, so `isUploadEnabled` +and its siblings now re-render when that configuration changes. Purely additive; consumers need no change. + +> Note: `isUploadEnabled` still does **not** re-render on an `own_capabilities` change. That predates v15 +> and is unchanged here. Components that need it subscribe via `useChannelCapabilities`. + +### `channel.getConfig()` → `channel.serverConfig`; `channel.config` is new + +The LLC renamed the channel's server-configuration accessor to a getter that says what it returns, which freed `config` to mean on `Channel` what it means on every other configurable class: + +| Read this | For | +| ---------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `channel.serverConfig` | The channel **type's** server flags (`replies`, `commands`, `url_enrichment`, …) | +| `useStateStore(channel.configState, …)` / `channel.config` | The **resolved** configuration — server flags ANDed with what you registered | + +`channel.getConfig()` is **removed** — `channel.serverConfig` is a getter returning the same value, so migrating is dropping the parentheses. **If you mock it in tests, note it is a getter** — `vi.fn()` cannot stand in for one; use `Object.defineProperty(channel, 'serverConfig', { get: … })` or a plain value. + +The React SDK's own `useMarkRead` switched from `channel.getConfig()?.read_events` to the resolved `readEvents.enabled` via `useStateStore`, so it now honours `client.config.set({ channel: { readEvents: { enabled: false } } })` **and** re-runs when that changes — a plain method call could not, being outside React's dependency graph. + +### `useAttachmentManagerState`: `hasCustomDoUploadRequest` → `customCdn`, plus the location and poll gates + +`hasCustomDoUploadRequest` is **removed**. It answered "is a custom upload function installed?", which was only ever consulted as a proxy for "do uploads bypass Stream's rules?" — and the LLC now shows those are different questions: an upload function that still posts to Stream stays subject to them. Read `customCdn` instead, which is the flag that actually decides. + +```ts +// v14 +const { hasCustomDoUploadRequest } = useAttachmentManagerState(); + +// v15 +const { customCdn } = useAttachmentManagerState(); +``` + +The hook also now returns `attachmentsEnabled`, `locationEnabled`, `pollsEnabled` and `maxNumberOfFilesPerMessage`. The three gates are the composer's **resolved** answers, each already ANDed with the matching channel-type flag (`uploads`, `shared_locations`, `polls`) — so a menu can ask one hook instead of combining `channel.serverConfig` with client configuration itself. `location` and `polls` have no getter on the attachment manager; they exist only on the resolved configuration, which is why they are selected rather than read off the instance. + +### Per-component request-handler props removed — register them on `client.config` + +`doSendMessageRequest`, `doUpdateMessageRequest`, `doDeleteMessageRequest` and `doMarkReadRequest` are **removed** from both `` and ``. Register the handlers declaratively instead: + +```tsx +// v14 + + … +; + +// v15 +client.config.set({ + channel: { + requestHandlers: { + sendMessageRequest: async ({ localMessage, message, options }) => ({ + message: await mySend(message, options), + }), + }, + }, +}); +; +``` + +Three things change with it: + +- **The handler signature is the LLC's, not the prop's.** Handlers take a single params object (`{ localMessage, message, options }`) and must return `{ message }`. The props took positional arguments and tolerated a `void` return, because an adapter inside the SDK filled in the rest. +- **`thread` variants are gone as a separate shape.** Thread flows register under the `thread` key (`client.config.set({ thread: { requestHandlers: … } })`); the LLC resolves per instance. +- **Registration is global to the client**, not scoped to a mounted subtree. If you were passing different handlers to different `` instances, branch inside one handler on the `channel`/`cid` you receive. + +`useChannelEditMessageHandler` is removed with them. It existed to apply `doUpdateMessageRequest` to the edit path and fell back to `client.updateMessage` when no handler was passed — with the prop gone it wrapped nothing. Register an `updateMessageRequest` handler as above; `channel.updateMessageWithLocalUpdate` already routes through it. + +**Why.** The props and declarative registration wrote to the same slot, so the SDK carried a coordinator that tracked which mounted component owned each handler, restored the previous claimant on unmount, and re-applied everything whenever the LLC re-derived its configuration. All of that existed only to reconcile two ways of doing one thing. Removing the props deletes it — the SDK no longer arbitrates ownership, because there is only one owner. + +### `useChannelConfig` returns the channel's resolved configuration, not the raw server config + +The hook used to read the channel _type's_ server configuration out of `client.channelConfigsByTypeStore`. It now returns the channel's **resolved** configuration — the same server flags, already ANDed with whatever the integrator registered through `client.config`. Field names and shape change with it: + +| v14 (raw server flag) | v15 (resolved) | +| --------------------------------------- | --------------------------------------------- | +| `channelConfig?.typing_events` | `channelConfig?.typingEvents.enabled` | +| `channelConfig?.read_events` | `channelConfig?.readEvents.enabled` | +| `channelConfig?.replies` | `channelConfig?.replies.enabled` | +| `channelConfig?.user_message_reminders` | `channelConfig?.userMessageReminders.enabled` | +| `channelConfig?.commands` | `channelConfig?.availableCommands` | + +`availableCommands` is the server's list, unchanged in shape — it is a list, not a gate, so there is nothing to reconcile. It is renamed for two reasons: whether a command is _usable_ right now is `messageComposer.isCommandDisabled(command)` and depends on the message context, so "enabled" would be wrong; and `messageComposer.config.commands` is an unrelated field holding `{ sendValidator }`. It is carried on the resolved configuration anyway so that **every question about what a channel permits has one answer** — mixing two sources is what let a UI offer features the client had disabled. + +Fields of `ChannelConfigWithInfo` that have no client-side counterpart are no longer reachable through this hook. Read them from `channel.serverConfig`, and be aware of what that means: you are getting the server's half only, which is correct for a purely server-owned setting and wrong for anything the integrator can also configure. + +The hook takes an optional `channel` now, for callers outside a channel subtree. It resolves from context otherwise, and deliberately does **not** throw when there is none — `Channel` calls it while establishing that very context. + +**Everything the React SDK reads now goes through the resolved configuration.** `serverConfig` has no callers left in `src/`. diff --git a/ai-docs/instance-configuration.md b/ai-docs/instance-configuration.md new file mode 100644 index 000000000..db7b881a9 --- /dev/null +++ b/ai-docs/instance-configuration.md @@ -0,0 +1,255 @@ +# Instance configuration in React + +`client.config` configures instances the SDK creates for you — channels, threads, message composers, +and the client's own managers. It lives on the `stream-chat` client, so it is available to a React app +without any component API. + +The full reference is in the `stream-chat` package: [instance +configuration](https://github.com/GetStream/stream-chat-js/blob/master/docs/instance-configuration.md). +This page covers what is React-specific: where to put the call, what it unlocks that component props +never could, and how to read the resolved values from a component. + +## Where to put it + +Register configuration where you create the client — module scope, not inside a component: + +```tsx +// client.ts +import { StreamChat } from 'stream-chat'; + +export const chatClient = StreamChat.getInstance(import.meta.env.VITE_STREAM_KEY); + +chatClient.config.set({ + channel: { + messagePaginator: { pageSize: 50, stateThrottleMs: 250 }, + }, + messageComposer: { + drafts: { enabled: true }, + }, +}); +``` + +```tsx +// App.tsx +import { Chat, Channel, MessageList, MessageInput } from 'stream-chat-react'; +import { chatClient } from './client'; + +export const App = () => ( + + + + + + +); +``` + +**Not in an effect.** Some configuration is read once when an instance is constructed, and channels +are constructed by `client.channel()` / `client.queryChannels()` — which an app typically calls before +or during the same commit that mounts ``. Registering from `useEffect` runs after that, so those +values would arrive too late for instances that already exist. + +**There is deliberately no `` prop for this.** Binding registration to a component's lifecycle +would recreate exactly that ordering problem, and would give you two places where configuration can +come from. The client is already the object you hold outside React. + +## What this unlocks + +These were not reachable from the React SDK at all before — there was no prop, and no way to set a +default for channels the SDK creates: + +```ts +chatClient.config.set({ + // Applies to every message list — the channel's and every thread's. + messagePaginator: { + stateThrottleMs: 250, // how often a list re-renders under a burst of events + retryCount: 2, // retries per failed page request + lockItemOrder: true, // keep a visible message's position when it updates + }, + channel: { + messagePaginator: { pageSize: 50 }, // channel messages per page + pinnedMessagesPaginator: { pageSize: 25 }, + }, + thread: { + messagePaginator: { pageSize: 25 }, // thread replies per page + }, + client: { + notifications: { durations: { error: 10_000 } }, + reminders: { scheduledOffsetsMs: [5 * 60_000, 60 * 60_000] }, + // Batching of read/delivery receipts — previously hardcoded in the LLC. + messageDelivery: { markAsReadThrottleTimeoutMs: 2000 }, + }, +}); +``` + +Feature gates are configurable too. Each is combined with the channel type's server flag, so either side +can switch a feature off: + +```ts +chatClient.config.set({ + channel: { + typingEvents: { enabled: false }, // with `typing_events` + readEvents: { enabled: false }, // with `read_events` + replies: { enabled: false }, // with `replies` + userMessageReminders: { enabled: false }, // with `user_message_reminders` + deliveryEvents: { enabled: false }, // with `delivery_events` + }, + messageComposer: { + attachments: { enabled: false }, // with `uploads` + polls: { enabled: false }, // with `polls` + location: { enabled: false }, // with `shared_locations` + linkPreviews: { enabled: false }, // with `url_enrichment` + }, +}); +``` + +Set `messageComposer.attachments.customCdn: true` if a custom `doUploadRequest` stores files outside +Stream — that tells the SDK its `uploads` flag and `upload-file` capability do not apply. + +A few settings that used to be LLC constants are now paths here too: +`messageOperations.failedSendCacheTtlMs` (how long a failed send stays retryable — a shared key, since +messages are sent from channels _and_ threads, with `channel.messageOperations` / +`thread.messageOperations` overriding it per parent), +`client.threads.connectionRecoveryThrottleMs`, and +`messageComposer.location.minShareDurationMs`. Use `chatClient.config.getTree()` to see everything you +have registered, without needing to know the key names up front. + +The top-level `messagePaginator` key exists because one `MessagePaginator` class backs both the channel +list and thread replies, and settings like throttling have no reason to differ between them. `pageSize` +does differ, so the per-parent slices override the shared one field by field. + +### `pageSize` is not `channelQueryOptions.messages.limit` + +`` sizes the **initial** channel query +(`Channel.tsx`). The paginator's `pageSize` sizes **every subsequent page** — what a scroll-up fetches. +They are different numbers and you usually want both: + +```tsx +chatClient.config.set({ channel: { messagePaginator: { pageSize: 50 } } }); + + + … +; +``` + +## Setup functions, for behaviour + +Values go in `set`. Reach for a setup function when what you want to change is behaviour — middleware, +comparators, a replaced request implementation: + +```ts +chatClient.config.setSetupFunction('messageComposer', ({ composer }) => { + composer.updateConfig({ text: { maxLengthOnSend: 500 } }); + return () => composer.updateConfig({ text: { maxLengthOnSend: undefined } }); +}); +``` + +This replaces the older `client.setMessageComposerSetupFunction(fn)`, which is deprecated in the LLC. It +still works — it shipped in v9, so customer code may rely on it — but nothing in this repo uses it any +more, and new code should not: + +```ts +// deprecated +chatClient.setMessageComposerSetupFunction(({ composer }) => { + /* … */ +}); + +// current +chatClient.config.setSetupFunction('messageComposer', ({ composer }) => { + /* … */ +}); +``` + +The type aliases `MessageComposerSetupFunction`, `MessageComposerSetupState` and +`MessageComposerTearDownFunction` went further — they are **removed**, not deprecated, because they were +never exported from the package root and so no supported import could break. Use +`InstanceSetupFunction<'messageComposer'>`, `InstanceSetupState<'messageComposer'>` and +`InstanceSetupTearDownFunction`. + +A setup function is also the right place for anything you want to survive a `client.config.reset()`: +reset re-derives configuration and re-runs setup functions, but it discards imperative +`composer.updateConfig(...)` calls made outside one. + +## Request handlers + +Register them through `client.config`. The `doSendMessageRequest`, `doUpdateMessageRequest`, +`doDeleteMessageRequest` and `doMarkReadRequest` props on `` and `` are **removed** — +see the v14 → v15 migration guide. + +```ts +// centrally, for every channel +chatClient.config.set({ + channel: { + requestHandlers: { + sendMessageRequest: async ({ message, options }) => { + const { message: sent } = await sendViaProxy(message, options); + return { message: sent }; + }, + }, + }, +}); +``` + +Thread-scoped requests go under the `thread` key with the same shape. + +Registration is per **client**, not per mounted subtree, which is the one thing the props could do that +this cannot. If behaviour has to differ between channels, branch inside a single handler on the +`channel` or `cid` it receives: + +```ts +sendMessageRequest: async ({ localMessage, message, options }) => { + const channel = chatClient.channel(...); + return isSupportChannel(localMessage.cid) + ? { message: await sendViaProxy(message, options) } + : { message: await sendNormally(message, options) }; +}, +``` + +Why the props went: they and `client.config` wrote to the same slot, so the SDK carried a coordinator +that tracked which mounted component owned each handler, restored the previous owner on unmount, and +re-applied everything whenever the LLC re-derived. All of it existed to reconcile two ways of doing one +thing. With one owner there is nothing to arbitrate. + +## Reading effective configuration + +Composer configuration is a reactive store, so components can subscribe to it: + +```tsx +import { useStateStore } from 'stream-chat-react'; + +const DraftsIndicator = ({ composer }: { composer: MessageComposer }) => { + const { enabled } = useStateStore(composer.configState, (state) => ({ + enabled: state.drafts.enabled, + })); + + return enabled ? Drafts on : null; +}; +``` + +Paginator configuration is reactive too, so the same hook works on it: + +```tsx +const { pageSize } = useStateStore(channel.messagePaginator.configState, (config) => ({ + pageSize: config.pageSize, +})); +``` + +`Channel` and `Thread` expose the same shape as every other configurable class — `configState` for the store and `config` for the current value. `Channel` used to stop at `configState`, because +`channel.getConfig()` meant the channel type's _server_ configuration and a sibling `config` would have +read as the same thing. That method is now `channel.serverConfig`, which says what it returns, so the +collision is gone. + +`config` is `Readonly`, so assigning to a field of it is a compile error — it returns the store's live +object, and the write would change state without re-rendering anything. Note that `Readonly` is shallow: +a nested write such as `composer.config.text.publishTypingEvents = false` still compiles, and you must +not do it. Reach for `updateConfig()` in every case. + +Some features are also gated by the server, and **the resolved configuration already accounts for it**. +`channel.config.typingEvents.enabled` is the channel type's `typing_events` already ANDed with what you +registered; the same holds for `readEvents`, `replies`, `userMessageReminders`, `deliveryEvents`, and the +composer's `attachments`, `polls`, `location` and `linkPreviews`. + +So read the resolved value, never the raw flag. `channel.serverConfig?.typing_events` answers only the +server's half, and a UI gating on it will offer features the client has already disabled. Client +configuration can narrow what the server allows, never widen it — the `stream-chat` doc's _server has the +last word_ section has the full rules. diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index 64b0c1f6d..9d6840a26 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, @@ -422,7 +421,7 @@ const App = () => { useEffect(() => { if (!chatClient) return; - chatClient.setMessageComposerSetupFunction(({ composer }) => { + chatClient.config.setSetupFunction('messageComposer', ({ composer }) => { // todo: find a way to register multiple setup functions so that the SDK can have own setup independent from the integrator setup composer.compositionMiddlewareExecutor.insert({ middleware: [createCommandInjectionMiddleware(composer)], diff --git a/examples/vite/src/AppSettings/AppSettings.scss b/examples/vite/src/AppSettings/AppSettings.scss index b12c156a3..bd38ed25b 100644 --- a/examples/vite/src/AppSettings/AppSettings.scss +++ b/examples/vite/src/AppSettings/AppSettings.scss @@ -971,6 +971,25 @@ color: var(--str-chat__text-primary); border-radius: 14px; overflow: hidden; + + // `dvh`, not `vh`: on mobile browsers `vh` counts the area behind the retracting address bar, so a + // full-screen dialog would overflow by exactly that strip and clip its own footer. + &--fullscreen { + width: 100vw; + height: 100dvh; + max-width: none; + border-radius: 0; + } + } + + .app__settings-modal__fullscreen-button { + flex-shrink: 0; + color: var(--str-chat__text-primary); + + .str-chat__icon { + height: var(--str-chat__icon-size-sm); + width: var(--str-chat__icon-size-sm); + } } .app__settings-modal__body { @@ -1001,6 +1020,13 @@ .app__settings-modal__content-stack { display: flex; flex-direction: column; + /* Fill the section-navigator's content box instead of sizing to content. `__tab-body` already + carries `overflow-y: auto` (from `str-chat__prompt__body`), but it only engages once something + bounds its height — without this the stack grew past the modal and `.app__settings-modal`'s + `overflow: hidden` silently clipped the overflow, with no scrollbar. Short tabs are unaffected: + the body just gets slack and shows no scrollbar. */ + height: 100%; + min-height: 0; } .app__settings-modal__tab-header .str-chat__prompt__header__title { @@ -1037,6 +1063,69 @@ display: flex; flex-direction: column; gap: var(--str-chat__spacing-xl); + /* The inherited `4px` bottom padding leaves the last control flush against the modal edge once the + body scrolls. Match the horizontal padding (and the row gap) so scrolling to the end has the same + breathing room as the sides. */ + padding-bottom: var(--str-chat__spacing-xl); + } + + // The JSON document is the whole tree at once; most edits are one field, so the listing below is the + // primary surface and this is the escape hatch. Collapsed by default for that reason. + .app__configuration-tab__raw > summary { + cursor: pointer; + font-weight: 600; + padding: var(--str-chat__spacing-xs) 0; + } + + .app__configuration-tab__reference-control { + display: inline-flex; + align-items: center; + gap: var(--str-chat__spacing-xs); + margin-inline-start: auto; + } + + .app__configuration-tab__reference-input { + font: inherit; + color: var(--str-chat__text-primary); + background: var(--str-chat__background-core-surface); + border: 1px solid var(--str-chat__border-core-default); + border-radius: 6px; + padding: 2px 6px; + + &[type='checkbox'] { + // A checkbox ignores padding and would sit a pixel high against the row's baseline. + width: 16px; + height: 16px; + padding: 0; + accent-color: var(--str-chat__background-brand-default); + } + + &[type='number'] { + width: 90px; + } + + &[type='text'] { + width: 140px; + } + } + + .app__settings-modal__tab-footer { + // Pinned rather than scrolling with the content: the Configuration tab's editor is long enough that + // Apply used to sit several screens below the JSON it applies. + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: var(--str-chat__spacing-xs); + border-block-start: 1px solid var(--str-chat__border-core-default); + background: var(--str-chat__background-core-elevation-2); + padding: var(--str-chat__spacing-md) var(--str-chat__spacing-xl); + } + + .app__settings-modal__tab-footer__hint { + margin: 0; + color: var(--str-chat__text-secondary); + font-size: 13px; + line-height: 1.4; } .app__settings-modal__field { @@ -1060,6 +1149,21 @@ color: var(--str-chat__text-secondary); font-size: 13px; line-height: 1.4; + + // Longer comments are split into one paragraph per idea rather than run together. Keep the gap + // between them, but not above the first or below the last — those would double up with the + // surrounding field spacing. + p { + margin: 8px 0; + + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + } } .app__settings-modal__options-row { @@ -1085,8 +1189,14 @@ } .app__settings-modal__option-button[aria-pressed='true'] { - border-color: var(--str-chat__border-utility-selected); - background: var(--str-chat__background-utility-selected); + // Deliberately no `background` or `border-color`. `str-chat__button` already draws the pressed state + // as an `::after` overlay tinted with `--str-chat__background-utility-selected`; setting the same + // token as the element's own background applied that tint twice and replaced the background the + // button had asked for. Measured: white text ended up on rgb(172,173,176) — 2.24:1, against the + // 4.5:1 WCAG AA needs. Leaving the background to Stream gives 11.22:1 in light and 5.49:1 in dark. + // + // The weight stays: it is the one affordance the base button does not provide, and it costs no + // contrast. font-weight: 600; } @@ -1167,6 +1277,174 @@ } } + /* --- Configuration tab --------------------------------------------------------------------- + One full-width editor holding the live configuration tree. `flex: none` is load-bearing: the tab + body is a flex column, and a shrinkable child gets squashed to fit instead of overflowing — the + editor would compress and the body would never scroll. */ + .app__configuration-tab__editor { + flex: none; + } + + .app__configuration-tab__registered summary { + color: var(--str-chat__text-secondary); + cursor: pointer; + font-size: 13px; + padding: 4px 0; + } + + .app__configuration-tab__registered[open] summary { + color: var(--str-chat__text-primary); + font-weight: 600; + } + + .app__configuration-tab__report-list { + margin: 4px 0 0; + padding-inline-start: 18px; + } + + // The reference list can run to a few hundred rows for the whole tree, so it scrolls in place rather + // than pushing the editor's buttons off the bottom of the modal. + .app__configuration-tab__reference { + list-style: none; + margin: 8px 0 0; + max-height: 420px; + overflow-y: auto; + padding: 0; + scrollbar-color: var(--str-chat__border-core-default) transparent; + scrollbar-width: thin; + } + + .app__configuration-tab__reference-row { + border-top: 1px solid var(--str-chat__border-core-default); + padding: 6px 0; + } + + .app__configuration-tab__reference-row:first-child { + border-top: none; + } + + .app__configuration-tab__reference-head { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .app__configuration-tab__reference-head code { + font-size: 12px; + } + + .app__configuration-tab__reference-type { + color: var(--str-chat__text-secondary); + font-size: 11px; + text-transform: uppercase; + } + + .app__configuration-tab__reference-flag { + background: var(--str-chat__background-core-elevation-2); + border-radius: 6px; + color: var(--str-chat__text-secondary); + font-size: 11px; + padding: 1px 6px; + } + + .app__configuration-tab__reference-description { + color: var(--str-chat__text-secondary); + font-size: 12px; + line-height: 1.45; + margin-top: 2px; + } + + .app__configuration-tab__report-list li { + font-family: + ui-monospace, + SFMono-Regular, + SF Mono, + Menlo, + monospace; + font-size: 12px; + line-height: 1.5; + } + + .app__configuration-tab__textarea { + width: 100%; + border: 1px solid var(--str-chat__border-core-default); + border-radius: 10px; + padding: 10px 12px; + background: var(--str-chat__background-core-elevation-2); + color: var(--str-chat__text-primary); + font-family: + ui-monospace, + SFMono-Regular, + SF Mono, + Menlo, + monospace; + font-size: 13px; + line-height: 1.4; + resize: vertical; + scrollbar-color: var(--str-chat__border-core-default) transparent; + scrollbar-width: thin; + } + + .app__configuration-tab__textarea:focus-visible { + outline: 2px solid var(--str-chat__border-utility-selected); + outline-offset: 1px; + } + + .app__configuration-tab__textarea[readonly] { + background: var(--str-chat__background-core-elevation-1); + color: var(--str-chat__text-secondary); + } + + .app__configuration-tab__textarea::-webkit-scrollbar { + height: 8px; + width: 8px; + } + + .app__configuration-tab__textarea::-webkit-scrollbar-track { + background: transparent; + } + + .app__configuration-tab__textarea::-webkit-scrollbar-thumb { + background: var(--str-chat__border-core-default); + border-radius: 4px; + } + + .app__configuration-tab__error, + .app__configuration-tab__notice { + border-radius: 8px; + font-size: 13px; + line-height: 1.4; + padding: 8px 10px; + } + + .app__configuration-tab__error { + background: var(--str-chat__background-utility-error-subtle, transparent); + border: 1px solid var(--str-chat__border-utility-error, currentColor); + color: var(--str-chat__text-utility-error, inherit); + font-family: + ui-monospace, + SFMono-Regular, + SF Mono, + Menlo, + monospace; + } + + .app__configuration-tab__notice--info { + background: var(--str-chat__background-core-elevation-1); + color: var(--str-chat__text-secondary); + } + + .app__configuration-tab__notice--warning { + background: var(--str-chat__background-core-elevation-1); + border: 1px solid var(--str-chat__border-utility-warning, currentColor); + color: var(--str-chat__text-primary); + } + + .app__configuration-tab__presets { + row-gap: 8px; + } + /* Full-screen the settings modal on small viewports. Viewport-driven (not layout-report-driven) so it doesn't change the width SectionNavigator measures — no feedback loop. */ @media (max-width: 640px) { diff --git a/examples/vite/src/AppSettings/AppSettings.tsx b/examples/vite/src/AppSettings/AppSettings.tsx index 8ace52205..8a739ee29 100644 --- a/examples/vite/src/AppSettings/AppSettings.tsx +++ b/examples/vite/src/AppSettings/AppSettings.tsx @@ -18,6 +18,7 @@ import { import { ActionsMenu } from './ActionsMenu'; import { ChannelDetailTab } from './tabs/ChannelDetail'; +import { ConfigurationTab } from './tabs/Configuration'; import { GeneralTab } from './tabs/General'; import { MessageActionsTab } from './tabs/MessageActions'; import { NotificationsTab } from './tabs/Notifications'; @@ -28,13 +29,16 @@ import { IconGear, IconMoon, IconSidebar, + IconSliders, IconSun, IconTextDirection, } from '../icons.tsx'; import clsx from 'clsx'; +import { FullscreenProvider } from './fullscreen'; type TabId = | 'channelDetail' + | 'configuration' | 'general' | 'messageActions' | 'notifications' @@ -74,6 +78,12 @@ const settingsSectionConfig: SettingsSectionConfig[] = [ }, { Content: SidebarTab, Icon: IconSidebar, id: 'sidebar', title: 'Sidebar' }, { Content: ReactionsTab, Icon: IconEmoji, id: 'reactions', title: 'Reactions' }, + { + Content: ConfigurationTab, + Icon: IconSliders, + id: 'configuration', + title: 'Configuration', + }, ]; const createSettingsNavButton = ({ @@ -178,6 +188,12 @@ const SidebarRtlToggle = ({ iconOnly = true }: { iconOnly?: boolean }) => { export const AppSettings = ({ iconOnly = true }: { iconOnly?: boolean }) => { const [open, setOpen] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + const toggleFullscreen = useCallback(() => setFullscreen((on) => !on), []); + const fullscreenState = useMemo( + () => ({ fullscreen, toggleFullscreen }), + [fullscreen, toggleFullscreen], + ); const closeSettingsModal = useCallback(() => setOpen(false), []); const settingsSections = useMemo( () => createSettingsSections(closeSettingsModal), @@ -198,17 +214,20 @@ export const AppSettings = ({ iconOnly = true }: { iconOnly?: boolean }) => { text='Settings' /> -
- -
+ +
+ +
+
); diff --git a/examples/vite/src/AppSettings/fullscreen.tsx b/examples/vite/src/AppSettings/fullscreen.tsx new file mode 100644 index 000000000..af01031ee --- /dev/null +++ b/examples/vite/src/AppSettings/fullscreen.tsx @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; + +/** + * Whether the settings modal fills the viewport. + * + * A context rather than a prop because the toggle is rendered by the shared tab header — the one place + * every tab already routes through — while the state belongs to the modal that resizes. Threading it + * through `SectionNavigator` and all seven tabs would touch every one of them to move a boolean. + */ +export type FullscreenState = { + fullscreen: boolean; + toggleFullscreen: () => void; +}; + +const FullscreenContext = createContext(null); + +export const FullscreenProvider = FullscreenContext.Provider; + +/** `null` when a tab is rendered outside the settings modal, in which case no toggle is shown. */ +export const useFullscreen = () => useContext(FullscreenContext); diff --git a/examples/vite/src/AppSettings/tabs/Configuration/ConfigurationTab.tsx b/examples/vite/src/AppSettings/tabs/Configuration/ConfigurationTab.tsx new file mode 100644 index 000000000..d2eb386cb --- /dev/null +++ b/examples/vite/src/AppSettings/tabs/Configuration/ConfigurationTab.tsx @@ -0,0 +1,674 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { ChannelManagerState, ThreadManagerState } from 'stream-chat'; +import { Button, useChatContext, useStateStore } from 'stream-chat-react'; +import { + SettingsTabBody, + SettingsTabFooter, + SettingsTabLayoutHeader, +} from '../SettingsTabLayoutComponents.tsx'; +import { SearchableSelect } from '../../SearchableSelect'; +import { INSTANCE_CONFIG_TREE_KEYS } from 'stream-chat'; +import { + type ConfigTreePatch, + diffAgainstCurrent, + findConstructionOnlyPaths, + formatTree, + hasPathValue, + isPlainObject, + omitPaths, + parseTree, + pickChanged, + type Plain, + readCurrentTree, + referenceRows, + removedPaths, + scopeLabel, + scopeNeeds, + SHARED_KEYS, + type TreeKey, + withPath, +} from './configurationTree'; +import { ReferenceValueEditor } from './ReferenceValueEditor'; + +type ConfigurationTabProps = { + close: () => void; +}; + +/** + * `problems` empty means everything landed. Kept as a separate field rather than inferred from the line + * count: a single problem and a single success message are both one line, and conflating them dropped the + * explanatory heading exactly when it was needed most. + */ +type Report = { + at: number; + headline: string; + problems: string[]; +}; + +const paginatorsSelector = (state: ChannelManagerState) => ({ + paginators: state.paginators, +}); + +const threadsSelector = (state: ThreadManagerState) => ({ threads: state.threads }); + +export const ConfigurationTab = ({ close }: ConfigurationTabProps) => { + const { channelManager, client } = useChatContext(); + const { paginators } = useStateStore(channelManager.state, paginatorsSelector); + const { threads } = useStateStore(client.threads.state, threadsSelector); + + const [scope, setScope] = useState('all'); + const [selectedType, setSelectedType] = useState(''); + const [selectedThreadId, setSelectedThreadId] = useState(''); + const [draft, setDraft] = useState(''); + // The tree the editor was last seeded with. Apply diffs against this so it registers only what you + // actually changed, rather than the whole resolved tree. + const [seed, setSeed] = useState>({}); + const [report, setReport] = useState(null); + // Bumped whenever configuration changes, so the editor can be re-seeded from live state. + const [revision, setRevision] = useState(0); + + const channels = useMemo( + () => + Array.from( + new Map( + paginators.flatMap((paginator) => paginator.items ?? []).map((c) => [c.cid, c]), + ).values(), + ), + [paginators], + ); + + /** + * Channel **types** among the loaded channels, not the channels themselves. + * + * Nothing here varies per channel. Configuration is registered per entity type, and the only thing that + * can make a resolved value differ — server-side channel config, which vetoes declarative values — is + * itself keyed by type (`client.channelConfigsByType`). So two channels of the same type resolve + * identically, and offering a channel picker would imply a distinction that does not exist. + */ + const channelTypes = useMemo( + () => Array.from(new Set(channels.map((c) => c.type))).sort(), + [channels], + ); + + const channelType = channelTypes.includes(selectedType) + ? selectedType + : (channelTypes[0] ?? ''); + + // Any channel of the type will do — they resolve the same. This one is only a read sample. + const channel = channels.find((c) => c.type === channelType); + + // A thread, unlike a channel, is only a read sample in the same sense: what you register applies to + // every thread. It exists as a picker at all because a thread has to have been *loaded* for there to be + // a resolved value to show, and which one is loaded is not something the tab can decide. + const thread = + threads.find((candidate) => candidate.id === selectedThreadId) ?? threads[0]; + + const needs = scopeNeeds(scope); + + const current = useMemo(() => { + void revision; + return readCurrentTree( + { channel, client, thread }, + scope === 'all' ? undefined : scope, + ); + }, [channel, client, scope, revision, thread]); + + const reseed = useCallback(() => { + setDraft(formatTree(current.tree)); + setSeed(current.tree); + setReport(null); + }, [current]); + + // Re-seed when the scope changes (key, channel or thread) or when the tree is reset. Deliberately not + // on every `revision` bump: that would overwrite whatever the user is typing the moment any config + // changed elsewhere. + useEffect(() => { + const { tree } = readCurrentTree( + { channel, client, thread }, + scope === 'all' ? undefined : scope, + ); + setDraft(formatTree(tree)); + setSeed(tree); + setReport(null); + }, [channel, channelType, client, scope, thread]); + + useEffect(() => { + const unsubscribes = INSTANCE_CONFIG_TREE_KEYS.map((key) => + client.config.getConfigState(key).subscribe(() => { + // Deferred: instances subscribe to these same stores, and subscriber order decides who runs + // first. Without the defer we would re-read before the instance has re-derived, and report a + // value that is one Apply behind. + queueMicrotask(() => setRevision((r) => r + 1)); + }), + ); + + return () => unsubscribes.forEach((unsubscribe) => unsubscribe()); + }, [client]); + + const parsed = useMemo(() => parseTree(draft), [draft]); + + /** + * What `client.config` actually holds, as opposed to what the editor shows. + * + * The editor deliberately shows *resolved* values — every knob with the value its instance ended up + * with — because that is what you want to see and change. But that view cannot tell you which of those + * values are yours: a `pageSize` of 100 looks identical whether you set it or the SDK did. `getTree()` + * answers exactly that, and is the only way to ask without knowing the keys up front. + */ + const registered = useMemo(() => { + void revision; + return client.config.getTree(); + }, [client, revision]); + + /** + * Every path the SDK says exists in this scope, whether or not anything currently holds a value for it. + * + * This is the part that does not depend on an instance existing, which is the whole point: `thread` is + * listed with its sub-paths before any thread has been opened, so "what can I configure here?" is + * answerable without opening the SDK source. + */ + const reference = useMemo( + () => referenceRows(scope === 'all' ? undefined : scope), + [scope], + ); + + const insertPath = useCallback( + (path: string, value: unknown) => { + if (!parsed.ok) return; + setDraft(formatTree(withPath(parsed.tree, path, value))); + }, + [parsed], + ); + + /** + * Drops a path from the draft, which the next `Apply` turns into an unregistration. + * + * Not the same as setting it to a falsy value: `false` or `0` registers that value, while clearing + * takes the registration away and lets the default show through again. + */ + const clearPath = useCallback( + (path: string) => { + if (!parsed.ok) return; + const segments = path.split('.'); + const prune = (node: Plain, depth: number): Plain => { + const key = segments[depth]; + if (!(key in node)) return node; + const rest = { ...node }; + if (depth === segments.length - 1) delete rest[key]; + else { + const child = rest[key]; + if (child && typeof child === 'object' && !Array.isArray(child)) { + const pruned = prune(child as Plain, depth + 1); + if (Object.keys(pruned).length === 0) delete rest[key]; + else rest[key] = pruned; + } + } + return rest; + }; + setDraft(formatTree(prune(parsed.tree, 0))); + }, + [parsed], + ); + + /** + * Unregisters paths the editor no longer has, by replaying the key without them. + * + * `client.config` has no subtraction primitive — `set` and `setConfig` deep-merge, and the only thing + * that removes anything is `reset(key)`, which clears the whole key. So "unregister one path" has to be + * spelled read-the-key, reset it, register the survivors. That is a real gap in the SDK surface, not a + * quirk of this app: any settings UI that lets you clear a value has to write this. + * + * `reset(key)` also tears down the key's setup function, which a deletion has no business touching, so + * it is read first and reinstalled after. Doing so is only possible because `getSetupFunction` exists; + * without it this workaround would silently destroy tier-2 registrations. + */ + const unregister = useCallback( + (paths: readonly string[]) => { + const registeredNow = client.config.getTree() as Plain; + const byKey = new Map(); + + for (const path of paths) { + const [key, ...rest] = path.split('.'); + if (!rest.length) continue; + byKey.set(key, [...(byKey.get(key) ?? []), rest.join('.')]); + } + + const unregistered: string[] = []; + + for (const [key, relativePaths] of byKey) { + const subtree = registeredNow[key]; + if (!isPlainObject(subtree)) continue; + + // Only paths this key actually registered. Deleting a line that was showing a default is not a + // removal — there is nothing to take away, and saying otherwise would claim an effect that the + // resolved value (still the default) plainly contradicts. + const held = relativePaths.filter((path) => hasPathValue(subtree, path)); + if (!held.length) continue; + + const survivors = omitPaths(subtree, held); + const setupFunction = client.config.getSetupFunction(key); + + client.config.reset(key); + if (Object.keys(survivors).length) { + client.config.setConfig(key, survivors as never); + } + if (setupFunction) client.config.setSetupFunction(key, setupFunction); + + unregistered.push(...held.map((path) => `${key}.${path}`)); + } + + return unregistered; + }, + [client], + ); + + const apply = useCallback(() => { + if (!parsed.ok) return; + + const changed = pickChanged(parsed.tree, seed); + const removed = removedPaths(seed, parsed.tree); + const problems: string[] = []; + + if (Object.keys(changed).length === 0 && removed.length === 0) { + setReport({ + at: Date.now(), + headline: + 'Nothing to apply — the editor matches what the instances already resolved to.', + problems: [], + }); + return; + } + + // Removals first: unregistering replays the key, so doing it afterwards would drop the values this + // same Apply had just registered. + const unregistered = unregister(removed); + client.config.set(changed as ConfigTreePatch); + + // Kept out of `problems` on purpose. Clearing a path that was only ever showing a default is not a + // failure — nothing was asked for and nothing broke — so it must not drag the headline into saying + // something did not take effect. + const clearedNothing = removed.filter((path) => !unregistered.includes(path)); + + // Re-read after applying and compare, rather than assuming it landed. Server authority, tier-2 setup + // functions and construction-only paths all silently keep the old value. + const after = readCurrentTree( + { channel, client, thread }, + scope === 'all' ? undefined : scope, + ); + const { judged, rejected } = diffAgainstCurrent(changed, after.tree); + + for (const { path, requested, resulting } of rejected) { + problems.push( + `${path}: asked for ${JSON.stringify(requested)}, still ${JSON.stringify(resulting)}`, + ); + } + + const constructionOnly = findConstructionOnlyPaths(changed); + if (constructionOnly.length) { + problems.push( + `read only at construction, so instances already built keep their value: ${constructionOnly.join(', ')}`, + ); + } + if (parsed.unknownKeys.length) { + problems.push( + `not a built-in key, so nothing reads it unless you registered an instance against it: ${parsed.unknownKeys.join(', ')}`, + ); + } + + if (unregistered.length) { + // An unregistered path does not vanish — the instance falls back to its default and still reports a + // value. Leaving the deleted line off the screen would show it as unset when it is not, so the + // editor is re-seeded from what was actually read back. + setDraft(formatTree(after.tree)); + setSeed(after.tree); + } else { + // What is on screen is the new baseline, so a second Apply with no edits is correctly a no-op + // rather than re-registering the same values. + setSeed(parsed.tree); + } + + const nothingToVerifyAgainst = judged === 0 && !unregistered.length; + const parts: string[] = []; + + if (unregistered.length) { + parts.push(`Unregistered ${unregistered.join(', ')} — now back to the default.`); + } + if (clearedNothing.length) { + parts.push( + `${clearedNothing.join(', ')} ${clearedNothing.length === 1 ? 'was' : 'were'} already showing a default — nothing was registered there to clear.`, + ); + } + if (Object.keys(changed).length && !problems.length && !nothingToVerifyAgainst) { + parts.push('Every path read back with the value you asked for.'); + } + if (nothingToVerifyAgainst && Object.keys(changed).length) { + parts.push( + 'Registered, but no instance holds these paths yet, so this cannot confirm they took effect — they will apply to instances built from now on.', + ); + } + if (problems.length) parts.push('Some paths did not take effect:'); + + setReport({ + at: Date.now(), + headline: parts.join(' ') || 'Applied.', + problems, + }); + }, [channel, client, scope, parsed, seed, thread, unregister]); + + const reset = useCallback(() => { + client.config.reset(); + setReport({ + at: Date.now(), + headline: 'Reset. Both tiers cleared; every instance re-derived its configuration.', + problems: [], + }); + // Re-read after the reset so the editor shows what instances fell back to. + queueMicrotask(() => { + const { tree } = readCurrentTree( + { channel, client, thread }, + scope === 'all' ? undefined : scope, + ); + setDraft(formatTree(tree)); + setSeed(tree); + }); + }, [channel, client, scope, thread]); + + const registeredKeyCount = Object.keys(registered).length; + const registeredSummary = + registeredKeyCount === 0 + ? 'nothing yet' + : `${registeredKeyCount} ${registeredKeyCount === 1 ? 'key' : 'keys'}`; + + const sharedInScope = SHARED_KEYS.filter((key) => scope === 'all' || scope === key); + + return ( +
+ + + +
+
Scope
+
+ One button per key of the tree, listed by the SDK itself ( + INSTANCE_CONFIG_TREE_KEYS) rather than by a list kept here — so a + key added to the SDK shows up without this tab being touched. Keys name the{' '} + entity type that consumes the configuration, which is why reminders + live under client while the composer is a key of its own. +
+
+ {(['all', ...INSTANCE_CONFIG_TREE_KEYS] as const).map((key) => ( + // Outline + `aria-pressed`, like every other tab. Swapping to solid/primary when selected + // looked stronger but failed contrast in dark mode: Stream's pressed `::after` *lightens* a + // solid primary, so white-on-blue measured 2.47:1 there, under the 4.5:1 AA needs. The + // outline form is tinted by the same `::after` and measures 11.22:1 light / 5.49:1 dark. + + ))} +
+
+ + {needs.channel && channelTypes.length > 0 && ( +
+
Channel type
+
+ What you register applies to every channel and composer. + Resolved values can still differ by channel type, because + server-side channel config — the thing that can veto a declarative value — + is keyed by type. Two channels of the same type resolve identically, so this + picks a type, not a channel. +
+ ({ label: type, value: type }))} + searchPlaceholder='Search channel types' + value={channelType} + /> +
+ )} + + {needs.thread && ( +
+
Thread
+
+ {threads.length === 0 ? ( + <> + No thread is loaded, so there are no resolved values to show. The{' '} + thread paths are still listed under Reference below and can + still be registered — they apply to every thread built from now on. + + ) : ( + <> + Only a read sample: registering applies to every{' '} + thread. A thread has to be loaded for there to be a resolved value at + all, which is the only reason this picker exists. + + )} +
+ {threads.length > 0 && ( + ({ + label: candidate.id, + value: candidate.id, + }))} + searchPlaceholder='Search loaded threads' + value={thread?.id ?? ''} + /> + )} +
+ )} + +
+
{scopeLabel(scope)}
+
+

+ What the live instances resolved to + {needs.channel && channelType && ( + <> + {' '} + for channel type {channelType} + + )} + . Editing here changes nothing until you press Apply. +

+ {current.functionsOmitted > 0 && ( +

+ {current.functionsOmitted}{' '} + {current.functionsOmitted === 1 ? 'setting holds' : 'settings hold'} a + function rather than a value. JSON cannot carry a function, so{' '} + {current.functionsOmitted === 1 ? 'it is' : 'they are'} not shown here and{' '} + Apply leaves{' '} + {current.functionsOmitted === 1 ? 'it' : 'them'} untouched. +

+ )} + {sharedInScope.length > 0 && ( +

+ {sharedInScope.map((key, index) => ( + + {index > 0 && (index === sharedInScope.length - 1 ? ' and ' : ', ')} + {key} + + ))}{' '} + {sharedInScope.length === 1 ? 'is read' : 'are read'} by every parent at + once, so no single instance owns{' '} + {sharedInScope.length === 1 ? 'its' : 'their'} value — which is why{' '} + {sharedInScope.length === 1 ? 'it is' : 'they are'} missing above. + Register {sharedInScope.length === 1 ? 'it' : 'them'} under{' '} + Reference below, then look at any parent to see the + effect. +

+ )} +
+
+ Edit as JSON +
+

+ The same values as below, in one editable document — useful for pasting a + whole tree in or out. Editing either place updates the other. +

+
+