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 89d4c92f3..84f22f9d8 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 @@ -234,3 +234,109 @@ These hooks are unchanged and still work outside their provider — no action ne - `MessageComposerContext` is typed `MessageComposerContextValue | undefined`. - The gallery header renders the gallery item's own timestamp and no longer honors the `ComponentContext.MessageTimestamp` override. + +### 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 81ea62104..ca990b96a 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -412,7 +412,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 e37608492..8ef5e90c2 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. +

+
+