diff --git a/CLAUDE.md b/CLAUDE.md index 76450c0fa5..fa5920ccfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth - **`insights.ts` — `InsightMetrics` + `postInsights`.** WS-health telemetry sent to `https://chat-insights.getstream.io`. This is internal; do not call from end-user code paths. The fields captured by `buildWsBaseInsight` include token and connection metadata — treat changes here as security-sensitive. - **`uploadManager.ts` / `LiveLocationManager.ts` / `CooldownTimer.ts`** — feature controllers, each owns its own `StateStore` slice. - **Domain subsystems** (each a folder with its own `index.ts` barrel): - - `messageComposer/` — biggest subsystem (≈3.5k lines). Composer + sub-composers (text, attachment, link previews, poll, location, custom-data) wired together by `MessageComposer` and driven by the middleware executor. Composition can target a `Channel`, `Thread`, or an existing local message (edit flow). Server-side composer config from `getConfig()` is merged on top of `DEFAULT_COMPOSER_CONFIG` with a customizer that prevents enabling features the server has disabled. + - `messageComposer/` — biggest subsystem (≈3.5k lines). Composer + sub-composers (text, attachment, link previews, poll, location, custom-data) wired together by `MessageComposer` and driven by the middleware executor. Composition can target a `Channel`, `Thread`, or an existing local message (edit flow). Server-side composer config from `getConfig()` is merged on top of `DEFAULT_COMPOSER_CONFIG` via `mergeServerRestrictions` (`src/configuration/serverAuthority.ts`), which prevents enabling features the server has disabled and is re-applied on every route that resolves configuration, not only at construction. - `messageDelivery/` — `MessageDeliveryReporter` (instance on the client) and `MessageReceiptsTracker` (per-channel sorted-by-timestamp tracker for delivered/read receipts; uses binary search over twin sorted arrays). - `notifications/` — toast-style `NotificationManager` (severities `error`/`warning`/`info`/`success`, configurable durations and sort comparator). Default instance is created by the client; pass `options.notifications` to provide your own. - `offline-support/` — `AbstractOfflineDB` is **abstract**. Mobile/RN SDKs inject a concrete implementation via `client.setOfflineDBApi(...)`. The `OfflineDBSyncManager` reconciles pending tasks on reconnect. Don't take it as a built-in feature of this package — it's an injection point with no default impl here. diff --git a/README.md b/README.md index 914c02f63a..7a95abb8ed 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ yarn start ## 📚 More Code Examples -Read up more on [Logging](./docs/logging.md), [User Token](./docs/userToken.md), and [Webhooks](./docs/webhooks.md) (including compressed payloads and SQS / SNS delivery) or visit our [documentation](https://getstream.io/chat/docs/) for more examples. +Read up more on [Instance configuration](./docs/instance-configuration.md), [Logging](./docs/logging.md), [User Token](./docs/userToken.md), and [Webhooks](./docs/webhooks.md) (including compressed payloads and SQS / SNS delivery) or visit our [documentation](https://getstream.io/chat/docs/) for more examples. ## ✍️ Contributing diff --git a/docs/instance-configuration.md b/docs/instance-configuration.md new file mode 100644 index 0000000000..c7f6e2d2fc --- /dev/null +++ b/docs/instance-configuration.md @@ -0,0 +1,1129 @@ +# Instance configuration + +Suppose you want the message list to load 50 messages per page instead of the default 100. + +The page size lives on `channel.messagePaginator.config.pageSize`. That paginator is created inside the +`Channel` constructor, and channels are created inside `client.channel()`, `client.queryChannels()` and +offline hydration — so by the time you hold a `Channel`, its paginator is already built. You can mutate +it on every channel you happen to have a reference to, but you cannot make it the default for the +channels the SDK creates on your behalf. + +`client.config` is how you do that. It configures instances the SDK creates for you: channels, threads, +message composers, and the client's own managers. + +> **`client.config` is not `client.channelServerConfigs`.** The latter is an internal cache of the +> **server-provided channel configuration**, keyed by cid — a channel's own `config_overrides` can make it +> differ from other channels of its type, so the cache holds one entry per channel. It is not part of the +> supported surface — read server config through `channel.serverConfig`. `client.config` is yours: what you +> register for the instances the SDK creates. + +## Two ways in + +Which one you use depends on whether the thing you are changing is a **value** or a **behaviour**. + +```ts +// values — page sizes, throttles, feature flags, durations, limits +client.config.set({ channel: { messagePaginator: { pageSize: 50 } } }); + +// behaviour — custom request logic, middleware, comparators +client.config.setSetupFunction('channel', ({ channel }) => { + /* … */ +}); +``` + +The first is the front door and should cover most of what you need. The second is the escape hatch. +Both use the same four key names — `'client'`, `'channel'`, `'thread'`, `'messageComposer'` — and +underneath they are one mechanism. + +--- + +## 1. Declarative configuration + +One call, typically next to `StreamChat.getInstance()`: + +```ts +import { StreamChat } from 'stream-chat'; + +const client = StreamChat.getInstance(apiKey); + +client.config.set({ + // Applies to every message paginator — the channel list and thread replies alike. + messagePaginator: { stateThrottleMs: 250, retryCount: 2 }, + channel: { + messagePaginator: { pageSize: 50 }, + pinnedMessagesPaginator: { pageSize: 25 }, + }, + thread: { + messagePaginator: { pageSize: 25 }, + }, + messageComposer: { + drafts: { enabled: true }, + linkPreviews: { enabled: true, debounceURLEnrichmentMs: 800 }, + attachments: { maxNumberOfFilesPerMessage: 5 }, + }, + client: { + notifications: { durations: { error: 10_000 } }, + reminders: { scheduledOffsetsMs: [5 * 60_000, 60 * 60_000] }, + }, +}); + +await client.connectUser(user, token); +``` + +Inside a key, the tree mirrors that instance's own configuration plus the sub-objects it owns — but not +other keyed instances. + +### Why some things get their own key + +Whether something is configured through its parent or gets a top-level key of its own follows one rule: +**how many kinds of parent can it hang off, and does the configuration mean the same thing under each?** + +| | | +| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **One parent type** | Nest it. `channel.pinnedMessagesPaginator`, `channel.cooldownTimer`, the composer's own sub-managers — there is only one place to reach them from. | +| **Several parents, same meaning** | Own key. A `MessageComposer` hangs off a channel, a thread, _and_ a message being edited. `drafts.enabled` means the same in all three, so nesting it under `channel` would silently miss two thirds of the composers. | +| **Several parents, different meaning** | Nest it anyway. `MessageOperations` is built by both a channel and a thread, but sending to a channel and sending as a thread reply are genuinely different operations — a shared key would conflate them. | +| **Several parents, mixed** | Both. A `MessagePaginator` backs the channel message list _and_ thread replies. `stateThrottleMs` / `retryCount` / `throwErrors` have no reason to differ; `pageSize` does. | + +That last row is why `messagePaginator` exists as a top-level key **and** as a path under `channel` and +`thread`. Set the shared things once; override per parent where they genuinely differ: + +```ts +client.config.set({ + messagePaginator: { stateThrottleMs: 250, retryCount: 2 }, // both lists + channel: { messagePaginator: { pageSize: 100 } }, // channel only + thread: { messagePaginator: { pageSize: 25 } }, // replies only +}); +``` + +The per-parent slice wins field by field, so a slice naming only `pageSize` leaves the shared +`stateThrottleMs` in place. `channel.pinnedMessagesPaginator` is deliberately **not** covered by the +shared key: it has a single parent, and it is a different class with its own ordering and endpoint. + +`set` deep-merges, so a later call only touches what it names: + +```ts +const flags = await fetchFeatureFlags(); + +client.config.setConfig('messageComposer', { + location: { enabled: flags.sharedLocation }, +}); +// `drafts.enabled` and `linkPreviews` from the call above are untouched +``` + +### It is a config object, not JSON + +Many leaves are functions — `attachments.fileUploadFilter`, `linkPreviews.findURLFn`, +`location.getDeviceId`, `notifications.sortComparator`, `messagePaginator.hasPaginationQueryShapeChanged`. +Do not plan on serializing the tree. The scalar subset happens to be serializable, but nothing here +depends on that. + +It also means "declarative" does not mean "scalars only". Request handlers are ordinary configuration +and belong here rather than in a setup function: + +```ts +client.config.set({ + channel: { + requestHandlers: { + sendMessageRequest: async ({ localMessage, message, options }) => { + await auditLog.record('message.send', { id: localMessage.id }); + const { message: sent } = await sendViaProxy(message, options); + return { message: sent }; + }, + markReadRequest: async ({ channel, options }) => { + await channel.markRead(options); + return null; + }, + }, + }, + thread: { + requestHandlers: { + markReadRequest: async ({ thread }) => { + await auditLog.record('thread.read', { id: thread.id }); + await thread.markRead(); + return null; + }, + }, + }, +}); +``` + +`markReadRequest` returns `Promise`, and `channel.markRead()` / +`thread.markRead()` resolve to a different response shape — so return `null` after delegating rather +than forwarding their result directly. + +### Two entities configure themselves + +`LiveLocationManager` and `SearchController` are the only configurable classes this package never +constructs — an app builds them, or a downstream SDK does (`useLiveLocationSharingManager` and `` +in `stream-chat-react`). There is no owner to hand them a slice, so they register themselves against +their own key: + +```ts +client.config.set({ + liveLocationManager: { minUpdateThrottleMs: 5_000 }, + searchController: { keepSingleActiveSource: false }, +}); +``` + +Both then behave like every other key: registered before or after construction, a setup function, and +`reset()`. + +**One caveat, for `SearchController` only.** It reaches the configuration registry through a `client`, and +it is the one configurable class the SDK does not already hand one to — so pass it: + +```ts +new SearchController({ client, sources: [...] }); +``` + +Without a `client` the controller works exactly as before and `updateConfig` still applies; only the +declarative key and its setup function go unheard. `stream-chat-react`'s `` passes it for you. + +Release the subscription when you are done with the instance — `liveLocationManager.dispose()` or +`searchController.dispose()`. Both are the _configuration_ teardown and are separate from +`unregisterSubscriptions()`, which is ref-counted: several callers can share one manager, so releasing +configuration there would let the first one to leave stop a still-live instance from tracking +`client.config`. + +### Two setters, one declared key space + +`set(tree)` takes the whole tree at once; `setConfig(key, subtree)` takes one key. Both are typed against +the package's keys, so a typo is a compile error either way, and the key space cannot be extended — see +[The key space is closed](#6-the-key-space-is-closed). + +--- + +## 2. Setup functions — the escape hatch + +Reach for this when what you want to change is behaviour, not a value: middleware, comparators, a +replaced request implementation. Mutate what you need and return a function that undoes it. + +```ts +// 'messageComposer' — insert composition middleware +client.config.setSetupFunction('messageComposer', ({ composer }) => { + const id = 'my-app/message-composer-middleware/mentions-guard'; + + composer.compositionMiddlewareExecutor.insert({ + middleware: [ + { + id, + handlers: { + compose: ({ state, next, discard }) => + countMentions(state.message) > 10 ? discard() : next(state), + }, + }, + ], + position: { before: 'stream-io/message-composer-middleware/composition-validation' }, + unique: true, + }); + + return () => composer.compositionMiddlewareExecutor.remove([id]); +}); +``` + +```ts +// 'channel' — replace where the message list fetches from +client.config.setSetupFunction('channel', ({ channel }) => { + const original = channel.messagePaginator.config.doRequest; + + channel.messagePaginator.updateConfig({ + doRequest: async (queryShape) => { + const { messages } = await fetchFromCache(channel.cid, queryShape); + // `cursor` is optional; supply one only for cursor-paginated sources, as + // `{ headward, tailward }`. + return { items: messages.map(formatMessage) }; + }, + }); + + return () => { + channel.messagePaginator.updateConfig({ doRequest: original }); + }; +}); +``` + +```ts +// 'client' — the client's own managers +client.config.setSetupFunction('client', ({ client: c }) => { + // `client.on` returns `{ unsubscribe }`, so hand back the method itself as the teardown. + const { unsubscribe } = c.on('connection.changed', handleConnectionChange); + return unsubscribe; +}); +``` + +```ts +// 'thread' — reaches the reply paginator, composer and message operations +client.config.setSetupFunction('thread', ({ thread }) => { + thread.messagePaginator.updateConfig({ lockItemOrder: true }); +}); +``` + +Pass `null` to clear a setup function; its teardown runs against every live instance. + +### The rules + +1. **Registering applies immediately** — to instances that already exist and to every one created + afterwards. There is no "register before you connect" requirement. +2. **Replacing tears down first.** The previous function's teardown runs before the new one is applied. +3. **Disposing an instance tears down.** `unregisterSubscriptions()` for composers and threads, + `_disconnect()` for channels, `disconnectUser()` for the client. +4. **Errors are contained.** A throwing setup or teardown is caught and logged; it cannot break + `client.channel()` or a `Thread` construction. +5. **Your function may run more than once for the same instance.** That is the contract: return a + teardown that restores what you changed. +6. **Order does not matter.** Registering for a key nobody has subscribed to yet, and subscribing to a + key with nothing registered yet, both work. + +One exception to rule 1 worth knowing: a `Thread` only receives a **setup function** once +`registerSubscriptions()` has been called (which is how `MessageComposer` already behaves — it is what +gives the teardown its symmetry). Declarative configuration is unaffected; the constructor applies it +directly. + +### Precedence + +**Declarative configuration is applied first, the setup function second**, on every change to either. +So a setup function always wins for the same field — which makes "a global default plus one conditional +exception" the natural shape: + +```ts +client.config.set({ channel: { messagePaginator: { pageSize: 50 } } }); + +client.config.setSetupFunction('channel', ({ channel }) => { + if (channel.type !== 'announcement') return; + const previous = channel.messagePaginator.config.pageSize; + channel.messagePaginator.updateConfig({ pageSize: 200 }); + return () => { + channel.messagePaginator.updateConfig({ pageSize: previous }); + }; +}); +``` + +Every channel gets 50; `announcement` channels get 200. + +--- + +## 3. How a value is resolved + +Everything above describes _what_ you can register. This section is the order it is applied in, and when +that order re-runs. + +### The stages, in order + +For any one instance, its resolved configuration is built from these layers, later ones winning: + +| # | Stage | Scope | Where the stage comes from | +| --- | ----------------------------- | ------------------------------------------ | ---------------------------------------------------- | +| 1a | **Package defaults** | every instance | `DEFAULT_*_CONFIG` constants | +| 1b | **Built-in defaults** | every instance of one subclass or owner | values the SDK supplies for the instance it builds | +| 2 | **Declarative tree** (tier 1) | per **entity type** | `client.config.set({ … })` | +| 3 | **Construction argument** | one instance | whoever called `new …({ config })` | +| 4 | **Setup function** (tier 2) | per **entity type**, but sees the instance | `client.config.setSetupFunction(key, fn)` | +| 5 | **Imperative changes** | one instance | `instance.updateConfig(…)` called from your own code | +| 6 | **Server authority** | per **channel type** | the channel's server config; narrows only, goes last | + +Stages 2 and 4 are the two tiers. Stage 4 running after stage 2 is what makes a setup function beat the +declarative tree for the same field, which is the whole basis of "a global default plus one conditional +exception". + +**Server authority is last on purpose.** Every route into the configuration — declarative, setup function, +or a direct `updateConfig()` — has the server's restrictions re-asserted over the result, so nothing above +can widen past them. That ordering is what makes +[the server has the last word](#5-the-server-has-the-last-word) literally true rather than roughly true. + +**The stages are re-resolved, not accumulated.** Each layer is kept separately and the whole order is +replayed whenever any of them changes — so applying the server's restrictions is idempotent, and stage 6 +narrowing a field never destroys the request underneath it. That is what lets both of these hold at once, +which a single stored value cannot do: + +- you turn a feature off, the server permits it, and it stays off — your request is still on record; +- the server turns a feature off and later permits it again, and the value returns to whatever you asked + for, rather than being stuck at the server's old answer. + +Practically, it also means stage 5 is not a one-way door: an imperative change survives a later declarative +one on the same field, because stage 5 is replayed after stage 2 every time. + +> **This applies to `MessageComposer` only.** It is the one class that stores the stages separately, because +> it is the one with a server restriction to re-apply. Everywhere else — `Channel`, `Thread`, the paginators, +> the client-level managers — a re-derivation rebuilds from the registered inputs alone and an imperative +> change is dropped. See [what triggers a cycle](#the-recalculation-cycle) for exactly when, and prefer a +> setup function when a per-instance value has to persist. + +**There is no per-instance stage in the declarative tier, by design.** Nothing in `config.set()` targets a +single object — you register per entity type and branch inside a setup function, which receives the +instance: + +```ts +client.config.setSetupFunction('messageComposer', ({ composer }) => { + if (!composer.threadId) return; // channel composers keep the default + composer.updateConfig({ text: { publishTypingEvents: false } }); +}); +``` + +That is stage 5 doing per-instance work. It is not a fourth tier: the _registration_ is still per type, and +it re-runs for every instance, so the branch decides. + +**Stage 1b exists because "construction argument" was ambiguous.** A value arriving through a constructor +can come from two very different places, and the two must not rank the same: + +- **An integrator** writing `new MessagePaginator({ paginatorOptions: { pageSize: 7 } })` is stating intent + for one specific object. That is stage 3, and stage 3 beats the declarative tree. +- **The SDK** supplying a value on the instance's behalf — `MessageIntervalPaginator` setting `pageSize` to + 100, `MessagePaginator` setting `stateThrottleMs` to 500, `Thread` giving its reply paginator a page size + of 50 — is stating a default, not an intent. That is stage 1b, and `client.config.set()` overrides it. + +Both kinds arrive in the same constructor argument, so the SDK-supplied ones are passed separately and +never mixed with the integrator's. Without that separation the documented order cannot be applied to a +paginator at all: a paginator built with **no configuration whatsoever** already carries `pageSize`, +`stateThrottleMs`, `initialCursor` and `hasPaginationQueryShapeChanged`, and treating those as stage 3 +would let them beat every registration. + +The order is now the same for every entity. Earlier versions of this SDK layered `MessageComposer` and +`BasePaginator` in opposite orders, so the same registration answered differently depending on which +object read it. + +### Where the stages live: a registry and a resolver + +Two objects carry out the stages above, and neither object holds what the other holds. + +| | `InstanceConfigurationRegistry` — the registry | `ConfigController` — the resolver | +| --------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------ | +| Reached as | `client.config` (public) | nothing — the controller is internal | +| Holds | what an integrator **asked for** | what one instance **ended up with** | +| How many exist | one per client | one per configurable instance | +| Keyed by | one of the package's keys (`'channel'`, `'messageComposer'`, …) | nothing; the controller does not know the instance has a key | +| Knows the defaults | no | yes, and freezes the defaults | +| Knows other instances | yes — `reset()` and the late-registration warning both need that | no | +| Operations | `set` / `setConfig` / `setSetupFunction` / `reset` | derive, re-derive, patch | + +The registry is deliberately ignorant of resolution. The registry never reads a `DEFAULT_*_CONFIG`, never +merges a layer, and never sees an instance's resolved value — reading a registry store answers "what was +registered", never "what is in effect". The resolver is the mirror image: the resolver owns the defaults, +the layer order, the server's authority and the no-op guard, and knows nothing about keys, registration, or +any other instance. + +`applyInstanceConfiguration` is the bridge, and the only place that touches both. It is internal — every +key belongs to a class this package constructs, so it has no caller outside: + +``` +client.config.set({ messagePaginator: { pageSize: 30 } }) + │ registered intent, stored under a key + ▼ +InstanceConfigurationRegistry ← the registry: keys, setup functions, reset + │ applyInstanceConfiguration subscribes one instance to one key + ▼ +paginator.initializeConfig(slice) ← the instance is handed its own subtree + │ + ▼ +ConfigController ← the resolver: runs the stages, publishes once + │ + ▼ +paginator.config ← the resolved value +``` + +Written as a pipeline, the stages of the previous section are: + +``` +package defaults (1a) DEFAULT_*_CONFIG, frozen + → built-in defaults (1b) what the subclass or the owner supplies for this instance + → declarative slice (2) the subtree registered under this instance's key + → construction args (3) what the integrator passed to the constructor + → patches (4,5) every updateConfig — see the caveat below + → server authority (6) the channel's restrictions and ceilings, applied last +``` + +Each arrow is "the layer on the right wins for a field it names". The whole pipeline re-runs from the left +on every change, which is what makes stage 6 idempotent — see the note above on re-resolution. + +The patches step is the one that differs by entity, exactly as the blockquote above says: `MessageComposer` +**retains** each `updateConfig` as a layer and replays it on every derivation, so a request survives the +server changing its mind. Every other entity writes a patch straight into the resolved value, where the +next derivation replaces it. That is one option on the resolver rather than two implementations, so +extending the retained behaviour to another entity is a switch rather than a rewrite. + +Stage 6 runs either way: a patch written straight into the resolved value still has the restrictions +applied over it, so no route can publish more than the server allows. The difference is only what happens +afterwards — a retained request is honoured if the server later relents, an unretained one was refused and +is gone. + +Stage 1b's precedence is pinned by tests in `test/unit/configuration/messagePaginator.config.test.ts` +("the documented layer order"): a registration beats an SDK-supplied default, an integrator's construction +argument beats a registration, and an untouched SDK default still applies. + +**Why the split is worth knowing.** The registry has to work before any instance exists, because +registering configuration before `client.channel()` is the normal case, and it has to work for a key this +package has never heard of. The resolver has to work for an instance nobody registered — a +`SearchController` built without a client resolves configuration perfectly well and simply never hears a +registration. Neither object could satisfy both requirements alone. + +### The recalculation cycle + +Configuration is never patched in place when something changes upstream. The instance **re-derives** from +its inputs, the setup function is re-applied on top, and the server's restrictions are re-asserted over +the result. That whole cycle is what runs, every time: + +``` +teardown of the previous setup function + ↓ +re-derive from defaults + declarative + construction (stages 1–3) + ↓ +re-apply the setup function (stage 4) + ↓ +replay the stored imperative patches (stage 5 — MessageComposer only) + ↓ +re-assert the server's restrictions over the result (stage 6) +``` + +Stage 5 is in the diagram for completeness, but only `MessageComposer` has anything to replay there — every +other entity reaches the cycle with no stored patches, as the table further down spells out. + +Re-deriving rather than patching is what keeps the tiers honest: a field _removed_ from the declarative +tree has to disappear, which a merge could never express. + +**What triggers a cycle** — any of these, for each affected instance: + +| Trigger | Example | +| ---------------------------------------------- | -------------------------------------------------------------------- | +| its own key's declarative config changes | `config.set({ channel: … })` | +| its own key's setup function is set or cleared | `config.setSetupFunction('channel', fn)` | +| a **shared key** it also derives from changes | `config.set({ messagePaginator: … })` reaches channels _and_ threads | +| the channel's server config arrives | `channel.watch()` delivering `shared_locations` | +| `config.reset()` | every registered key, every instance | + +The shared-key row is why `Channel` and `Thread` declare `alsoWatch: ['messagePaginator', +'messageOperations']`: a change under a shared key must run the _full_ cycle, not a bare re-derivation, +because a bare re-derivation applies the declarative tree and stops — it never re-runs the setup function, so +stage 4's overrides would be lost. (Not stage 5: neither route preserves that one for a `Channel` or a +`Thread`, as the table below says.) + +**Whether stage 5 survives a cycle depends on the entity**, and the difference is worth knowing before you +reach for `updateConfig()`: + +| | an imperative `updateConfig()` | cleared by `config.reset()` | +| ----------------- | --------------------------------------------------------------------------------- | --------------------------- | +| `MessageComposer` | **kept** — the patches are a stored layer, replayed on every cycle | yes | +| everything else | **dropped** on the next cycle — it is not one of the inputs a re-derivation reads | yes | + +Only the composer stores the layers separately, because it is the only class with a server restriction that +has to be re-applied without destroying the request underneath it. Extending that to the rest is deferred +(**FU-35**); until then, treat `updateConfig()` on anything else as valid until the next cycle. + +A setup function (stage 4) is the way to make a per-instance value persist either way: it is _re-run_ as part +of the cycle, so its effect is reapplied rather than remembered. + +```ts +// lost on the next re-derivation or reset — a paginator does not store stage 5 +channel.messagePaginator.updateConfig({ pageSize: 200 }); + +// survives, because the function is re-run as part of every cycle +client.config.setSetupFunction('channel', ({ channel }) => + channel.messagePaginator.updateConfig({ pageSize: 200 }), +); +``` + +**Every stage lands in the same place.** The result is written to the instance's `configState`, so anything +subscribed sees each cycle — see [Reading configuration back](#reading-configuration-back). + +--- + +## 4. What you can configure + +### Asking the SDK, instead of reading this list + +Everything in this section is also available at runtime, which matters whenever you cannot consult the +types at the moment you need them — a settings screen listing what an operator may change, a JavaScript +caller with no autocomplete, a generated reference page. + +```ts +import { INSTANCE_CONFIG_TREE_SHAPE, flattenConfigShape } from 'stream-chat'; + +for (const { path, node } of flattenConfigShape()) { + if (node.kind === 'group') continue; + console.log(path, node.type, node.description); + // thread.messagePaginator.pageSize number Items requested per page. … +} +``` + +Each node carries a `kind` (`'group'` or `'value'`), and a value node adds a `type`, a one-line +`description`, and `enumValues` where the choice is closed. `type: 'function'` marks a path the +declarative tier cannot carry at all — JSON has no functions — so those are reachable only through a +setup function. + +The shape stays complete on its own: every level of it is declared as `Record`, +so a field added to any configuration type fails the build until it is described. What it deliberately +does **not** carry is default values, because an effective default depends on where the object is +constructed — `pageSize` is 10 for a bare paginator and 100 for the channel message list — and a table of +them would be a second source of truth that disagrees with the instances. Read current values from the +instance (`channel.messagePaginator.config`) and registered values from `client.config.getTree()`. + +### Declarative paths, and their defaults + +This is the whole tree with the values the SDK ships. If a path is not here, it is not declaratively +configurable — use a setup function. + +```ts +{ + // No defaults of their own — these are layers applied *under* the per-parent slices below, so an + // unset field simply leaves the parent's default in place. + messagePaginator: {}, + // Applies to the channel's `MessageOperations` *and* every thread's, because messages are sent from + // both. Defaults live here rather than on the parents. + messageOperations: { + failedSendCacheMaxSize: 100, // failed sends kept for retry; oldest evicted past this + failedSendCacheTtlMs: 300_000, // 5 minutes + }, + channel: { + requestHandlers: {}, // none — the SDK's own request paths are used + messagePaginator: { + debounceMs: 300, // ⟳ rebuild + hasPaginationQueryShapeChanged: (prev, next) => !isEqual(prev, next), + initialCursor: undefined, // ⚑ construction-only + initialOffset: undefined, // ⚑ construction-only + lockItemOrder: false, + pageSize: 100, // channel message list default + retryCount: 0, // i.e. one attempt + stateThrottleMs: 500, // ⟳ rebuild — raised from the base's `undefined` + throwErrors: false, + unreadReferencePolicy: 'snapshot', // ⚑ construction-only + }, + pinnedMessagesPaginator: { + // as above, except: + stateThrottleMs: undefined, // no throttle, unlike the main list + }, + messageOperations: {}, // per-parent override of the shared key below + }, + thread: { + requestHandlers: {}, + messagePaginator: { + // as the channel's, except: + pageSize: 50, // thread reply default + }, + messageOperations: {}, // per-parent override of the shared key + }, + messageComposer: { + attachments: { + acceptedFiles: [], // empty means "all" + fileUploadFilter: () => true, + maxNumberOfFilesPerMessage: 10, + trackUploadProgress: true, + }, + commands: { sendValidator: defaultCommandSendabilityValidator }, + drafts: { enabled: false }, + linkPreviews: { + debounceURLEnrichmentMs: 1500, + enabled: false, + findURLFn: /* linkifyjs-based */, + }, + location: { + enabled: /* the channel's server-side `shared_locations` flag — not a constant */, + getDeviceId: () => generateUUIDv4(), + minShareDurationMs: 60_000, // shorter live-location durations are rejected as invalid + }, + text: { enabled: true, publishTypingEvents: true }, + }, + client: { + notifications: { + durations: { error: 3000, info: 3000, success: 3000, warning: 3000 }, + }, + reminders: { + scheduledOffsetsMs: [120_000, 1_800_000, 3_600_000, 7_200_000, 28_800_000, 86_400_000], + stopTimerRefreshBoundaryMs: 1_209_600_000, // 2 weeks + }, + messageDelivery: { + markAsDeliveredBufferTimeoutMs: 1000, // delivery reports batched over this window + markAsReadThrottleTimeoutMs: 1000, // ⟳ rebuild — minimum gap between auto mark-reads + maxDeliveredMessageCountInPayload: 100, // rest carried to the next request + retryCountLimitForTimeoutIncrease: 3, // timeouts before the window widens + }, + threads: { + connectionRecoveryThrottleMs: 1000, // ⚑ applies from the next `registerSubscriptions()` + }, + }, +} +``` + +Three things worth noticing. + +**Two keys are shared across parents, not nested.** `messagePaginator` backs the channel message list +_and_ thread replies; `messageOperations` backs sends from both. Each has a top-level key carrying what is +common, plus `channel.*` / `thread.*` slices that override it field by field. + +**`messageComposer.location.enabled` has no constant default.** It is the channel's server-side +`shared_locations` flag, so it varies per channel type. See [Server authority](#5-the-server-has-the-last-word). + +**`stateThrottleMs` differs between the two channel paginators** — 500ms on the message list (so a +burst of WebSocket events coalesces into roughly two renders per second) and unset on pinned messages. + +**Two markers above:** + +- **⟳ rebuild** — read once when the paginator builds its throttles and debounced query, so the SDK + routes these through a rebuild method for you. A change takes effect whenever you set it. +- **⚑ construction-only** — read once and never consulted again. See + [Order matters for a few fields](#order-matters-for-a-few-fields). + +### Reading configuration back + +Resolved configuration is read the same way everywhere: + +| member | what it is | +| -------------- | ---------------------------------------------- | +| `configState` | a `StateStore` — subscribe to react to changes | +| `config` | its current value, typed `Readonly` | +| `updateConfig` | merge a change in, notifying subscribers | + +```ts +const unsubscribe = channel.messagePaginator.configState.subscribe(({ pageSize }) => { + // fires immediately with the current value, then on every change +}); +``` + +Every configurable object has all three — `Channel`, `Thread`, `MessageComposer`, every paginator, +`MessageOperations`, `client.notifications`, `client.reminders`, `client.threads`, +`client.messageDeliveryReporter`, `SearchController`, `LiveLocationManager`. There are no exceptions left. + +`Channel` and `Thread` used to be two, for one reason that has since been removed: while the server-side +getter was still called `channel.getConfig()`, a `channel.config` beside it would have read as the same +thing in getter form while returning `{ requestHandlers }`, and nothing would have caught the confusion. +Renaming the server side to `channel.serverConfig` removed the collision. `Thread` never had that name to +collide with, and it resolves through the same `ConfigController` as everything else now, so it gets the +frozen defaults, the single layer order and the skipped no-op write for free rather than hand-rolling +them. + +Earlier versions kept several of these in plain objects that changed silently, so a subscriber that had +already read a value never learned it had moved. That is no longer the case anywhere. + +**`Readonly` catches the top level only.** It rejects `paginator.config.pageSize = 5` — which would mutate +state while notifying nobody — and points you at `updateConfig`. It does **not** reject a nested write like +`composer.config.text.publishTypingEvents = false`, because `Readonly` is shallow. Runtime freezing covers +that gap, and how far it reaches differs by class: + +- **`MessageComposer` and `Channel`** deep-freeze each resolution, so _every_ nested write throws a + `TypeError` at the offending line. Relying on the frozen package defaults alone was not enough — the + resolved value only copies subtrees some layer touched, and the subtrees the server's restrictions name + are copied on every single resolution: `location` and `text` on the composer, and on a channel the five + gates (`typingEvents`, `readEvents`, `replies`, `deliveryEvents`, `userMessageReminders`) that are the + whole point of reading `channel.config`. Those were the writable ones. +- **Everywhere else** only the package defaults are frozen, so an untouched subtree throws and a copied one + does not. Mutating a copied subtree still changes state without notifying anyone. + +`updateConfig` is the only supported route in both cases. + +### Finding out what is configured + +`client.config` holds what you **registered**; the objects above hold what they **resolved to**. To +enumerate the former without knowing the keys up front: + +```ts +client.config.getTree(); +// { messagePaginator: { pageSize: 50 }, client: { notifications: { durations: { error: 10_000 } } } } +``` + +Keys with nothing registered are omitted, so `{}` means nothing is configured rather than "several empty +subtrees". `INSTANCE_CONFIG_TREE_KEYS` is exported if you need the key list +itself. + +### Not declaratively configurable + +| | Why | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- | +| `paginator.itemIndex`, `createItemIndex` | An index instance and a factory, not values. Swapping an index would drop already-loaded items. | +| `paginator.doRequest`, `itemOrderComparator`, `deriveCursor` | Installed per paginator subclass. Replace them from a setup function, where the existing value is visible and restorable. | +| `channel.cooldownTimer` | No configuration of its own — derives from the channel's `cooldown` setting and your capabilities. | +| `channel.messageReceiptsTracker` | Constructor wiring only. | +| composer middleware executors | Ordering and composition, not values. Setup function only. | + +### Objects that need no key at all + +`ChannelPaginator`, `SearchController` and the search sources are constructed **by you**, so they already +take options — configure them there. + +`ChannelManager` is the exception worth explaining, because the reason changed. The client now builds it +(`client.channelManager`) and passes no options, so construction is not a route you have. It still gets no +key, for a different reason: everything configurable about it is a paginator instance, a handler map or a +resolver function — none of which the declarative tier can carry — and all three have setters: + +```ts +client.channelManager.insertPaginator({ paginator }); +client.channelManager.setOwnershipResolver(['inbox']); +client.channelManager.setEventHandlers(handlers); +``` + +From a setup function on the `'client'` key, those run at the right moment automatically: + +```ts +client.config.setSetupFunction('client', ({ client }) => + client.channelManager.setOwnershipResolver(['inbox']), +); +``` + +Were it ever to grow a plain-data setting, it would appear at `client.channelManager` — nested under the +key of its only parent, like `client.threads` and `client.messageDelivery`, not as a key of its own. + +### Reaching further, from a setup function + +Anything the SDK builds internally hangs off one of the four keys, so a setup function can reach it even +when it has no declarative path. You only need this table for tier 2: + +| Through | You can reach | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `'channel'` | `messagePaginator`, `pinnedMessagesPaginator`, `cooldownTimer`, `messageReceiptsTracker`, `messageOperations`, `configState` | +| `'thread'` | `messagePaginator`, `messageComposer`, `messageOperations`, `configState` | +| `'messageComposer'` | `attachmentManager`, `textComposer`, `pollComposer`, `linkPreviewsManager`, `locationComposer`, `customDataManager`, and the four middleware executors | +| `'client'` | `reminders`, `notifications`, `threads`, `polls`, `channelManager`, `messageDeliveryReporter`, `uploadManager` | + +--- + +## 5. The server has the last word + +> **Client configuration can only narrow what the server grants. It can never widen it.** + +The SDK enforces this in three different ways, and knowing which applies explains what you will observe. + +**A merge, re-asserted on every write.** One flag does this: `shared_locations` becomes +`messageComposer.location.enabled`. A feature the server disables cannot be re-enabled from the client — not +declaratively, not from a setup function, and not by calling `composer.updateConfig()` yourself, because the +restriction is re-applied _after_ whatever you asked for (stage 6 of +[how a value is resolved](#3-how-a-value-is-resolved)). A feature you disable is likewise not re-enabled by +the server. + +```ts +client.config.set({ messageComposer: { location: { enabled: true } } }); +// compiles, applies, and has no effect when the app has `shared_locations` disabled. +``` + +**This is the one silent no-op in the API.** It cannot be turned into a compile error, because the +restriction is per-app runtime data rather than something the types can know. + +The merge itself is `mergeServerRestrictions(requested, restrictions)`, exported from the package. Reading +the restrictions stays the entity's job — only a composer knows that `location.enabled` is gated on +`shared_locations`, and only an existing composer has a channel to ask — but the _rule_ has one +implementation, so a configurable object with its own server-gated field applies it the same way: + +```ts +this.configState.partialNext( + mergeServerRestrictions(requestedConfig, { + location: { enabled: this.channel.serverConfig?.shared_locations }, + }), +); +``` + +Call it on **every** route that resolves configuration, not just at construction. A restriction applied +once at construction holds until the first update and then silently stops holding, which is exactly the +defect this rule was extracted from. + +**Default a server-gated flag to `true`.** The two sides combine with AND, so `true` is the identity — it +means "no opinion, let the server decide". `false` is absorbing: it silently vetoes a feature the server +granted, and an integrator has no reason to suspect a second switch exists. Use `false` only for a feature +with **no** server flag, that is genuinely opt-in. `linkPreviews.enabled` is the cautionary case: it +defaulted to `false`, which overrode every app that had enabled `url_enrichment` server-side, and flipping +it to `true` is one of this version's breaking changes. + +**Prefer a required boolean with a default over an optional one.** An optional flag's "off" value is +`undefined`, and the merge skips `undefined` — so a field that defaults to absent can be switched on and +never off again. + +**Guards at the point of use.** `typing_events`, `read_events`, `delivery_events`, `url_enrichment` and +the channel's command list are checked where they are used, independently of your configuration — so +those are already safe: + +```ts +client.config.set({ messageComposer: { text: { publishTypingEvents: true } } }); +// `channel.keystroke()` still emits nothing when the channel type has `typing_events: false`. +``` + +**A numeric ceiling, applied the same way.** `max_message_length` caps +`messageComposer.text.maxLengthOnSend` and `maxLengthOnEdit`. It _narrows_ rather than replaces, which is a +different rule from the merge above and the reason it is passed separately: a limit you set below the +server's maximum is yours to keep, one above it is lowered, and setting none at all means the server's +maximum is what applies. + +```ts +client.config.set({ messageComposer: { text: { maxLengthOnSend: 200 } } }); +// stays 200 on a channel type allowing 5000 — asking for less is always allowed. +``` + +Worth knowing because the default is "no limit": before this, a composer let a message be written that the +send endpoint then rejected. Now the composer refuses it, which is the same limit enforced somewhere you can +show it. + +**An async permission check for uploads.** App settings (`image_upload_config` / `file_upload_config`) +gate allowed and blocked file extensions, mime types and size limits, checked per file when it is +uploaded — not through configuration at all. + +### Capabilities are a separate axis + +`own_capabilities` is per-user, per-channel **authorization**, not configuration. Even when the server +config and your configuration both enable something, the user may lack the capability, and no client +configuration can grant one. Read it from `channel.state.ownCapabilitiesStore` (reactive) rather than +`channel.data.own_capabilities`. + +One capability has a documented exception, and it is not a grant: `attachments.customCdn: true` declares +that uploads go to storage Stream does not host, so `upload-file` — which authorizes Stream's upload +endpoint — has nothing to permit or refuse and stops being consulted. Configuration is not overriding the +authorization; it is stating that the authorization is about a different endpoint. See +[`doUploadRequest` no longer implies a custom upload destination](#douploadrequest-no-longer-implies-a-custom-upload-destination). + +### Requested vs effective + +**Reading `config` gives the effective value, for every field.** That was not always true — `linkPreviews` +used to be the exception, with the server's `url_enrichment` ANDed inside `linkPreviewsManager.enabled` +rather than in the resolved configuration, so `composer.config.linkPreviews.enabled` was the requested +value while its neighbours were effective. Same object, two rules, nothing marking which was which. The +check moved into the composer's server restrictions and the getter now just reads the resolved value: + +```ts +composer.config.location.enabled; // effective +composer.config.linkPreviews.enabled; // effective — no longer the odd one out +composer.linkPreviewsManager.enabled; // the same value, reached through the manager +``` + +The model to hold: **the config store holds what is in force; what you asked for is kept separately and +re-resolved, so reading it back after the server narrows a field does not tell you what you requested** — +`composer.requestedConfig` is where the unnarrowed values live. When a declarative value is known to be +narrowed by the server, the SDK logs it at debug level so the no-op is at least discoverable. + +**Which is why a setter must not guard on the effective value:** + +```ts +// wrong +set enabled(next: boolean) { + if (next === this.enabled) return; // `this.enabled` is post-authority + this.composer.updateConfig({ linkPreviews: { enabled: next } }); +} +``` + +Where the server masks the field the effective value never moves, so the guard skips recording a _request_ +that differs from the previous one — and the stale earlier request is honoured the moment the server +relents, which is the opposite of the last instruction given. Drop the guard: `ConfigController` already +declines to publish when the resolved value does not move, which is the same check against the right +value. + +--- + +## 6. The key space is closed + +The keys are this package's, and cannot be extended. `InstanceConfigTree` and +`InstanceSetupFunctionArgs` are type aliases rather than interfaces, so a key can be neither misspelled +into existence nor added by module augmentation: + +```ts +client.config.setConfig('myWidget', { pollIntervalMs: 1_000 }); // ✗ does not compile +client.config.set({ myWidget: { pollIntervalMs: 1_000 } }); // ✗ does not compile +client.config.setSetupFunction('cahnnel', fn); // ✗ does not compile +``` + +**Configuring a class of your own.** It belongs to you, so configure it directly — a constructor +argument, a setter, or a registry of your own if you have several. Routing it through `client.config` +would put a value the SDK can neither type nor apply into a tree whose only reader is the SDK. + +The machinery is internal too. `ConfigController`, which resolves a configuration, and +`applyInstanceConfiguration`, which subscribes an instance to a key, are both unexported: every +configurable class is one this package constructs, so neither has a caller outside it. `ConfigController` +was exported while the key space was open; with the key space closed there is nothing left for an outside +class to plug into. + +The runtime keeps a warning for a key it does not define with nothing subscribed to it — reachable only +from JavaScript, or past a cast — so the mistake the compiler cannot see still surfaces. + +Adding a configurable class _inside_ this package is a different matter, and the contract is above: own a +`ConfigController`, expose `configState` / `config` / `updateConfig` / `initializeConfig`, add the key to +`InstanceConfigTree` and `shape.ts`, and subscribe with `applyInstanceConfiguration`. + +--- + +## 7. Resetting + +```ts +client.config.reset('channel'); // one key +client.config.reset(); // everything +``` + +Reset clears the declarative configuration, clears the setup function (running its teardown), and then +has every live instance **re-derive** its configuration. + +That last step is not "restore a saved copy". Configuration is _computed_: the composer merges defaults, +then your declarative values, then the channel's server flags; `PinnedMessagePaginator` installs a +request function and two comparators as closures over itself. Re-deriving reproduces all of it, which is +why a reset recovers a known state **even if a setup function's teardown was incomplete** — and why a +reset picks up the server's _current_ configuration rather than whatever it was when the channel was +constructed. + +What reset does **not** do is undo setup-function changes made outside the configuration surface — +inserted middleware, added subscriptions, event handlers you registered. The contract is that +**configuration returns to its derived baseline**, not that the object returns to factory state. + +It does, however, discard **imperative** configuration changes, because those are not among the inputs +it derives from. That includes `composer.updateConfig(...)` and every sub-composer setter routed through +it — `textComposer.defaultValue`, `attachmentManager.maxNumberOfFilesPerMessage`, +`linkPreviewsManager.enabled`, and so on. If you need such a value to survive a reset, set it +declaratively or re-apply it from a setup function (which runs again after every re-derivation). + +There is also no "restore the defaults" constant to reset to, and that is deliberate: an instance's +baseline is its package defaults _plus_ subclass overrides _plus_ constructor options _plus_ the server +merge. Resetting a `PinnedMessagePaginator` to the base paginator defaults would leave it ordering by the +wrong field with no request function at all. + +--- + +## Order matters for a few fields + +A handful of options are read once, during construction: a paginator's `unreadReferencePolicy`, +`initialCursor` and `initialOffset`. They are configurable — the SDK passes your declarative +configuration into the constructors — but only for instances built **after** you register it. + +```ts +client.config.set({ + channel: { messagePaginator: { unreadReferencePolicy: 'read-state-only' } }, +}); +const a = client.channel('messaging', 'a'); // ✅ built afterwards — applies +``` + +```ts +const b = client.channel('messaging', 'b'); +client.config.set({ + channel: { messagePaginator: { unreadReferencePolicy: 'read-state-only' } }, +}); +// ⚠️ `b` already exists; this field cannot apply to it. Logged as a warning. +``` + +The practical rule is simple: **register your configuration next to `StreamChat.getInstance()`**, before +you open any channels. + +This warning is the only one this API emits — the other diagnostics are debug level. It is louder because +it fires only when configuration genuinely did not take effect. + +--- + +## Migrating from the old API + +| Before | After | +| -------------------------------------------- | ------------------------------------------------------- | +| `client.setMessageComposerSetupFunction(fn)` | `client.config.setSetupFunction('messageComposer', fn)` | + +That one still works and is marked `@deprecated` — it shipped in v9.9.0, so there is released code to +keep working. + +The row worth advertising: **a setup function that only assigns configuration values usually collapses +into one `client.config.set({ … })` call.** Most existing ones exist only because there was no +declarative option. + +### Removed outright, not deprecated + +Three members that only ever existed on the v10 release-candidate line are **removed**, because a +deprecation exists to keep _released_ code compiling and no stable release ever exposed them: + +| Removed | Use instead | +| ----------------------------------------- | ----------------------------------------- | +| `client.setInstanceConfigurationFunction` | `client.config.setSetupFunction(key, fn)` | +| `client.instanceConfigurationService` | `client.config` | +| `client.configsStore` | `channel.serverConfig` | + +`client.configs` is also gone. It _did_ ship, and the key space is unchanged — still cid — but the name is +now `client.channelServerConfigs`, which says whose configuration it holds: `client.config` beside it is +the integrator's. Read server channel configuration through `channel.serverConfig` rather than either. + +### Type aliases removed + +Three deprecated type aliases are gone. They named the `messageComposer` key's setup types before the key +space was generalized: + +| Removed | Use instead | +| --------------------------------- | ------------------------------------------ | +| `MessageComposerSetupFunction` | `InstanceSetupFunction<'messageComposer'>` | +| `MessageComposerSetupState` | `InstanceSetupState<'messageComposer'>` | +| `MessageComposerTearDownFunction` | `InstanceSetupTearDownFunction` | + +Also listed in `v9-to-v10-migration-guide-type-renames.md`, so that table stays a complete record of +removed type names. + +**No supported import path breaks.** They lived in `src/configuration/types.ts` and were never exported +from the package root in v9, and `package.json#exports` routes consumers to the bundles rather than to +source, so there was no way to import them. Deprecating a name nobody could reach costs a reader more than +it saves anyone. `client.setMessageComposerSetupFunction` — which _did_ ship, in v9.9.0 — stays deprecated +and now takes `InstanceSetupState<'messageComposer'>['setupFunction']`. + +### Composer configuration gained required fields + +Three fields were added to `MessageComposerConfig`, all with defaults, so **nothing changes for callers +who pass partials** — which is every caller using `client.config.set()`, `updateConfig()`, or the +composer's `config` construction option, since all three take a `DeepPartial`. + +| Type | New field | Default | What it gates | +| ------------------------- | ----------- | ------------------- | ---------------------------- | +| `MessageComposerConfig` | `polls` | `{ enabled: true }` | Poll composition | +| `AttachmentManagerConfig` | `enabled` | `true` | File attachments | +| `AttachmentManagerConfig` | `customCdn` | `false` | Whether uploads reach Stream | + +**Who breaks:** only code that annotates a variable as the _complete_ `MessageComposerConfig` or +`AttachmentManagerConfig` and builds it as an object literal — TypeScript will now ask for the new keys. +Adding them with the defaults above is the whole migration. + +They are required rather than optional on purpose. An optional boolean's "off" value is `undefined`, and +the composer retains its patches and merges them with a merge that skips `undefined` — so a field that +defaults to absent can be switched on and never off again. `false` is a real value, so `customCdn` is +reversible. + +### Channel-type flags now reconcile into composer configuration + +`uploads` and `polls` from the channel type join `shared_locations` in +[§5 The server has the last word](#5-the-server-has-the-last-word): they are ANDed with `attachments.enabled` and `polls.enabled` +respectively, so either the server or the integrator can switch a feature off and neither can widen. + +**Read the resolved value, not the raw flag.** `channel.serverConfig?.uploads` answers only the server's +half; `composer.config.attachments.enabled` is the whole answer. UI that gates on the raw flag will offer +features the composer has already disabled — which is the bug this closed in `stream-chat-react`'s +`AttachmentSelector`. + +`commands` is deliberately _not_ mirrored. The server sends a list, not a gate: there is nothing to AND +and no integrator intent to express, so consumers keep reading it from `channel.serverConfig`. + +### `doUploadRequest` no longer implies a custom upload destination + +**Behaviour change, and the one most likely to bite.** `AttachmentManager` used to waive Stream's +`upload-file` capability whenever a custom `doUploadRequest` was supplied. That conflated two unrelated +things: a custom upload function says _how_ files are sent, not _where_ they land. Wrapping the request to +add retries or headers, or proxying it through your own backend, still ends at Stream. + +The waiver is now keyed on the new `attachments.customCdn` flag, which moves two groups in opposite +directions: + +| You have | Before | Now | +| ------------------------------------------------- | ----------------------- | -------------------------------------------- | +| `doUploadRequest` that still posts to Stream | capability **bypassed** | capability **enforced** — the correction | +| `doUploadRequest` to storage Stream does not host | capability bypassed | **set `customCdn: true`** to keep the bypass | + +```ts +client.config.set({ + messageComposer: { attachments: { customCdn: true } }, +}); +``` + +Miss it and uploads to your own storage start being refused for users without `upload-file`, and the +attachment action disappears from the UI. + +`customCdn` also decides whether the channel type's `uploads` flag applies, for the same reason: Stream +has no say over storage it does not host. + +Related: `AttachmentManager.isUploadEnabled` and `uploadFiles` now enforce **the same** predicate. They +had drifted apart — `uploadFiles` carried the bypass, the getter did not — so a UI asking the getter could +hide an action the SDK would have honoured. The new getter is +`config.enabled && hasAvailableUploadSlots && (!usesStreamStorage || hasUploadPermission)`, and +`uploadFiles` calls it. The `usesStreamStorage` getter is public. + +`setInstanceConfigurationFunction` is worth a note of its own. It took +`{ StreamChat, Channel, Thread, MessageComposer }`; three of those four keys were stored and never +invoked, so passing them was a silent no-op, and the one that did work (`MessageComposer`) duplicates the +setter above. Replace calls with `client.config.setSetupFunction(key, fn)` using the lowercase keys — +or better, with a declarative `client.config.set({ … })`. + +## Configuring the client itself at construction + +The `client` key is the one that cannot be configured after the fact — its configuration registry is +created inside the `StreamChat` constructor, alongside the managers it configures. Pass a tree through +the constructor when you need `reminders` or `notifications` configured before they are built: + +```ts +const client = StreamChat.getInstance(apiKey, { + config: { + client: { reminders: { scheduledOffsetsMs: [5 * 60_000] } }, + }, +}); +``` diff --git a/src/CooldownTimer.ts b/src/CooldownTimer.ts index 07d293a54a..c57dee068c 100644 --- a/src/CooldownTimer.ts +++ b/src/CooldownTimer.ts @@ -64,26 +64,39 @@ export class CooldownTimer extends WithSubscriptions { return this.state.getLatestValue().ownLatestMessageDate; } + /** + * Subscribes the timer to the two stores it derives from — `channel.state` for `cooldown` and + * `ownCapabilities`, the message paginator's store for the current user's latest message. + * + * `Channel` calls this right after constructing the timer and unregisters it in `_disconnect`, the same + * way it drives `messageReceiptsTracker`. That replaces four imperative `cooldownTimer.refresh()` calls + * in `Channel`, and the three WS-event handlers that used to live here — which duplicated those calls + * and never ran, because nothing registered them. Between them the two arrangements still missed every + * `query()`, and any `updatePartial` that changed `cooldown` without changing capabilities. + */ public registerSubscriptions = () => { this.incrementRefCount(); if (this.hasSubscriptions) return; this.addUnsubscribeFunction( - this.channel.on('message.new', (event) => { - const isOwnMessage = - event.message?.user?.id && event.message.user.id === this.getOwnUserId(); - if (!isOwnMessage) return; - this.setOwnLatestMessageDate(toDateOrUndefined(event.message?.created_at)); - }).unsubscribe, + this.channel.state.subscribeWithSelector( + ({ data, ownCapabilities }) => ({ cooldown: data?.cooldown, ownCapabilities }), + () => this.refresh(), + ), ); + // `ownLatestMessageDate` comes from the paginator's head interval. Selected on `items` rather than on + // the derived date: any ingest can change which message is the own-latest, and `refresh` already + // declines to publish unless one of its inputs actually moved. this.addUnsubscribeFunction( - this.channel.on('channel.updated', (event) => { - const cooldownChanged = event.channel?.cooldown !== this.cooldownConfigSeconds; - if (!cooldownChanged) return; - this.refresh(); - }).unsubscribe, + this.channel.messagePaginator.state.subscribeWithSelector( + ({ items }) => ({ items }), + () => this.refresh(), + ), ); + + // The countdown has no reason to keep running once the timer stops deriving. + this.addUnsubscribeFunction(() => this.clearTimeout()); }; public setCooldownRemaining = (cooldownRemaining: number) => { diff --git a/src/LiveLocationManager.ts b/src/LiveLocationManager.ts index 0a69f40ab4..e67f9770ac 100644 --- a/src/LiveLocationManager.ts +++ b/src/LiveLocationManager.ts @@ -10,7 +10,10 @@ */ import { withCancellation } from './utils/concurrency'; +import { deepFreezeConfig } from './configuration/utils/deepFreezeConfig'; import { StateStore } from './store'; +import { ConfigController } from './configuration/ConfigController'; +import { applyInstanceConfiguration } from './configuration/utils/applyInstanceConfiguration'; import { WithSubscriptions } from './utils/WithSubscriptions'; import type { StreamChat } from './client'; import type { Unsubscribe } from './store'; @@ -60,6 +63,22 @@ export type LiveLocationManagerConstructorParameters = { // Hard-coded minimal throttle timeout export const UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT = 3000; +export type LiveLocationManagerConfig = { + /** + * Shortest gap between live-location update requests (defaults to 3000ms). + * + * A failsafe against rate limiting, not a protocol limit: integrators already control the update + * cadence through a custom `watchLocation`, and this floor stops a chatty one from flooding the API. + * Raising it is always safe; lowering it risks 429s, so only do so against a known quota. + */ + minUpdateThrottleMs: number; +}; + +export const DEFAULT_LIVE_LOCATION_MANAGER_CONFIG: LiveLocationManagerConfig = + deepFreezeConfig({ + minUpdateThrottleMs: UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT, + }); + export class LiveLocationManager extends WithSubscriptions { public state: StateStore; private client: StreamChat; @@ -67,6 +86,19 @@ export class LiveLocationManager extends WithSubscriptions { private _deviceId: string; private watchLocation: WatchLocation; + /** The shared configuration machinery — see {@link ConfigController}. */ + private readonly configController: ConfigController; + /** Teardown for this manager's configuration subscription, released by {@link dispose}. */ + private unsubscribeConfiguration?: Unsubscribe; + + /** + * Resolved configuration, as a store — the shape every configurable class exposes + * (`configState` / `config` / `updateConfig`). + */ + get configState(): StateStore { + return this.configController.state; + } + static symbol = Symbol(LiveLocationManager.name); constructor({ @@ -88,6 +120,51 @@ export class LiveLocationManager extends WithSubscriptions { this._deviceId = getDeviceId(); this.getDeviceId = getDeviceId; this.watchLocation = watchLocation; + this.configController = new ConfigController({ + defaults: DEFAULT_LIVE_LOCATION_MANAGER_CONFIG, + }); + + // Last statement of the constructor, so a setup function sees a whole object. Registered here rather + // than only in `registerSubscriptions` — this manager is constructed by whoever needs it and `init()` + // is async, so gating configuration on registration would leave a window where a registered value did + // not apply. + this.subscribeConfiguration(); + } + + /** + * Subscribes this instance to the `'liveLocationManager'` configuration key, if it is not subscribed + * already. Idempotent, which is what lets both the constructor and {@link registerSubscriptions} call + * it: the first gives a value registered before `init()` resolves somewhere to land, the second brings + * a manager back after {@link dispose}. + */ + private subscribeConfiguration = () => { + if (this.unsubscribeConfiguration) return; + + this.unsubscribeConfiguration = applyInstanceConfiguration({ + args: { liveLocationManager: this }, + config: this.client.config, + key: 'liveLocationManager', + applyConfig: (config) => this.initializeConfig(config), + reinitializeConfig: () => + this.initializeConfig( + this.client.config.getConfig('liveLocationManager') ?? undefined, + ), + }); + }; + + /** The current resolved configuration. `Readonly` — change it through {@link updateConfig}. */ + get config(): Readonly { + return this.configState.getLatestValue(); + } + + /** Merges a partial configuration into the resolved config and notifies subscribers. */ + updateConfig(config: Partial) { + this.configController.patch(config); + } + + /** Rebuilds the resolved configuration from package defaults plus the declarative slice. */ + initializeConfig(config?: Partial) { + this.configController.initialize(config); } public async init() { @@ -97,14 +174,46 @@ export class LiveLocationManager extends WithSubscriptions { public registerSubscriptions = () => { this.incrementRefCount(); + // Restores configuration after a {@link dispose}, so a manager that is torn down and then used again + // is configurable again — React StrictMode's mount/cleanup/mount runs exactly that sequence against + // one instance. A no-op in the ordinary case: the constructor already subscribed. + this.subscribeConfiguration(); + if (this.hasSubscriptions) return; this.addUnsubscribeFunction(this.subscribeLiveLocationSharingUpdates()); this.addUnsubscribeFunction(this.subscribeTargetMessagesChange()); }; + /** + * Ref-counted, and deliberately does **not** touch the configuration subscription: several callers can + * share one manager, so an early caller leaving must not take anything the remaining ones still need. + * Use {@link dispose} for the instance-level teardown. + */ public unregisterSubscriptions = () => super.unregisterSubscriptions(); + /** + * Releases the configuration subscription, running the `'liveLocationManager'` setup function's + * teardown. Call it when you are finished with the manager. + * + * Separate from {@link unregisterSubscriptions} because the two have different lifetimes. Event + * subscriptions are shared and ref-counted; configuration is registered once, by the constructor, for + * the life of the instance. Releasing it from the ref-counted call meant the first of two callers to + * leave silently stopped a still-live manager from tracking `client.config` — permanently, since + * nothing but the constructor registers it. Mirrors `SearchController.dispose` and the configuration + * half of `Channel._disconnect`. + * + * Until this is called, the client's configuration registry holds a handle to this manager, so a + * long-lived client and many short-lived managers need it to be called. + * + * Recoverable: a later {@link registerSubscriptions} re-subscribes, so disposing a manager that is + * then reused costs a re-run of the setup function rather than silence. + */ + public dispose = () => { + this.unsubscribeConfiguration?.(); + this.unsubscribeConfiguration = undefined; + }; + get messages() { return this.state.getLatestValue().messages; } @@ -175,8 +284,7 @@ export class LiveLocationManager extends WithSubscriptions { // but the minimal timeout still has to be set as a failsafe (to prevent rate-limitting) if (Date.now() < nextAllowedUpdateCallTimestamp) return; - nextAllowedUpdateCallTimestamp = - Date.now() + UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT; + nextAllowedUpdateCallTimestamp = Date.now() + this.config.minUpdateThrottleMs; withCancellation(LiveLocationManager.symbol, async () => { const promises: Promise[] = []; diff --git a/src/channel.ts b/src/channel.ts index 5e89e83a14..8edf334523 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -17,6 +17,18 @@ import { import { normalizeUploadFile } from './upload-utils'; import type { StreamChat } from './client'; import { chatLoggerSystem } from './logger'; +import { applyInstanceConfiguration } from './configuration/utils/applyInstanceConfiguration'; +import { ConfigController } from './configuration/ConfigController'; +import { copyConfigPatch } from './configuration/utils/copyConfigPatch'; +import { deepFreezeConfig } from './configuration/utils/deepFreezeConfig'; +import { mergeServerRestrictions } from './configuration/utils/serverAuthority'; +import type { ServerRestrictions } from './configuration/utils/serverAuthority'; +import type { ChannelDeclarativeConfig } from './configuration/types'; +import { + mergeDeclarativeMessageOperationsConfig, + mergeDeclarativePaginatorConfig, + toDeclarativePaginatorConfig, +} from './configuration/utils/declarativeSlices'; import type { AIState, BanUserOptions, @@ -26,6 +38,7 @@ import type { ChannelResponse, ChannelStateResponseFields, ChannelUpdateOptions, + Command, CreateDraftResponse, DeleteMessageOptions, Event, @@ -54,7 +67,8 @@ import type { UserResponse, } from './types'; import { AIStates } from './types'; -import { StateStore } from './store'; +import type { StateStore } from './store'; +import type { Unsubscribe } from './store'; import type { ChannelMemberRequest as Gen_ChannelMemberRequest, ChannelPushPreferencesResponse as Gen_ChannelPushPreferencesResponse, @@ -125,7 +139,14 @@ export type CustomMarkReadRequestFn = (params: { options?: MarkReadRequest; }) => Promise> | null>; -export type ChannelInstanceConfig = { +/** + * A channel's **resolved** configuration — what {@link Channel.config} returns. + * + * Not `ChannelConfigWithInfo`, which is the generated type for the channel *type's server* + * configuration behind {@link Channel.serverConfig}. The two are related: the gates below are the + * server's flags already ANDed with what the integrator registered. + */ +export type ChannelConfig = { requestHandlers?: { deleteMessageRequest?: CustomDeleteMessageRequestFn; markReadRequest?: CustomMarkReadRequestFn; @@ -133,8 +154,101 @@ export type ChannelInstanceConfig = { retrySendMessageRequest?: CustomSendMessageRequestFn; updateMessageRequest?: CustomUpdateMessageRequestFn; }; + /** + * Typing indicators for this channel (defaults to enabled). ANDed with the channel type's + * `typing_events`, so either side can switch them off and neither can widen. + * + * This is the channel-wide gate, read by {@link Channel.keystroke} and {@link Channel.stopTyping}. + * `messageComposer.text.publishTypingEvents` sits on top of it as a per-composer refinement — a thread + * composer can stay quiet while the channel still permits typing events. + */ + typingEvents: { enabled: boolean }; + /** + * Read receipts for this channel (defaults to enabled). ANDed with the channel type's `read_events`, + * so either side can switch them off and neither can widen. Read by {@link Channel.markRead} and + * {@link Channel.markUnread}. + */ + readEvents: { enabled: boolean }; + /** + * Threaded replies for this channel (defaults to enabled). ANDed with the channel type's `replies`. + */ + replies: { enabled: boolean }; + /** + * Message reminders — "remind me" and "save for later" (defaults to enabled). ANDed with the channel + * type's `user_message_reminders`. + */ + userMessageReminders: { enabled: boolean }; + /** + * Delivery receipts (defaults to enabled). ANDed with the channel type's `delivery_events`. + */ + deliveryEvents: { enabled: boolean }; + /** + * The slash commands this channel type offers, as the server reports them. + * + * Named for availability rather than enablement on purpose: whether any given command can be *used* + * right now is `messageComposer.isCommandDisabled(command)`, which depends on the message context — + * editing and quoting disable different ones. A list called `enabledCommands` would routinely contain + * disabled entries. + * + * **Server-owned, and the one field here the integrator cannot set.** It is a list rather than a gate, + * so there is nothing to AND and no intent to express — the server's answer simply *is* the value, and + * it is absent from the declarative tree for that reason. + * + * It lives on the resolved configuration anyway so that consumers never need a second place to look: + * every question about what this channel permits is answered by `config`. Reading the raw + * {@link Channel.serverConfig} instead is what made a UI show features the client had disabled — the + * gates below have a client half, and mixing the two sources meant sometimes reading only one. + */ + availableCommands: Command[]; }; +/** + * Frozen for the same reason every other default config constant is: resolution spreads over it, so a + * subtree no layer touches stays identical by reference and would otherwise be mutable through the + * public `channel.config`. See `deepFreezeConfig`. + */ +/** + * The fields of the declarative `channel` slice that a channel resolves for **itself**. + * + * The slice also carries `messagePaginator`, `pinnedMessagesPaginator` and `messageOperations`, which are + * handed to those objects directly (see {@link Channel.initializeConfig}). Passing the whole slice to the + * controller published them on `channel.config` as well, where nothing read them: `ChannelConfig` does not + * declare them, and a registration against one of them notified every `configState` subscriber for a change + * that did not concern the channel. + */ +const ownDeclarativeConfig = ( + slice?: ChannelDeclarativeConfig, +): Partial | undefined => { + if (!slice) return undefined; + + const { + deliveryEvents, + readEvents, + replies, + requestHandlers, + typingEvents, + userMessageReminders, + } = slice; + + return { + deliveryEvents, + readEvents, + replies, + requestHandlers, + typingEvents, + userMessageReminders, + } as Partial; +}; + +export const DEFAULT_CHANNEL_CONFIG: ChannelConfig = deepFreezeConfig({ + availableCommands: [], + deliveryEvents: { enabled: true }, + readEvents: { enabled: true }, + replies: { enabled: true }, + typingEvents: { enabled: true }, + userMessageReminders: { enabled: true }, +}); + /** * The Channel class manages its own state. */ @@ -154,13 +268,31 @@ export class Channel extends ChannelApi { /** Refcount backing the reactive `active` flag (a shared Channel instance can have several consumers). */ private _activeRefCount = 0; push_preferences?: Gen_ChannelPushPreferencesResponse; - public readonly configState = new StateStore({}); + /** + * The shared configuration machinery. Owned rather than inherited — `Channel` already extends + * `ChannelApi`, so single inheritance is spent. + * + * `mergeSlice: 'deep'` because the config has nested groups: registering `typingEvents.enabled` must + * not drop `readEvents`. `applyAuthority` is what makes `channel.config` the *whole* answer rather + * than the client's half — see {@link serverRestrictions}. + */ + private readonly configController: ConfigController; public readonly messageComposer: MessageComposer; public readonly messageReceiptsTracker: MessageReceiptsTracker; public readonly messagePaginator: MessagePaginator; public readonly pinnedMessagesPaginator: PinnedMessagePaginator; public readonly messageOperations: MessageOperations; public readonly cooldownTimer: CooldownTimer; + /** + * Teardown for this channel's configuration subscription, released by {@link _disconnect}. Channels + * are retained in `client.activeChannels`, so leaving this subscribed would keep growing the + * configuration store's handler set across reconnects. + */ + private unsubscribeConfiguration?: Unsubscribe; + /** Teardown for the server-config re-derivation subscription, released by {@link _disconnect}. */ + private unsubscribeServerConfig?: Unsubscribe; + /** The declarative slice last derived from, so a late server answer can re-derive from the same one. */ + private declarativeConfig?: Partial; /** * Creates a `Channel` instance bound to the given chat client. @@ -201,6 +333,19 @@ export class Channel extends ChannelApi { this.lastTypingEvent = null; this.isTyping = false; + // Read the declarative configuration *now*, so it can go into the sub-objects as constructor + // options. Some of their fields are read once during construction (`unreadReferencePolicy`, the + // initial cursor/offset), so configuring them afterwards would silently do nothing. + const declarativeConfig = client.config.getConfig('channel') ?? undefined; + // The general `messagePaginator` key applies to every MessagePaginator — this channel's list and + // every thread's replies. The per-parent slice below overrides it. + const messagePaginatorConfig = mergeDeclarativePaginatorConfig( + client.config.getConfig('messagePaginator') ?? undefined, + declarativeConfig?.messagePaginator, + ); + + // The composer reads its own key (`messageComposer`) from the client, so nothing is passed here — + // composer configuration is deliberately not nested under `channel`. this.messageComposer = new MessageComposer({ client: this._client, compositionContext: this, @@ -209,13 +354,25 @@ export class Channel extends ChannelApi { // Created before MessageReceiptsTracker and CooldownTimer: both read the message paginator // (receipts resolve read cursors via findItemByTimestamp; CooldownTimer.refresh reads the // latest window at construction). - this.messagePaginator = new MessagePaginator({ channel: this }); - this.pinnedMessagesPaginator = new PinnedMessagePaginator({ channel: this }); + this.messagePaginator = new MessagePaginator({ + channel: this, + // Split: the policy is a constructor argument, the rest is configuration. Passing the whole slice + // put a non-config key into the paginator's published `config` — see `toDeclarativePaginatorConfig`. + unreadReferencePolicy: messagePaginatorConfig?.unreadReferencePolicy, + paginatorOptions: { + declarativeConfig: toDeclarativePaginatorConfig(messagePaginatorConfig), + }, + }); + this.pinnedMessagesPaginator = new PinnedMessagePaginator({ + channel: this, + paginatorOptions: { declarativeConfig: declarativeConfig?.pinnedMessagesPaginator }, + }); this.messageReceiptsTracker = new MessageReceiptsTracker({ channel: this }); this.messageReceiptsTracker.registerSubscriptions(); this.cooldownTimer = new CooldownTimer({ channel: this }); + this.cooldownTimer.registerSubscriptions(); this.messageOperations = new MessageOperations({ ingest: (m) => { @@ -286,6 +443,135 @@ export class Channel extends ChannelApi { // after connect may already be muted). Kept in sync afterwards by the client fan-out on // `notification.channel_mutes_updated` / `health.check`. this._syncMuteStatus(); + + this.configController = new ConfigController({ + defaults: DEFAULT_CHANNEL_CONFIG, + initialSlice: ownDeclarativeConfig(declarativeConfig), + // Nested groups: naming `typingEvents.enabled` must not drop `readEvents`. + mergeSlice: 'deep', + // Frozen so a nested write throws instead of changing state silently. Freezing + // DEFAULT_CHANNEL_CONFIG is not enough on its own: resolution rebuilds the gate subtrees every + // time, so the resolved config holds new unfrozen objects rather than the frozen defaults. + // + // Copied first, because a subtree of the resolved object and the matching subtree of the slice + // stored in `client.config` can be the same object — so freezing one freezes the other, and the + // paginators that resolve from that slice could no longer merge into it. + applyAuthority: (requested) => + deepFreezeConfig( + copyConfigPatch({ + ...(mergeServerRestrictions( + requested, + this.serverRestrictions, + ) as ChannelConfig), + // Assigned rather than merged: the deep merge would concatenate the two lists, and the + // server owns this one outright. + availableCommands: this.serverConfig?.commands ?? [], + }), + ) as ChannelConfig, + }); + + // The server's answer usually arrives *after* construction — a channel built before it has been + // queried or watched reads `serverConfig` as undefined, so the restrictions state nothing and the + // defaults stand. Re-derive when this channel's config lands, or an app that disables `read_events` + // server-side would keep a channel that believes read receipts are on. + // + // Selected by cid, and `this.cid` is read at selection time rather than captured: a channel created + // from members alone starts on a temporary cid and adopts the server's in `query()`, which assigns + // it *before* calling `_addChannelConfig`, so the write that carries the config is already selecting + // under the real key. + this.unsubscribeServerConfig = client.channelServerConfigsStore.subscribeWithSelector( + ({ configs }) => ({ channelConfig: configs[this.cid] }), + () => this.configController.rederive(this.declarativeConfig), + ); + + // Share one derivation path with `config.reset()`, so the two cannot drift. Idempotent: the + // sub-objects were already configured through their constructors above; this re-applies the + // mutable half through the same code a reset uses. + this.initializeConfig(declarativeConfig); + + // Last statement of the constructor: every sub-object a setup function might reach now exists. + // A throwing setup function is contained by the helper, so it cannot break `client.channel()`. + this.unsubscribeConfiguration = applyInstanceConfiguration({ + args: { channel: this }, + config: client.config, + key: 'channel', + applyConfig: (config) => this.initializeConfig(config), + // Reads the slice *fresh* rather than replaying a remembered one: by the time reset calls this, + // the declarative store has been cleared, so this correctly derives the un-configured baseline. + reinitializeConfig: () => + this.initializeConfig(client.config.getConfig('channel') ?? undefined), + // This channel's message paginator also derives from the shared `messagePaginator` key, so a + // change there has to run the full cycle — declarative then setup function — rather than a bare + // re-derivation, which would drop the setup function's overrides. + alsoWatch: ['messagePaginator', 'messageOperations'], + }); + } + + /** + * The configuration fields this channel's *type* decides server-side. + * + * Both are boolean gates, so `mergeServerRestrictions` ANDs them with what was requested: either the + * server or the integrator may switch a feature off, and neither can widen. Re-read on every + * derivation rather than captured, so a flag that changes mid-session is picked up. + */ + private get serverRestrictions(): ServerRestrictions { + const channelConfig = this.serverConfig; + + return { + deliveryEvents: { enabled: channelConfig?.delivery_events }, + readEvents: { enabled: channelConfig?.read_events }, + replies: { enabled: channelConfig?.replies }, + typingEvents: { enabled: channelConfig?.typing_events }, + userMessageReminders: { enabled: channelConfig?.user_message_reminders }, + }; + } + + /** + * Derives this channel's configuration — and its sub-objects' — from the declarative slice. + * + * Called by the constructor and by `client.config.reset()`. The channel owns only its own + * `requestHandlers`; each sub-object derives its own configuration, so the knowledge of what + * `messagePaginator.pageSize` means stays inside the paginator. + */ + initializeConfig(declarativeConfig?: ChannelDeclarativeConfig): void { + // Remembered so the server-config subscription can re-derive from the same slice without being + // handed it again — the server's answer arrives on its own schedule, not the tree's. + this.declarativeConfig = declarativeConfig as Partial | undefined; + + // A derivation, so it *replaces*: a handler dropped from the declarative tree has to disappear. + // Anything else writing directly into `configState.requestHandlers` — the React SDK's + // per-component props do — has to re-apply afterwards; see the note in `useChannelRequestHandlers`. + // + // The no-op guard that used to live here is now the controller's: it skips the publish when the + // resolved value is deep-equal to the last one, which matters because this runs on every + // `alsoWatch` key change too (a `messagePaginator` or `messageOperations` registration re-runs the + // whole `channel` cycle), so the no-op publishes outnumber the real ones. + this.configController.initialize(ownDeclarativeConfig(declarativeConfig)); + + // The shared `messagePaginator` key applies to every MessagePaginator — this channel's list and + // every thread's replies — and the per-parent slice overrides it. + this.messagePaginator.initializeConfig( + toDeclarativePaginatorConfig( + mergeDeclarativePaginatorConfig( + this.getClient().config.getConfig('messagePaginator') ?? undefined, + declarativeConfig?.messagePaginator, + ), + ), + ); + // Single parent, so it stays nested and takes no share of the shared key. + this.pinnedMessagesPaginator.initializeConfig( + declarativeConfig?.pinnedMessagesPaginator, + ); + + // `MessageOperations` backs both channel and thread sends, so it has a shared top-level key with a + // per-parent override — the same shape as `messagePaginator`. Defaults are spread first so a field + // dropped from the declarative tree returns to its default rather than lingering. + this.messageOperations.initializeConfig( + mergeDeclarativeMessageOperationsConfig( + this.getClient().config.getConfig('messageOperations') ?? undefined, + declarativeConfig?.messageOperations, + ), + ); } /** @@ -304,13 +590,49 @@ export class Channel extends ChannelApi { } /** - * Returns the config for this channel ID (CID). + * Resolved configuration, as a store. Delegates rather than holding a copy, so the field and the + * controller's store cannot drift. * - * @returns The channel config. + * Still directly writable, and deliberately so: the React SDK installs per-component request handlers + * by calling `partialNext({ requestHandlers })` on it. That write bypasses the controller, which is + * why `requestHandlers` is the one field a re-derivation replaces wholesale — see + * {@link initializeConfig}. */ - getConfig() { - const client = this.getClient(); - return client.configs[this.cid]; + get configState(): StateStore { + return this.configController.state; + } + + /** + * This channel's **resolved** configuration — the shape every configurable class exposes. + * + * Not to be confused with {@link serverConfig}, which is this channel's configuration as the + * server reports it. This one has already folded that in: `typingEvents.enabled` is the server's + * `typing_events` ANDed with whatever the integrator registered, so it is the whole answer. The + * near-collision is why the server side became `serverConfig`, a getter that says what it + * is. + */ + get config(): Readonly { + return this.configController.value; + } + + /** + * This channel's configuration as the server reports it — feature flags such as `uploads`, + * `typing_events`, `read_events` and `commands`. + * + * Mostly a property of the channel *type*, but not only: a channel's own `config_overrides` narrow it + * for that channel alone, which is why the cache behind this is keyed by cid rather than by type. See + * `StreamChat._addChannelConfig`. + * + * `undefined` until this channel has been queried or watched — there is nothing to fall back on that + * would not be another channel's overrides. {@link config} covers that case with its defaults. + * + * Distinct from {@link config}, which is this instance's resolved configuration and already has the + * relevant flags below folded into it. Prefer `config` when deciding whether a feature is available: + * this getter answers only the server's half, so gating UI on it offers features the client has + * already disabled. + */ + get serverConfig() { + return this.getClient().channelServerConfigs[this.cid]; } _sendMessage(...args: Parameters) { @@ -1282,7 +1604,11 @@ export class Channel extends ChannelApi { } _isTypingIndicatorsEnabled(): boolean { - if (!this.getConfig()?.typing_events || !this.getClient().wsConnection?.isHealthy) { + // The resolved value, not the raw server flag: it already ANDs the channel type's `typing_events` + // with what the integrator registered, so a client-side `typingEvents.enabled: false` is honoured + // too. The other two axes are runtime facts no configuration can express. + const { typingEvents } = this.configController.value; + if (!typingEvents.enabled || !this.getClient().wsConnection?.isHealthy) { return false; } return this.getClient().user?.privacy_settings?.typing_indicators?.enabled ?? true; @@ -1313,8 +1639,10 @@ export class Channel extends ChannelApi { override async markRead(...args: Parameters) { this._checkInitialized(); - if (!this.getConfig()?.read_events) { - throw new Error('Read events are disabled for this application'); + if (!this.configController.value.readEvents.enabled) { + throw new Error( + "Read events are disabled — either by the channel type's `read_events` setting or by `channel.readEvents.enabled` in your configuration", + ); } return await super.markRead(...args); @@ -1425,8 +1753,10 @@ export class Channel extends ChannelApi { override async markUnread(...args: Parameters) { this._checkInitialized(); - if (!this.getConfig()?.read_events) { - throw new Error('Read events are disabled for this application'); + if (!this.configController.value.readEvents.enabled) { + throw new Error( + "Read events are disabled — either by the channel type's `read_events` setting or by `channel.readEvents.enabled` in your configuration", + ); } return await super.markUnread(...args); @@ -1787,12 +2117,15 @@ export class Channel extends ChannelApi { this.getClient()._addChannelConfig(channel); - // the only config param that is necessary to be updated based on server config soon as the config is delivered - if (typeof channel.config?.shared_locations !== 'undefined') { - this.messageComposer.updateConfig({ - location: { enabled: channel.config.shared_locations }, - }); - } + // The composer derives part of its configuration from this channel's server-side config, which for a + // channel opened via `client.channel(type, id)` arrives only now — after the composer was built. A + // composer with registered subscriptions hears about it through the store; one without has no other + // route, so it is told here. + // + // Restrictions, not a request: passing the server's value to `updateConfig` would record a server + // *permission* as something the client asked for, and so re-enable a feature an integrator had + // deliberately turned off (**DV-18**). + this.messageComposer.applyServerRestrictions(); // Seed the message paginator with the first (latest) page BEFORE _initializeState, which // hydrates the read state and (via MessageReceiptsTracker) resolves read/delivered cursors @@ -1846,7 +2179,6 @@ export class Channel extends ChannelApi { this.data = channel; this.state.syncStateFromChannelData(this.data, previousData); this.offlineMode = false; - this.cooldownTimer.refresh(); if (areCapabilitiesChanged) { this.getClient().dispatchEvent({ @@ -2377,9 +2709,6 @@ export class Channel extends ChannelApi { // 1. the message is mine // 2. the message is a thread reply from any user const preventUnreadCountUpdate = ownMessage || isThreadMessage; - if (ownMessage) { - this.cooldownTimer.refresh(); - } if (preventUnreadCountUpdate) break; // The own unread count IS `read[ownUserId].unread_messages` (see @@ -2584,7 +2913,6 @@ export class Channel extends ChannelApi { }; channel.data = newChannelData; channel.state.syncStateFromChannelData(channel.data, previousChannelData); - this.cooldownTimer.refresh(); } break; case 'reaction.new': @@ -2861,11 +3189,18 @@ export class Channel extends ChannelApi { // Tear down the channel.state subscriptions BEFORE flipping `pendingDisposal` — that setter // now publishes to the store, so no subscriber handler runs against a half-torn-down channel. + + // Runs the `'channel'` setup function's teardown and removes this channel from the configuration + // store's subscribers. Cleared so a repeated `_disconnect` cannot double-run it. + this.unsubscribeConfiguration?.(); + this.unsubscribeConfiguration = undefined; + this.unsubscribeServerConfig?.(); + this.unsubscribeServerConfig = undefined; this.messageReceiptsTracker.unregisterSubscriptions(); // A deleted channel (or one the user was removed from) must not be re-watched — see #2599. this.watchStatus = ChannelWatchStatus.NotWatching; this.pendingDisposal = true; - this.cooldownTimer.clearTimeout(); + this.cooldownTimer.unregisterSubscriptions(); // Release the store-backed paginators so the message store no longer pins this removed channel // (and its whole message graph) through its subscriber registry. The channel is being discarded // here (pending disposal + deleted from activeChannels, never reused), mirroring Thread teardown. diff --git a/src/client.ts b/src/client.ts index d1f2cbf283..e04e203509 100644 --- a/src/client.ts +++ b/src/client.ts @@ -70,13 +70,13 @@ import { ReminderManager } from './reminders'; import type { AbstractOfflineDB } from './offline-support'; import { getPendingTaskChannelData } from './offline-support/util'; import { FixedSizeQueueCache } from './utils/FixedSizeQueueCache'; +import { isEqual } from './utils/mergeWith/mergeWithCore'; import type { MessageComposer } from './messageComposer'; -import type { - MessageComposerSetupState, - SetInstanceConfigurationFunctions, -} from './configuration'; -import { InstanceConfigurationService } from './configuration/InstanceConfigurationService'; +import type { InstanceSetupState } from './configuration'; +import { InstanceConfigurationRegistry } from './configuration/InstanceConfigurationRegistry'; +import { applyInstanceConfiguration } from './configuration/utils/applyInstanceConfiguration'; import { StateStore } from './store'; +import type { Unsubscribe } from './store'; import type { ConnectUserDetailsRequest, FileUploadRequest, @@ -175,7 +175,17 @@ export class StreamChat extends ChatApi { moderation: Moderation; mutedChannels: ChannelMute[]; readonly mutedUsersStore: StateStore<{ mutedUsers: UserMuteResponse[] }>; - readonly configsStore: StateStore; + /** + * Reactive store behind {@link channelServerConfigs}. The only reactive way to observe server channel + * configuration today, which is why `stream-chat-react` reads it — a public, per-channel feature + * resolver is the intended replacement. + * + * Named for what it holds plus a `Store` suffix for what it is, matching {@link mutedUsersStore}. These + * are the backend's configs, not the ones you register through {@link config}. + * + * @internal + */ + readonly channelServerConfigsStore: StateStore; blockedUsers: StateStore; node: boolean; options: StreamChatOptions; @@ -219,7 +229,16 @@ export class StreamChat extends ChatApi { appIdentifier?: AppIdentifier; private cachedUserAgent?: string; readonly messageComposerCache: FixedSizeQueueCache; - instanceConfigurationService = new InstanceConfigurationService(); + /** + * Configuration you register for instances the SDK creates on your behalf — channels, threads, + * composers, and the client's own managers. See `InstanceConfigurationRegistry`. + * + * Not to be confused with {@link channelServerConfigs}, which holds the **server-provided channel + * configs** keyed by cid. This one is yours; that one is the backend's. + */ + readonly config = new InstanceConfigurationRegistry(); + /** Teardown for the `'client'` setup function, released by {@link disconnectUser}. */ + private unsubscribeClientConfiguration?: Unsubscribe; /** * Initializes a client. @@ -257,7 +276,7 @@ export class StreamChat extends ChatApi { this.mutedUsersStore = new StateStore<{ mutedUsers: UserMuteResponse[] }>({ mutedUsers: [], }); - this.configsStore = new StateStore<{ configs: Configs }>({ + this.channelServerConfigsStore = new StateStore({ configs: {}, }); this.blockedUsers = new StateStore({ userIds: [] }); @@ -304,8 +323,6 @@ export class StreamChat extends ChatApi { // keeps a reference to all the channels that are in use this.activeChannels = {}; - // mapping between channel groups and configs - this.configs = {}; this.persistUserOnConnectionFailure = this.options?.persistUserOnConnectionFailure; // If its a server-side client, then lets initialize the tokenManager, since token will be @@ -325,6 +342,59 @@ export class StreamChat extends ChatApi { this.reminders = new ReminderManager({ client: this }); this.messageDeliveryReporter = new MessageDeliveryReporter({ client: this }); this.messageComposerCache = new FixedSizeQueueCache(64); + + // Seed the declarative configuration before wiring, so a tree passed via `options.config` reaches + // the managers above. `'client'` is the one key that cannot be configured after construction — + // this registry is born here, so there is no earlier moment for a caller to register anything. + if (this.options.config) this.config.set(this.options.config); + this.initializeManagerConfig(); + + // Last statement: everything a setup function might reach now exists. `StateStore.subscribe` fires + // immediately, so a function registered later still applies at once. + this.wireClientConfiguration(); + } + + /** + * Subscribes the client's managers to the `'client'` configuration key. + * + * Called by the constructor and again by {@link _setUser}, because {@link disconnectUser} releases this + * subscription to run the setup function's teardown. A client is reusable — `getInstance` hands the same + * object back, and disconnect/connect is the documented multi-user and mobile-background flow — and the + * managers this key configures (`reminders`, `threads`, `messageDeliveryReporter`, `notifications`) + * outlive the user. Without the re-arm the key went permanently dead on the second connect: `setConfig`, + * `setSetupFunction` and `reset` all stopped reaching any manager, silently. + * + * Idempotent through the `unsubscribeClientConfiguration` guard, so the constructor's wiring is not + * duplicated by the first `connectUser`. + */ + private wireClientConfiguration() { + if (this.unsubscribeClientConfiguration) return; + this.unsubscribeClientConfiguration = applyInstanceConfiguration({ + args: { client: this }, + config: this.config, + key: 'client', + applyConfig: () => this.initializeManagerConfig(), + reinitializeConfig: () => this.initializeManagerConfig(), + }); + } + + /** + * Derives each manager's configuration from package defaults plus the `client` declarative subtree. + * Shared by the constructor and `config.reset()`, so the two cannot drift. + * + * Defaults are spread first, and every manager is written unconditionally, because this is a + * derivation* rather than a patch — the same rule `Channel.initializeConfig` follows. Guarding on + * `if (config?.reminders)` and merging made `reset()` a no-op for this key: the store is cleared + * before instances re-derive, so the guards all failed and the registered values stayed in force. + * It also meant a field *removed* from the tree lingered, which is exactly what a merge cannot express. + */ + private initializeManagerConfig() { + const config = this.config.getConfig('client'); + + this.reminders.initializeConfig(config?.reminders); + this.threads.initializeConfig(config?.threads); + this.messageDeliveryReporter.initializeConfig(config?.messageDelivery); + this.notifications.initializeConfig(config?.notifications); } get mutedUsers() { @@ -335,12 +405,32 @@ export class StreamChat extends ChatApi { this.mutedUsersStore.next({ mutedUsers }); } - get configs() { - return this.configsStore.getLatestValue().configs; + /** + * Cache of server-provided channel configuration, keyed by **cid** — a channel's own + * `config_overrides` can make it differ from every other channel of its type, so one entry per + * channel is the only key space that can represent the answer. See {@link _addChannelConfig}. + * + * Read it through {@link Channel.serverConfig} rather than here. Not to be confused with + * {@link config}, which is the configuration *you* register for SDK-created instances. + * + * This is `client.configs` from v9 under a name that says whose configuration it is — the key space is + * unchanged, so a v9 `client.configs[cid]` lookup translates directly. There is deliberately no + * `configs` alias: it read as a sibling of the integrator-facing {@link config} while holding the + * backend's answer, and the two being one letter apart is what made `channel.getConfig()` ambiguous + * enough to rename as well. + * + * Assigning through this setter notifies subscribers; mutating the returned record in place does not. + * Prefer {@link _addChannelConfig}. + * + * @internal + */ + get channelServerConfigs() { + return this.channelServerConfigsStore.getLatestValue().configs; } - set configs(configs: Configs) { - this.configsStore.next({ configs }); + /** @internal */ + set channelServerConfigs(configs: Configs) { + this.channelServerConfigsStore.next({ configs }); } /** @@ -396,18 +486,13 @@ export class StreamChat extends ChatApi { _hasConnectionID = () => Boolean(this._getConnectionID()); + /** + * @deprecated Use `client.config.setSetupFunction('messageComposer', fn)`. + */ public setMessageComposerSetupFunction = ( - setupFunction: MessageComposerSetupState['setupFunction'], + setupFunction: InstanceSetupState<'messageComposer'>['setupFunction'], ) => { - this.instanceConfigurationService.setSetupFunctions({ - MessageComposer: setupFunction, - }); - }; - - public setInstanceConfigurationFunction = ( - setupFunctions: SetInstanceConfigurationFunctions, - ) => { - this.instanceConfigurationService.setSetupFunctions(setupFunctions); + this.config.setSetupFunction('messageComposer', setupFunction); }; /** @@ -493,6 +578,9 @@ export class StreamChat extends ChatApi { this.user = user; // this one is actually used for requests. This is a copy of current user provided to `connectUser` function. this._user = { ...user }; + // Re-arm the `'client'` key if a previous `disconnectUser` released it. Teardown at disconnect, + // setup at connect — see {@link wireClientConfiguration}. + this.wireClientConfiguration(); } /** @@ -637,6 +725,9 @@ export class StreamChat extends ChatApi { this.mutedChannels = []; this.uploadManager.reset(); this.messageComposerCache.clear(); + // Runs the `'client'` setup function's teardown. Cleared so repeated calls cannot double-run it. + this.unsubscribeClientConfiguration?.(); + this.unsubscribeClientConfiguration = undefined; // Since we wipe all user data already, we should reset token manager as well closePromise @@ -1546,13 +1637,28 @@ export class StreamChat extends ChatApi { return await super.search(request, requestOptions); } - _addChannelConfig({ cid, config }: ChannelResponse) { - if (this._cacheEnabled()) { - this.configs = { - ...this.configs, - [cid]: config, - }; - } + /** + * Caches one channel's server configuration, read through {@link Channel.serverConfig}. + * + * Keyed by **cid** rather than by channel type: a channel's own `config_overrides` narrow its type's + * settings for that channel alone, so two channels of one type can disagree. + * + * Two writes are skipped. A response with no `config` — `notification.message_new` is one route that + * omits it — would otherwise un-learn a config already known for the channel. A config deep-equal to + * the stored one keeps a repeated query from waking that channel's subscribers, which matters because + * the API returns a fresh object every time and the store compares by reference. + * + * @internal + */ + _addChannelConfig({ cid, config }: Pick) { + if (!config) return; + if (!this._cacheEnabled()) return; + if (isEqual(this.channelServerConfigs[cid], config)) return; + + this.channelServerConfigs = { + ...this.channelServerConfigs, + [cid]: config, + }; } /** diff --git a/src/configuration/ConfigController.ts b/src/configuration/ConfigController.ts new file mode 100644 index 0000000000..d06cc94ccd --- /dev/null +++ b/src/configuration/ConfigController.ts @@ -0,0 +1,298 @@ +import { StateStore } from '../store'; +import { mergeWith } from '../utils/mergeWith'; +import { isEqual } from '../utils/mergeWith/mergeWithCore'; +import { copyConfigPatch } from './utils/copyConfigPatch'; +import { deepFreezeConfig } from './utils/deepFreezeConfig'; + +export type ConfigControllerOptions> = { + /** + * Package defaults. Deep-frozen on construction, so a nested write through the entity's public + * `config` getter throws instead of silently changing the default for every instance in the process — + * a bug found three times in three entities before this was centralized. + */ + defaults: TConfig; + /** + * Defaults the SDK itself supplies for this instance — a subclass's, or an owner's for the object it + * builds. Layered above {@link ConfigControllerOptions.defaults} and **below** the declarative slice. + * + * The distinction is the whole reason this exists. These arrive through the same constructor as an + * integrator's arguments, so before they were separated a paginator built with no configuration at all + * already carried `pageSize`, `stateThrottleMs`, `initialCursor` and `hasPaginationQueryShapeChanged` + * as "construction arguments" — which meant the documented order could not be applied to it without + * those SDK values beating every `client.config.set()`. + */ + builtInDefaults?: Partial; + /** + * Arguments the **integrator** passed to the constructor. Stage 3 of + * `docs/instance-configuration.md` §3, so they outrank the declarative tree, and they survive a reset — + * unlike the slice. Only what a caller actually supplied belongs here; anything the SDK injects on its + * own behalf goes in {@link ConfigControllerOptions.builtInDefaults}. + */ + constructorOptions?: Partial; + /** + * The declarative slice known at construction, used only to seed the store. + * + * Separate from calling {@link ConfigController.initialize} afterwards, because that would run + * `getBehaviourOverrides` — an override on the owning class — while the owner's own constructor is + * still in flight, memoizing closures over half-initialized fields. + */ + initialSlice?: Partial; + /** + * How the declarative slice combines with the layers beneath it. `'shallow'` (the default) suits a flat + * config; `'deep'` is for one with nested groups, where naming one member must not drop its siblings. + * Declared rather than implied, because reading `updateConfig(config: Partial)` never told you which + * you were getting. + */ + mergeSlice?: 'shallow' | 'deep'; + /** + * Fields that outrank every layer — behaviour no option can express, such as a comparator or a request + * function closed over the entity. Folded into the single derivation, so one re-derivation is one + * publish carrying a complete config. + * + * Must return **stable references**: rebuilding the closures per call makes every derived config differ + * from the last, which defeats the no-op guard and republishes on every unrelated re-derivation. + */ + getBehaviourOverrides?: () => Partial; + /** + * Runs after a change lands, with the value it replaced. + * + * This is where read-once fields are re-applied. It exists because "store the value" and "make the + * value take effect" were separate steps that each entity had to remember to pair: a paginator's + * `updateConfig({ debounceMs })` stored 900 and left the debounce running at 300, so resolved + * configuration reported a value the entity was not using. Routing every write through one place means + * the pairing cannot be forgotten. + * + * Not called for the initial value — there is no previous state to compare, and construction is where + * the entity sets these up itself. + */ + onChanged?: (next: Readonly, previous: Readonly) => void; + /** + * Whether a {@link ConfigController.patch} survives the next derivation. + * + * Off by default: a patch is written into the resolved value, and the next derivation — which rebuilds + * from defaults and registrations — replaces it. On, each patch is kept as an input and replayed every + * time, so the request outlives anything else re-resolving. + * + * `MessageComposer` is the one entity that needs this on, because it is the one a server can narrow: a + * stored `false` cannot say whether the client or the server turned a feature off, so re-applying + * restrictions either makes the server's answer permanent or wipes the client's (**DV-18**). Retaining + * the request makes the resolution idempotent instead. **FU-35** is the question of which other + * entities should switch it on. + * + * Off is still safe alongside {@link ConfigControllerOptions.applyAuthority} — {@link + * ConfigController.patch} applies authority on that path too — but the request is written into the + * result rather than kept, so a field the server currently narrows is refused outright instead of + * taking effect if the server later relents. + */ + retainPatches?: boolean; + /** + * The final transform, applied to the request to produce what is published — the composer's server + * restrictions and upper bounds. + * + * Kept as one opaque hook so the controller never learns the authority rules themselves; those live in + * `serverAuthority.ts`. Runs on *every* derivation and on {@link ConfigController.patch}, which is the + * point: a restriction applied only at construction stops holding the first time anything else updates + * the configuration. + */ + applyAuthority?: (requested: TConfig) => TConfig; +}; + +/** + * Lays one layer over another, ignoring keys whose value is `undefined`. + * + * Not a plain spread, because `Partial` admits an explicit `undefined` and a spread would write it — + * turning "I did not set this" into "I set this to nothing" and wiping the default underneath. The + * declarative merge helpers draw the same line. + */ +const layer = (target: T, source?: Partial): T => { + if (!source) return target; + for (const [key, value] of Object.entries(source)) { + if (value === undefined) continue; + (target as Record)[key] = value; + } + return target; +}; + +/** + * The configuration machinery every configurable entity needs, as an object they own rather than a base + * class they extend — `MessageComposer`, `Thread`, `ReminderManager`, `ThreadManager` and + * `LiveLocationManager` already extend `WithSubscriptions`, and `Channel` extends `ChannelApi`, so single + * inheritance is spent. + * + * It owns the four things that were previously re-implemented per entity and got wrong + * independently: freezing the defaults, deriving in a fixed layer order, skipping a write that changes + * nothing, and re-applying read-once fields after a change. + * + * Not exported from the package. Every configurable class is one this package constructs, and the key + * space is closed, so there is no caller outside it — see `docs/instance-configuration.md` §6. + * + * An entity keeps the shape every configurable class exposes by forwarding: + * + * ```ts + * class MyEntity { + * private readonly configController = new ConfigController({ + * defaults: DEFAULT_MY_ENTITY_CONFIG, + * onChanged: (next, previous) => { + * if (next.pollIntervalMs !== previous.pollIntervalMs) this.restartPolling(); + * }, + * }); + * + * get configState() { return this.configController.state; } + * get config() { return this.configController.value; } + * updateConfig(patch: Partial) { this.configController.patch(patch); } + * initializeConfig(slice?: Partial) { this.configController.initialize(slice); } + * } + * ``` + * + * @internal + */ +export class ConfigController< + TConfig extends Record, + TSlice = Partial, +> { + readonly state: StateStore; + private readonly options: ConfigControllerOptions; + /** Retained `patch` calls, under `retainPatches`. Cleared by {@link initialize}. */ + private patchLayer: Partial = {}; + /** The slice last derived from, so {@link rederive} can re-run without being handed it again. */ + private slice?: Partial; + + constructor(options: ConfigControllerOptions) { + deepFreezeConfig(options.defaults); + this.options = { + ...options, + // Copied at the boundary, like a patch: these are read on every derivation for the entity's whole + // life, so holding the caller's object would let a later mutation of it change resolved + // configuration with no notification. + constructorOptions: + options.constructorOptions && copyConfigPatch(options.constructorOptions), + }; + this.slice = options.initialSlice; + // Seeded without `getBehaviourOverrides`: the hook is an override on the owning class, and running it + // here would call into a subclass before its own fields are initialized. Entities install their + // behaviour from their constructor, once they are whole. The authority hook *does* run — a value + // published before the server has had its say would be wrong from the first read. + this.state = new StateStore(this.resolve({ withBehaviourOverrides: false })); + } + + /** What was asked for, before {@link ConfigControllerOptions.applyAuthority} has its say. */ + get requested(): Readonly { + return this.resolve({ withBehaviourOverrides: true, skipAuthority: true }); + } + + get value(): Readonly { + return this.state.getLatestValue(); + } + + /** + * Rebuilds from the real inputs — defaults, constructor options, the slice, then behaviour overrides — + * and publishes once, or not at all when nothing moved. Applied in that order, so the slice may narrow + * a constructor option and behaviour overrides outrank both. + */ + initialize(slice?: TSlice): void { + this.patchLayer = {}; + this.slice = slice as Partial | undefined; + this.write(this.resolve({ withBehaviourOverrides: true })); + } + + /** + * Re-runs the derivation **keeping** retained patches — for when an input the entity does not own has + * moved, such as the server's answer arriving. Without `retainPatches` there is nothing to keep, so + * this is the same as {@link initialize}. + */ + rederive(slice?: TSlice): void { + this.slice = slice as Partial | undefined; + this.write(this.resolve({ withBehaviourOverrides: true })); + } + + /** + * Applies a partial configuration, skipping the write when every field already matches. + * + * Copied on the way in, because a deep merge can reuse the caller's nested object rather than copying it + * — the entity would then be holding an object the caller can still change, and changing it would move + * resolved configuration with nobody notified. + */ + patch(patch: Partial): void { + const owned = copyConfigPatch(patch); + if (!this.options.retainPatches) { + // Written onto the current value rather than through `resolve`, because `resolve` rebuilds from the + // layers in `orderedLayers` and this patch is not one of them — `patchLayer` is only filled in on + // the retaining path below. Resolving here would produce a config without the patch, so the + // `updateConfig` call would do nothing. That is the whole difference between the two paths: whether + // a patch becomes an input that later derivations replay. + // + // A plain spread, deliberately: an explicit `undefined` has to be able to clear a field, which is + // how a paginator's state throttle is switched off. `resolve` skips `undefined` keys and could not + // express it. + const patched = { ...this.value, ...owned } as TConfig; + // `resolve` applies authority itself, and this is the one write path that does not call it — so + // without this line the server's limits would hold everywhere except `updateConfig`. + // + // The patch is written into the value rather than stored, so if the server lowers a field here the + // caller's value is lost, and it is not restored if the server later allows it. Turning on + // `retainPatches` is what changes that. + const { applyAuthority } = this.options; + this.write(applyAuthority ? applyAuthority(patched) : patched); + return; + } + this.patchLayer = mergeWith( + this.patchLayer as object, + owned as object, + ) as Partial; + this.write(this.resolve({ withBehaviourOverrides: true })); + } + + /** + * The layers beneath the patch layer, in `docs/instance-configuration.md` §3 order: package defaults, + * then the SDK's own defaults for this instance, then the declarative tree (stage 2), then the + * integrator's construction arguments (stage 3). + * + * One order for every entity. `BasePaginator` used to layer the last two the other way round while + * `MessageComposer` followed the doc, so the same registration answered differently depending on which + * object read it. Aligning them required separating {@link ConfigControllerOptions.builtInDefaults} + * from {@link ConfigControllerOptions.constructorOptions} first — the order was never the problem, the + * contents of that layer were. + */ + private orderedLayers(): (Partial | undefined)[] { + const { builtInDefaults, constructorOptions } = this.options; + return [builtInDefaults, this.slice, constructorOptions, this.patchLayer]; + } + + private resolve({ + skipAuthority, + withBehaviourOverrides, + }: { + withBehaviourOverrides: boolean; + skipAuthority?: boolean; + }): TConfig { + const { applyAuthority, defaults, getBehaviourOverrides, mergeSlice } = this.options; + const layers = this.orderedLayers(); + + // Seeded with a shallow spread on both paths. A subtree no layer touches stays identical to the + // frozen module default, which is safe *because* it is frozen — and cheap, which matters on the + // composer's publish path. `mergeWith` copies any subtree a layer does touch, and never writes into + // its target. + let requested = + mergeSlice === 'deep' + ? (layers.reduce( + (resolved, next) => mergeWith(resolved, (next ?? {}) as object), + { ...defaults } as object, + ) as TConfig) + : (layers.reduce((resolved, next) => layer(resolved, next), { + ...defaults, + } as TConfig) as TConfig); + + if (withBehaviourOverrides) { + requested = { ...requested, ...(getBehaviourOverrides?.() ?? {}) } as TConfig; + } + if (skipAuthority || !applyAuthority) return requested; + return applyAuthority(requested); + } + + private write(next: TConfig): void { + const previous = this.value; + if (isEqual(previous, next)) return; + this.state.next(next); + this.options.onChanged?.(next, previous); + } +} diff --git a/src/configuration/InstanceConfigurationRegistry.ts b/src/configuration/InstanceConfigurationRegistry.ts new file mode 100644 index 0000000000..ecc9368b40 --- /dev/null +++ b/src/configuration/InstanceConfigurationRegistry.ts @@ -0,0 +1,430 @@ +/** + * Holds the configuration an integrator registers for classes the SDK constructs on their behalf — + * `Channel`, `Thread`, `MessageComposer` and the client's own managers. Reached as `client.config`. + * + * Not to be confused with `client.channelServerConfigs`, which holds the **server-provided channel configs**. + * + * There are two ways in, over one mechanism: + * + * - `set(tree)` / `setConfig(key, subtree)` — declarative values (page sizes, throttles, feature + * flags). The primary surface. + * - `setSetupFunction(key, fn)` — an imperative escape hatch for behaviour that cannot be expressed as + * a value (middleware, comparators, custom request logic). + * + * Declarative configuration is applied before the setup function, so a setup function always wins for + * the same field. + * + * The key space is **fixed** by this package: `InstanceConfigTree` and `InstanceSetupFunctionArgs` are type + * aliases, so a key can be neither misspelled into existence nor added by module augmentation. Stores are + * still created lazily, because for any key the setter and the subscriber can arrive in either order. + * + * One service per client, not a singleton: a process-global registry would leak configuration between + * clients, which breaks tests and apps that connect as more than one user. + */ + +import { StateStore } from '../store'; +import { chatLoggerSystem } from '../logger'; +import { mergeWith } from '../utils/mergeWith'; +import { isEqual } from '../utils/mergeWith/mergeWithCore'; +import { copyConfigPatch } from './utils/copyConfigPatch'; +import { getPath, hasPath, isWalkableRecord } from '../utils/objectPath'; +import { CONSTRUCTION_ONLY_CONFIG_PATHS, INSTANCE_CONFIG_TREE_KEYS } from './keys'; +import type { + InstanceConfigKey, + InstanceConfigOf, + InstanceConfigState, + InstanceConfigTree, + InstanceSetupFunction, + InstanceSetupKey, + InstanceSetupState, +} from './types'; +import type { DeepPartial } from '../types.utility'; + +const logger = chatLoggerSystem.getLogger('instance-configuration'); + +/** + * Registered by `applyInstanceConfiguration` on behalf of one live instance. The service holds these + * so `reset` can reach every live instance of a key, and so the registry can tell whether a key has any + * live instance at all. + * + * @internal + */ +/** + * A handle to one live instance that derives configuration from a key. + * + * Deliberately just the re-derivation hook rather than the instance itself: the registry never reads an + * instance's configuration, it only needs a way to tell the instance to rebuild. + */ +export type ConfiguredInstance = { + /** The instance's own `initializeConfig`, bound. Invoked by `reset` after both slots are cleared. */ + reinitializeConfig?: () => void; +}; + +// Stores are keyed by an open string, so their value types cannot be correlated with the key at this +// level. Callers narrow through `getSetupState` / `getConfigState`. +type AnySetupStore = StateStore>; +type AnyConfigStore = StateStore>; + +export class InstanceConfigurationRegistry { + /** + * **Setup functions** — the second of the two ways to configure, known as *tier 2* because tier 2 is + * applied after tier 1 and therefore wins for the same field. Keyed by configuration key: the function + * registered for `'channel'`, `'messageComposer'` and so on, each wrapped in a store so that registering + * a setup function is observable. + * + * A setup function receives the instance and may do anything, including the things plain data cannot + * express — branch on the instance, install middleware, swap another function in. Declarative + * configuration ({@link configStates}) is the other way, and carries values only. + * + * A store rather than a bare function because timing is not guaranteed in either direction. Instances + * subscribe to the store for the relevant key, so a setup function registered after those instances were + * built still reaches every one of them, and an instance built afterwards picks up the setup function + * already sitting in the store. + * + * Entries appear on first access through {@link getSetupState}, never up front — the key space is open, + * so the full set of keys is not knowable here. + */ + private setupStates = new Map(); + + /** + * **Declarative configuration** — the first of the two ways to configure, known as *tier 1* because + * tier 1 is applied before tier 2 and is therefore the layer a setup function overrides. Keyed by + * configuration key: the subtree registered through {@link InstanceConfigurationRegistry.set} or + * {@link InstanceConfigurationRegistry.setConfig}, `null` until a caller registers something. + * + * Plain data, no code — the ordinary way to configure, and the reason a configuration tree can be + * written as JSON. Anything needing code goes through a setup function ({@link setupStates}). + * + * These stores hold **registered intent only** — the values an integrator asked for. The value an + * instance ended up with lives on the instance itself, as `configState`, after defaults, construction + * arguments, the setup function and the server's restrictions have all been applied. Reading a + * declarative store answers "what was asked for", never "what is in effect". + * + * Entries appear on first access through {@link getConfigState}, matching `setupStates`. + */ + private configStates = new Map(); + + /** + * The instances currently alive that derive configuration from each key — a `Channel` under + * `'channel'`, a `MessageComposer` under `'messageComposer'`, and so on. `applyInstanceConfiguration` + * adds an entry when an instance is constructed, and the callback returned by + * {@link registerInstance} removes the entry when that instance is disposed of. Liveness is the whole + * point of the map: a disposed instance must neither be re-derived nor counted. + * + * A single instance appears under several keys, because registration also covers every key named in + * `alsoWatch` — a `Channel` is registered under `'channel'`, `'messagePaginator'` and + * `'messageOperations'`. {@link reset} de-duplicates across keys so that one reset re-derives each + * instance once rather than once per key. + * + * Two features read the map. {@link reset} walks the registered instances and makes each one re-derive, + * once both configuration slots are cleared. {@link hasLiveInstances} answers the narrower question + * "have instances already been built for this key?", which is what separates a construction-only path + * registered too late — worth a warning, because already-built instances will never see the value — from + * the same path registered before construction, where the value applies normally. + */ + private liveInstances = new Map>(); + + /** + * The setup-function store for a key, created on first access. Lazy creation is a correctness + * requirement rather than an optimization: for a key the SDK does not define, neither the setter nor + * the subscriber can be assumed to come first. + */ + getSetupState(key: K): StateStore> { + let store = this.setupStates.get(key); + if (!store) { + store = new StateStore>({ + setupFunction: null, + }); + this.setupStates.set(key, store); + } + return store as unknown as StateStore>; + } + + /** The declarative-configuration store for a key, created on first access. */ + getConfigState( + key: K, + ): StateStore> { + let store = this.configStates.get(key); + if (!store) { + store = new StateStore>({ config: null }); + this.configStates.set(key, store); + } + return store as unknown as StateStore>; + } + + // ------------------------------------------------------------------------- + // Tier 2 — setup functions + // ------------------------------------------------------------------------- + + /** + * Registers the setup function for a key, replacing any previous one (whose teardown runs first) and + * applying it to every live instance. Pass `null` to clear. + */ + setSetupFunction( + key: K, + setupFunction: InstanceSetupFunction | null, + ): void { + this.warnIfKeyLooksUnknown(key); + this.getSetupState(key).partialNext({ setupFunction }); + } + + getSetupFunction(key: K): InstanceSetupFunction | null { + return this.getSetupState(key).getLatestValue().setupFunction; + } + + // ------------------------------------------------------------------------- + // Tier 1 — declarative configuration + // ------------------------------------------------------------------------- + + /** + * Registers declarative configuration for several keys at once. Deep-merges into whatever is already + * registered, so a later call only affects the paths it names. + */ + set(tree: DeepPartial): void { + for (const [key, subtree] of Object.entries(tree)) { + // Skip absent entries but keep going — one empty or unrecognized entry must never discard the + // rest of the tree. + if (subtree === undefined || subtree === null) continue; + this.setConfig( + key as InstanceConfigKey, + subtree as DeepPartial>, + ); + } + } + + /** Registers declarative configuration for one key, deep-merged into what is already there. */ + setConfig( + key: K, + config: DeepPartial>, + ): void { + this.warnIfKeyLooksUnknown(key); + this.warnAboutLateConstructionOnlyPaths(key, config); + + const store = this.getConfigState(key); + const current = store.getLatestValue().config; + const next = mergeWith( + { ...((current ?? {}) as Record) }, + // Copied at the boundary. `mergeWith` reuses a source subtree verbatim where the target has nothing, + // and on a first registration the target is empty — so without this the registry aliased the caller's + // objects, and a later `patch.text.maxLengthOnSend = 5` changed resolved configuration behind every + // live instance's back, with no notification. Functions and class instances pass through by reference, + // which is what a caller hands over rather than a structure to merge into. + copyConfigPatch(config) as unknown as object, + ) as DeepPartial>; + + store.partialNext({ config: next }); + } + + getConfig( + key: K, + ): DeepPartial> | null { + return this.getConfigState(key).getLatestValue().config; + } + + /** + * Everything currently registered, as one tree. + * + * Built for the case `getConfig(key)` cannot serve: enumerating what has been configured without + * knowing the keys up front — a settings UI, a diagnostic dump, or a test asserting that every + * configurable thing has a place in the tree. + * + * Includes custom keys alongside the built-in ones, since both are equally real. Keys with nothing + * registered are omitted rather than emitted as `{}`, so an empty result means "nothing configured" + * instead of "five empty subtrees". This is *registered intent*, not resolved values — for those, read + * the instance's `config`. + */ + getTree(): DeepPartial { + const tree: Record = {}; + + // The store map is keyed by a plain string — every entry was created through a typed setter, so the + // cast recovers what the type system already guaranteed at the call site. + for (const key of this.configStates.keys() as Iterable) { + const config = this.getConfigState(key).getLatestValue().config; + if (config && Object.keys(config).length > 0) tree[key] = config; + } + + return tree as DeepPartial; + } + + // ------------------------------------------------------------------------- + // Reset + // ------------------------------------------------------------------------- + + /** + * Returns the given key — or every key that has been touched — to its baseline: clears the + * declarative configuration, clears the setup function (running its teardown), then has every live + * instance re-derive its configuration from current inputs. + * + * Re-derivation, rather than restoring a saved copy, is what makes this recover a known state even + * when a setup function's teardown was incomplete; teardowns are integrator-written. It also + * re-installs constructor-set behaviour (a `PinnedMessagePaginator`'s `doRequest` and comparators) + * that no snapshot of configuration values could have restored. + * + * This does **not** undo setup-function changes made *outside* the configuration surface — inserted + * middleware, added subscriptions. The contract is that configuration returns to its derived + * baseline, not that the object returns to factory state. + */ + reset(key?: InstanceConfigKey): void { + const keys = + key === undefined + ? new Set([ + ...this.configStates.keys(), + ...this.setupStates.keys(), + ...this.liveInstances.keys(), + ]) + : new Set([key]); + + this.resetting = true; + try { + for (const currentKey of keys) { + this.getConfigState(currentKey as InstanceConfigKey).partialNext({ + config: null, + }); + // Clearing the setup function runs its teardown through the subscription in + // `applyInstanceConfiguration`. Teardown first, re-derivation last, so a buggy teardown cannot + // undo the re-derivation. + // + // Read rather than created: a configuration-only key such as `messagePaginator` has no setup + // function, and anything subscribed to one already has a store in this map. + this.setupStates.get(currentKey)?.partialNext({ setupFunction: null }); + } + } finally { + this.resetting = false; + } + + // Every slot is cleared before anything re-derives, and each instance runs **once** even when it is + // registered under several keys — a `Channel` is registered under `channel`, `messagePaginator` and + // `messageOperations`. + // + // Both halves of that need {@link isResetting}. Without it the loop above re-derives an instance as + // each key is cleared, so it derives against a *half-cleared* tree — the opposite of "every slot is + // cleared first" — and once per populated key rather than once in total. + const instancesToReinitialize = new Set(); + for (const currentKey of keys) { + for (const instance of this.liveInstances.get(currentKey) ?? []) { + instancesToReinitialize.add(instance); + } + } + + for (const instance of instancesToReinitialize) { + try { + instance.reinitializeConfig?.(); + } catch (error) { + logger.error('reinitializeConfig threw during reset', error); + } + } + } + + private resetting = false; + + /** + * Whether {@link reset} is currently clearing slots. + * + * `applyInstanceConfiguration` reads it to skip the per-key notifications the clearing loop emits: an + * instance that supplied a `reinitializeConfig` is guaranteed exactly one re-derivation in reset's final + * phase, so reacting to each individual clear would only re-derive it repeatedly, and against a tree + * that is not finished being cleared. Teardown is *not* skipped — it lives in the helper's closure and + * has no other route. + * + * @internal + */ + get isResetting(): boolean { + return this.resetting; + } + + // ------------------------------------------------------------------------- + // Consumer registry + // ------------------------------------------------------------------------- + + /** + * Called by `applyInstanceConfiguration`. Returns the deregistration function. + * + * @internal + */ + registerInstance(key: InstanceConfigKey, instance: ConfiguredInstance): () => void { + let set = this.liveInstances.get(key); + if (!set) { + set = new Set(); + this.liveInstances.set(key, set); + } + set.add(instance); + + return () => { + const current = this.liveInstances.get(key); + if (!current) return; + current.delete(instance); + if (current.size === 0) this.liveInstances.delete(key); + }; + } + + /** @internal */ + hasLiveInstances(key: InstanceConfigKey): boolean { + return (this.liveInstances.get(key)?.size ?? 0) > 0; + } + + // ------------------------------------------------------------------------- + // Diagnostics + // ------------------------------------------------------------------------- + + /** + * The key space is closed by the types, so a misspelling is a compile error — but a JavaScript caller, or + * a cast past the types, can still reach this with a key nothing will ever read. + * + * Warns rather than throwing, because a throw here would turn a no-op registration into a crash for a + * caller the types already warned. The live-instance check keeps it quiet for a key whose owner has not + * been constructed yet, which is the normal ordering. + */ + private warnIfKeyLooksUnknown(key: InstanceConfigKey): void { + if ((INSTANCE_CONFIG_TREE_KEYS as readonly string[]).includes(key)) return; + if (this.hasLiveInstances(key)) return; + logger + .withExtraTags(key) + .warn( + 'Configuration registered for a key this package does not define, with nothing subscribed to ' + + 'it. Check the spelling; a key declared by a downstream SDK is expected here only if its ' + + 'owner subscribes later.', + ); + } + + /** + * Paths read once during construction cannot take effect on instances that already exist. That is + * precisely detectable, so it warns — the one `warn` in this API, because unlike the debug-level + * diagnostics it fires only when configuration genuinely did not apply. + * + * Only paths whose value actually **moves** warn. Re-registering a value identical to the one already + * stored changes nothing, so there is nothing that failed to apply and nothing to report — and without + * this, a settings UI that applies on every keystroke, or any `set()` on a render path, produced one + * warning per call about a value that had not changed. Measured before the guard: 100 identical + * `setConfig` calls, 100 warnings. + */ + private warnAboutLateConstructionOnlyPaths( + key: InstanceConfigKey, + config: DeepPartial>, + ): void { + if (!this.hasLiveInstances(key)) return; // nothing constructed yet — these will apply + + const paths = CONSTRUCTION_ONLY_CONFIG_PATHS[key]; + if (!paths || !isWalkableRecord(config)) return; + + const current = this.getConfigState(key).getLatestValue().config; + const registered = isWalkableRecord(current) ? current : undefined; + + const late = paths.filter((path) => { + if (!hasPath(config, path)) return false; + // Compared against what is registered rather than what the instance resolved to: this diagnostic is + // about a *registration* arriving too late, and the instance's own value may legitimately differ + // (a setup function or the server may have moved it). + if (!registered || !hasPath(registered, path)) return true; + return !isEqual(getPath(registered, path), getPath(config, path)); + }); + if (late.length === 0) return; + + logger + .withExtraTags(key) + .warn( + `These paths are read once during construction, so they will not affect the ${key} ` + + `instance(s) that already exist: ${late.join(', ')}. Register configuration before the ` + + 'instances are created — typically alongside StreamChat.getInstance().', + ); + } +} diff --git a/src/configuration/InstanceConfigurationService.ts b/src/configuration/InstanceConfigurationService.ts deleted file mode 100644 index c602ea4bb3..0000000000 --- a/src/configuration/InstanceConfigurationService.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * InstanceConfigurationService is a singleton class that is used to store the configuration for the instances of classes exposed by the SKD such as: - * - StreamChat - * - Channel - * - Thread - * - MessageComposer - * - * Every existing and future instance configuration of the above classes will be setup using the following pattern: - * - StreamChat: StreamChat.setClientSetupFunction(setupFunction) - * - Channel: StreamChat.setChannelSetupFunction(setupFunction) - * - Thread: StreamChat.setThreadSetupFunction(setupFunction) - * - MessageComposer: StreamChat.setMessageComposerSetupFunction(setupFunction) - * - * The setupFunction is a function that is used to set up the instance configuration. - */ - -import { StateStore } from '../store'; -import type { - ChannelSetupState, - MessageComposerSetupState, - SetInstanceConfigurationFunctions, - SetInstanceConfigurationServiceStates, - StreamChatSetupState, - ThreadSetupState, -} from './types'; - -type InstanceKey = keyof SetInstanceConfigurationServiceStates; - -export class InstanceConfigurationService { - private static instance: InstanceConfigurationService; - private setupStates: SetInstanceConfigurationServiceStates = { - Channel: new StateStore({ - setupFunction: null, - }), - MessageComposer: new StateStore({ - setupFunction: null, - }), - StreamChat: new StateStore({ - setupFunction: null, - }), - Thread: new StateStore({ - setupFunction: null, - }), - }; - - setSetupFunctions(setupFunctions: SetInstanceConfigurationFunctions) { - for (const [instance, setupFunction] of Object.entries(setupFunctions)) { - const setupState = - this.setupStates[instance as keyof SetInstanceConfigurationServiceStates]; - if (typeof setupState === 'undefined') return; // null is allowed - // todo: fix typing - (setupState as StateStore<{ setupFunction: unknown }>).partialNext({ - setupFunction: setupFunction as SetInstanceConfigurationFunctions[InstanceKey], - }); - } - } - - get Channel() { - return this.setupStates.Channel; - } - - get MessageComposer() { - return this.setupStates.MessageComposer; - } - - get StreamChat() { - return this.setupStates.StreamChat; - } - - get Thread() { - return this.setupStates.Thread; - } -} diff --git a/src/configuration/index.ts b/src/configuration/index.ts index fcb073fefc..f6dba0dab2 100644 --- a/src/configuration/index.ts +++ b/src/configuration/index.ts @@ -1 +1,8 @@ +export * from './shape'; export * from './types'; +export * from './utils'; +// The service is reached as `client.config`, never constructed by integrators — export the type only. +export type { + ConfiguredInstance, + InstanceConfigurationRegistry, +} from './InstanceConfigurationRegistry'; diff --git a/src/configuration/keys.ts b/src/configuration/keys.ts new file mode 100644 index 0000000000..9e5914dd41 --- /dev/null +++ b/src/configuration/keys.ts @@ -0,0 +1,86 @@ +import type { InstanceConfigTree, InstanceSetupFunctionArgs } from './types'; + +/** + * The configuration key space, as values rather than types — which is why these live here and not in + * `types.ts`: that module is types only, and a constant in it is invisible to anyone scanning for + * runtime behaviour. + */ + +/** + * The keys this package wires itself, i.e. the ones that take a setup function. Used to scope + * diagnostics only; the key space is closed by the types, so there is nothing here to reject. + * + * Exported for the settings UI in `examples/vite`, which enumerates the tree. Diagnostics rather than + * API: the contents track whatever this package happens to wire, so they can change in a minor. + * + * @internal + */ +export const BUILT_IN_INSTANCE_KEYS: readonly (keyof InstanceSetupFunctionArgs)[] = [ + 'channel', + 'client', + 'liveLocationManager', + 'messageComposer', + 'searchController', + 'thread', +]; + +/** + * Every key of the declarative configuration tree. + * + * Distinct from {@link BUILT_IN_INSTANCE_KEYS}, which lists keys that take a *setup function* — that set + * omits `messagePaginator`, which is configuration-only. Typed as an exhaustive `Record` rather than a + * bare array so adding a key to {@link InstanceConfigTree} fails the build until it is listed here, which + * is what keeps the two from drifting. + * + * The exported array is diagnostics, not API — a new key is a minor, and this list grows with it. + * + * @internal + */ +const INSTANCE_CONFIG_TREE_KEY_PRESENCE: Record = { + channel: true, + client: true, + liveLocationManager: true, + messageComposer: true, + messageOperations: true, + messagePaginator: true, + searchController: true, + thread: true, +}; + +export const INSTANCE_CONFIG_TREE_KEYS = Object.keys( + INSTANCE_CONFIG_TREE_KEY_PRESENCE, +).sort() as readonly (keyof InstanceConfigTree)[]; + +/** + * Dot-paths, per key, that are read once during construction. Configuration registered *before* an + * instance is built reaches these through constructor options; registered afterwards it cannot, so the + * appliers warn rather than fail silently. + * + * `stateThrottleMs` and `debounceMs` are read once too but are **not** listed, because a late change to + * either is re-applied by the config controller's change hook. + * + * Exported for the settings UI, which flags these paths. Diagnostics rather than API: the set tracks + * which fields happen to be read once, so it can change in a minor. + * + * @internal + */ +export const CONSTRUCTION_ONLY_CONFIG_PATHS: Readonly> = + { + // The shared key needs its own entry: paths here are relative to the key's own subtree, and the + // warning is looked up by top-level key. Without this, setting `unreadReferencePolicy` through + // `messagePaginator` was silent while the identical field under `channel`/`thread` warned — the same + // read-once field, warned through one route and not the other. + messagePaginator: ['initialCursor', 'initialOffset', 'unreadReferencePolicy'], + channel: [ + 'messagePaginator.initialCursor', + 'messagePaginator.initialOffset', + 'messagePaginator.unreadReferencePolicy', + 'pinnedMessagesPaginator.initialCursor', + 'pinnedMessagesPaginator.initialOffset', + ], + thread: [ + 'messagePaginator.initialCursor', + 'messagePaginator.initialOffset', + 'messagePaginator.unreadReferencePolicy', + ], + }; diff --git a/src/configuration/shape.ts b/src/configuration/shape.ts new file mode 100644 index 0000000000..107a998429 --- /dev/null +++ b/src/configuration/shape.ts @@ -0,0 +1,644 @@ +import type { + ChannelDeclarativeConfig, + ClientDeclarativeConfig, + DeclarativeMessagePaginatorConfig, + InstanceConfigTree, + ThreadDeclarativeConfig, +} from './types'; +import type { DeclarativePaginatorConfig } from '../pagination/paginators/BasePaginator'; +import type { MessageOperationsConfig } from '../messageOperations/MessageOperations'; +import type { MessageDeliveryReporterConfig } from '../messageDelivery/MessageDeliveryReporter'; +import type { ThreadManagerConfig } from '../thread_manager'; +import type { LiveLocationManagerConfig } from '../LiveLocationManager'; +import type { SearchControllerConfig } from '../search/SearchController'; +import type { NotificationManagerConfig } from '../notifications/types'; +import type { ReminderManagerConfig } from '../reminders/ReminderManager'; +import type { + AttachmentManagerConfig, + CommandsConfig, + DraftsConfiguration, + LinkPreviewsManagerConfig, + LocationComposerConfig, + MessageComposerConfig, + PollComposerConfig, + TextComposerConfig, +} from '../messageComposer/configuration/types'; + +/** + * What a configuration value holds. `'object'` covers anything whose interior is not described further + * — a map of handler functions, a cursor — and tells a caller not to expect editable leaves inside. + */ +export type ConfigValueType = + | 'boolean' + | 'enum' + | 'function' + | 'number' + | 'number[]' + | 'object' + | 'string' + | 'string[]'; + +export type ConfigValueNode = { + /** One line on what the value does. This is the payload a settings UI or a JS caller reads. */ + description: string; + /** The permitted values, for `type: 'enum'` only. */ + enumValues?: readonly string[]; + kind: 'value'; + /** + * `'function'` marks a path the declarative tree cannot carry: JSON has no functions, so these are + * reachable only through a setup function or a direct `updateConfig` call. + */ + type: ConfigValueType; +}; + +export type ConfigGroupNode = { + description: string; + fields: ConfigShape; + kind: 'group'; +}; + +export type ConfigNode = ConfigGroupNode | ConfigValueNode; + +export type ConfigShape = { readonly [field: string]: ConfigNode }; + +/** + * The declarative paginator knobs, shared by every paginator path in the tree. + * + * Annotated as `Record` rather than left to inference, which is the whole point: adding a field + * to `DeclarativePaginatorConfig` fails the build here until it is described. Same guard as + * `INSTANCE_CONFIG_TREE_KEY_PRESENCE` uses for the top-level keys. + */ +const PAGINATOR_FIELDS: Record = { + debounceMs: { + description: + 'Delay before a queued page request fires, collapsing rapid scrolling into one query.', + kind: 'value', + type: 'number', + }, + hasPaginationQueryShapeChanged: { + description: + 'Decides whether a new query is different enough to discard loaded pages rather than append to them.', + kind: 'value', + type: 'function', + }, + initialCursor: { + description: + 'Cursor the first page is fetched from. Read once, when the paginator is built.', + kind: 'value', + type: 'object', + }, + initialOffset: { + description: 'Offset the first page is fetched from, for offset-based sources.', + kind: 'value', + type: 'number', + }, + lockItemOrder: { + description: + 'Keeps loaded items in their current order instead of re-sorting when an item is updated.', + kind: 'value', + type: 'boolean', + }, + pageSize: { + description: + 'Items requested per page. The effective default differs per paginator — the channel message list asks for more than the base default.', + kind: 'value', + type: 'number', + }, + retryCount: { + description: 'Retries attempted for a failed page request before the error surfaces.', + kind: 'value', + type: 'number', + }, + stateThrottleMs: { + description: + 'Shortest gap between state publications, so a burst of events becomes a couple of renders rather than one per event.', + kind: 'value', + type: 'number', + }, + throwErrors: { + description: + 'Rethrows a failed page request instead of only recording it in the paginator state.', + kind: 'value', + type: 'boolean', + }, +}; + +const MESSAGE_PAGINATOR_FIELDS: Record< + keyof DeclarativeMessagePaginatorConfig, + ConfigNode +> = { + ...PAGINATOR_FIELDS, + unreadReferencePolicy: { + description: + "'snapshot' freezes the unread divider where the user opened the channel until it is explicitly cleared; 'read-state-only' follows the server read state, so the divider moves as messages are marked read.", + enumValues: ['snapshot', 'read-state-only'], + kind: 'value', + type: 'enum', + }, +}; + +const MESSAGE_OPERATIONS_FIELDS: Record = { + failedSendCacheMaxSize: { + description: 'Failed sends kept for retry; the oldest is evicted past this.', + kind: 'value', + type: 'number', + }, + failedSendCacheTtlMs: { + description: 'How long a failed send stays retryable.', + kind: 'value', + type: 'number', + }, +}; + +const REQUEST_HANDLERS_NODE: ConfigValueNode = { + description: + 'Overrides for the API calls this entity makes. Functions, so they travel through a setup function rather than the declarative tree.', + kind: 'value', + type: 'function', +}; + +const paginatorGroup = (description: string): ConfigGroupNode => ({ + description, + fields: PAGINATOR_FIELDS, + kind: 'group', +}); + +const messagePaginatorGroup = (description: string): ConfigGroupNode => ({ + description, + fields: MESSAGE_PAGINATOR_FIELDS, + kind: 'group', +}); + +const messageOperationsGroup = (description: string): ConfigGroupNode => ({ + description, + fields: MESSAGE_OPERATIONS_FIELDS, + kind: 'group', +}); + +// --------------------------------------------------------------------------- +// messageComposer +// --------------------------------------------------------------------------- + +const ATTACHMENTS_FIELDS: Record = { + enabled: { + description: + 'Offers file attachments in the composer. The server must also allow them per channel type (`uploads`), and a server "no" wins.', + kind: 'value', + type: 'boolean', + }, + acceptedFiles: { + description: + 'File types offered in the file picker, as extensions or MIME patterns. Empty means no restriction.', + kind: 'value', + type: 'string[]', + }, + customCdn: { + description: + "Whether a custom upload request stores files somewhere Stream does not host. Left false — the default — files are treated as reaching Stream, so Stream's `uploads` flag and `upload-file` capability apply.", + kind: 'value', + type: 'boolean', + }, + doUploadRequest: { + description: 'Replaces the built-in upload request with your own.', + kind: 'value', + type: 'function', + }, + fileUploadFilter: { + description: 'Rejects selected files before they are uploaded.', + kind: 'value', + type: 'function', + }, + maxNumberOfFilesPerMessage: { + description: 'Attachments allowed on a single message.', + kind: 'value', + type: 'number', + }, + trackUploadProgress: { + description: + 'Reports upload progress on each attachment. Turning it off skips the progress bookkeeping.', + kind: 'value', + type: 'boolean', + }, +}; + +const COMMANDS_FIELDS: Record = { + sendValidator: { + description: 'Decides whether a message carrying a slash command may be sent.', + kind: 'value', + type: 'function', + }, +}; + +const DRAFTS_FIELDS: Record = { + enabled: { + description: 'Stores unsent composer content as a draft on the server.', + kind: 'value', + type: 'boolean', + }, +}; + +const LINK_PREVIEWS_FIELDS: Record = { + debounceURLEnrichmentMs: { + description: 'Delay after typing stops before URLs in the message are enriched.', + kind: 'value', + type: 'number', + }, + enabled: { + description: 'Turns URL enrichment and link previews in the composer on.', + kind: 'value', + type: 'boolean', + }, + findURLFn: { + description: 'Finds the URLs in the composed text that should be enriched.', + kind: 'value', + type: 'function', + }, + onLinkPreviewDismissed: { + description: 'Runs when a link preview is dismissed.', + kind: 'value', + type: 'function', + }, +}; + +const LOCATION_FIELDS: Record = { + enabled: { + description: + 'Offers location sharing in the composer. The server must also allow it per channel type (`shared_locations`), and a server "no" wins.', + kind: 'value', + type: 'boolean', + }, + getDeviceId: { + description: 'Supplies a stable identifier for the device sharing the location.', + kind: 'value', + type: 'function', + }, + minShareDurationMs: { + description: + 'Shortest live-location duration treated as valid. A shorter one makes the composed location invalid rather than being clamped.', + kind: 'value', + type: 'number', + }, +}; + +const TEXT_FIELDS: Record = { + defaultValue: { + description: 'Text the composer starts with.', + kind: 'value', + type: 'string', + }, + enabled: { + description: + 'Accepts text input. Turning it off disables input, change and selection events.', + kind: 'value', + type: 'boolean', + }, + maxLengthOnEdit: { + description: + "Longest text accepted while editing an existing message. Capped by the channel type's `max_message_length`: a smaller value here wins, a larger one is lowered to the server's.", + kind: 'value', + type: 'number', + }, + maxLengthOnSend: { + description: + "Longest text accepted when sending a new message. Capped by the channel type's `max_message_length`: a smaller value here wins, a larger one is lowered to the server's. Unset means the server's maximum applies.", + kind: 'value', + type: 'number', + }, + publishTypingEvents: { + description: + 'Emits typing events as the user types. Off by default for threads and message editing.', + kind: 'value', + type: 'boolean', + }, +}; + +const POLLS_FIELDS: Record = { + enabled: { + description: + 'Offers poll creation in the composer. The server must also allow it per channel type (`polls`), and a server "no" wins.', + kind: 'value', + type: 'boolean', + }, +}; + +const MESSAGE_COMPOSER_FIELDS: Record = { + attachments: { + description: 'Uploads and the file picker.', + fields: ATTACHMENTS_FIELDS, + kind: 'group', + }, + commands: { + description: 'Slash-command validation.', + fields: COMMANDS_FIELDS, + kind: 'group', + }, + drafts: { description: 'Server-side drafts.', fields: DRAFTS_FIELDS, kind: 'group' }, + linkPreviews: { + description: 'URL enrichment and link previews.', + fields: LINK_PREVIEWS_FIELDS, + kind: 'group', + }, + location: { + description: 'Static and live location sharing.', + fields: LOCATION_FIELDS, + kind: 'group', + }, + polls: { + description: 'Poll creation.', + fields: POLLS_FIELDS, + kind: 'group', + }, + text: { description: 'The text input itself.', fields: TEXT_FIELDS, kind: 'group' }, +}; + +// --------------------------------------------------------------------------- +// client +// --------------------------------------------------------------------------- + +const MESSAGE_DELIVERY_FIELDS: Record = { + markAsDeliveredBufferTimeoutMs: { + description: 'How long delivery reports are buffered before being sent as one batch.', + kind: 'value', + type: 'number', + }, + markAsReadThrottleTimeoutMs: { + description: + 'Shortest gap between automatic markRead calls. Read once, when the throttle is built.', + kind: 'value', + type: 'number', + }, + maxDeliveredMessageCountInPayload: { + description: + 'Delivery receipts sent in a single request; the remainder is carried to the next one.', + kind: 'value', + type: 'number', + }, + retryCountLimitForTimeoutIncrease: { + description: 'Consecutive timeouts before the buffer window is widened.', + kind: 'value', + type: 'number', + }, +}; + +const THREAD_MANAGER_FIELDS: Record = { + connectionRecoveryThrottleMs: { + description: + 'Shortest gap between thread-list reloads triggered by connection recovery. Applies from the next registerSubscriptions().', + kind: 'value', + type: 'number', + }, +}; + +const LIVE_LOCATION_MANAGER_FIELDS: Record = + { + minUpdateThrottleMs: { + description: + 'Shortest gap between live-location update requests, in milliseconds. A rate-limit failsafe — raising it is always safe, lowering it risks 429s.', + kind: 'value', + type: 'number', + }, + }; + +const SEARCH_CONTROLLER_FIELDS: Record = { + keepSingleActiveSource: { + description: + 'Keeps exactly one search source active at a time, rather than letting several run together.', + kind: 'value', + type: 'boolean', + }, +}; + +const NOTIFICATION_FIELDS: Record = { + durations: { + description: + 'How long a notification stays up, in milliseconds, per severity: error, warning, info, success.', + kind: 'value', + type: 'object', + }, + sortComparator: { + description: 'Orders the notifications shown at once.', + kind: 'value', + type: 'function', + }, +}; + +const REMINDER_FIELDS: Record = { + scheduledOffsetsMs: { + description: + 'Offsets from now offered when scheduling a reminder, in milliseconds — the "in 30 minutes / tomorrow" choices.', + kind: 'value', + type: 'number[]', + }, + stopTimerRefreshBoundaryMs: { + description: + 'How far ahead a reminder must be before its refresh timer stops running; beyond this it is refreshed on demand instead.', + kind: 'value', + type: 'number', + }, +}; + +const CLIENT_FIELDS: Record = { + messageDelivery: { + description: 'Delivery and read receipt reporting.', + fields: MESSAGE_DELIVERY_FIELDS, + kind: 'group', + }, + notifications: { + description: 'The client-wide notification (toast) manager.', + fields: NOTIFICATION_FIELDS, + kind: 'group', + }, + reminders: { + description: 'Message reminders and their scheduling offsets.', + fields: REMINDER_FIELDS, + kind: 'group', + }, + threads: { + description: 'The thread list manager.', + fields: THREAD_MANAGER_FIELDS, + kind: 'group', + }, +}; + +// --------------------------------------------------------------------------- +// channel / thread +// --------------------------------------------------------------------------- + +const CHANNEL_FIELDS: Record = { + messageOperations: messageOperationsGroup( + 'Sending and retrying messages in the channel. Overrides the shared `messageOperations` key for channels only.', + ), + messagePaginator: messagePaginatorGroup( + 'The channel message list. Overrides the shared `messagePaginator` key for channels only.', + ), + pinnedMessagesPaginator: paginatorGroup( + "The channel's pinned message list. Nested rather than top-level: a channel is its only parent.", + ), + requestHandlers: REQUEST_HANDLERS_NODE, + typingEvents: { + description: 'Typing indicators for the channel.', + fields: { + enabled: { + description: + 'Publishes typing events from this channel. The server must also allow them per channel type (`typing_events`), and a server "no" wins. `messageComposer.text.publishTypingEvents` refines this per composer.', + kind: 'value', + type: 'boolean', + }, + }, + kind: 'group', + }, + replies: { + description: 'Threaded replies for the channel.', + fields: { + enabled: { + description: + 'Offers threaded replies. The server must also allow them per channel type (`replies`), and a server "no" wins.', + kind: 'value', + type: 'boolean', + }, + }, + kind: 'group', + }, + userMessageReminders: { + description: 'Message reminders — "remind me" and "save for later".', + fields: { + enabled: { + description: + 'Offers message reminders. The server must also allow them per channel type (`user_message_reminders`), and a server "no" wins.', + kind: 'value', + type: 'boolean', + }, + }, + kind: 'group', + }, + deliveryEvents: { + description: 'Delivery receipts for the channel.', + fields: { + enabled: { + description: + 'Reports message delivery. The server must also allow it per channel type (`delivery_events`), and a server "no" wins.', + kind: 'value', + type: 'boolean', + }, + }, + kind: 'group', + }, + readEvents: { + description: 'Read receipts for the channel.', + fields: { + enabled: { + description: + 'Allows marking the channel read or unread. The server must also allow it per channel type (`read_events`), and a server "no" wins.', + kind: 'value', + type: 'boolean', + }, + }, + kind: 'group', + }, +}; + +const THREAD_FIELDS: Record = { + messageOperations: messageOperationsGroup( + 'Sending and retrying thread replies. Overrides the shared `messageOperations` key for threads only.', + ), + messagePaginator: messagePaginatorGroup( + 'The thread reply list. Overrides the shared `messagePaginator` key for threads only.', + ), + requestHandlers: REQUEST_HANDLERS_NODE, +}; + +/** + * A runtime description of the whole declarative configuration tree: every path, what it holds, and what + * it does. + * + * The tree's types already describe all of this, and a TypeScript caller gets it as autocomplete on + * `client.config.set()`. This is the same knowledge for everyone who cannot read the types at the moment + * they need it — a settings UI listing what can be changed, a JavaScript caller, a documentation + * generator. Without it, the only way to learn that `thread` accepts `messagePaginator` is to open the + * SDK source. + * + * **Completeness is enforced by the compiler, not by discipline.** Every level is annotated + * `Record`, so a field added to any configuration type fails the build + * until it is described here. That is what separates this from a hand-maintained list, which drifts + * behind the tree silently and is exactly the failure this replaces. + * + * **What is deliberately absent.** No default values: an effective default depends on the construction + * site — `pageSize` is 10 for a bare paginator and larger for the channel message list — so a table of + * them here would be a second source of truth that disagrees with the instances. Read current values from + * the instance (`channel.messagePaginator.config`) and registered values from + * {@link InstanceConfigurationRegistry.getTree}. Construction-only paths are absent for the same reason: + * `CONSTRUCTION_ONLY_CONFIG_PATHS` already lists them. + * + * **Built-in keys only.** The key space is open, so a key registered through module augmentation has no + * entry here. Merge {@link InstanceConfigurationRegistry.getTree} in to see those. + */ +export const INSTANCE_CONFIG_TREE_SHAPE: Record< + keyof InstanceConfigTree, + ConfigGroupNode +> = { + channel: { + description: + 'Everything a Channel builds, and the channel-specific slice of shared keys.', + fields: CHANNEL_FIELDS, + kind: 'group', + }, + client: { + description: + 'Managers the client owns outright. Nested rather than top-level keys, since each has exactly one parent.', + fields: CLIENT_FIELDS, + kind: 'group', + }, + liveLocationManager: { + description: + 'Live-location sharing. Constructed by the integrator or a downstream SDK rather than by this package, and reaches this key by registering itself.', + fields: LIVE_LOCATION_MANAGER_FIELDS, + kind: 'group', + }, + messageComposer: { + description: + "Every MessageComposer — a channel's, a thread's, and the message-scoped ones built for editing. Its own key because the same settings mean the same thing under all three.", + fields: MESSAGE_COMPOSER_FIELDS, + kind: 'group', + }, + messageOperations: { + description: + 'Every MessageOperations at once. `channel.messageOperations` and `thread.messageOperations` override it per parent.', + fields: MESSAGE_OPERATIONS_FIELDS, + kind: 'group', + }, + messagePaginator: { + description: + 'Every MessagePaginator at once — the channel message list and thread replies alike. `channel.messagePaginator` and `thread.messagePaginator` override it per parent.', + fields: MESSAGE_PAGINATOR_FIELDS, + kind: 'group', + }, + searchController: { + description: + 'Message/channel/user search. Reaches this key only when constructed with a `client` — see SearchControllerOptions.', + fields: SEARCH_CONTROLLER_FIELDS, + kind: 'group', + }, + thread: { + description: + 'Everything a Thread builds, and the thread-specific slice of shared keys.', + fields: THREAD_FIELDS, + kind: 'group', + }, +}; + +/** Every path in the shape as `a.b.c`, with the node it points at. Sorted, so output is stable. */ +export const flattenConfigShape = ( + shape: ConfigShape = INSTANCE_CONFIG_TREE_SHAPE, + prefix = '', +): { node: ConfigNode; path: string }[] => { + const out: { node: ConfigNode; path: string }[] = []; + + for (const field of Object.keys(shape).sort()) { + const node = shape[field]; + const path = prefix ? `${prefix}.${field}` : field; + out.push({ node, path }); + if (node.kind === 'group') out.push(...flattenConfigShape(node.fields, path)); + } + + return out; +}; diff --git a/src/configuration/types.ts b/src/configuration/types.ts index 1157e40709..8c450229e9 100644 --- a/src/configuration/types.ts +++ b/src/configuration/types.ts @@ -1,81 +1,220 @@ import type { StreamChat } from '../client'; +import type { + LiveLocationManager, + LiveLocationManagerConfig, +} from '../LiveLocationManager'; +import type { + SearchController, + SearchControllerConfig, +} from '../search/SearchController'; import type { MessageComposer } from '../messageComposer'; -import type { Channel } from '../channel'; -import type { Thread } from '../thread'; -import type { StateStore } from '../store'; +import type { MessageComposerConfig } from '../messageComposer/configuration/types'; +import type { Channel, ChannelConfig } from '../channel'; +import type { Thread, ThreadConfig } from '../thread'; +import type { ReminderManagerConfig } from '../reminders/ReminderManager'; +import type { NotificationManagerConfig } from '../notifications/types'; +import type { MessageDeliveryReporterConfig } from '../messageDelivery/MessageDeliveryReporter'; +import type { ThreadManagerConfig } from '../thread_manager'; +import type { MessageOperationsConfig } from '../messageOperations/MessageOperations'; +import type { DeclarativePaginatorConfig as ImportedDeclarativePaginatorConfig } from '../pagination/paginators/BasePaginator'; +import type { DeepPartial } from '../types.utility'; -export type MessageComposerTearDownFunction = () => void; +// --------------------------------------------------------------------------- +// Keys +// --------------------------------------------------------------------------- -export type MessageComposerSetupFunction = ({ - composer, -}: { - composer: MessageComposer; -}) => void | MessageComposerTearDownFunction; - -export type MessageComposerSetupState = { - /** - * Each `MessageComposer` runs this function each time its signature changes or - * whenever you run `MessageComposer.registerSubscriptions`. Function returned - * from `applyModifications` will be used as a cleanup function - it will be stored - * and ran before new modification is applied. Cleaning up only the - * modified parts is the general way to go but if your setup gets a bit - * complicated, feel free to restore the whole composer with `MessageComposer.restore`. - */ - setupFunction: MessageComposerSetupFunction | null; +/** + * Maps a configuration key to the argument its setup function receives. + * + * A closed set, and deliberately not an `interface`: a type alias cannot be reached by module + * augmentation, so the key space cannot be extended from outside this package. Configuration for a class + * this package does not own belongs to whoever owns that class — a registry of its own, not a key in this + * one, which the SDK could neither type nor apply. + */ +export type InstanceSetupFunctionArgs = { + channel: { channel: Channel }; + client: { client: StreamChat }; + liveLocationManager: { liveLocationManager: LiveLocationManager }; + messageComposer: { composer: MessageComposer }; + searchController: { searchController: SearchController }; + thread: { thread: Thread }; }; -export type StreamChatTearDownFunction = () => void; +/** + * The keys that take a **setup function** — every one names a class the setup function receives an + * instance of. + * + * Closed, and not extensible: an undeclared string is a compile error, and {@link + * InstanceSetupFunctionArgs} is a type alias rather than an interface, so a key cannot be added by module + * augmentation either. This key space describes what *this package* configures. + * + * A strict subset of {@link InstanceConfigKey}: `messagePaginator` and `messageOperations` take + * configuration but have no setup function, because they are not built one-per-key — a channel and every + * one of its threads each own one. + */ +export type InstanceSetupKey = keyof InstanceSetupFunctionArgs; + +// --------------------------------------------------------------------------- +// Tier 2 — setup functions +// --------------------------------------------------------------------------- + +export type InstanceSetupFunctionArgsOf = + InstanceSetupFunctionArgs[K]; + +export type InstanceSetupTearDownFunction = () => void; -export type StreamChatSetupFunction = ({ - client, -}: { - client: StreamChat; -}) => void | StreamChatTearDownFunction; +/** + * Runs against every instance of its class — those that already exist when it is registered, and + * every one created afterwards. Return a function that undoes whatever you changed: it is invoked + * before the setup function is re-applied, and when the instance is disposed of. + */ +export type InstanceSetupFunction = ( + args: InstanceSetupFunctionArgsOf, +) => void | InstanceSetupTearDownFunction; -export type StreamChatSetupState = { - setupFunction: StreamChatSetupFunction | null; +export type InstanceSetupState = { + setupFunction: InstanceSetupFunction | null; }; -export type ChannelTearDownFunction = () => void; +// --------------------------------------------------------------------------- +// Tier 1 — declarative configuration +// --------------------------------------------------------------------------- -export type ChannelSetupFunction = ({ - channel, -}: { - channel: Channel; -}) => void | ChannelTearDownFunction; +/** Whether `jumpToTheFirstUnreadMessage` prefers the paginator's snapshot or the channel read state. */ +export type UnreadReferencePolicy = 'snapshot' | 'read-state-only'; -export type ChannelSetupState = { - setupFunction: ChannelSetupFunction | null; +/** + * Paginator fields settable through the declarative tree. Single source of truth lives with the + * paginator itself (`DeclarativePaginatorConfig` in `BasePaginator`), so the tree and the paginator's + * own `initializeConfig` can never accept different sets of fields. + * + * Deliberately excluded there: + * - `itemIndex` / `createItemIndex` — an index instance and a factory, not configuration. Deep-merging + * a class instance is unsound, and swapping an index would drop already-loaded items. + * - `doRequest`, `itemOrderComparator`, `deriveCursor` — installed per paginator subclass + * (`PinnedMessagePaginator` supplies all three). Replace them through a setup function, where the + * existing value is visible and restorable. + * + * `initialCursor` and `initialOffset` are included but read only during construction — see + * {@link CONSTRUCTION_ONLY_CONFIG_PATHS}. + */ +export type { DeclarativePaginatorConfig } from '../pagination/paginators/BasePaginator'; + +/** Adds the message-list-only unread reference policy, which is a constructor argument. */ +export type DeclarativeMessagePaginatorConfig = ImportedDeclarativePaginatorConfig & { + unreadReferencePolicy?: UnreadReferencePolicy; }; -export type ThreadTearDownFunction = () => void; +export type ChannelDeclarativeConfig = { + /** Overrides the shared top-level `messageOperations` key for channels only. */ + messageOperations?: Partial; + messagePaginator?: DeclarativeMessagePaginatorConfig; + pinnedMessagesPaginator?: ImportedDeclarativePaginatorConfig; + requestHandlers?: ChannelConfig['requestHandlers']; + /** Typing indicators, ANDed with the channel type's `typing_events`. */ + typingEvents?: Partial; + /** Read receipts, ANDed with the channel type's `read_events`. */ + readEvents?: Partial; + /** Threaded replies, ANDed with the channel type's `replies`. */ + replies?: Partial; + /** Message reminders, ANDed with the channel type's `user_message_reminders`. */ + userMessageReminders?: Partial; + /** Delivery receipts, ANDed with the channel type's `delivery_events`. */ + deliveryEvents?: Partial; + // `commands` is deliberately absent: the server owns the list outright, so there is nothing to set. +}; -export type ThreadSetupFunction = ({ - thread, -}: { - thread: Thread; -}) => void | ThreadTearDownFunction; +export type ThreadDeclarativeConfig = { + /** Overrides the shared top-level `messageOperations` key for thread replies only. */ + messageOperations?: Partial; + messagePaginator?: DeclarativeMessagePaginatorConfig; + requestHandlers?: ThreadConfig['requestHandlers']; +}; -export type ThreadSetupState = { - setupFunction: ThreadSetupFunction | null; +export type ClientDeclarativeConfig = { + /** + * Nested rather than top-level keys: each of these managers has exactly one parent — the client — so + * there is nothing to say once and reuse, which is what a top-level key buys (**DEC-25**). + */ + messageDelivery?: Partial; + threads?: Partial; + notifications?: DeepPartial; + /** + * `Partial`, not `DeepPartial`: `scheduledOffsetsMs` is a `number[]`, and `DeepPartial` would widen + * its elements to `number | undefined`, which `ReminderManager.updateConfig` rightly rejects. + */ + reminders?: Partial; }; -export type SetInstanceConfigurationServiceStates = { - Channel: StateStore; - MessageComposer: StateStore; - StreamChat: StateStore; - Thread: StateStore; +/** + * Maps a configuration key to its declarative configuration subtree. Augmentable alongside + * {@link InstanceSetupFunctionArgs}, so a custom key gets declarative configuration on the same terms + * as a built-in one. + * + * **When something gets its own key rather than being nested under a parent** — the rule that decides + * the shape of this interface: + * + * - **One parent type ⇒ nest it.** `channel.pinnedMessagesPaginator`, `channel.cooldownTimer`, the + * composer's own sub-managers. There is only one place it can be reached from. + * - **Several parent types, and the configuration means the same thing under each ⇒ own key.** + * `MessageComposer` hangs off a `Channel`, a `Thread`, *and* a message (the React SDK builds + * message-scoped composers for editing). `drafts.enabled` means the same in all three, so nesting it + * under `channel` would silently miss two thirds of the composers. + * - **Several parent types, but the configuration is inherently parent-specific ⇒ nest it anyway.** + * No built-in falls here today. It is kept as a case because it is the one that decides against a shared + * key, and the next entity to arrive may need it. + * - **Several parent types, mixed ⇒ both.** A shared top-level key for what means the same thing, plus + * per-parent paths that override it field by field (see {@link mergeDeclarativeMessageOperationsConfig}). + * Both shared keys are here: `MessagePaginator` backs the channel message list *and* thread replies + * (`stateThrottleMs` / `retryCount` have no reason to differ, `pageSize` legitimately does), and + * `MessageOperations` backs channel *and* thread sends. `messageOperations` was briefly nested under + * `channel` on the reasoning that a channel send and a thread reply are different operations — which made + * `thread.messageOperations` unconfigurable entirely (**DV-15**). Counting parents is the check. + */ +export type InstanceConfigTree = { + channel: ChannelDeclarativeConfig; + client: ClientDeclarativeConfig; + /** + * Constructed by whoever needs it — the React SDK's `useLiveLocationSharingManager`, or an app + * directly — never by this package. It reaches its configuration the way a `MessageComposer` does, by + * registering itself against this key, so its owner does not have to thread a slice through. + */ + liveLocationManager: Partial; + messageComposer: DeepPartial; + /** + * Applies to **every** `MessageOperations` — the channel's and every thread's, since messages are sent + * from both. `channel.messageOperations` and `thread.messageOperations` override it per parent. + */ + messageOperations: Partial; + /** + * Applies to **every** `MessagePaginator` — the channel message list and thread replies alike. + * `channel.messagePaginator` and `thread.messagePaginator` override it per parent. + * + * `channel.pinnedMessagesPaginator` is deliberately **not** included: it has a single parent, so by + * the rule above it stays nested. It is also a different class (`PinnedMessagePaginator`, with its own + * ordering and endpoint) rather than a `MessagePaginator`. + */ + messagePaginator: DeclarativeMessagePaginatorConfig; + /** + * Same story as {@link InstanceConfigTree.liveLocationManager}, with one caveat: a `SearchController` + * only reaches this key when it was constructed with a `client` — it is the one configurable class the + * SDK does not hand a client to. See `SearchControllerOptions.client`. + */ + searchController: Partial; + thread: ThreadDeclarativeConfig; }; -export type SetupFnOf = - T extends StateStore - ? S extends { setupFunction?: infer F } - ? F - : never - : never; - -export type SetInstanceConfigurationFunctions = { - [K in keyof SetInstanceConfigurationServiceStates]?: SetupFnOf< - SetInstanceConfigurationServiceStates[K] - >; +/** + * Every key that takes **declarative configuration** — {@link InstanceSetupKey} plus the two that are + * configuration-only. + * + * Closed for the same reason, and {@link InstanceConfigTree} is likewise a type alias, so this set is + * fixed by the package. + */ +export type InstanceConfigKey = keyof InstanceConfigTree; + +export type InstanceConfigOf = InstanceConfigTree[K]; + +export type InstanceConfigState = { + config: DeepPartial> | null; }; diff --git a/src/configuration/utils/applyInstanceConfiguration.ts b/src/configuration/utils/applyInstanceConfiguration.ts new file mode 100644 index 0000000000..7ba5435307 --- /dev/null +++ b/src/configuration/utils/applyInstanceConfiguration.ts @@ -0,0 +1,184 @@ +import { chatLoggerSystem } from '../../logger'; +import type { + ConfiguredInstance, + InstanceConfigurationRegistry, +} from '../InstanceConfigurationRegistry'; +import type { + InstanceConfigKey, + InstanceConfigOf, + InstanceSetupFunctionArgsOf, + InstanceSetupKey, + InstanceSetupTearDownFunction, +} from '../types'; +import type { DeepPartial } from '../../types.utility'; +import type { Unsubscribe } from '../../store'; + +const logger = chatLoggerSystem.getLogger('instance-configuration'); + +/** @internal */ +export type ApplyInstanceConfigurationParams = { + /** The instance's argument for its setup function — `{ channel }`, `{ composer }`, and so on. */ + args: InstanceSetupFunctionArgsOf; + /** The client's configuration registry, i.e. `client.config`. */ + config: InstanceConfigurationRegistry; + key: K; + /** + * Other keys this instance derives from. `Channel` and `Thread` both read the shared `messagePaginator` + * and `messageOperations` keys, so a change there has to re-run this instance's own cycle rather than + * only re-deriving: re-deriving alone would drop the setup function's overrides, since tier 2 is + * applied after tier 1. + * + * Keys rather than stores, which buys two things beyond brevity. The instance is registered as a + * live instance of each, so `hasLiveInstances` is true for a shared key and its construction-only paths get + * same late-registration warning the per-parent slices already got. And there is no longer a structural + * store type needed to work around `StateStore`'s invariance. + */ + alsoWatch?: readonly InstanceConfigKey[]; + /** + * Applies a declarative configuration slice to the instance. Omit it if the instance has no + * declarative surface and only wants the setup function. + * + * Called on every cycle, **including when this key has no configuration of its own** — an instance may + * derive from other inputs too (`alsoWatch`, or the server), so it has to be told to re-derive rather + * than being skipped. Hence the optional argument. + */ + applyConfig?: (config?: DeepPartial>) => void; + /** + * The instance's own `initializeConfig`, bound. Invoked by `config.reset()` after both slots are + * cleared, so the instance re-derives its configuration from current inputs. Omit it to get + * clear-registrations-only reset semantics. + */ + reinitializeConfig?: () => void; +}; + +/** + * Subscribes one instance to the configuration registered for its key, and returns the unsubscribe. + * + * This is the single place the *subscription* semantics live, so every configured instance behaves + * identically: + * + * - applies whatever is already registered, immediately; + * - re-applies on every change to either slot, declarative configuration first and the setup function + * second, so a setup function always wins for the same field; + * - runs the previous setup function's teardown before re-applying, and again on unsubscribe; + * - contains errors — a throwing setup function, teardown or applier is logged and never propagates, + * so it cannot break `client.channel()` or a `Thread` construction. + * + * Not exported from the package. It only does anything for a key in {@link InstanceSetupKey}, and those + * keys all belong to classes this package constructs, so there is no caller outside it. `ConfigController` + * _is_ exported, because resolution is reusable on its own — see `docs/instance-configuration.md` §6. + * + * @internal + */ +export const applyInstanceConfiguration = ({ + alsoWatch, + applyConfig, + args, + config: service, + key, + reinitializeConfig, +}: ApplyInstanceConfigurationParams): Unsubscribe => { + const scopedLogger = logger.withExtraTags(key); + let tearDown: InstanceSetupTearDownFunction | null = null; + + const runTearDown = () => { + if (!tearDown) return; + const pending = tearDown; + // Cleared before invoking, so a throwing teardown is never retried. + tearDown = null; + try { + pending(); + } catch (error) { + scopedLogger.error('Setup function teardown threw', error); + } + }; + + const apply = () => { + runTearDown(); + + if (applyConfig) { + const declarative = service.getConfigState(key).getLatestValue().config; + try { + applyConfig(declarative ?? undefined); + } catch (error) { + scopedLogger.error('Applying declarative configuration threw', error); + } + } + + const setupFunction = service.getSetupState(key).getLatestValue().setupFunction; + if (setupFunction) { + try { + tearDown = setupFunction(args) ?? null; + } catch (error) { + scopedLogger.error('Setup function threw', error); + } + } + }; + + // One handle, shared by every key this instance registers under. `reset` de-duplicates by object + // identity, so a handle allocated per key would defeat it — a `Channel` registered under `channel`, + // `messagePaginator` and `messageOperations` would re-derive three times for one reset. + const instanceHandle: ConfiguredInstance = { reinitializeConfig }; + + const unregisterInstance = service.registerInstance(key, instanceHandle); + + // `StateStore.subscribe` fires immediately, and we subscribe to two stores — so suppress while + // wiring and apply exactly once afterwards. + let suppress = true; + + // `reset` clears every slot and *then* re-derives each live instance exactly once. An instance that + // supplied a `reinitializeConfig` is therefore covered already, so reacting to the individual clears + // would re-derive it once per populated key — and against a tree still half-cleared, since the + // notifications fire synchronously inside reset's loop. An instance without one has no other route, so + // it keeps reacting. + const coveredByResetsOwnPass = () => !!reinitializeConfig && service.isResetting; + + const onConfigChange = () => { + if (suppress || coveredByResetsOwnPass()) return; + apply(); + }; + + const onSetupChange = () => { + if (suppress) return; + // Teardown is not reset's to run: it lives in this closure, so `reinitializeConfig` cannot reach it. + // Run it here and leave the re-derivation to reset's final phase. + if (coveredByResetsOwnPass()) { + runTearDown(); + return; + } + apply(); + }; + + const unsubscribeConfig = service + .getConfigState(key) + .subscribeWithSelector(({ config }) => ({ config }), onConfigChange); + const unsubscribeSetup = service + .getSetupState(key) + .subscribeWithSelector(({ setupFunction }) => ({ setupFunction }), onSetupChange); + // Selector-based like the two above, so a store that publishes without its `config` moving — `reset` + // clearing an already-empty slot does exactly that, since `partialNext` always allocates — does not + // trigger a cycle. + const unsubscribeExtra = (alsoWatch ?? []).map((watchedKey) => + service + .getConfigState(watchedKey) + .subscribeWithSelector(({ config }) => ({ config }), onConfigChange), + ); + // Registering against the watched keys too is what makes `hasLiveInstances` true for them. The shared + // `instanceHandle` is what lets `reset()` de-duplicate across keys, so being registered under several + // does not multiply re-derives. + const unregisterExtra = (alsoWatch ?? []).map((watchedKey) => + service.registerInstance(watchedKey, instanceHandle), + ); + suppress = false; + + apply(); + + return () => { + unsubscribeConfig(); + unsubscribeSetup(); + unsubscribeExtra.forEach((unsubscribe) => unsubscribe()); + unregisterExtra.forEach((unregister) => unregister()); + unregisterInstance(); + runTearDown(); + }; +}; diff --git a/src/configuration/utils/copyConfigPatch.ts b/src/configuration/utils/copyConfigPatch.ts new file mode 100644 index 0000000000..9269941e69 --- /dev/null +++ b/src/configuration/utils/copyConfigPatch.ts @@ -0,0 +1,61 @@ +import { isWalkableRecord } from '../../utils/objectPath'; + +/** + * Copies a caller-supplied configuration patch, so the value the SDK stores shares no mutable object with + * the caller. + * + * **Why this exists.** `mergeWith` reuses a source subtree verbatim when the target has nothing at that key + * (`createNewTarget` returns `srcValue`), and the declarative registry's target starts empty — so the first + * `client.config.set({ messageComposer: patch })` left `getConfig('messageComposer').text === patch.text`. + * Two consequences, both silent: mutating `patch.text` afterwards changed resolved configuration behind + * every live instance's back with no notification, and the registry held the caller's objects for the + * client's lifetime. + * + * **Why not `structuredClone`.** Configuration is not JSON — `commands.sendValidator`, + * `attachments.fileUploadFilter`, `linkPreviews.findURLFn`, `location.getDeviceId`, + * `messagePaginator.hasPaginationQueryShapeChanged` and every `requestHandlers` entry are functions, and + * `structuredClone` throws on them. + * + * So: plain objects and arrays are copied, and everything else is passed through by reference — + * functions, `Date`s, `RegExp`s, class instances. Those are values a caller *hands over* rather than a + * structure the SDK merges into, and copying them would be wrong as well as impossible: a cloned + * `ItemIndex` would not be the index the paginator loaded items into. + * + * **Repeated objects are copied once.** Every object copied is remembered, so a graph that points back at + * itself terminates instead of overflowing the stack — reachable from `client.config.set()` and + * `updateConfig`, both of which take an object an integrator built. The same bookkeeping keeps two + * references to one object as two references to one copy, rather than duplicating it. + * + * @internal + */ +const copyInto = (value: T, copies: WeakMap): T => { + if (Array.isArray(value)) { + if (copies.has(value)) return copies.get(value) as T; + + const copy: unknown[] = []; + // Registered before the entries are walked, so an entry pointing back at this array finds the copy + // instead of recursing forever. + copies.set(value, copy); + for (const entry of value) copy.push(copyInto(entry, copies)); + return copy as unknown as T; + } + + // Plain objects only. A class instance, a Date or a RegExp is an opaque value here — see + // `isWalkableRecord`, which draws the same line for dot-path access. + if (typeof value === 'object' && value !== null) { + if (!isWalkableRecord(value)) return value; + if (copies.has(value)) return copies.get(value) as T; + + const copy: Record = {}; + copies.set(value, copy); + for (const key of Reflect.ownKeys(value)) { + if (!Object.prototype.propertyIsEnumerable.call(value, key)) continue; + copy[key] = copyInto((value as Record)[key], copies); + } + return copy as T; + } + + return value; +}; + +export const copyConfigPatch = (value: T): T => copyInto(value, new WeakMap()); diff --git a/src/configuration/utils/declarativeSlices.ts b/src/configuration/utils/declarativeSlices.ts new file mode 100644 index 0000000000..c1c65c7b4a --- /dev/null +++ b/src/configuration/utils/declarativeSlices.ts @@ -0,0 +1,81 @@ +import type { DeclarativePaginatorConfig } from '../../pagination/paginators/BasePaginator'; +import type { MessageOperationsConfig } from '../../messageOperations/MessageOperations'; +import type { DeclarativeMessagePaginatorConfig } from '../types'; + +/** + * How a declarative subtree is combined before it reaches the object that owns it. + * + * Two shared keys (`messagePaginator`, `messageOperations`) are also offered nested under `channel` and + * `thread`, so an owner has to layer the general registration and its own override — and, for the + * paginator, split off the one member of the subtree that is a construction argument rather than + * configuration. + */ + +/** + * Layers a per-parent slice of a **shared** configuration key over the shared one, field by field. + * + * Two keys are shared between `Channel` and `Thread` — `messagePaginator` and `messageOperations` + * (**DEC-25**, **DV-15**) — because both entities own one of each and most of the settings mean the same + * thing under either parent. The shared key carries what is common; the per-parent slice overrides only the + * fields it names. + * + * Fields the specific slice does not mention — including ones it sets to `undefined` explicitly — fall + * through to the shared slice, so `{ messagePaginator: { pageSize: 50 } }` is not undone by a + * `channel.messagePaginator` slice that only names `stateThrottleMs`. That `undefined` skip is the whole + * reason this is not a plain object spread. + * + * One level deep on purpose: every field on both config types is a scalar or a function, so there is no + * nested object for a deep merge to reach. Use `mergeWith` if that stops being true. + */ +const mergeDeclarativeSlice = ( + general?: TConfig, + specific?: TConfig, +): TConfig | undefined => { + if (!general) return specific; + if (!specific) return general; + + const merged: TConfig = { ...general }; + for (const [key, value] of Object.entries(specific)) { + if (typeof value === 'undefined') continue; + (merged as Record)[key] = value; + } + return merged; +}; + +/** Layers `channel.messageOperations` / `thread.messageOperations` over the shared `messageOperations` key. */ +export const mergeDeclarativeMessageOperationsConfig = ( + general?: Partial, + specific?: Partial, +): Partial | undefined => + mergeDeclarativeSlice(general, specific); + +/** Layers `channel.messagePaginator` / `thread.messagePaginator` over the shared `messagePaginator` key. */ +export const mergeDeclarativePaginatorConfig = ( + general?: DeclarativeMessagePaginatorConfig, + specific?: DeclarativeMessagePaginatorConfig, +): DeclarativeMessagePaginatorConfig | undefined => + mergeDeclarativeSlice(general, specific); + +/** + * Drops the construction-only arguments from a message-paginator slice, leaving only what is actually + * paginator *configuration*. + * + * `unreadReferencePolicy` rides in the same subtree for the integrator's convenience, but it is not a + * `BasePaginatorConfig` field — `MessagePaginator` reads it once into a private member. Passed through to + * `initializeConfig` it landed in the published `config` as an untyped key that nothing reads, and a + * registration arriving after construction made resolved configuration *contradict* behaviour: the + * construction-only warning correctly said the value would not apply, and then + * `paginator.config.unreadReferencePolicy` reported it as though it had. A settings UI reading resolved + * config showed `read-state-only` for a paginator behaving as `snapshot`. + * + * So the owning `Channel` / `Thread` splits the slice: the constructor argument goes to the constructor, + * and only this half reaches the paginator's configuration. Both already read the policy separately, so + * nothing is lost. + */ +export const toDeclarativePaginatorConfig = ( + slice?: DeclarativeMessagePaginatorConfig, +): DeclarativePaginatorConfig | undefined => { + if (!slice) return undefined; + const { unreadReferencePolicy: _constructionOnly, ...paginatorConfig } = slice; + return paginatorConfig; +}; diff --git a/src/configuration/utils/deepFreezeConfig.ts b/src/configuration/utils/deepFreezeConfig.ts new file mode 100644 index 0000000000..d3fb2dbf76 --- /dev/null +++ b/src/configuration/utils/deepFreezeConfig.ts @@ -0,0 +1,33 @@ +/** + * Recursively freezes a package-level default configuration object. + * + * **Why a runtime guard rather than a type.** Resolved configuration is built by deep-merging over these + * constants, and the merge only *copies* a subtree that some layer actually touches — so a subtree nobody + * configured stays identical by reference to the module-level default, and is reachable through the + * instance's public `config` getter. A write through it therefore changed the default for every instance + * of every client in the process, including ones created afterwards. `Readonly` cannot catch that: it + * is shallow, so it rejects `config.pageSize = 5` but accepts `config.drafts.enabled = true` — and the + * nested form is the one that reaches shared state. In ESM, which is always strict, a write to a frozen + * object throws a `TypeError` at the offending line instead of silently succeeding somewhere else. + * + * Deliberately lives in its own module rather than `src/utils.ts`: that barrel is `vi.mock`ed wholesale by + * some suites, and a default-config constant must not depend on which of its exports a test happens to + * stub. + * + * Functions are frozen as values but not walked — a function's `prototype` is not configuration. Freezing + * is idempotent and stops at anything already frozen, so shared sub-configs cost one visit. + * + * @internal + */ +export const deepFreezeConfig = (value: T): Readonly => { + if (value === null || typeof value !== 'object' || Object.isFrozen(value)) { + return value as Readonly; + } + + Object.freeze(value); + for (const nested of Object.values(value as Record)) { + deepFreezeConfig(nested); + } + + return value as Readonly; +}; diff --git a/src/configuration/utils/index.ts b/src/configuration/utils/index.ts new file mode 100644 index 0000000000..be895cff34 --- /dev/null +++ b/src/configuration/utils/index.ts @@ -0,0 +1,5 @@ +// Only the modules that were already public belong here — `src/configuration/index.ts` re-exports this +// barrel wholesale, so anything added becomes public API. `applyInstanceConfiguration`, +// `copyConfigPatch`, `deepFreezeConfig` and `declarativeSlices` are `@internal` and are imported by path +// instead. +export * from './serverAuthority'; diff --git a/src/configuration/utils/serverAuthority.ts b/src/configuration/utils/serverAuthority.ts new file mode 100644 index 0000000000..904d069b81 --- /dev/null +++ b/src/configuration/utils/serverAuthority.ts @@ -0,0 +1,146 @@ +import { mergeWith } from '../../utils/mergeWith'; +import type { MergeWithCustomizer } from '../../utils/mergeWith/mergeWithCore'; +import type { DeepPartial } from '../../types.utility'; + +/** + * The fields a server decides for some configurable object — a partial configuration holding *only* those + * fields, with the value the server currently reports. + * + * Only server-decided fields may appear. The merge below lets any scalar on this side win, so an + * unrelated field smuggled in here would override the caller's value while looking like a server + * restriction. + */ +export type ServerRestrictions = DeepPartial; + +/** + * Merges a set of server restrictions over a requested configuration under two rules: + * + * 1. **Booleans are ANDed.** A flag the *client* turned off stays off even where the server would allow it — + * asking for less than you are granted is always legitimate — and a flag the server turned off stays off + * whatever the client asked. Either side may narrow; neither may widen. + * 2. **Any other scalar: the server wins.** This is what makes "client configuration can only narrow what + * the server grants" true rather than aspirational. + * + * Objects are left to the normal deep merge, so the rules apply leaf by leaf. + * + * A third rule lives in {@link ServerUpperBounds}, passed separately because a ceiling narrows rather than + * replaces. + * + * **Rule 1 is deliberately not keyed on a field name.** It used to read `key === 'enabled'`, which happened + * to be correct because `location.enabled` was the only boolean restriction — and was a trap for whoever + * added the next one. A gate named anything else (`text.publishTypingEvents` for `typing_events`, say) would + * have fallen through to rule 2, and a client's deliberate `false` would have been overwritten by a + * permissive server: exactly the widening **DV-16** was about, reintroduced one field at a time. Boolean + * restrictions are gates, and the conjunction of two gates is the rule for all of them. + */ +const serverRestrictionCustomizer: MergeWithCustomizer = ( + requestedValue, + restrictionValue, +) => { + // Not a leaf — hand it back to the deep merge and decide further down. + // + // `typeof null === 'object'`, so `null` is excluded explicitly: it has no interior to descend into. The + // upper-bound customizer below has always guarded this and this one did not; the two disagreeing was a + // latent difference rather than a live bug, since no configuration field is nullable today. + const isInterior = (value: unknown) => typeof value === 'object' && value !== null; + if (isInterior(requestedValue)) return undefined; + // Nothing requested here but the server describes a subtree — descend so it lands, rather than answering + // with the absent request and dropping it. Deliberately *not* extended to a requested scalar under an + // object restriction: rule 2 refuses that below, which is the point of its scalar check. + if (requestedValue == null && isInterior(restrictionValue)) return undefined; + + // Rule 1: both sides are gates, so the stricter one wins whichever side it is on. + if (typeof requestedValue === 'boolean' && typeof restrictionValue === 'boolean') { + return requestedValue && restrictionValue; + } + + // Rule 2: the server had the last word. + if ( + ['string', 'number', 'bigint', 'boolean', 'symbol'].includes(typeof restrictionValue) + ) { + return restrictionValue; + } + + // The server stated nothing for this field, so the request stands. + return requestedValue; +}; + +/** + * Numeric ceilings the server imposes — a partial configuration holding only fields where the server states + * a *maximum*, such as a channel type's `max_message_length`. + * + * Separate from {@link ServerRestrictions} because the two combine differently, and putting a ceiling in the + * wrong bucket is a silent bug rather than a type error: a restriction *replaces* the requested value, which + * for a limit would widen a caller who deliberately asked for something stricter. + */ +export type ServerUpperBounds = DeepPartial; + +/** + * Tightest wins. A ceiling can only lower the requested value, never raise it — and it applies in full when + * the caller asked for no limit at all, which is the common case and the reason the server's maximum is + * worth reading: an unlimited composer otherwise lets a message be written that the API will reject. + */ +const upperBoundCustomizer: MergeWithCustomizer = ( + requestedValue, + boundValue, +) => { + // Not a leaf — hand it back to the deep merge and decide at the leaves. + if (typeof requestedValue === 'object' && requestedValue !== null) return undefined; + // The server states no ceiling for this field, so the request stands. + if (typeof boundValue !== 'number') return requestedValue; + // No client limit — the server's is the effective one. For `undefined` the deep merge would reach the + // same answer on its own; the branch earns its place on a value that is neither, where delegating + // would keep the nonsense and drop the ceiling. + if (typeof requestedValue !== 'number') return boundValue; + + return Math.min(requestedValue, boundValue); +}; + +/** + * Applies a server's restrictions to a configuration a caller asked for, so the result never claims more + * than the server allows. + * + * **Why this is a named function rather than an inline merge.** It has to run on *every* route by which a + * configuration can change — construction, the declarative tree, a setup function, a direct + * `updateConfig` — because a restriction applied only at construction holds until the first time anything + * updates the configuration and then silently stops holding. `MessageComposer` learned this the hard way: + * only its `deriveConfig` applied the restrictions, so registering `location.enabled: true` on a running + * app widened past a `shared_locations: false` server and produced a composer offering a feature the API + * rejects (**DV-16**). + * + * **What it deliberately does not do.** It knows nothing about *where* restrictions come from. Reading + * them is the entity's job, because only the entity knows what to ask — a composer reads its channel's + * `serverConfig`, something else might read capabilities — and the answer depends on an instance that + * exists. That is also why this does not live in `InstanceConfigurationRegistry`: that service merges + * declarative layers before any instance exists, and its merges follow the opposite rule (a more specific + * layer *may* re-enable what a broader one disabled), which rule 1 would break. + * + * @example + * ```ts + * // Inside a configurable class, on every path that resolves configuration: + * this.configState.partialNext( + * mergeServerRestrictions(requestedConfig, { + * location: { enabled: this.channel.serverConfig?.shared_locations }, + * }), + * ); + * ``` + */ +export const mergeServerRestrictions = ( + requested: TConfig, + restrictions: ServerRestrictions, + upperBounds?: ServerUpperBounds, +): TConfig => { + const restricted = mergeWith( + requested, + restrictions, + serverRestrictionCustomizer as MergeWithCustomizer, + ); + + if (!upperBounds) return restricted; + + return mergeWith( + restricted, + upperBounds, + upperBoundCustomizer as MergeWithCustomizer, + ); +}; diff --git a/src/index.ts b/src/index.ts index e91c2c754e..fb22e0bc5a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,51 @@ export * from './client'; export * from './client_state'; export * from './channel'; export * from './channel_state'; -export * from './configuration'; +// Don't use * here: the `Custom*Data` interfaces below are augmented by integrators, and `export *` can +// break module augmentation (TS#46617). The configuration key types are deliberately *not* augmentable — +// they are type aliases — so they are listed for the same mechanical reason, not to invite extension. +// https://github.com/microsoft/TypeScript/issues/46617 +// Named in the signatures of `client.config.set` / `setConfig`, so a caller has to be able to write it. +export type { DeepPartial } from './types.utility'; +export { + BUILT_IN_INSTANCE_KEYS, + CONSTRUCTION_ONLY_CONFIG_PATHS, + INSTANCE_CONFIG_TREE_KEYS, +} from './configuration/keys'; +export type { + ChannelDeclarativeConfig, + ClientDeclarativeConfig, + DeclarativeMessagePaginatorConfig, + DeclarativePaginatorConfig, + InstanceConfigOf, + InstanceConfigState, + InstanceConfigTree, + InstanceSetupFunction, + InstanceSetupFunctionArgs, + InstanceSetupFunctionArgsOf, + InstanceSetupKey, + InstanceSetupState, + InstanceSetupTearDownFunction, + ThreadDeclarativeConfig, + UnreadReferencePolicy, +} from './configuration/types'; +export type { + ConfiguredInstance, + InstanceConfigurationRegistry, +} from './configuration/InstanceConfigurationRegistry'; +export { mergeServerRestrictions } from './configuration/utils/serverAuthority'; +export type { + ServerRestrictions, + ServerUpperBounds, +} from './configuration/utils/serverAuthority'; +export { flattenConfigShape, INSTANCE_CONFIG_TREE_SHAPE } from './configuration/shape'; +export type { + ConfigGroupNode, + ConfigNode, + ConfigShape, + ConfigValueNode, + ConfigValueType, +} from './configuration/shape'; export * from './connection'; export { type CooldownTimerState } from './CooldownTimer'; export * from './insights'; @@ -26,7 +70,13 @@ export * from './search'; export * from './signing'; export * from './store'; export { Thread } from './thread'; -export type { ThreadState, ThreadReadState, ThreadUserReadState } from './thread'; +export type { + CustomThreadMarkReadRequestFn, + ThreadConfig, + ThreadReadState, + ThreadState, + ThreadUserReadState, +} from './thread'; export * from './thread_manager'; export * from './token_manager'; export * from './types'; diff --git a/src/logger.ts b/src/logger.ts index fba5894033..884085c908 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -6,6 +6,7 @@ export type ChatLoggerScope = | 'channel-manager' | 'client' | 'connection' + | 'instance-configuration' | 'message-composer' | 'offline-db' | 'state-store' diff --git a/src/messageComposer/LocationComposer.ts b/src/messageComposer/LocationComposer.ts index 43596d66ce..9d4fdcad63 100644 --- a/src/messageComposer/LocationComposer.ts +++ b/src/messageComposer/LocationComposer.ts @@ -24,8 +24,6 @@ export type LocationComposerState = { export type LocationComposerSnapshot = LocationComposerState; -const MIN_LIVE_LOCATION_SHARE_DURATION = 60 * 1000; // 1 minute; - const initState = ({ message, }: { @@ -64,8 +62,7 @@ export class LocationComposer { location.message_id && location.latitude && location.longitude && - (typeof durationMs === 'undefined' || - durationMs >= MIN_LIVE_LOCATION_SHARE_DURATION) + (typeof durationMs === 'undefined' || durationMs >= this.config.minShareDurationMs) ) { return { ...location, diff --git a/src/messageComposer/attachmentManager.ts b/src/messageComposer/attachmentManager.ts index d435ef8d85..0933391f70 100644 --- a/src/messageComposer/attachmentManager.ts +++ b/src/messageComposer/attachmentManager.ts @@ -156,7 +156,11 @@ export class AttachmentManager { set maxNumberOfFilesPerMessage( maxNumberOfFilesPerMessage: AttachmentManagerConfig['maxNumberOfFilesPerMessage'], ) { - if (maxNumberOfFilesPerMessage === this.maxNumberOfFilesPerMessage) return; + // No early return on "same value". The guard that used to be here compared against the *effective* + // value, while the write it skipped records the *requested* one — so asking for a value the server + // was currently masking recorded nothing, and the earlier request stayed in the patch layer to be + // honoured the moment the server relented. `ConfigController.write` already skips a publish when the + // resolved value does not move, which is the same guard applied to the right value. this.composer.updateConfig({ attachments: { maxNumberOfFilesPerMessage } }); } @@ -182,8 +186,36 @@ export class AttachmentManager { return this.availableUploadSlots > 0; } + /** + * Whether uploaded bytes reach Stream, and therefore whether Stream's rules govern attachments here. + * + * Declared through {@link AttachmentManagerConfig.customCdn}, not inferred from `doUploadRequest`. A + * custom upload function says how files are sent, not where: wrapping the request to add retries, + * headers or a proxy through your own backend still ends at Stream. Inferring a different destination + * from it waived Stream's constraints for those integrators too. + */ + get usesStreamStorage() { + return !this.config.customCdn; + } + + /** + * Whether this composer can accept a file right now — **the** answer to that question, for the SDK and + * for any UI deciding whether to offer an upload control. `uploadFiles` enforces exactly this; two + * predicates for one question could only agree by coincidence. + * + * - `config.enabled` — the configured answer, already ANDed with the channel type's `uploads` flag by + * the composer's server restrictions (which apply only when files go to Stream, for the same reason + * as below). The integrator's own switch, which nothing overrides. + * - a free slot under `maxNumberOfFilesPerMessage`. + * - the `upload-file` capability, **only when files go to Stream**. It governs Stream's upload + * endpoint, so on storage Stream does not host there is nothing for it to permit or refuse. + */ get isUploadEnabled() { - return this.hasUploadPermission && this.hasAvailableUploadSlots; + return ( + this.config.enabled && + this.hasAvailableUploadSlots && + (!this.usesStreamStorage || this.hasUploadPermission) + ); } get successfulUploads() { @@ -744,11 +776,7 @@ export class AttachmentManager { }; uploadFiles = async (files: FileReference[] | FileList | FileLike[]) => { - if ( - (this.hasCustomDoUploadRequest && !this.hasAvailableUploadSlots) || - (!this.hasCustomDoUploadRequest && !this.isUploadEnabled) - ) - return; + if (!this.isUploadEnabled) return; const iterableFiles: FileReference[] | FileLike[] = isFileList(files) ? Array.from(files) diff --git a/src/messageComposer/configuration/commands.configuration.ts b/src/messageComposer/configuration/commands.configuration.ts index 173054c83d..276cdba5cc 100644 --- a/src/messageComposer/configuration/commands.configuration.ts +++ b/src/messageComposer/configuration/commands.configuration.ts @@ -1,9 +1,4 @@ -import type { - CommandsConfig, - CommandSendValidator, - MessageComposerConfig, -} from './types'; -import type { DeepPartial } from '../../types.utility'; +import type { CommandsConfig, CommandSendValidator } from './types'; import { stripMentionTokens } from '../middleware'; export const MENTION_ONLY_COMMANDS = new Set(['mute', 'unmute', 'unban']); @@ -33,23 +28,3 @@ export const defaultCommandSendabilityValidator: CommandSendValidator = ({ export const DEFAULT_COMMANDS_CONFIG: CommandsConfig = { sendValidator: defaultCommandSendabilityValidator, }; -export const applyCommandValidatorOverride = ( - targetConfig: MessageComposerConfig, - sourceConfig?: DeepPartial, -) => { - const overrideValidator = sourceConfig?.commands?.sendValidator as - | CommandSendValidator - | undefined; - - if (typeof overrideValidator === 'undefined') { - return targetConfig; - } - - return { - ...targetConfig, - commands: { - ...targetConfig.commands, - sendValidator: overrideValidator, - }, - }; -}; diff --git a/src/messageComposer/configuration/configuration.ts b/src/messageComposer/configuration/configuration.ts index 9a8ff18c0f..30227b0c4c 100644 --- a/src/messageComposer/configuration/configuration.ts +++ b/src/messageComposer/configuration/configuration.ts @@ -5,14 +5,16 @@ import type { LinkPreviewsManagerConfig, LocationComposerConfig, MessageComposerConfig, + PollComposerConfig, TextComposerConfig, } from './types'; import { generateUUIDv4 } from '../../utils'; +import { deepFreezeConfig } from '../../configuration/utils/deepFreezeConfig'; import { DEFAULT_COMMANDS_CONFIG } from './commands.configuration'; export const DEFAULT_LINK_PREVIEW_MANAGER_CONFIG: LinkPreviewsManagerConfig = { debounceURLEnrichmentMs: 1500, - enabled: false, + enabled: true, findURLFn: (text: string): string[] => find(text, 'url', { defaultProtocol: 'https' }).reduce((acc, link) => { try { @@ -30,6 +32,8 @@ export const DEFAULT_LINK_PREVIEW_MANAGER_CONFIG: LinkPreviewsManagerConfig = { export const DEFAULT_ATTACHMENT_MANAGER_CONFIG: AttachmentManagerConfig = { acceptedFiles: [], // an empty array means all files are accepted + customCdn: false, + enabled: true, fileUploadFilter: () => true, maxNumberOfFilesPerMessage: API_MAX_FILES_ALLOWED_PER_MESSAGE, trackUploadProgress: true, @@ -40,16 +44,29 @@ export const DEFAULT_TEXT_COMPOSER_CONFIG: TextComposerConfig = { publishTypingEvents: true, }; +export const DEFAULT_POLL_COMPOSER_CONFIG: PollComposerConfig = { + enabled: true, +}; + export const DEFAULT_LOCATION_COMPOSER_CONFIG: LocationComposerConfig = { enabled: true, getDeviceId: () => generateUUIDv4(), + minShareDurationMs: 60 * 1000, }; -export const DEFAULT_COMPOSER_CONFIG: MessageComposerConfig = { +/** + * Frozen, because `MessageComposer.requestedConfig` seeds its merge with a *shallow* spread of this + * object: any subtree no configuration layer names stays identical by reference to the one here, and is + * reachable through the public `composer.config`. Without the freeze, + * `composer.config.drafts.enabled = true` changed the default for every composer on every client in the + * process. See {@link deepFreezeConfig}. + */ +export const DEFAULT_COMPOSER_CONFIG: MessageComposerConfig = deepFreezeConfig({ attachments: DEFAULT_ATTACHMENT_MANAGER_CONFIG, commands: DEFAULT_COMMANDS_CONFIG, drafts: { enabled: false }, linkPreviews: DEFAULT_LINK_PREVIEW_MANAGER_CONFIG, location: DEFAULT_LOCATION_COMPOSER_CONFIG, + polls: DEFAULT_POLL_COMPOSER_CONFIG, text: DEFAULT_TEXT_COMPOSER_CONFIG, -}; +}); diff --git a/src/messageComposer/configuration/index.ts b/src/messageComposer/configuration/index.ts index 62da8735a5..469a1abca0 100644 --- a/src/messageComposer/configuration/index.ts +++ b/src/messageComposer/configuration/index.ts @@ -1,6 +1,5 @@ export * from './configuration'; export * from './types'; -export { applyCommandValidatorOverride } from './commands.configuration'; export { DEFAULT_COMMANDS_CONFIG } from './commands.configuration'; export { defaultCommandSendabilityValidator } from './commands.configuration'; export { MENTION_ONLY_COMMANDS } from './commands.configuration'; diff --git a/src/messageComposer/configuration/types.ts b/src/messageComposer/configuration/types.ts index 193fbe4697..e427c03bae 100644 --- a/src/messageComposer/configuration/types.ts +++ b/src/messageComposer/configuration/types.ts @@ -64,6 +64,11 @@ export type CommandsConfig = { }; export type AttachmentManagerConfig = { + /** + * Allows for toggling file attachments (defaults to `true`). The feature also has to be enabled at the + * channel-level config via `uploads`; the two are ANDed, so either side can switch it off. + */ + enabled: boolean; // todo: document removal of noFiles prop showing how to achieve the same with custom fileUploadFilter function /** * Function that allows to prevent uploading files based on the functions output. @@ -79,6 +84,23 @@ export type AttachmentManagerConfig = { acceptedFiles: string[]; /** Function that allows to customize the upload request. */ doUploadRequest?: UploadRequestFn; + /** + * Whether a custom {@link AttachmentManagerConfig.doUploadRequest} stores files somewhere Stream does + * not host (defaults to `false`). + * + * Left `false`, uploads are treated as reaching Stream — which covers the built-in request *and* a + * custom one that still posts to Stream, such as a wrapper adding retries or headers, or a proxy + * through your own backend. Stream's rules then govern attachments: the `upload-file` capability and + * the channel type's `uploads` flag. + * + * Set it to `true` when the bytes never reach Stream. Stream has no say over storage it does not host, + * so neither rule applies and the feature is governed by {@link AttachmentManagerConfig.enabled} alone. + * + * Declared rather than inferred from the presence of `doUploadRequest`, because supplying an upload + * function says *how* files are sent, not *where* — and treating it as a destination waived Stream's + * constraints for integrators who were still uploading to Stream. + */ + customCdn: boolean; /** * When `true`, the attachment manager sets `localMetadata.uploadProgress` and passes * `options.onProgress` to `doUploadRequest` (built-in and custom). Set to `false` to disable @@ -90,7 +112,14 @@ export type AttachmentManagerConfig = { export type LinkPreviewsManagerConfig = { /** Number of milliseconds to debounce firing the URL enrichment queries when typing (defaults to `1500`). */ debounceURLEnrichmentMs: number; - /** Allows for toggling the URL enrichment and link previews in `MessageInput` (defaults to `false`). */ + /** + * Allows for toggling URL enrichment and link previews in `MessageInput` (defaults to `true`). + * + * ANDed with the channel type's `url_enrichment`, so previews appear only where the server will + * actually enrich the message. `true` is the default for the same reason every other server-gated + * feature uses it: it means "no opinion — let the server decide". A `false` default double-gated the + * feature, leaving it off even where the server had enabled it. + */ enabled: boolean; /** Custom function to identify URLs in a string and request OG data */ findURLFn: (text: string) => string[]; @@ -106,6 +135,20 @@ export type LocationComposerConfig = { enabled: boolean; /** Function that provides a stable ID for the device from which the location is shared. */ getDeviceId: () => string; + /** + * Shortest live-location duration accepted as valid (defaults to 60s). A shorter `durationMs` makes + * the composed location invalid rather than clamping it, so this is a product decision about the + * minimum useful sharing window — not a protocol limit. + */ + minShareDurationMs: number; +}; + +export type PollComposerConfig = { + /** + * Allows for toggling poll composition (defaults to `true`). The feature also has to be enabled at the + * channel-level config via `polls`; the two are ANDed, so either side can switch it off. + */ + enabled: boolean; }; export type MessageComposerConfig = { @@ -119,6 +162,8 @@ export type MessageComposerConfig = { linkPreviews: LinkPreviewsManagerConfig; /** Configuration for the location composer */ location: LocationComposerConfig; + /** Configuration for the poll composer */ + polls: PollComposerConfig; /** Maximum number of characters in a message */ text: TextComposerConfig; }; diff --git a/src/messageComposer/linkPreviewsManager.ts b/src/messageComposer/linkPreviewsManager.ts index 61b0715e4a..c2e23375bc 100644 --- a/src/messageComposer/linkPreviewsManager.ts +++ b/src/messageComposer/linkPreviewsManager.ts @@ -156,18 +156,15 @@ export class LinkPreviewsManager implements ILinkPreviewsManager { } get enabled() { - /** - * We have to check whether the message will be enriched server side (url_enrichment). - * If not, then it does not make sense to do previews in composer. - */ - return ( - !!this.channel.getConfig()?.url_enrichment && - this.composer.config.linkPreviews.enabled - ); + return this.composer.config.linkPreviews.enabled; } set enabled(enabled: LinkPreviewsManagerConfig['enabled']) { - if (enabled === this.enabled) return; + // No early return on "same value". The guard that used to be here compared against the *effective* + // value, while the write it skipped records the *requested* one — so asking for a value the server + // was currently masking recorded nothing, and the earlier request stayed in the patch layer to be + // honoured the moment the server relented. `ConfigController.write` already skips a publish when the + // resolved value does not move, which is the same guard applied to the right value. this.composer.updateConfig({ linkPreviews: { enabled } }); } diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index e040ff2274..934bba5eb5 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -6,7 +6,7 @@ import { LocationComposer } from './LocationComposer'; import { MessageComposerEffectHandlers } from './MessageComposerEffectHandlers'; import { PollComposer } from './pollComposer'; import { TextComposer } from './textComposer'; -import { applyCommandValidatorOverride, DEFAULT_COMPOSER_CONFIG } from './configuration'; +import { DEFAULT_COMPOSER_CONFIG } from './configuration'; import type { MessageComposerMiddlewareValue } from './middleware'; import { MessageComposerMiddlewareExecutor, @@ -15,7 +15,13 @@ import { import type { Unsubscribe } from '../store'; import { StateStore } from '../store'; import { formatMessage, generateUUIDv4, isLocalMessage } from '../utils'; -import { mergeWith } from '../utils/mergeWith'; +import { ConfigController } from '../configuration/ConfigController'; +import { deepFreezeConfig } from '../configuration/utils/deepFreezeConfig'; +import { mergeServerRestrictions } from '../configuration/utils/serverAuthority'; +import type { + ServerRestrictions, + ServerUpperBounds, +} from '../configuration/utils/serverAuthority'; import { Channel } from '../channel'; import { Thread } from '../thread'; import type { @@ -30,6 +36,7 @@ import type { UserResponse, } from '../types'; import { chatLoggerSystem } from '../logger'; +import { applyInstanceConfiguration } from '../configuration/utils/applyInstanceConfiguration'; import { WithSubscriptions } from '../utils/WithSubscriptions'; import type { StreamChat } from '../client'; import type { CommandSendability, MessageComposerConfig } from './configuration/types'; @@ -45,7 +52,6 @@ import type { LocationComposerSnapshot } from './LocationComposer'; import type { PollComposerSnapshot } from './pollComposer'; import type { TextComposerSnapshot } from './textComposer'; import type { DeepPartial } from '../types.utility'; -import type { MergeWithCustomizer } from '../utils/mergeWith/mergeWithCore'; import { getMentionedUsersInText, stripCommandFromText, @@ -177,7 +183,15 @@ export class MessageComposer extends WithSubscriptions { readonly channel: Channel; readonly state: StateStore; readonly editingAuditState: StateStore; - readonly configState: StateStore; + + /** + * Resolved configuration, as a store — the shape every configurable class exposes + * (`configState` / `config` / `updateConfig`). Delegates rather than holding a copy, so the field and + * the controller's store cannot drift. + */ + get configState(): StateStore { + return this.configController.state; + } readonly compositionContext: CompositionContext; readonly compositionMiddlewareExecutor: MessageComposerMiddlewareExecutor; readonly draftCompositionMiddlewareExecutor: MessageDraftComposerMiddlewareExecutor; @@ -190,6 +204,20 @@ export class MessageComposer extends WithSubscriptions { customDataManager: CustomDataManager; private snapshots: MessageComposerSnapshot[] = []; private effectHandlers: MessageComposerEffectHandlers; + /** + * The shared configuration machinery, with the three hooks this entity is the only one to need: + * `retainPatches` so an `updateConfig` request is *retained* and re-applied rather than written + * into the result (**DV-18**), and `applyAuthority` for the server's last word. + * + * A third hook, `finalizeRequest`, was added here for `commands.sendValidator` and then removed: the + * deep merge assigns function values directly and skips `undefined`, so the override it called reached + * the same answer on every layer shape — a later layer that stays silent cannot erase an earlier + * choice, because a merge only writes keys that are present. + */ + private readonly configController: ConfigController< + MessageComposerConfig, + DeepPartial + >; // todo: mediaRecorder: MediaRecorderController; constructor({ @@ -216,43 +244,25 @@ export class MessageComposer extends WithSubscriptions { ); } - /** - * Customizes config merges for the composer constructor. - * - * It catches two scalar override cases that should not use the default deep merge: - * - client-disabled `enabled` flags stay disabled even if the channel config tries to re-enable them - * - scalar channel-config values replace client defaults for matching config keys - * - * All other values fall back to the normal `mergeWith` behavior. - */ - const mergeMessageComposerConfigCustomizer: MergeWithCustomizer< + this.configController = new ConfigController< + MessageComposerConfig, DeepPartial - > = (originalVal, channelConfigVal, key) => - typeof originalVal === 'object' - ? undefined - : originalVal === false && key === 'enabled' // prevent enabling features that are disabled client-side - ? false - : ['string', 'number', 'bigint', 'boolean', 'symbol'].includes( - // prevent enabling features that are disabled server-side - typeof channelConfigVal, - ) - ? channelConfigVal // scalar values get overridden by server-side config - : originalVal; - - this.configState = new StateStore( - applyCommandValidatorOverride( - mergeWith( - mergeWith(DEFAULT_COMPOSER_CONFIG, config ?? {}), - { - location: { - enabled: this.channel.getConfig()?.shared_locations, - }, - }, - mergeMessageComposerConfigCustomizer, - ), - config, - ), - ); + >({ + defaults: DEFAULT_COMPOSER_CONFIG, + constructorOptions: config as Partial | undefined, + initialSlice: this.declarativeConfig as Partial | undefined, + mergeSlice: 'deep', + // Stages 4 and 5 both arrive through `updateConfig`, and both have to outlive a re-resolution. + retainPatches: true, + applyAuthority: (requested) => + deepFreezeConfig( + mergeServerRestrictions( + requested, + this.serverRestrictionsFor(requested), + this.serverUpperBounds, + ), + ) as MessageComposerConfig, + }); let message: LocalMessage | DraftMessage | undefined = undefined; if (compositionIsDraftResponse(composition)) { @@ -306,7 +316,15 @@ export class MessageComposer extends WithSubscriptions { static generateId = generateUUIDv4; - get config(): MessageComposerConfig { + /** + * The current resolved configuration. + * + * `Readonly` for the same reason every other configurable class's getter is: the value is the store's + * live object, so assigning to a field would change state while notifying nobody. Use + * {@link updateConfig}. `Readonly` is shallow, so nested writes are caught at runtime instead — the + * whole resolution is deep-frozen by {@link resolvedConfig}, not only the untouched defaults. + */ + get config(): Readonly { return this.configState.getLatestValue(); } @@ -477,12 +495,152 @@ export class MessageComposer extends WithSubscriptions { return editedMessageWasUpdated || draftWasChanged || composingMessageFromScratch; } + /** + * Records a configuration change as something *you* asked for, then republishes. + * + * The patch is kept — see {@link imperativeConfig} — rather than merged into the published result and + * forgotten. That is what makes the request survive a later re-resolution, including one triggered by + * the server changing its mind. + */ updateConfig(config: DeepPartial) { - this.configState.partialNext( - applyCommandValidatorOverride(mergeWith(this.config, config), config), - ); + this.configController.patch(config as Partial); + } + + /** + * What this composer has been **asked** for, before the server has any say — stages 1 to 5 of + * `docs/instance-configuration.md` §3. + * + * Available on every entity that retains its patches, not just this one: it is the controller's, and + * the split it exposes is what **FU-35** would extend elsewhere by switching on `retainPatches`. + */ + get requestedConfig(): Readonly { + return this.configController.requested; + } + + /** The declarative slice for this composer, re-read live so a change is picked up. */ + private get declarativeConfig(): DeepPartial { + return (this.client.config.getConfig('messageComposer') ?? + {}) as DeepPartial; + } + + /** + * The configuration fields this composer's channel decides server-side. + * + * Reading them is the composer's job rather than the shared helper's: only the composer knows that + * `location.enabled` is gated on `shared_locations`, and only an existing composer has a channel to + * ask. `serverConfig` is re-read on every call, so a restriction that changes mid-session is picked up + * rather than captured once. + * + * Every entry here is a boolean gate, so `mergeServerRestrictions` ANDs it with what was requested and + * either side may switch the feature off — a client asking for less than the server grants is always + * legitimate. That is the whole point of mirroring these flags into configuration rather than leaving + * consumers to read `serverConfig` themselves: a raw server flag answers only the server's half, so a UI + * reading it offers features the composer has already disabled and would refuse to compose. + * + * `commands` is deliberately absent. The server sends a *list* of commands rather than a gate, so there + * is nothing to AND and no integrator intent to mirror; consumers read it from the channel's config. + * + * Takes the requested configuration because one restriction is conditional — see `uploads` below. + */ + private serverRestrictionsFor( + requested: MessageComposerConfig, + ): ServerRestrictions { + const channelConfig = this.channel.serverConfig; + + return { + /** + * `uploads` describes **Stream's upload endpoint**, not the concept of attaching files. Setting + * `attachments.customCdn` says the bytes go to storage Stream neither hosts nor charges for, and + * the flag says nothing about whether that can work — applying it there would make "turn uploads + * on in Stream" a precondition for uploading to your own CDN, which is not this SDK's to require. + * + * Keyed on `customCdn` rather than on the presence of `doUploadRequest`: a custom upload function + * says how files are sent, not where, and one that still posts to Stream must stay subject to + * Stream's rules. + * + * `undefined` rather than `true`: the server is not asserting the opposite either, it simply has + * no say. `mergeServerRestrictions` leaves the request standing for a field it states nothing + * about, so the integrator's `attachments.enabled` decides alone — the same way an unset + * `shared_locations` behaves. + */ + attachments: { + enabled: requested.attachments.customCdn ? undefined : channelConfig?.uploads, + }, + linkPreviews: { enabled: channelConfig?.url_enrichment }, + location: { enabled: channelConfig?.shared_locations }, + polls: { enabled: channelConfig?.polls }, + }; } + /** + * Ceilings this composer's channel imposes server-side. + * + * `max_message_length` caps both length limits rather than setting them: a composer asking for something + * shorter keeps its own number, and one asking for nothing at all inherits the server's — which is the + * default, and the case worth having. Left unlimited, the composer happily accepts text the send endpoint + * then rejects, so the limit is enforced late and as an API error instead of in the editor. + */ + private get serverUpperBounds(): ServerUpperBounds { + const maxMessageLength = this.channel.serverConfig?.max_message_length; + + return { + text: { maxLengthOnEdit: maxMessageLength, maxLengthOnSend: maxMessageLength }, + }; + } + + /** + * Resolves the configuration and publishes it, unless the result is deep-equal to what is already there. + * + * The guard is needed because `StateStore.next`'s own `===` no-op can never apply here: every resolution + * allocates a new object, so without a comparison *every* publish notifies, whether or not any value + * moved. + * + * Worth the walk: `isEqual` over a resolved composer config measures ~1.7µs, against a resolution at + * ~3.5µs plus every subscriber's work. The dominant source of no-op publishes is fixed upstream in + * `StreamChat._addChannelConfig`, which stops a repeated channel query from waking that channel's + * composer at all; this + * catches the rest — re-registering a declarative value that has not changed, a `reset` with nothing + * registered, an empty `updateConfig({})`. + */ + private publishConfig = () => { + this.configController.rederive(this.declarativeConfig); + }; + + /** + * Rebuilds the configuration from its inputs and **discards imperative changes** — every + * {@link updateConfig} patch, including those made through a sub-composer setter such as + * `textComposer.defaultValue` or `attachmentManager.maxNumberOfFilesPerMessage`. + * + * Called by the constructor and by `client.config.reset()`, where dropping them is the point: a reset + * means "back to what is registered". Anything that merely needs the configuration re-resolved — the + * server's answer arriving, a declarative change — must use {@link publishConfig} or + * {@link applyServerRestrictions}, which keep them. + */ + initializeConfig = () => { + this.configController.initialize(this.declarativeConfig); + }; + + /** + * Re-resolves the configuration against the channel's current server-side restrictions. + * + * Call this when the server's answer may have changed — its config has just arrived, or it was updated. + * Safe in both directions, which is the whole reason it exists: a feature you disabled stays disabled + * when the server permits it, and a feature the server *stops* restricting goes back to whatever you + * asked for, because the restriction is applied to your request rather than to the previous result. + * + * Reachable rather than public: `Channel.query` is the only caller, covering a composer that has not + * registered subscriptions and so cannot hear the answer change through + * {@link subscribeChannelConfigChanged}. Nothing outside this package needs it — registering subscriptions + * is the supported way to stay current, and a composer that has done so is already covered. Marked + * `@internal` so it is not read as a supported extension point; the name is kept because what it does + * is* re-assert the server's restrictions, even though the whole resolution is what performs that. + * + * @internal + */ + applyServerRestrictions = () => { + this.publishConfig(); + }; + refreshId = () => { this.state.partialNext({ id: MessageComposer.generateId() }); }; @@ -598,6 +756,7 @@ export class MessageComposer extends WithSubscriptions { public registerSubscriptions = (): UnregisterSubscriptions => { if (!this.hasSubscriptions) { this.addUnsubscribeFunction(this.subscribeMessageComposerSetupStateChange()); + this.addUnsubscribeFunction(this.subscribeChannelConfigChanged()); this.addUnsubscribeFunction(this.subscribeMessageUpdated()); this.addUnsubscribeFunction(this.subscribeMessageDeleted()); @@ -641,24 +800,34 @@ export class MessageComposer extends WithSubscriptions { return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe()); }; - private subscribeMessageComposerSetupStateChange = () => { - let tearDown: (() => void) | null = null; - const unsubscribe = - this.client.instanceConfigurationService.MessageComposer.subscribeWithSelector( - ({ setupFunction: setup }) => ({ - setup, - }), - ({ setup }) => { - tearDown?.(); - tearDown = setup?.({ composer: this }) ?? null; - }, - ); + private subscribeMessageComposerSetupStateChange = () => + applyInstanceConfiguration({ + args: { composer: this }, + config: this.client.config, + key: 'messageComposer', + // Re-resolve rather than merge the slice in. `requestedConfig` reads the declarative slice live, so + // there is nothing to copy — and copying it through `updateConfig` would file it under *imperative* + // changes, letting a later declarative change override an imperative one. That inverts stages 2 and + // 5 of the documented order, which says the more specific, later request wins. + applyConfig: () => this.publishConfig(), + reinitializeConfig: this.initializeConfig, + }); - return () => { - tearDown?.(); - unsubscribe(); - }; - }; + /** + * The channel's server-side config (`client.channelServerConfigs[cid]`) is populated by `query`/`watch`, + * which for a channel opened via `client.channel(type, id)` happens *after* this composer was + * constructed. Left unwatched, the composer would keep the defaults it derived when `serverConfig` was + * still undefined — so `location.enabled` would stay `true` for an app that disables `shared_locations` + * server-side. Re-deriving when the config lands keeps the server authoritative. + * + * Selected by cid, matching the store's key space: `shared_locations` and `max_message_length` are both + * overridable per channel, so a sibling channel's config is not this composer's answer. + */ + private subscribeChannelConfigChanged = () => + this.client.channelServerConfigsStore.subscribeWithSelector( + ({ configs }) => ({ channelConfig: configs[this.channel.cid] }), + () => this.applyServerRestrictions(), + ); private subscribeMessageDeleted = () => this.client.on('message.deleted', (event) => { diff --git a/src/messageComposer/middleware/textComposer/commands.ts b/src/messageComposer/middleware/textComposer/commands.ts index cc8eea7aeb..9ac80a0c24 100644 --- a/src/messageComposer/middleware/textComposer/commands.ts +++ b/src/messageComposer/middleware/textComposer/commands.ts @@ -38,8 +38,7 @@ export class CommandSearchSource extends BaseSearchSourceSync } query(searchQuery: string) { - const channelConfig = this.channel.getConfig(); - const commands = channelConfig?.commands || []; + const commands = this.channel.config.availableCommands; const selectedCommands: Command[] = commands.filter( (command): command is Command => !!( diff --git a/src/messageComposer/textComposer.ts b/src/messageComposer/textComposer.ts index bb5c91a041..09bd7b2668 100644 --- a/src/messageComposer/textComposer.ts +++ b/src/messageComposer/textComposer.ts @@ -163,7 +163,11 @@ export class TextComposer { } set enabled(enabled: boolean) { - if (enabled === this.enabled) return; + // No early return on "same value". The guard that used to be here compared against the *effective* + // value, while the write it skipped records the *requested* one — so asking for a value the server + // was currently masking recorded nothing, and the earlier request stayed in the patch layer to be + // honoured the moment the server relented. `ConfigController.write` already skips a publish when the + // resolved value does not move, which is the same guard applied to the right value. this.composer.updateConfig({ text: { enabled } }); } @@ -181,7 +185,11 @@ export class TextComposer { } set maxLengthOnEdit(maxLengthOnEdit: number | undefined) { - if (maxLengthOnEdit === this.maxLengthOnEdit) return; + // No early return on "same value". The guard that used to be here compared against the *effective* + // value, while the write it skipped records the *requested* one — so asking for a value the server + // was currently masking recorded nothing, and the earlier request stayed in the patch layer to be + // honoured the moment the server relented. `ConfigController.write` already skips a publish when the + // resolved value does not move, which is the same guard applied to the right value. this.composer.updateConfig({ text: { maxLengthOnEdit } }); } @@ -190,7 +198,11 @@ export class TextComposer { } set maxLengthOnSend(maxLengthOnSend: number | undefined) { - if (maxLengthOnSend === this.maxLengthOnSend) return; + // No early return on "same value". The guard that used to be here compared against the *effective* + // value, while the write it skipped records the *requested* one — so asking for a value the server + // was currently masking recorded nothing, and the earlier request stayed in the patch layer to be + // honoured the moment the server relented. `ConfigController.write` already skips a publish when the + // resolved value does not move, which is the same guard applied to the right value. this.composer.updateConfig({ text: { maxLengthOnSend } }); } diff --git a/src/messageDelivery/MessageDeliveryReporter.ts b/src/messageDelivery/MessageDeliveryReporter.ts index 48ce22ed90..607eb21eb4 100644 --- a/src/messageDelivery/MessageDeliveryReporter.ts +++ b/src/messageDelivery/MessageDeliveryReporter.ts @@ -1,4 +1,7 @@ import type { StreamChat } from '../client'; +import { deepFreezeConfig } from '../configuration/utils/deepFreezeConfig'; +import type { StateStore } from '../store'; +import { ConfigController } from '../configuration/ConfigController'; import { Channel } from '../channel'; import type { ThreadUserReadState } from '../thread'; import { Thread } from '../thread'; @@ -13,10 +16,30 @@ import type { import { throttle, userHasReadReceipts } from '../utils'; import { isAPIError, isErrorRetryable } from '../errors'; -const MAX_DELIVERED_MESSAGE_COUNT_IN_PAYLOAD = 100 as const; -const MARK_AS_DELIVERED_BUFFER_TIMEOUT = 1000 as const; -const MARK_AS_READ_THROTTLE_TIMEOUT = 1000 as const; -const RETRY_COUNT_LIMIT_FOR_TIMEOUT_INCREASE = 3 as const; +export type MessageDeliveryReporterConfig = { + /** How long delivery reports are buffered before being sent as one batch (defaults to 1000ms). */ + markAsDeliveredBufferTimeoutMs: number; + /** + * Minimum gap between automatic `markRead` calls (defaults to 1000ms). + * + * Read once, when the throttle is built — assigning it later does nothing, which is why + * {@link MessageDeliveryReporter.setMarkAsReadThrottleOptions} exists and why the declarative path + * routes through it. + */ + markAsReadThrottleTimeoutMs: number; + /** Most delivery receipts sent in a single request; the remainder is carried to the next (100). */ + maxDeliveredMessageCountInPayload: number; + /** Consecutive timeouts before the buffer window is widened (defaults to 3). */ + retryCountLimitForTimeoutIncrease: number; +}; + +export const DEFAULT_MESSAGE_DELIVERY_REPORTER_CONFIG: MessageDeliveryReporterConfig = + deepFreezeConfig({ + markAsDeliveredBufferTimeoutMs: 1000, + markAsReadThrottleTimeoutMs: 1000, + maxDeliveredMessageCountInPayload: 100, + retryCountLimitForTimeoutIncrease: 3, + }); const isChannel = (item: Channel | Thread): item is Channel => item instanceof Channel; const isThread = (item: Channel | Thread): item is Thread => item instanceof Thread; @@ -44,14 +67,84 @@ export class MessageDeliveryReporter { protected markDeliveredRequestPromise: Promise | null = null; protected markDeliveredTimeout: ReturnType | null = null; - protected requestTimeoutMs: number = MARK_AS_DELIVERED_BUFFER_TIMEOUT; - // increased up to RETRY_COUNT_LIMIT_FOR_TIMEOUT_INCREASE + protected requestTimeoutMs: number = + DEFAULT_MESSAGE_DELIVERY_REPORTER_CONFIG.markAsDeliveredBufferTimeoutMs; + // increased up to config.retryCountLimitForTimeoutIncrease protected requestRetryCount: number = 0; + /** The shared configuration machinery — see {@link ConfigController}. */ + private readonly configController: ConfigController; + /** + * Resolved configuration, as a store — the shape every configurable class exposes + * (`configState` / `config` / `updateConfig`). + */ + get configState(): StateStore { + return this.configController.state; + } + constructor({ client }: MessageDeliveryReporterOptions) { this.client = client; + this.configController = new ConfigController({ + defaults: DEFAULT_MESSAGE_DELIVERY_REPORTER_CONFIG, + // The markRead throttle captures its interval in a closure, so storing a new one is not enough — + // it has to be rebuilt. Doing that here rather than in `updateConfig` is what makes the pairing + // hold for *every* route, including a declarative change. + onChanged: (next, previous) => { + if (next.markAsReadThrottleTimeoutMs === previous.markAsReadThrottleTimeoutMs) + return; + this.throttledMarkRead = this.buildThrottledMarkRead( + next.markAsReadThrottleTimeoutMs, + ); + }, + }); + } + + /** The current resolved configuration. `Readonly` — change it through {@link updateConfig}. */ + get config(): Readonly { + return this.configState.getLatestValue(); + } + + /** + * Merges a partial configuration in. `markAsReadThrottleTimeoutMs` is routed through its rebuild + * setter, because the throttle captured the old interval in a closure and would otherwise ignore it. + */ + updateConfig(config: Partial) { + this.configController.patch(config); + } + + /** + * Rebuilds the resolved configuration from package defaults plus the declarative slice. + * + * The derivation entry point every configurable entity exposes, so the owner routes a slice here and + * knows nothing about MessageDeliveryReporter's defaults or merge semantics. This logic used to live in the owner, + * which is how `reset()` became a no-op for the client key (F4) and how a registered + * `notifications.sortComparator` became unremovable (G8) — an owner writing another object's + * derivation gets that object's rules wrong sooner or later. + * + * Routed through {@link updateConfig} rather than replacing the store, which is exact here because + * every field of `MessageDeliveryReporterConfig` is required and present in the defaults, so a patch naming all of + * them amounts to a replacement. `NotificationManager` cannot do this — its `sortComparator` is + * optional with no default, so a patch can never remove one — which is why it replaces outright. + */ + initializeConfig(config?: Partial) { + this.configController.initialize(config); } + /** + * Rebuilds the `markRead` throttle for a new interval. + * + * Needed because the throttle is created as a field initializer — before any declarative + * configuration has been applied — so the interval is captured once. Assigning the config value + * alone would leave the original throttle in place, silently. + */ + setMarkAsReadThrottleOptions = ({ + markAsReadThrottleTimeoutMs, + }: Pick) => { + // Kept as released surface, but it no longer has to pair the write with the rebuild — the + // controller's `onChanged` does that for whichever route the value arrives by. + this.updateConfig({ markAsReadThrottleTimeoutMs }); + }; + private get markDeliveredRequestInFlight() { return this.markDeliveredRequestPromise !== null; } @@ -69,18 +162,18 @@ export class MessageDeliveryReporter { } private static hasPermissionToReportDeliveryFor(collection: Channel | Thread) { - if (isChannel(collection)) return !!collection.getConfig()?.delivery_events; - if (isThread(collection)) return !!collection.channel.getConfig()?.delivery_events; + if (isChannel(collection)) return collection.config.deliveryEvents.enabled; + if (isThread(collection)) return collection.channel.config.deliveryEvents.enabled; } private increaseBackOff() { - if (this.requestRetryCount >= RETRY_COUNT_LIMIT_FOR_TIMEOUT_INCREASE) return; + if (this.requestRetryCount >= this.config.retryCountLimitForTimeoutIncrease) return; this.requestRetryCount = this.requestRetryCount + 1; this.requestTimeoutMs = this.requestTimeoutMs * 2; } private resetBackOff() { - this.requestTimeoutMs = MARK_AS_DELIVERED_BUFFER_TIMEOUT; + this.requestTimeoutMs = this.config.markAsDeliveredBufferTimeoutMs; this.requestRetryCount = 0; } @@ -102,9 +195,11 @@ export class MessageDeliveryReporter { private confirmationsFromDeliveryReportCandidates() { const entries = Array.from(this.deliveryReportCandidates); - const sendBuffer = new Map(entries.slice(0, MAX_DELIVERED_MESSAGE_COUNT_IN_PAYLOAD)); + const sendBuffer = new Map( + entries.slice(0, this.config.maxDeliveredMessageCountInPayload), + ); this.deliveryReportCandidates = new Map( - entries.slice(MAX_DELIVERED_MESSAGE_COUNT_IN_PAYLOAD), + entries.slice(this.config.maxDeliveredMessageCountInPayload), ); return { latest_delivered_messages: this.confirmationsFrom(sendBuffer), sendBuffer }; @@ -329,24 +424,26 @@ export class MessageDeliveryReporter { }; /** - * Throttles the MessageDeliveryReporter.markRead call + * Builds the throttled `markRead`. A factory rather than an inline `throttle(...)` so the interval can + * be swapped later — see {@link setMarkAsReadThrottleOptions}. * - * @param collection - * @param options + * @param intervalMs - minimum gap between automatic `markRead` calls */ // Auto mark-read is throttled and fire-and-forget: it's triggered by state changes / WS events, // not by an awaiting caller, so a rejection here has nowhere to propagate and would otherwise // surface as an unhandled rejection (e.g. `channel.markRead` throwing when read events are // disabled, or a transient network error). Swallow it — the auto path retries on the next // trigger, and explicit `markRead()` callers still receive the error. - public throttledMarkRead = throttle( - (collection: Channel | Thread, options?: MarkReadRequest) => { - void this.markRead(collection, options).catch(() => undefined); - }, - MARK_AS_READ_THROTTLE_TIMEOUT, - { - leading: true, - trailing: true, - }, - ).throttledFn; + private buildThrottledMarkRead = (intervalMs: number) => + throttle( + (collection: Channel | Thread, options?: MarkReadRequest) => { + void this.markRead(collection, options).catch(() => undefined); + }, + intervalMs, + { leading: true, trailing: true }, + ).throttledFn; + + public throttledMarkRead = this.buildThrottledMarkRead( + DEFAULT_MESSAGE_DELIVERY_REPORTER_CONFIG.markAsReadThrottleTimeoutMs, + ); } diff --git a/src/messageOperations/MessageOperations.ts b/src/messageOperations/MessageOperations.ts index 5d5a8eb9bf..b5f0d86291 100644 --- a/src/messageOperations/MessageOperations.ts +++ b/src/messageOperations/MessageOperations.ts @@ -1,5 +1,8 @@ // todo: add tests import type { MessageRequest, UpdateMessageOptions } from '../types'; +import { deepFreezeConfig } from '../configuration/utils/deepFreezeConfig'; +import type { StateStore } from '../store'; +import { ConfigController } from '../configuration/ConfigController'; import { formatMessage, localMessageToNewMessagePayload } from '../utils'; import { MessageOperationStatePolicy } from './MessageOperationStatePolicy'; import type { @@ -9,8 +12,18 @@ import type { OperationRequestFn, } from './types'; -const FAILED_SEND_CACHE_MAX_SIZE = 100; -const FAILED_SEND_CACHE_TTL_MS = 5 * 60 * 1000; +export type MessageOperationsConfig = { + /** Most failed sends kept for retry; the oldest is evicted past this (defaults to 100). */ + failedSendCacheMaxSize: number; + /** How long a failed send stays retryable (defaults to 5 minutes). */ + failedSendCacheTtlMs: number; +}; + +export const DEFAULT_MESSAGE_OPERATIONS_CONFIG: MessageOperationsConfig = + deepFreezeConfig({ + failedSendCacheMaxSize: 100, + failedSendCacheTtlMs: 5 * 60 * 1000, + }); type FailedSendCacheEntry = { message: MessageRequest; @@ -23,9 +36,50 @@ export class MessageOperations { private policy: MessageOperationStatePolicy; private failedSendCache = new Map(); + /** The shared configuration machinery — see {@link ConfigController}. */ + private readonly configController: ConfigController; + /** + * Resolved configuration, as a store — the shape every configurable class exposes + * (`configState` / `config` / `updateConfig`). + */ + get configState(): StateStore { + return this.configController.state; + } + constructor(ctx: MessageOperationsContext) { this.ctx = ctx; this.policy = new MessageOperationStatePolicy({ ingest: ctx.ingest, get: ctx.get }); + this.configController = new ConfigController({ + defaults: DEFAULT_MESSAGE_OPERATIONS_CONFIG, + }); + } + + /** The current resolved configuration. `Readonly` — change it through {@link updateConfig}. */ + get config(): Readonly { + return this.configState.getLatestValue(); + } + + /** Merges a partial configuration into the resolved config and notifies subscribers. */ + updateConfig(config: Partial) { + this.configController.patch(config); + } + + /** + * Rebuilds the resolved configuration from package defaults plus the declarative slice. + * + * The derivation entry point every configurable entity exposes, so the owner routes a slice here and + * knows nothing about MessageOperations's defaults or merge semantics. This logic used to live in the owner, + * which is how `reset()` became a no-op for the client key (F4) and how a registered + * `notifications.sortComparator` became unremovable (G8) — an owner writing another object's + * derivation gets that object's rules wrong sooner or later. + * + * Routed through {@link updateConfig} rather than replacing the store, which is exact here because + * every field of `MessageOperationsConfig` is required and present in the defaults, so a patch naming all of + * them amounts to a replacement. `NotificationManager` cannot do this — its `sortComparator` is + * optional with no default, so a patch can never remove one — which is why it replaces outright. + */ + initializeConfig(config?: Partial) { + this.configController.initialize(config); } private normalizeMessage(message: MessageRequest): MessageRequest { @@ -38,7 +92,7 @@ export class MessageOperations { const now = Date.now(); for (const [messageId, entry] of this.failedSendCache) { - if (now - entry.cachedAt > FAILED_SEND_CACHE_TTL_MS) { + if (now - entry.cachedAt > this.config.failedSendCacheTtlMs) { this.clearCachedFailedSend(messageId); } } @@ -53,7 +107,7 @@ export class MessageOperations { if ( !this.failedSendCache.has(params.messageId) && - this.failedSendCache.size >= FAILED_SEND_CACHE_MAX_SIZE + this.failedSendCache.size >= this.config.failedSendCacheMaxSize ) { const oldestMessageId = this.failedSendCache.keys().next().value; if (oldestMessageId) { @@ -72,7 +126,7 @@ export class MessageOperations { const cached = this.failedSendCache.get(messageId); if (!cached) return; - if (Date.now() - cached.cachedAt > FAILED_SEND_CACHE_TTL_MS) { + if (Date.now() - cached.cachedAt > this.config.failedSendCacheTtlMs) { this.clearCachedFailedSend(messageId); return; } diff --git a/src/notifications/NotificationManager.ts b/src/notifications/NotificationManager.ts index b6152f2735..47913706f1 100644 --- a/src/notifications/NotificationManager.ts +++ b/src/notifications/NotificationManager.ts @@ -1,4 +1,5 @@ import { StateStore } from '../store'; +import { isEqual } from '../utils/mergeWith/mergeWithCore'; import { generateUUIDv4 } from '../utils'; import type { AddNotificationPayload, @@ -7,16 +8,70 @@ import type { NotificationState, } from './types'; import { mergeWith } from '../utils/mergeWith'; +import { ConfigController } from '../configuration/ConfigController'; +import type { DeepPartial } from '../types.utility'; import { DEFAULT_NOTIFICATION_MANAGER_CONFIG } from './configuration'; export class NotificationManager { store: StateStore; private timeouts: Map = new Map(); - config: NotificationManagerConfig; + + /** The shared configuration machinery — see {@link ConfigController}. */ + private readonly configController: ConfigController; + /** + * Resolved configuration, as a store so consumers can react to it — the same shape every configurable + * class exposes (`configState` for the store, {@link config} for the current value). + */ + get configState(): StateStore { + return this.configController.state; + } constructor(config: Partial = {}) { this.store = new StateStore({ notifications: [] }); - this.config = mergeWith(DEFAULT_NOTIFICATION_MANAGER_CONFIG, config); + this.configController = new ConfigController({ + defaults: DEFAULT_NOTIFICATION_MANAGER_CONFIG, + constructorOptions: config, + // `durations` is a nested group, so naming one severity must keep the other three. + mergeSlice: 'deep', + }); + } + + /** + * The current resolved configuration. `Readonly` because the value is the store's live object — + * assigning to a field of it would change state without notifying anyone. Use {@link updateConfig}. + */ + get config(): Readonly { + return this.configState.getLatestValue(); + } + + /** Deep-merges a partial configuration into the resolved config and notifies subscribers. */ + updateConfig(config: Partial) { + // Deep-merged rather than patched, so the guard compares the merged *result*: `durations` is + // nested, and a patch naming one severity must not read as a change to the other three. + this.configState.next((current) => { + const next = mergeWith({ ...current }, config as object); + return isEqual(current, next) ? current : next; + }); + } + + /** + * Rebuilds the resolved configuration from package defaults plus the declarative slice, **replacing** + * what is there rather than merging into it. Called by the client's derivation, which shares one path + * with `client.config.reset()`. + * + * The distinction is not cosmetic here, and this manager is the only one that needs it. + * {@link updateConfig} deep-merges, and `sortComparator` is optional — so unlike every other field of + * every other manager config, it has no counterpart in {@link DEFAULT_NOTIFICATION_MANAGER_CONFIG} for + * a derivation to overwrite it with. Registering one through `client.config` therefore made it + * permanent: `reset()` re-derived, the merge kept it, and nothing could ever remove it. A merge cannot + * express a removal; this is the same rule `Channel.initializeConfig` follows for `requestHandlers`. + * + * The defaults are copied rather than spread, because a shallow spread would leave `durations` + * pointing at the module-level object and put it in the store, where a nested write would change the + * default for every client in the process. + */ + initializeConfig(config: DeepPartial = {}) { + this.configController.initialize(config as Partial); } get notifications() { diff --git a/src/notifications/configuration.ts b/src/notifications/configuration.ts index 63699fd696..fdf4c748e9 100644 --- a/src/notifications/configuration.ts +++ b/src/notifications/configuration.ts @@ -1,12 +1,14 @@ import type { NotificationManagerConfig } from './types'; +import { deepFreezeConfig } from '../configuration/utils/deepFreezeConfig'; const DURATION_MS = 3000 as const; -export const DEFAULT_NOTIFICATION_MANAGER_CONFIG: NotificationManagerConfig = { - durations: { - error: DURATION_MS, - info: DURATION_MS, - success: DURATION_MS, - warning: DURATION_MS, - }, -}; +export const DEFAULT_NOTIFICATION_MANAGER_CONFIG: NotificationManagerConfig = + deepFreezeConfig({ + durations: { + error: DURATION_MS, + info: DURATION_MS, + success: DURATION_MS, + warning: DURATION_MS, + }, + }); diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index a4c2ab9849..0fb2b44d13 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -1,4 +1,5 @@ import type { ItemLocation } from '../sortCompiler'; +import { deepFreezeConfig } from '../../configuration/utils/deepFreezeConfig'; import { binarySearch } from '../sortCompiler'; import { itemMatchesFilter } from '../filterCompiler'; import { isPatch, StateStore, type ValueOrPatch } from '../../store'; @@ -10,6 +11,7 @@ import { ComparisonResult } from '../types.normalization'; import type { ItemIndexApi } from '../ItemIndex'; import { StoreBackedItemIndex } from '../../entityStore/StoreBackedItemIndex'; import { isEqual } from '../../utils/mergeWith/mergeWithCore'; +import { ConfigController } from '../../configuration/ConfigController'; import { DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES } from '../../constants'; const noOrderChange = () => 0; @@ -319,7 +321,35 @@ export interface PaginatorPlugin { */ // plugins?: PaginatorPlugin[]; +/** + * The value-level subset of paginator configuration that can be supplied declaratively through + * `client.config`. Structural inputs (`itemIndex`, `createItemIndex`) and subclass behaviour overrides + * (`doRequest`, `itemOrderComparator`, `deriveCursor`) are deliberately absent — see + * {@link BasePaginator.initializeConfig}. + * + * Declared standalone rather than derived from {@link PaginatorOptions} to avoid a circular type. + */ +export type DeclarativePaginatorConfig = { + debounceMs?: number; + hasPaginationQueryShapeChanged?: PaginationQueryShapeChangeIdentifier; + initialCursor?: PaginatorCursor; + initialOffset?: number; + lockItemOrder?: boolean; + pageSize?: number; + retryCount?: number; + stateThrottleMs?: number; + throwErrors?: boolean; +}; + export type PaginatorOptions = { + /** + * Declarative configuration for this paginator, supplied by whoever constructs it from + * `client.config`. Kept separate from the other options so {@link BasePaginator.initializeConfig} + * can re-derive with a *fresh* slice — a reset then drops declarative values while preserving + * constructor-injected ones. Excluded from {@link BasePaginatorConfig}: it is an input, not part of + * the resolved configuration. + */ + declarativeConfig?: DeclarativePaginatorConfig; /** The number of milliseconds to debounce the search query. The default interval is 300ms. */ debounceMs?: number; /** @@ -388,29 +418,42 @@ type OptionalPaginatorConfigFields = | 'doRequest' | 'initialCursor' | 'initialOffset' - | 'itemIndex' - | 'createItemIndex' | 'itemOrderComparator' | 'throwErrors'; -export type BasePaginatorConfig = Pick< +/** + * Construction-only inputs that are not part of the resolved configuration. + * + * `itemIndex` and `createItemIndex` are here rather than in {@link BasePaginatorConfig} because the + * constructor destructures them out before building the config and resolves them once into + * {@link BasePaginator._itemIndex}. They were typed as config members and never written, so + * `paginator.config.itemIndex` compiled and returned `undefined` for every paginator ever built. + */ +type ResolvedPaginatorOptions = Omit< PaginatorOptions, + 'createItemIndex' | 'declarativeConfig' | 'itemIndex' +>; + +export type BasePaginatorConfig = Pick< + ResolvedPaginatorOptions, OptionalPaginatorConfigFields > & - Required, OptionalPaginatorConfigFields>>; + Required, OptionalPaginatorConfigFields>>; const baseHasPaginationQueryShapeChanged: PaginationQueryShapeChangeIdentifier< unknown > = (prevQueryShape, nextQueryShape) => !isEqual(prevQueryShape, nextQueryShape); -export const DEFAULT_PAGINATION_OPTIONS: BasePaginatorConfig = { - debounceMs: 300, - lockItemOrder: false, - pageSize: 10, - hasPaginationQueryShapeChanged: baseHasPaginationQueryShapeChanged, - retryCount: 0, - throwErrors: false, -} as const; +export const DEFAULT_PAGINATION_OPTIONS: BasePaginatorConfig = deepFreezeConfig( + { + debounceMs: 300, + lockItemOrder: false, + pageSize: 10, + hasPaginationQueryShapeChanged: baseHasPaginationQueryShapeChanged, + retryCount: 0, + throwErrors: false, + } as const, +); export abstract class BasePaginator { state: StateStore>; @@ -423,7 +466,34 @@ export abstract class BasePaginator { * active window + pagination status. */ intervalViews: StateStore>; - config: BasePaginatorConfig; + + /** The shared configuration machinery — see {@link ConfigController}. */ + private readonly configController: ConfigController>; + /** + * The paginator's resolved configuration, as a store so consumers can react to it. + * + * Every configurable class exposes its resolved configuration this way — `configState` for the store, + * {@link config} for the current value. Before this, paginators held a plain object that changed + * silently, so anything displaying a paginator's settings had to poll to notice a + * `client.config.set()` or `reset()`. + * + * This is *resolved* configuration, distinct from `client.config`, which holds the configuration you + * registered. The registered tree is an input to {@link initializeConfig}; this is its output. + */ + get configState(): StateStore> { + return this.configController.state; + } + + /** + * Options this paginator was constructed with, kept so {@link initializeConfig} can rebuild the + * config from its real inputs instead of restoring a snapshot of the result. Excludes the + * declarative slice, which is supplied fresh on every call — that is what lets a reset drop + * declarative configuration while keeping constructor-injected options. + */ + private readonly explicitOptions: Omit< + PaginatorOptions, + 'createItemIndex' | 'declarativeConfig' | 'itemIndex' + >; /** * Throttle for the active-window `state.items` publish (message list). Created only when @@ -515,41 +585,56 @@ export abstract class BasePaginator { return 'asc'; } - protected constructor({ - initialCursor, - initialOffset, - itemIndex, - createItemIndex, - ...options - }: PaginatorOptions = {}) { - this.config = { - ...DEFAULT_PAGINATION_OPTIONS, + /** + * @param options - what the **integrator** passed. Stage 3, so it outranks the declarative tree. + * @param builtInDefaults - what the SDK supplies on this instance's behalf — a subclass's defaults, or + * an owner's for the object it builds. Stage 1, so a `client.config.set()` overrides it. Keeping the + * two apart is what lets the documented layer order apply here at all: they arrive through the same + * object otherwise, and a paginator built with no configuration already carries four of them. + */ + protected constructor( + { + declarativeConfig, initialCursor, initialOffset, - ...options, - }; + itemIndex, + createItemIndex, + ...options + }: PaginatorOptions = {}, + builtInDefaults: Partial> = {}, + ) { + this.explicitOptions = { initialCursor, initialOffset, ...options }; + this.configController = new ConfigController>({ + defaults: DEFAULT_PAGINATION_OPTIONS as BasePaginatorConfig, + builtInDefaults, + constructorOptions: this.explicitOptions as Partial>, + initialSlice: declarativeConfig as Partial> | undefined, + getBehaviourOverrides: () => this.getBehaviourOverrides(), + // Both of these are read once into a closure, so storing a new value achieves nothing on its own. + // Pairing the write with the rebuild here rather than in `initializeConfig` is what finally makes + // `updateConfig({ debounceMs })` work: it used to store 900 and leave the debounce running at 300, + // so `config.debounceMs` reported a value the paginator was not using. + onChanged: (next, previous) => { + if (next.debounceMs !== previous.debounceMs) { + this.setDebounceOptions({ debounceMs: next.debounceMs }); + } + if (next.stateThrottleMs !== previous.stateThrottleMs) { + this.rebuildStatePublishThrottles(next.stateThrottleMs); + } + }, + }); const { debounceMs } = this.config; this.state = new StateStore>({ ...this.initialState, - cursor: initialCursor, - offset: initialOffset ?? 0, + // Seeded from the *resolved* config, not the raw constructor argument. These two are the reason + // `initialCursor`/`initialOffset` are construction-only: they prime state, not just configuration. + // Reading the argument directly missed a subclass's own default once those moved to stage 1 — and + // it also meant a declaratively registered cursor configured the paginator without seeding it. + cursor: this.config.initialCursor, + offset: this.config.initialOffset ?? 0, }); - if (this.config.stateThrottleMs) { - // Coalesce the paginator's own live `state.items` publishes (see `stateThrottleMs` doc). The - // trailing edge re-projects the active window fresh, so a burst emits ~once per interval. - this._windowPublishThrottle = throttle( - () => this.flushWindowPublish(), - this.config.stateThrottleMs, - { leading: true, trailing: true }, - ); - // Interval view publishes ride their own throttle so they coalesce like `state.items` but land - // on an independent trailing edge (see {@link _viewPublishThrottle}). - this._viewPublishThrottle = throttle( - () => this.flushIntervalViewPublish(), - this.config.stateThrottleMs, - { leading: true, trailing: true }, - ); - } + // Direct, not through the setter: `onChanged` fires on *changes*, and this is the initial build. + this.rebuildStatePublishThrottles(this.config.stateThrottleMs); this.intervalViews = new StateStore>({ logicalHead: [], logicalTail: [], @@ -666,20 +751,40 @@ export abstract class BasePaginator { return this.state.getLatestValue().offset; } + /** + * The current resolved configuration. + * + * `Readonly` on purpose: the value is the store's live object, so assigning to a field of it would + * mutate state without notifying anyone. That used to be the only way to change these values, so the + * type is what turns those call sites into compile errors rather than silent non-reactive writes — + * use {@link updateConfig}. + */ + get config(): Readonly> { + return this.configState.getLatestValue(); + } + + /** + * Merges a partial configuration into the resolved config and notifies subscribers, unless every + * field in the patch already holds an equal value. + */ + updateConfig(config: Partial>) { + this.configController.patch(config); + } + get pageSize() { return this.config.pageSize; } set pageSize(size: number) { - this.config.pageSize = size; + this.updateConfig({ pageSize: size }); } set initialCursor(cursor: PaginatorCursor) { - this.config.initialCursor = cursor; + this.updateConfig({ initialCursor: cursor }); } set initialOffset(offset: number) { - this.config.initialOffset = offset; + this.updateConfig({ initialOffset: offset }); } /** Single point of truth: always use the effective comparator */ @@ -2465,6 +2570,97 @@ export abstract class BasePaginator { this._executeQueryDebounced = debounce(this.executeQuery.bind(this), debounceMs); }; + /** + * Rebuilds the state-publish throttles for a new interval, or drops them when the interval is unset. + * + * This exists because `stateThrottleMs` is read *once*: the throttles capture the interval in their + * closures, so assigning `config.stateThrottleMs` afterwards does nothing at all — an unthrottled + * paginator never gains a throttle, and a throttled one keeps its original interval. Anything + * changing the value at runtime (declarative configuration registered after construction, or + * `client.config.reset()`) has to come through here. + * + * Pending publishes are flushed first, so a swap cannot swallow a trailing-edge emit that was + * already scheduled. + */ + setStateThrottleOptions = ({ stateThrottleMs }: { stateThrottleMs?: number }) => { + // Released surface. It no longer has to pair the write with the rebuild — the controller's + // `onChanged` does that for whichever route the value arrives by, including a declarative change. + this.updateConfig({ stateThrottleMs } as Partial>); + }; + + /** Rebuilds the state-publish throttles for a new interval, or drops them when it is unset. */ + private rebuildStatePublishThrottles(stateThrottleMs?: number) { + this.flushPendingPublishes(); + + this._windowPublishThrottle = undefined; + this._viewPublishThrottle = undefined; + + if (!stateThrottleMs) return; + + // Coalesce the paginator's own live `state.items` publishes (see `stateThrottleMs` doc). The + // trailing edge re-projects the active window fresh, so a burst emits ~once per interval. + this._windowPublishThrottle = throttle( + () => this.flushWindowPublish(), + stateThrottleMs, + { + leading: true, + trailing: true, + }, + ); + // Interval view publishes ride their own throttle so they coalesce like `state.items` but land on + // an independent trailing edge (see {@link _viewPublishThrottle}). + this._viewPublishThrottle = throttle( + () => this.flushIntervalViewPublish(), + stateThrottleMs, + { leading: true, trailing: true }, + ); + } + + /** + * Re-derives this paginator's configuration from its real inputs: package defaults, the options it + * was constructed with, and the declarative slice passed in. + * + * Called by the constructor and by `client.config.reset()` (through the owning `Channel` / `Thread`), + * so the two share one code path and cannot drift. Both read-once fields are routed through their + * rebuild setters, because assigning them would be silently discarded. + * + * Structural wiring is untouched here because it never reaches `config` in the first place: + * `itemIndex` and `createItemIndex` are destructured out of the constructor's options and resolved + * once into {@link _itemIndex}, so there is nothing in the derived config for a re-derivation to drop. + * + * Subclass behaviour overrides are folded in through {@link getBehaviourOverrides} rather than written + * afterwards, so one re-derivation is one `configState` publish carrying a complete config. It used to + * be a second write from an `initializeConfig` override, and the claim that both writes carried a + * complete config was wrong: the base derivation knows nothing of the overlay, so it published the + * config with `doRequest`, `deriveCursor` and `itemOrderComparator` **stripped**, and the subclass then + * put them back. A `PinnedMessagePaginator` re-derivation emitted three notifications, of which the + * first had no request function at all — a subscriber that paginated during that synchronous window + * would have found none. + * + * Applied last, so the overlay still wins over constructor options exactly as the second write did. + */ + initializeConfig(declarativeConfig?: DeclarativePaginatorConfig): void { + this.configController.initialize( + declarativeConfig as Partial> | undefined, + ); + } + + /** + * Behaviour a subclass installs that no set of options can express — a comparator or `deriveCursor` + * closed over `this`, a `doRequest` bound to a particular endpoint. Empty on the base paginator. + * + * Folded into the single derivation in {@link initializeConfig}, which is why an override must return + * **stable references**: rebuilding the closures on each call makes every derived config differ from + * the last, defeating the guard above and republishing on every unrelated re-derivation. Memoize them + * — their inputs are fixed at construction. + * + * Called only after construction. The base constructor builds `configState` directly rather than + * through `initializeConfig`, so an override is never invoked before its own fields are initialized. + */ + protected getBehaviourOverrides(): Partial> { + return {}; + } + protected shouldResetStateBeforeQuery( prevQueryShape: unknown | undefined, nextQueryShape: unknown | undefined, diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 76230b5832..30b40239f7 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -1,5 +1,6 @@ import type { AnyInterval, + BasePaginatorConfig, CursorDerivator, CursorDeriveResult, Interval, @@ -263,40 +264,50 @@ export class MessageIntervalPaginator extends BasePaginator< return 'desc'; } - constructor({ - channel, - id, - itemIndex, - parentMessageId, - requestSort, - sort, - itemOrder, - paginatorOptions, - }: MessagePaginatorOptions) { + constructor( + { + channel, + id, + itemIndex, + parentMessageId, + requestSort, + sort, + itemOrder, + paginatorOptions, + }: MessagePaginatorOptions, + builtInDefaults: Partial> = {}, + ) { const resolvedRequestSort = requestSort ?? sort ?? DEFAULT_BACKEND_SORT; const resolvedItemOrder = itemOrder ?? resolvedRequestSort; - super({ - hasPaginationQueryShapeChanged, - initialCursor: ZERO_PAGE_CURSOR, - itemIndex, - ...paginatorOptions, - // Back every message-interval paginator (channel main list, thread reply list, pinned list) - // with the client-global message store, so a message held in more than one of them has a - // single canonical copy and updates (reactions/edits) fan out to all holders — no copy-to-copy - // sync. When the store is unavailable (e.g. a detached paginator in a test) the index falls - // back to a private store and behaves exactly like a plain per-instance index. Overridable per - // instance via `paginatorOptions.createItemIndex` or an explicit `itemIndex`. - createItemIndex: - paginatorOptions?.createItemIndex ?? - ((owner) => - new StoreBackedItemIndex({ - store: channel.getClient?.().messageStore, - owner: owner as MessageIntervalPaginator, - getEntityId: owner.getItemId.bind(owner), - })), - pageSize: paginatorOptions?.pageSize ?? DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE, - }); - this.config.deriveCursor = makeDeriveCursor(this); + super( + { + itemIndex, + ...paginatorOptions, + // Back every message-interval paginator (channel main list, thread reply list, pinned list) + // with the client-global message store, so a message held in more than one of them has a + // single canonical copy and updates (reactions/edits) fan out to all holders — no copy-to-copy + // sync. When the store is unavailable (e.g. a detached paginator in a test) the index falls + // back to a private store and behaves exactly like a plain per-instance index. Overridable per + // instance via `paginatorOptions.createItemIndex` or an explicit `itemIndex`. + createItemIndex: + paginatorOptions?.createItemIndex ?? + ((owner) => + new StoreBackedItemIndex({ + store: channel.getClient?.().messageStore, + owner: owner as MessageIntervalPaginator, + getEntityId: owner.getItemId.bind(owner), + })), + }, + // SDK-supplied, so a declarative registration overrides them. `hasPaginationQueryShapeChanged` + // and the zero cursor were previously spread into the caller's options, where they outranked + // every `client.config.set({ messagePaginator: … })`. + { + hasPaginationQueryShapeChanged, + initialCursor: ZERO_PAGE_CURSOR, + pageSize: DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE, + ...builtInDefaults, + }, + ); this.channel = channel; this.parentMessageId = parentMessageId; this._id = id ?? `message-paginator-${generateUUIDv4()}`; @@ -314,18 +325,65 @@ export class MessageIntervalPaginator extends BasePaginator< return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; }, }); - this.config.itemOrderComparator = makeComparator({ - sort: this._itemOrder, - resolvePathValue: resolveDotPathValue, - tiebreaker: (l, r) => { - const leftId = this.getItemId(l); - const rightId = this.getItemId(r); - return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; - }, - }); + this.installIntervalBehaviour(); this.setFilterResolvers([dataFieldFilterResolver]); } + /** + * Memoized so every derivation sees the *same* two functions. `initializeConfig` folds this into the + * config it publishes and skips the publish when nothing moved — which only works if these references + * are stable. + * + * Safe to build once: both close over `this` and read `_itemOrder`, which is assigned in the + * constructor and has no setter. + */ + private intervalBehaviour?: Partial< + BasePaginatorConfig + >; + + private buildIntervalBehaviour(): Partial< + BasePaginatorConfig + > { + if (!this.intervalBehaviour) { + this.intervalBehaviour = { + deriveCursor: makeDeriveCursor(this), + itemOrderComparator: makeComparator({ + sort: this._itemOrder, + resolvePathValue: resolveDotPathValue, + tiebreaker: (l, r) => { + const leftId = this.getItemId(l); + const rightId = this.getItemId(r); + return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; + }, + }), + }; + } + return this.intervalBehaviour; + } + + /** + * Cursor derivation and in-memory item ordering, which this class installs on `config` rather than + * passing as constructor options. + * + * Contributed to the base derivation rather than written after it — see + * {@link BasePaginator.getBehaviourOverrides}. That is what keeps one re-derivation to one publish, + * and stops the intermediate publish that had these two stripped. + */ + protected override getBehaviourOverrides(): Partial< + BasePaginatorConfig + > { + return this.buildIntervalBehaviour(); + } + + /** + * The constructor's route to the same overlay. Deliberately calls the private builder rather than the + * overridable hook: this runs inside the constructor, and a subclass override would execute before its + * own fields were initialized. + */ + protected installIntervalBehaviour(): void { + this.updateConfig(this.buildIntervalBehaviour()); + } + get id() { return this._id; } diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index b9622a4c3d..fe044f7b71 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -1,4 +1,5 @@ import type { + BasePaginatorConfig, ExecuteQueryReturnValue, Interval, PostQueryReconcileParams, @@ -125,23 +126,25 @@ export class MessagePaginator extends MessageIntervalPaginator { */ readonly aggregateState: StateStore; - constructor({ - unreadReferencePolicy = 'snapshot', - ...options - }: MessagePaginatorOptions) { - super({ - ...options, - paginatorOptions: { + constructor( + { unreadReferencePolicy = 'snapshot', ...options }: MessagePaginatorOptions, + builtInDefaults: Partial> = {}, + ) { + super( + // NB: the store-backed item index is provided by MessageIntervalPaginator (the common + // ancestor), so both the main list and the pinned list share the client-global message store. + options, + { // Throttle message-list `state` publishes to at most once per 500ms (leading + trailing), so a // burst of events coalesces into ~2 renders/sec instead of one per event. Optimistic // (local-user) writes bypass the throttle via EntityStore.flushSubscribers → flushState. - // Overridable per-instance via `paginatorOptions.stateThrottleMs`. + // A default rather than a construction argument, so `paginatorOptions.stateThrottleMs` and a + // declarative registration both override it — and so a re-derivation restores it without the + // subclass having to re-inject it, which is what the old `initializeConfig` override existed for. stateThrottleMs: 500, - ...options.paginatorOptions, + ...builtInDefaults, }, - // NB: the store-backed item index is provided by MessageIntervalPaginator (the common - // ancestor), so both the main list and the pinned list share the client-global message store. - }); + ); this.unreadReferencePolicy = unreadReferencePolicy; this.unreadStateSnapshot = new StateStore({ lastReadAt: null, @@ -199,7 +202,7 @@ export class MessagePaginator extends MessageIntervalPaginator { const isThreadOnlyReply = !!message.parent_id && !message.show_in_channel; if (isThreadOnlyReply) return false; const skipSystemMessage = - !!this.channel.getConfig?.()?.skip_last_msg_update_for_system_msgs && + !!this.channel.serverConfig?.skip_last_msg_update_for_system_msgs && message.type === 'system'; return !skipSystemMessage; } diff --git a/src/pagination/paginators/PinnedMessagePaginator.ts b/src/pagination/paginators/PinnedMessagePaginator.ts index e113b6ea7b..9a6b0a4cef 100644 --- a/src/pagination/paginators/PinnedMessagePaginator.ts +++ b/src/pagination/paginators/PinnedMessagePaginator.ts @@ -1,4 +1,8 @@ -import type { PaginatorCursor, PaginatorOptions } from './BasePaginator'; +import type { + BasePaginatorConfig, + PaginatorCursor, + PaginatorOptions, +} from './BasePaginator'; import { MessageIntervalPaginator, type MessageQueryShape, @@ -26,6 +30,8 @@ export type PinnedMessagePaginatorOptions = { paginatorOptions?: PaginatorOptions; }; +const PINNED_AT_SORT: SortParamRequest[] = [{ field: 'pinned_at', direction: 1 }]; + /** * Pinned-message list paginator. * @@ -62,39 +68,75 @@ export class PinnedMessagePaginator extends MessageIntervalPaginator { paginatorOptions, }); + this.installPinnedMessageBehaviour(); + } + + /** + * Memoized for the reason {@link MessageIntervalPaginator}'s overlay is: the base derivation folds + * this in and skips the publish when nothing moved, which needs stable references. Both close over + * `this` and over a fixed `pinned_at` sort, so there is nothing to rebuild. + */ + private pinnedBehaviour?: Partial>; + + private buildPinnedBehaviour(): Partial< + BasePaginatorConfig + > { + if (!this.pinnedBehaviour) { + this.pinnedBehaviour = { + itemOrderComparator: makeComparator({ + sort: PINNED_AT_SORT, + resolvePathValue: resolveDotPathValue, + tiebreaker: this.pinnedTiebreaker, + }), + + // Fetch from the pinned-messages endpoint. The base `query` feeds the resolved query shape + // (including `id_around` jumps) here as `options`; we return both cursors and let the base gate + // them by direction. + doRequest: async ( + options: MessageQueryShape, + ): Promise<{ cursor?: PaginatorCursor; items: LocalMessage[] }> => { + const { messages } = await this.channel.getPinnedMessages({ + ...(options as PinnedMessagePaginationOptions), + sort: PINNED_AT_SORT, + }); + const items = messages.map(formatMessage); + return { cursor: this.getCursorFromQueryResults({ items }), items }; + }, + }; + } + return this.pinnedBehaviour; + } + + private pinnedTiebreaker = (l: LocalMessage, r: LocalMessage) => { + const leftId = this.getItemId(l); + const rightId = this.getItemId(r); + return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; + }; + + /** + * The ordering and request behaviour that makes this a *pinned*-message paginator, contributed to the + * base derivation so a re-derivation cannot drop it and does not need a second write to restore it. + * Spread over the interval overlay, so this class's `pinned_at` comparator wins. + */ + protected override getBehaviourOverrides(): Partial< + BasePaginatorConfig + > { + return { ...super.getBehaviourOverrides(), ...this.buildPinnedBehaviour() }; + } + + private installPinnedMessageBehaviour(): void { // Order by pinned_at (ascending), overriding the base's created_at comparators. Ascending keeps // the head edge (most-recently-pinned) at the end of an interval, matching the base's interval // direction getters (which are shared with created_at-asc semantics). - const tiebreaker = (l: LocalMessage, r: LocalMessage) => { - const leftId = this.getItemId(l); - const rightId = this.getItemId(r); - return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; - }; - const pinnedAtSort: SortParamRequest[] = [{ field: 'pinned_at', direction: 1 }]; + // + // A plain field rather than config, so a re-derivation never touches it — which is why only the + // constructor sets it. this.sortComparator = makeComparator({ - sort: pinnedAtSort, + sort: PINNED_AT_SORT, resolvePathValue: resolveDotPathValue, - tiebreaker, + tiebreaker: this.pinnedTiebreaker, }); - this.config.itemOrderComparator = makeComparator({ - sort: pinnedAtSort, - resolvePathValue: resolveDotPathValue, - tiebreaker, - }); - - // Fetch from the pinned-messages endpoint. The base `query` feeds the resolved query shape - // (including `id_around` jumps) here as `options`; we return both cursors and let the base gate - // them by direction. - this.config.doRequest = async ( - options: MessageQueryShape, - ): Promise<{ cursor?: PaginatorCursor; items: LocalMessage[] }> => { - const { messages } = await this.channel.getPinnedMessages({ - ...(options as PinnedMessagePaginationOptions), - sort: pinnedAtSort, - }); - const items = messages.map(formatMessage); - return { cursor: this.getCursorFromQueryResults({ items }), items }; - }; + this.updateConfig(this.buildPinnedBehaviour()); } buildMatchFilters = (): PinnedMessagePaginatorFilter => ({ diff --git a/src/pagination/utility.normalization.ts b/src/pagination/utility.normalization.ts index fda6dace8c..85bf290618 100644 --- a/src/pagination/utility.normalization.ts +++ b/src/pagination/utility.normalization.ts @@ -84,11 +84,24 @@ export function tokenize(s: string): string[] { return normalizeString(s).split(/\s+/).filter(Boolean); } -// dot-path accessor -export function resolveDotPathValue(obj: any, path: string): unknown[] { +/** + * Reads `a.b.c` off an item, for the filter and sort compilers. + * + * Descends through **anything indexable** — plain objects, arrays (`items.0.id`, `items.length`) and class + * instances (a `Reminder`, a `Poll`) — because a filter path legitimately reaches into all three. That is why + * this is not `getPath` from `src/utils/objectPath.ts`, which deliberately walks plain records only; the two + * are documented there as non-interchangeable. + * + * Stops at `null` / `undefined`, the only values that cannot be indexed. It used to stop at any *falsy* + * value, which made the result depend on a string's contents rather than on its shape: `name.length` + * resolved to `2` for `'ab'` and to `undefined` for `''`. A falsy value at the end of a path was never + * affected — the guard only ever ran against an intermediate — so `{ count: 0 }` on `'count'` has always + * returned `0`, and sorting and filtering on scalar fields were never wrong. + */ +export function resolveDotPathValue(obj: any, path: string): unknown { return path .split('.') - .reduce((reduced, key) => (!reduced ? undefined : reduced[key]), obj); + .reduce((reduced, key) => (reduced == null ? undefined : reduced[key]), obj); } export function isIterableButNotString(v: unknown): v is Iterable { diff --git a/src/reminders/ReminderManager.ts b/src/reminders/ReminderManager.ts index 90f6bdb34c..2bce1d38ad 100644 --- a/src/reminders/ReminderManager.ts +++ b/src/reminders/ReminderManager.ts @@ -1,6 +1,8 @@ import { Reminder } from './Reminder'; +import { deepFreezeConfig } from '../configuration/utils/deepFreezeConfig'; import { DEFAULT_STOP_REFRESH_BOUNDARY_MS } from './ReminderTimer'; import { StateStore } from '../store'; +import { ConfigController } from '../configuration/ConfigController'; import { ReminderPaginator } from '../pagination'; import { WithSubscriptions } from '../utils/WithSubscriptions'; import type { ReminderResponseBaseOrResponse } from './Reminder'; @@ -17,7 +19,7 @@ const oneMinute = 60 * 1000; const oneHour = 60 * oneMinute; const oneDay = 24 * oneHour; -export const DEFAULT_REMINDER_MANAGER_CONFIG: ReminderManagerConfig = { +export const DEFAULT_REMINDER_MANAGER_CONFIG: ReminderManagerConfig = deepFreezeConfig({ scheduledOffsetsMs: [ 2 * oneMinute, 30 * oneMinute, @@ -27,7 +29,7 @@ export const DEFAULT_REMINDER_MANAGER_CONFIG: ReminderManagerConfig = { oneDay, ], stopTimerRefreshBoundaryMs: DEFAULT_STOP_REFRESH_BOUNDARY_MS, -}; +}); const isReminderExistsError = (error: Error) => error.message.match('already has reminder created for this message_id'); @@ -57,36 +59,69 @@ export type ReminderManagerOptions = { export class ReminderManager extends WithSubscriptions { private client: StreamChat; - configState: StateStore; + /** The shared configuration machinery — see {@link ConfigController}. */ + private readonly configController: ConfigController; + + /** + * Resolved configuration, as a store — the shape every configurable class exposes + * (`configState` / `config` / `updateConfig`). Delegates rather than holding a copy, so the field and + * the controller's store cannot drift. + */ + get configState(): StateStore { + return this.configController.state; + } state: StateStore; paginator: ReminderPaginator; constructor({ client, config }: ReminderManagerOptions) { super(); this.client = client; - this.configState = new StateStore({ - scheduledOffsetsMs: - config?.scheduledOffsetsMs ?? DEFAULT_REMINDER_MANAGER_CONFIG.scheduledOffsetsMs, - stopTimerRefreshBoundaryMs: - config?.stopTimerRefreshBoundaryMs ?? - DEFAULT_REMINDER_MANAGER_CONFIG.stopTimerRefreshBoundaryMs, + this.configController = new ConfigController({ + defaults: DEFAULT_REMINDER_MANAGER_CONFIG, + constructorOptions: config, + // Live timers hold the boundary, so a change has to be pushed to them. Previously this sat inside + // `updateConfig` and so ran only on that route; here it covers every write. + onChanged: (next, previous) => { + if (next.stopTimerRefreshBoundaryMs === previous.stopTimerRefreshBoundaryMs) + return; + this.reminders.forEach((reminder) => { + reminder.timer.stopRefreshBoundaryMs = next.stopTimerRefreshBoundaryMs; + }); + }, }); this.state = new StateStore({ reminders: new Map() }); this.paginator = new ReminderPaginator(client); } // Config API START // + /** + * The current resolved configuration. `Readonly` because the value is the store's live object — + * assigning to a field of it would change state without notifying anyone. Use {@link updateConfig}. + */ + get config(): Readonly { + return this.configState.getLatestValue(); + } + updateConfig(config: Partial) { - if ( - typeof config.stopTimerRefreshBoundaryMs === 'number' && - config.stopTimerRefreshBoundaryMs !== this.stopTimerRefreshBoundaryMs - ) { - this.reminders.forEach((reminder) => { - reminder.timer.stopRefreshBoundaryMs = - config?.stopTimerRefreshBoundaryMs as number; - }); - } - this.configState.partialNext(config); + this.configController.patch(config); + } + + /** + * Rebuilds the resolved configuration from package defaults plus the declarative slice. + * + * The derivation entry point every configurable entity exposes, so the owner routes a slice here and + * knows nothing about ReminderManager's defaults or merge semantics. This logic used to live in the owner, + * which is how `reset()` became a no-op for the client key (F4) and how a registered + * `notifications.sortComparator` became unremovable (G8) — an owner writing another object's + * derivation gets that object's rules wrong sooner or later. + * + * Routed through {@link updateConfig} rather than replacing the store, which is exact here because + * every field of `ReminderManagerConfig` is required and present in the defaults, so a patch naming all of + * them amounts to a replacement. `NotificationManager` cannot do this — its `sortComparator` is + * optional with no default, so a patch can never remove one — which is why it replaces outright. + */ + initializeConfig(config?: Partial) { + this.configController.initialize(config); } get stopTimerRefreshBoundaryMs() { diff --git a/src/search/SearchController.ts b/src/search/SearchController.ts index e2362b76b5..af3b4a5f13 100644 --- a/src/search/SearchController.ts +++ b/src/search/SearchController.ts @@ -1,6 +1,11 @@ import { StateStore } from '../store'; +import type { Unsubscribe } from '../store'; import type { MessageResponse } from '../types'; +import type { StreamChat } from '../client'; import type { SearchSource } from './BaseSearchSource'; +import { ConfigController } from '../configuration/ConfigController'; +import { applyInstanceConfiguration } from '../configuration/utils/applyInstanceConfiguration'; +import { deepFreezeConfig } from '../configuration/utils/deepFreezeConfig'; export type SearchControllerState = { isActive: boolean; @@ -20,10 +25,23 @@ export type SearchControllerConfig = { }; export type SearchControllerOptions = { + /** + * Required for this controller to take part in `client.config`. + * + * It is the one configurable class this package never constructs — an app or a downstream SDK does + * (`` in `stream-chat-react`) — so there is no other route by which it could find the + * configuration registry. Left out, the controller still works and `updateConfig` still applies; + * only the declarative key and its setup function go unheard. + */ + client?: StreamChat; config?: Partial; sources?: SearchSource[]; }; +export const DEFAULT_SEARCH_CONTROLLER_CONFIG: SearchControllerConfig = deepFreezeConfig({ + keepSingleActiveSource: true, +}); + export class SearchController { /** * Not intended for direct use by integrators, might be removed without notice resulting in @@ -31,17 +49,67 @@ export class SearchController { */ _internalState: StateStore; state: StateStore; - config: SearchControllerConfig; - constructor({ config, sources }: SearchControllerOptions = {}) { + /** The shared configuration machinery — see {@link ConfigController}. */ + private readonly configController: ConfigController; + /** Teardown for the configuration subscription, when this controller was given a client. */ + private unsubscribeConfiguration?: Unsubscribe; + + /** + * Resolved configuration, as a store so consumers can react to it — the same shape every configurable + * class exposes (`configState` for the store, {@link config} for the current value). + */ + get configState(): StateStore { + return this.configController.state; + } + + constructor({ client, config, sources }: SearchControllerOptions = {}) { this.state = new StateStore({ isActive: false, searchQuery: '', sources: sources ?? [], }); this._internalState = new StateStore({}); - this.config = { keepSingleActiveSource: true, ...config }; + this.configController = new ConfigController({ + defaults: DEFAULT_SEARCH_CONTROLLER_CONFIG, + constructorOptions: config, + }); + + if (!client) return; + this.unsubscribeConfiguration = applyInstanceConfiguration({ + args: { searchController: this }, + config: client.config, + key: 'searchController', + applyConfig: (slice) => this.initializeConfig(slice), + reinitializeConfig: () => + this.initializeConfig(client.config.getConfig('searchController') ?? undefined), + }); } + + /** Releases the configuration subscription, running the setup function's teardown. */ + dispose() { + this.unsubscribeConfiguration?.(); + this.unsubscribeConfiguration = undefined; + } + + /** + * The current resolved configuration. `Readonly` because the value is the store's live object — + * assigning to a field of it would change state without notifying anyone. Use {@link updateConfig}. + */ + get config(): Readonly { + return this.configState.getLatestValue(); + } + + /** Merges a partial configuration into the resolved config and notifies subscribers. */ + updateConfig(config: Partial) { + this.configController.patch(config); + } + + /** Rebuilds the resolved configuration from package defaults plus the declarative slice. */ + initializeConfig(config?: Partial) { + this.configController.initialize(config); + } + get hasNext() { return this.sources.some((source) => source.hasNext); } diff --git a/src/thread.ts b/src/thread.ts index 95e7859a03..11b4e2972a 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -34,6 +34,15 @@ import { MessageOperations } from './messageOperations'; import { WithSubscriptions } from './utils/WithSubscriptions'; import { MessagePaginator } from './pagination'; import type { MergeNewestPageOptions } from './pagination'; +import { applyInstanceConfiguration } from './configuration/utils/applyInstanceConfiguration'; +import { ConfigController } from './configuration/ConfigController'; +import { deepFreezeConfig } from './configuration/utils/deepFreezeConfig'; +import type { ThreadDeclarativeConfig } from './configuration/types'; +import { + mergeDeclarativeMessageOperationsConfig, + mergeDeclarativePaginatorConfig, + toDeclarativePaginatorConfig, +} from './configuration/utils/declarativeSlices'; import type { PipelineEvent } from './EventHandlerPipeline'; export type ThreadState = { @@ -79,14 +88,24 @@ export type CustomThreadMarkReadRequestFn = (params: { options?: MarkReadRequest; }) => Promise> | null> | void; -export type ThreadInstanceConfig = { +export type ThreadConfig = { requestHandlers?: { markReadRequest?: CustomThreadMarkReadRequestFn; }; }; +/** + * Empty because every field of `ThreadConfig` is optional — a thread's own configuration is one handler + * wide. Declared and frozen anyway, so the entity carries the same defaults layer as every other + * configurable class rather than a special case. + */ +export const DEFAULT_THREAD_CONFIG: ThreadConfig = deepFreezeConfig({}); + export class Thread extends WithSubscriptions { - public readonly configState = new StateStore({}); + /** The shared configuration machinery — see {@link ConfigController}. */ + private readonly configController = new ConfigController({ + defaults: DEFAULT_THREAD_CONFIG, + }); public readonly state: StateStore; public readonly id: string; public readonly messageComposer: MessageComposer; @@ -193,15 +212,33 @@ export class Thread extends WithSubscriptions { this.client = client; - this.messagePaginator = new MessagePaginator({ - channel: this.channel, - parentMessageId: this.id, - requestSort: DEFAULT_SORT, - itemOrder: DEFAULT_ITEM_ORDER, - paginatorOptions: { - pageSize: DEFAULT_PAGE_LIMIT, + // Read the declarative configuration before the sub-objects exist, so it can go in as constructor + // options — the reply paginator's `unreadReferencePolicy` and initial cursor are read once. + const declarativeConfig = client.config.getConfig('thread') ?? undefined; + // Thread replies are backed by a MessagePaginator too, so the general key applies here as well; + // the per-parent slice overrides it (thread replies default to a smaller page than a channel). + const messagePaginatorConfig = mergeDeclarativePaginatorConfig( + client.config.getConfig('messagePaginator') ?? undefined, + declarativeConfig?.messagePaginator, + ); + + this.messagePaginator = new MessagePaginator( + { + channel: this.channel, + parentMessageId: this.id, + requestSort: DEFAULT_SORT, + itemOrder: DEFAULT_ITEM_ORDER, + // Split as in `Channel`: the policy is a constructor argument, not paginator configuration. + unreadReferencePolicy: messagePaginatorConfig?.unreadReferencePolicy, + paginatorOptions: { + declarativeConfig: toDeclarativePaginatorConfig(messagePaginatorConfig), + }, }, - }); + // Thread replies default to a smaller page than a channel list. Supplied by the SDK, not by the + // integrator, so it sits at stage 1 — `client.config.set({ messagePaginator: { pageSize } })` + // overrides it, which would not be true of a construction argument. + { pageSize: DEFAULT_PAGE_LIMIT }, + ); // Seed the reply paginator from the thread's `latest_replies` so a thread we already hold // data for (queried via the ThreadManager or hydrated from a ThreadResponse) renders its @@ -296,6 +333,67 @@ export class Thread extends WithSubscriptions { }, }, }); + + // Share one derivation path with `config.reset()`. Idempotent — the paginator was already + // configured through its constructor above; this re-applies the mutable half the way a reset does. + this.initializeConfig(declarativeConfig); + } + + /** + * Derives this thread's configuration — and its reply paginator's — from the declarative slice. + * + * Called by the constructor and by `client.config.reset()`. The thread owns only its own + * `requestHandlers`; the paginator derives its own configuration. + */ + initializeConfig(declarativeConfig?: ThreadDeclarativeConfig): void { + // Only the thread's own slice goes in. The paginator and the operations keys are handed to those + // objects below, so putting them here too would publish them on `thread.config` as well. + // + // Replaces rather than merges: a handler dropped from the declarative tree must disappear. The + // controller skips a write that changes nothing, which matters because `alsoWatch` re-runs this for + // any of three keys and `useThreadRequestHandlers` subscribes to the store. + this.configController.initialize({ + requestHandlers: declarativeConfig?.requestHandlers, + }); + + this.messagePaginator.initializeConfig( + toDeclarativePaginatorConfig( + mergeDeclarativePaginatorConfig( + this.client.config.getConfig('messagePaginator') ?? undefined, + declarativeConfig?.messagePaginator, + ), + ), + ); + + // A thread sends messages too, so it owns a `MessageOperations` of its own and takes the same shared + // key the channel does, with its own per-parent override. + this.messageOperations.initializeConfig( + mergeDeclarativeMessageOperationsConfig( + this.client.config.getConfig('messageOperations') ?? undefined, + declarativeConfig?.messageOperations, + ), + ); + } + + /** + * Resolved configuration as a store, so consumers can react to it — the shape every configurable class + * exposes. + */ + get configState(): StateStore { + return this.configController.state; + } + + /** + * This thread's resolved configuration. `Readonly` because the value is the store's live object — + * assigning to a field of it would change state without notifying anyone. Use {@link updateConfig}. + */ + get config(): Readonly { + return this.configController.value; + } + + /** Merges a partial configuration into the resolved config and notifies subscribers. */ + updateConfig(config: Partial): void { + this.configController.patch(config); } get channel() { @@ -411,6 +509,7 @@ export class Thread extends WithSubscriptions { return; } + this.addUnsubscribeFunction(this.subscribeThreadSetupStateChange()); this.addUnsubscribeFunction(this.subscribeParentMessageFromStore()); this.addUnsubscribeFunction(this.subscribeThreadUpdated()); this.addUnsubscribeFunction(this.subscribeMarkActiveThreadRead()); @@ -424,6 +523,43 @@ export class Thread extends WithSubscriptions { this.addUnsubscribeFunction(this.subscribeUserMessagesDeleted()); }; + /** + * Subscribes this thread to the `'thread'` configuration key. Registered through + * `WithSubscriptions`, so `unregisterSubscriptions()` runs the setup function's teardown. + * + * **This is where `Thread` differs from `Channel`,** which subscribes from its constructor. Everything + * below follows from that, and applies to a thread that never calls `registerSubscriptions()`: + * + * - no *setup function* runs for it — matching how `MessageComposer` already behaves; + * - it sees the declarative slice **as it stood when the thread was constructed**, because the + * constructor applies it directly, but no *later* `client.config.set({ thread: … })` or + * `set({ messagePaginator: … })` reaches it; + * - it is absent from the registry's `liveInstances`, so `client.config.reset()` skips it, and + * `hasLiveInstances('thread')` does not count it when deciding whether to warn about a + * construction-only path registered too late. + * + * So read "declarative configuration is unaffected" as *at construction only*. A thread held by a + * `ThreadManager` that has itself registered is covered — `subscribeManageThreadSubscriptions` calls + * `registerSubscriptions()` on every thread entering its state — so the common path is fine. A thread + * constructed directly, or held by an unregistered manager, is not. + * + * The alternative — applying the setup function at construction — would break the teardown symmetry + * `WithSubscriptions` provides, which is why the asymmetry stands. + */ + private subscribeThreadSetupStateChange = () => + applyInstanceConfiguration({ + args: { thread: this }, + config: this.client.config, + key: 'thread', + applyConfig: (config) => this.initializeConfig(config), + // Read fresh: by the time reset calls this, the declarative store has been cleared. + reinitializeConfig: () => + this.initializeConfig(this.client.config.getConfig('thread') ?? undefined), + // The reply paginator also derives from the shared `messagePaginator` key — run the full cycle + // on a change there, so the setup function's overrides survive. + alsoWatch: ['messagePaginator', 'messageOperations'], + }); + private subscribeThreadUpdated = () => this.client.on('thread.updated', (event) => { if (!event.thread || event.thread.parent_message_id !== this.id) { diff --git a/src/thread_manager.ts b/src/thread_manager.ts index 7fb1a3e596..eca47299c4 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -1,5 +1,7 @@ import { chatLoggerSystem } from './logger'; +import { deepFreezeConfig } from './configuration/utils/deepFreezeConfig'; import { StateStore } from './store'; +import { ConfigController } from './configuration/ConfigController'; import { throttle } from './utils'; import type { StreamChat } from './client'; @@ -18,7 +20,19 @@ const eventCarriesOwnUser = ( ): event is EventPayload<'health.check'> | EventPayload<'connection.ok'> => Object.hasOwn(event, 'me'); -const DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION = 1000; +export type ThreadManagerConfig = { + /** + * Minimum gap between thread-list reloads triggered by connection recovery (defaults to 1000ms). + * + * Read when subscriptions are registered, since the throttle captures the interval in a closure — a + * change applies from the next `registerSubscriptions()`, not retroactively. + */ + connectionRecoveryThrottleMs: number; +}; + +export const DEFAULT_THREAD_MANAGER_CONFIG: ThreadManagerConfig = deepFreezeConfig({ + connectionRecoveryThrottleMs: 1000, +}); const MAX_QUERY_THREADS_LIMIT = 25; export const THREAD_MANAGER_INITIAL_STATE = { active: false, @@ -76,15 +90,56 @@ export class ThreadManager extends WithSubscriptions { // used for threads which are not stored in the list // private threadCache: Record = {}; + /** The shared configuration machinery — see {@link ConfigController}. */ + private readonly configController: ConfigController; + /** + * Resolved configuration, as a store — the shape every configurable class exposes + * (`configState` / `config` / `updateConfig`). + */ + get configState(): StateStore { + return this.configController.state; + } + constructor({ client }: { client: StreamChat }) { super(); + this.configController = new ConfigController({ + defaults: DEFAULT_THREAD_MANAGER_CONFIG, + }); this.client = client; this.state = new StateStore(THREAD_MANAGER_INITIAL_STATE); this.threadsByIdGetterCache = { threads: [], threadsById: {} }; } + /** The current resolved configuration. `Readonly` — change it through {@link updateConfig}. */ + public get config(): Readonly { + return this.configState.getLatestValue(); + } + + /** Merges a partial configuration into the resolved config and notifies subscribers. */ + public updateConfig(config: Partial) { + this.configController.patch(config); + } + + /** + * Rebuilds the resolved configuration from package defaults plus the declarative slice. + * + * The derivation entry point every configurable entity exposes, so the owner routes a slice here and + * knows nothing about ThreadManager's defaults or merge semantics. This logic used to live in the owner, + * which is how `reset()` became a no-op for the client key (F4) and how a registered + * `notifications.sortComparator` became unremovable (G8) — an owner writing another object's + * derivation gets that object's rules wrong sooner or later. + * + * Routed through {@link updateConfig} rather than replacing the store, which is exact here because + * every field of `ThreadManagerConfig` is required and present in the defaults, so a patch naming all of + * them amounts to a replacement. `NotificationManager` cannot do this — its `sortComparator` is + * optional with no default, so a patch can never remove one — which is why it replaces outright. + */ + public initializeConfig(config?: Partial) { + this.configController.initialize(config); + } + public get threadsById() { const { threads } = this.state.getLatestValue(); @@ -229,7 +284,7 @@ export class ThreadManager extends WithSubscriptions { if (!lastConnectionDropAt || !wasActivatedAtLeastOnce) return; this.reload({ force: true }); }, - DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION, + this.config.connectionRecoveryThrottleMs, { trailing: true }, ).throttledFn; diff --git a/src/types.ts b/src/types.ts index ee81ca0edc..f03699f463 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,8 @@ import type { AxiosRequestConfig, AxiosResponse } from 'axios'; import type { StableWSConnection } from './connection'; import type { CustomEventTypes } from './custom_types'; import type { NotificationManager } from './notifications'; +import type { InstanceConfigTree } from './configuration/types'; +import type { DeepPartial } from './types.utility'; import type { APIError, Attachment, @@ -177,6 +179,15 @@ export type StreamChatOptions = { * Notifications are used to communicate events like errors, warnings, info, etc. Other services can publish notifications or subscribe to the NotificationManager state changes. */ notifications?: NotificationManager; + /** + * Declarative configuration for instances the SDK creates on your behalf, seeded before the client's + * own managers are constructed. + * + * Equivalent to calling `client.config.set(tree)` immediately after construction, except for the + * `client` subtree — the configuration registry is created inside the constructor, so this is the only + * way to configure `reminders` / `notifications` before they are built. + */ + config?: DeepPartial; /** * When true, user will be persisted on client. Otherwise if `connectUser` call fails, then you need to * call `connectUser` again to retry. @@ -325,6 +336,11 @@ export type OGAttachment = RequireLiteral; export type PushProvider = CreateDeviceRequest['push_provider']; +/** + * Server-provided channel configuration, keyed by **cid** (`messaging:general`, …). Most of + * `ChannelConfigWithInfo` is a type-level setting, but a channel's `config_overrides` can narrow it for + * that channel alone, so the effective answer is per channel. Read it via `channel.serverConfig`. + */ export type Configs = Record; export type ConnectionOpen = EventPayload<'health.check'> | EventPayload<'connection.ok'>; diff --git a/src/utils/objectPath.ts b/src/utils/objectPath.ts new file mode 100644 index 0000000000..fafb8a3565 --- /dev/null +++ b/src/utils/objectPath.ts @@ -0,0 +1,67 @@ +/** + * Dot-path access over **plain-object trees**, with presence and value as separate questions. + * + * The pair exists because {@link getPath} alone cannot answer "was this path registered?". A configuration + * patch may carry an explicit `undefined` — `{ messagePaginator: { initialCursor: undefined } }` — and a + * caller that has to tell that apart from an absent key needs {@link hasPath}, since both read back as + * `undefined`. That distinction is the whole reason the construction-only diagnostic in + * `InstanceConfigurationRegistry` can report a late registration at all. + * + * **Descends into plain objects only**, deliberately. A configuration tree holds class instances + * (`itemIndex`), functions and arrays as leaf *values*, and walking into their internals would be both + * meaningless and slow — `hasPath(config, 'messagePaginator.initialCursor')` must not start indexing an + * `ItemIndex`. + * + * **Three other dot-path walkers exist in this package and none is a drop-in replacement**, which is worth + * knowing before adding a fourth: + * + * - `get` in `src/utils.ts` (module-private, backs `uniqBy`) returns `undefined` for a missing path *and* for + * a present-but-undefined one, so it cannot express `hasPath`. It also descends on + * `typeof acc === 'object'`, which includes arrays and class instances. + * - `resolveDotPathValue` in `src/pagination/utility.normalization.ts` (backs the filter compiler) + * short-circuits on any falsy intermediate value, so `''.length` resolves to `undefined` rather than `0`. + * Its declared return type is `unknown[]` while it returns `unknown`. + * - `examples/vite`'s Configuration tab carries a segment-array variant identical in behaviour to this one. + * + * Consolidating those is a separate change: two of them are load-bearing for unrelated subsystems, and the + * filter compiler's falsy short-circuit is a behaviour difference rather than a refactor. + * + * @internal + */ + +/** + * A record this module is willing to walk into: an object literal or `Object.create(null)`, and nothing + * else. Arrays, `Date`s, `RegExp`s and class instances are configuration *values*, not interiors. + * + * The prototype check is what makes that true. A `typeof value === 'object' && !Array.isArray(value)` test — + * which is what this and the three sibling walkers all used — happily descends into a class instance, so + * `hasPath(config, 'itemIndex.length')` answered `true` for a path that is not configuration at all. + */ +export const isWalkableRecord = (value: unknown): value is Record => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Whether a dot-path is present. A key explicitly set to `undefined` counts as present — that is the point + * of having this alongside {@link getPath}. + */ +export const hasPath = (source: Record, path: string): boolean => { + const [head, ...rest] = path.split('.'); + if (!(head in source)) return false; + if (rest.length === 0) return true; + const next = source[head]; + return isWalkableRecord(next) ? hasPath(next, rest.join('.')) : false; +}; + +/** + * The value at a dot-path, or `undefined` when the path is absent. Pair with {@link hasPath} when the two + * cases have to be told apart. + */ +export const getPath = (source: Record, path: string): unknown => { + const [head, ...rest] = path.split('.'); + const next = source[head]; + if (rest.length === 0) return next; + return isWalkableRecord(next) ? getPath(next, rest.join('.')) : undefined; +}; diff --git a/test/typescript/index.js b/test/typescript/index.js index 6f69f862b8..289a954959 100644 --- a/test/typescript/index.js +++ b/test/typescript/index.js @@ -189,7 +189,7 @@ const executables = [ { f: rg.getConfig, imports: ['Channel', 'Unpacked'], - type: "Unpacked>", + type: "Unpacked", }, { f: rg.getDevices, diff --git a/test/typescript/response-generators/channel.js b/test/typescript/response-generators/channel.js index 451e7037dd..0aa7fd0e3d 100644 --- a/test/typescript/response-generators/channel.js +++ b/test/typescript/response-generators/channel.js @@ -127,7 +127,7 @@ async function demoteModerators() { async function getConfig() { const channel = await utils.createTestChannel(uuidv4(), johnID); - return await channel.getConfig(); + return await channel.serverConfig; } async function hide() { diff --git a/test/unit/CooldownTimer.test.ts b/test/unit/CooldownTimer.test.ts index 303af4c197..e42f190d3b 100644 --- a/test/unit/CooldownTimer.test.ts +++ b/test/unit/CooldownTimer.test.ts @@ -23,6 +23,130 @@ describe('CooldownTimer', () => { vi.useRealTimers(); }); + /** + * `canSkipCooldown` derives from `own_capabilities` and is *stored*, so a capability-only change has to + * trigger a refresh. `channel.updated` handling is guarded on `cooldown` having moved, which filters + * exactly this case out, and `updatePartial()` announced the change without refreshing. + * + * These drive `channel.updatePartial()` — the real route — rather than registering the timer's own + * subscriptions and dispatching the event by hand. The earlier version of this suite did the latter, and + * it proved nothing: nothing in `src/` calls `cooldownTimer.registerSubscriptions()`, so the subscription + * it exercised does not exist in a running app. A probe has to fail in the broken configuration to be + * worth anything, and that one passed against code that was inert. + */ + /** + * The timer derives itself from `channel.state` and the message paginator's store, subscribed in its + * constructor. Before that it was refreshed imperatively from four places in `Channel`, which left two + * writes to `channel.data` uncovered: `query()` (which never called it) and any `updatePartial` that + * changed `cooldown` without changing capabilities. + */ + describe('derives from state rather than being refreshed', () => { + const open = async (id: string) => { + const client = await getClientWithUser({ id: 'user-1' }); + return client.channel('messaging', id); + }; + + it('picks up a cooldown that arrives through a channel-data sync', async () => { + const channel = await open('cooldown-sync'); + expect(channel.cooldownTimer.cooldownConfigSeconds).toBe(0); + + const previous = channel.data; + channel.data = { ...previous, cooldown: 30 } as Partial; + channel.state.syncStateFromChannelData(channel.data, previous); + + expect(channel.cooldownTimer.cooldownConfigSeconds).toBe(30); + }); + + it('picks up a capability change through a channel-data sync', async () => { + const channel = await open('cooldown-capability-sync'); + expect(channel.cooldownTimer.canSkipCooldown).toBe(false); + + const previous = channel.data; + channel.data = { + ...previous, + own_capabilities: ['skip-slow-mode'], + } as Partial; + channel.state.syncStateFromChannelData(channel.data, previous); + + expect(channel.cooldownTimer.canSkipCooldown).toBe(true); + }); + + it('picks up the own latest message from a paginator ingest', async () => { + const channel = await open('cooldown-paginator'); + const created_at = '2024-01-01T00:00:00.000Z'; + + seedLatestWindow(channel, generateMsg({ created_at, user: { id: 'user-1' } })); + + expect(channel.cooldownTimer.ownLatestMessageDate?.toISOString()).toBe(created_at); + }); + + it('stops deriving once unregistered', async () => { + const channel = await open('cooldown-unregister'); + channel.cooldownTimer.unregisterSubscriptions(); + + const previous = channel.data; + channel.data = { ...previous, cooldown: 30 } as Partial; + channel.state.syncStateFromChannelData(channel.data, previous); + + expect(channel.cooldownTimer.cooldownConfigSeconds).toBe(0); + }); + }); + + describe('capability changes through updatePartial', () => { + const setup = async (own_capabilities: string[]) => { + const client = await getClientWithUser({ id: 'user-1' }); + const channel = client.channel('messaging', 'cooldown-capabilities'); + channel.data = { + cid: channel.cid, + cooldown: 30, + id: channel.id, + own_capabilities, + type: channel.type, + } as Partial; + channel.cooldownTimer.refresh(); + return { channel, client }; + }; + + const updatePartialWithCapabilities = async ( + channel: Channel, + own_capabilities: string[], + ) => { + vi.spyOn(channel, 'updateChannelPartial').mockResolvedValue({ + channel: { ...channel.data, own_capabilities }, + } as never); + await channel.updatePartial({ set: { frozen: false } } as never); + }; + + it('picks up a newly granted skip-slow-mode', async () => { + const { channel } = await setup([]); + expect(channel.cooldownTimer.canSkipCooldown).toBe(false); + + await updatePartialWithCapabilities(channel, ['skip-slow-mode']); + + expect(channel.cooldownTimer.canSkipCooldown).toBe(true); + }); + + it('picks up a revoked skip-slow-mode', async () => { + const { channel } = await setup(['skip-slow-mode']); + expect(channel.cooldownTimer.canSkipCooldown).toBe(true); + + await updatePartialWithCapabilities(channel, []); + + expect(channel.cooldownTimer.canSkipCooldown).toBe(false); + }); + + it('clears a running cooldown as soon as the capability is granted', async () => { + const { channel } = await setup([]); + channel.cooldownTimer.setCooldownRemaining(12); + expect(channel.cooldownTimer.cooldownRemaining).toBe(12); + + await updatePartialWithCapabilities(channel, ['skip-slow-mode']); + + // `refresh()` short-circuits to zero once the cooldown can be skipped. + expect(channel.cooldownTimer.cooldownRemaining).toBe(0); + }); + }); + it('ticks down every second until it reaches 0', async () => { vi.useFakeTimers(); const now = new Date('2026-01-01T00:00:10.000Z'); diff --git a/test/unit/LiveLocationManager.test.ts b/test/unit/LiveLocationManager.test.ts index b9bc15ffb4..a6cf30d730 100644 --- a/test/unit/LiveLocationManager.test.ts +++ b/test/unit/LiveLocationManager.test.ts @@ -129,6 +129,129 @@ describe('LiveLocationManager', () => { expect(manager.hasSubscriptions).toBeFalsy(); }); + /** + * The configuration subscription is registered by the constructor, not by `registerSubscriptions`, and + * nothing re-registers it — so releasing it while another caller still holds the manager stops a + * still-live instance from ever seeing `client.config` again. `super.unregisterSubscriptions()` returns + * the same marker symbol on both paths, so only `hasSubscriptions` can tell a decrement from a real + * teardown. + */ + describe('configuration subscription lifecycle', () => { + const makeManager = async (client: StreamChat) => { + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ + active_live_locations: [], + duration: '', + }); + const manager = new LiveLocationManager({ + client, + getDeviceId, + watchLocation, + }); + await manager.init(); + return manager; + }; + + it('survives a caller leaving while another still holds the manager', async () => { + const client = await getClientWithUser({ id: 'user-refcount' }); + const manager = await makeManager(client); + + // A second consumer joins, then leaves. The first is still holding on. + manager.registerSubscriptions(); + manager.unregisterSubscriptions(); + + expect(manager.hasSubscriptions).toBeTruthy(); + + client.config.set({ liveLocationManager: { minUpdateThrottleMs: 7000 } }); + + expect(manager.config.minUpdateThrottleMs).toBe(7000); + }); + + it('still reaches the manager after several overlapping callers leave', async () => { + const client = await getClientWithUser({ id: 'user-refcount-many' }); + const manager = await makeManager(client); + + manager.registerSubscriptions(); + manager.registerSubscriptions(); + manager.unregisterSubscriptions(); + manager.unregisterSubscriptions(); + + expect(manager.hasSubscriptions).toBeTruthy(); + + client.config.set({ liveLocationManager: { minUpdateThrottleMs: 9000 } }); + + expect(manager.config.minUpdateThrottleMs).toBe(9000); + }); + + it('keeps tracking config after a full unregister', async () => { + const client = await getClientWithUser({ id: 'user-last-caller' }); + const manager = await makeManager(client); + + // Event subscriptions are ref-counted and this releases them; configuration is not, and lives for + // the instance. A manager whose subscriptions are re-registered later is still configurable. + manager.unregisterSubscriptions(); + expect(manager.hasSubscriptions).toBeFalsy(); + + client.config.set({ liveLocationManager: { minUpdateThrottleMs: 7000 } }); + + expect(manager.config.minUpdateThrottleMs).toBe(7000); + }); + + it('stops tracking config after dispose', async () => { + const client = await getClientWithUser({ id: 'user-dispose' }); + const manager = await makeManager(client); + const before = manager.config.minUpdateThrottleMs; + + manager.dispose(); + + client.config.set({ liveLocationManager: { minUpdateThrottleMs: 7000 } }); + + expect(manager.config.minUpdateThrottleMs).toBe(before); + }); + + it('is configurable again after dispose and re-registration', async () => { + const client = await getClientWithUser({ id: 'user-strictmode' }); + const manager = await makeManager(client); + + // React StrictMode runs mount → cleanup → mount against one instance. If dispose were + // unrecoverable, the re-mounted manager would be permanently deaf to `client.config`. + manager.unregisterSubscriptions(); + manager.dispose(); + manager.registerSubscriptions(); + + client.config.set({ liveLocationManager: { minUpdateThrottleMs: 7000 } }); + + expect(manager.config.minUpdateThrottleMs).toBe(7000); + }); + + it('re-runs the setup function when re-registered after dispose', async () => { + const client = await getClientWithUser({ id: 'user-strictmode-setup' }); + const teardown = vi.fn(); + const setup = vi.fn(() => teardown); + client.config.setSetupFunction('liveLocationManager', setup); + + const manager = await makeManager(client); + expect(setup).toHaveBeenCalledTimes(1); + + manager.dispose(); + expect(teardown).toHaveBeenCalledTimes(1); + + manager.registerSubscriptions(); + + expect(setup).toHaveBeenCalledTimes(2); + }); + + it('leaves event subscriptions alone on dispose', async () => { + const client = await getClientWithUser({ id: 'user-dispose-subs' }); + const manager = await makeManager(client); + + manager.dispose(); + + // `dispose` is the configuration teardown only — the ref-counted half stays with + // `unregisterSubscriptions`. + expect(manager.hasSubscriptions).toBeTruthy(); + }); + }); + describe('message addition or removal', () => { it('does not update active location if there are no active live locations', async () => { const client = await getClientWithUser({ id: 'user-abc' }); diff --git a/test/unit/MessageComposer/LocationComposer.test.ts b/test/unit/MessageComposer/LocationComposer.test.ts index 65e5a0770c..b86850a0b1 100644 --- a/test/unit/MessageComposer/LocationComposer.test.ts +++ b/test/unit/MessageComposer/LocationComposer.test.ts @@ -12,6 +12,7 @@ const deviceId = 'deviceId'; const defaultConfig: LocationComposerConfig = { enabled: true, getDeviceId: () => deviceId, + minShareDurationMs: 60 * 1000, }; const user = { id: 'user-id' }; diff --git a/test/unit/MessageComposer/attachmentManager.test.ts b/test/unit/MessageComposer/attachmentManager.test.ts index 0139bb4c90..fd2bd328c6 100644 --- a/test/unit/MessageComposer/attachmentManager.test.ts +++ b/test/unit/MessageComposer/attachmentManager.test.ts @@ -236,6 +236,8 @@ describe('AttachmentManager', () => { expect(attachmentManager.config).toEqual({ ...config, acceptedFiles: [], + customCdn: false, + enabled: true, trackUploadProgress: true, }); }); @@ -278,6 +280,43 @@ describe('AttachmentManager', () => { expect(attachmentManager.hasUploadPermission).toBe(false); }); + it('is true without the upload-file capability when files go to storage outside Stream', () => { + // The capability governs Stream's upload endpoint. On storage Stream does not host there is + // nothing for it to permit or refuse, so it must not decide. + const { + messageComposer: { attachmentManager }, + mockChannel, + } = setup({ config: { customCdn: true } }); + mockChannel.data = { ...mockChannel.data, own_capabilities: [] }; + + expect(attachmentManager.usesStreamStorage).toBe(false); + expect(attachmentManager.hasUploadPermission).toBe(false); + expect(attachmentManager.isUploadEnabled).toBe(true); + }); + + it('still requires the capability for a custom request without customCdn', () => { + // A custom upload function is not a statement about the destination — see `usesStreamStorage`. + const { + messageComposer: { attachmentManager }, + mockChannel, + } = setup({ + config: { doUploadRequest: () => Promise.resolve({ file: 'https://x/f' }) }, + }); + mockChannel.data = { ...mockChannel.data, own_capabilities: [] }; + + expect(attachmentManager.usesStreamStorage).toBe(true); + expect(attachmentManager.isUploadEnabled).toBe(false); + }); + + it('is false when attachments are disabled, whatever the destination', () => { + // The asymmetry: storage outside Stream escapes the permission, never the integrator's own switch. + const { + messageComposer: { attachmentManager }, + } = setup({ config: { customCdn: true, enabled: false } }); + + expect(attachmentManager.isUploadEnabled).toBe(false); + }); + it('should return false for isUploadEnabled when no upload slots are available', () => { // Create a message with maximum number of attachments const composition: DraftResponse = { @@ -1921,6 +1960,64 @@ describe('AttachmentManager', () => { }); describe('uploadFiles', () => { + it('refuses a custom request without customCdn when the capability is missing', async () => { + // **Behaviour change.** The permission bypass added for custom upload functions keyed on the mere + // presence of `doUploadRequest`, which waived Stream's capability for integrators who were still + // uploading to Stream. It is now keyed on `customCdn`, so this case is governed again. Nothing covered + // the old bypass, so this is also the first test either way round. + const { + messageComposer: { attachmentManager }, + mockChannel, + } = setup({ + config: { doUploadRequest: () => Promise.resolve({ file: 'https://x/f' }) }, + }); + mockChannel.data = { ...mockChannel.data, own_capabilities: [] }; + const file = new File([''], 'test.jpg', { type: 'image/jpeg' }); + + await attachmentManager.uploadFiles([file]); + + expect(attachmentManager.attachments).toHaveLength(0); + }); + + it('uploads without the capability once customCdn is declared', async () => { + const doUploadRequest = vi.fn(() => + Promise.resolve({ file: 'https://cdn.example/f' }), + ); + const { + messageComposer: { attachmentManager }, + mockChannel, + } = setup({ config: { customCdn: true, doUploadRequest } }); + mockChannel.data = { ...mockChannel.data, own_capabilities: [] }; + const file = new File([''], 'test.jpg', { type: 'image/jpeg' }); + + await attachmentManager.uploadFiles([file]); + + expect(doUploadRequest).toHaveBeenCalled(); + expect(attachmentManager.successfulUploadsCount).toBe(1); + }); + + it('refuses when attachments are disabled, even for storage outside Stream', async () => { + // Declaring `customCdn` waives Stream's `upload-file` permission, because those bytes never reach + // Stream. `config.enabled` is the integrator's *own* switch, so it has to survive that waiver — + // otherwise turning attachments off would keep working for exactly the people who configured the + // SDK most deliberately. + const { + messageComposer: { attachmentManager }, + } = setup({ + config: { + customCdn: true, + doUploadRequest: () => Promise.resolve({ file: 'https://cdn.example/f' }), + enabled: false, + }, + }); + const file = new File([''], 'test.jpg', { type: 'image/jpeg' }); + + await attachmentManager.uploadFiles([file]); + + expect(attachmentManager.successfulUploadsCount).toBe(0); + expect(attachmentManager.attachments).toHaveLength(0); + }); + it('should upload files successfully', async () => { const { messageComposer: { attachmentManager }, diff --git a/test/unit/MessageComposer/linkPreviewsManager.test.ts b/test/unit/MessageComposer/linkPreviewsManager.test.ts index 4d7860778a..ed2d743edf 100644 --- a/test/unit/MessageComposer/linkPreviewsManager.test.ts +++ b/test/unit/MessageComposer/linkPreviewsManager.test.ts @@ -11,6 +11,7 @@ import { } from '../../../src'; import { DeepPartial } from '../../../src/types.utility'; import { mergeWith } from '../../../src/utils/mergeWith'; +import { stubServerConfig } from '../test-utils/stubServerConfig'; const existingLinkUrl = 'https://existing.com'; const linkUrl = 'https://example.com'; @@ -92,14 +93,14 @@ const setup = ({ mockClient.getOG = vi.fn().mockResolvedValue(enrichURLReturnValue); const mockChannel = mockClient.channel('channelType', 'channelId'); - mockChannel.getConfig = vi.fn().mockImplementation(() => ({ url_enrichment: true })); + const setServerConfig = stubServerConfig(mockChannel, { url_enrichment: true }); const messageComposer = new MessageComposer({ client: mockClient, composition, compositionContext: mockChannel, config: config === null ? {} : mergeWith(DEFAULT_CONFIG, { linkPreviews: config }), }); - return { mockClient, mockChannel, messageComposer }; + return { messageComposer, mockChannel, mockClient, setServerConfig }; }; describe('LinkPreviewsManager', () => { @@ -112,7 +113,9 @@ describe('LinkPreviewsManager', () => { const { messageComposer: { linkPreviewsManager }, } = setup({ config: null }); - expect(linkPreviewsManager.config.enabled).toBe(false); + // `true` means "no opinion — let the server decide", matching every other server-gated feature. + // The channel type's `url_enrichment` still has to allow it; the harness sets that flag to true. + expect(linkPreviewsManager.config.enabled).toBe(true); expect(linkPreviewsManager.config.debounceURLEnrichmentMs).toBe( DEFAULT_LINK_PREVIEW_MANAGER_CONFIG.debounceURLEnrichmentMs, ); @@ -417,14 +420,73 @@ describe('LinkPreviewsManager', () => { }); }); + describe('the setter against a disabling server', () => { + // The server has the last word, so `enabled = true` cannot win — that part always held. What did not + // is the *record* of what was asked for: the setter used to skip the write when the new value equalled + // the current one, and while the server masks the field the current one is always `false`. So asking + // for `false` after asking for `true` recorded nothing, the earlier `true` stayed in the retained + // patch layer, and the moment the server relented it was honoured — the opposite of the last + // instruction given. + const setup2 = (url_enrichment: boolean) => { + const client = new StreamChat('apiKey'); + client.user = { id: 'user' } as never; + client.channelServerConfigsStore.partialNext({ + configs: { 'channelType:channelId': { url_enrichment } as never }, + }); + const channel = client.channel('channelType', 'channelId'); + const composer = new MessageComposer({ + client, + compositionContext: channel, + }); + composer.registerSubscriptions(); + return { client, composer }; + }; + + it('cannot enable previews the server has disabled', () => { + const { composer } = setup2(false); + + composer.linkPreviewsManager.enabled = true; + + expect(composer.linkPreviewsManager.enabled).toBe(false); + expect(composer.config.linkPreviews.enabled).toBe(false); + }); + + it('honours the last request once the server relents', () => { + const { client, composer } = setup2(false); + + composer.linkPreviewsManager.enabled = true; + composer.linkPreviewsManager.enabled = false; // changed their mind, while masked + + client.channelServerConfigsStore.partialNext({ + configs: { 'channelType:channelId': { url_enrichment: true } as never }, + }); + + expect(composer.linkPreviewsManager.enabled).toBe(false); + }); + + it('still applies a request made while masked, if it was the last one', () => { + const { client, composer } = setup2(false); + + composer.linkPreviewsManager.enabled = true; + + client.channelServerConfigsStore.partialNext({ + configs: { 'channelType:channelId': { url_enrichment: true } as never }, + }); + + expect(composer.linkPreviewsManager.enabled).toBe(true); + }); + }); + describe('findAndEnrichUrls', () => { it('should not process URLs if disabled back-end url_enrichment', async () => { - const { - messageComposer: { linkPreviewsManager }, - mockChannel, - mockClient, - } = setup(); - mockChannel.getConfig.mockReturnValueOnce({ url_enrichment: false }); + const { messageComposer, mockChannel, mockClient, setServerConfig } = setup(); + const { linkPreviewsManager } = messageComposer; + // `url_enrichment` is reconciled into `config.linkPreviews.enabled` by the composer's server + // restrictions rather than read live, so a *late* change reaches it through the composer's + // subscription — the same route its four sibling gates already take. A real consumer registers + // these on mount. + messageComposer.registerSubscriptions(); + setServerConfig({ url_enrichment: false }); linkPreviewsManager.findAndEnrichUrls('Check out https://example.com'); let enrichPromiseResolve; mockClient.getOG = vi.fn().mockImplementation(() => { diff --git a/test/unit/MessageComposer/messageComposer.test.ts b/test/unit/MessageComposer/messageComposer.test.ts index 75b9642f01..378fe08354 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -20,6 +20,7 @@ import { DraftResponse, MessageResponse } from '../../../src/types'; import { MockOfflineDB } from '../offline-support/MockOfflineDB'; import { getCommandByName } from '../../../src/messageComposer/middleware/textComposer/commandUtils'; import { generateMsg } from '../test-utils/generateMessage'; +import { stubServerConfig } from '../test-utils/stubServerConfig'; const generateUuidV4Output = 'test-uuid'; // Mock dependencies @@ -104,13 +105,16 @@ const setup = ({ } = {}) => { const mockClient = new StreamChat('test-api-key'); mockClient.user = user; - const cid = 'messaging:test-channel-id'; + const channelType = 'messaging'; + const channelId = 'test-channel-id'; if (channelConfig) { + // Keyed by cid, not channel type — a channel's `config_overrides` make the effective config + // per channel. See `Configs`. // @ts-expect-error incomplete channel config object - mockClient.configs[cid] = channelConfig; + mockClient.channelServerConfigs[`${channelType}:${channelId}`] = channelConfig; } // Create a proper Channel instance with only the necessary attributes mocked - const mockChannel = mockClient.channel('messaging', 'test-channel-id'); + const mockChannel = mockClient.channel(channelType, channelId); // Mock the getClient method vi.spyOn(mockChannel, 'getClient').mockReturnValue(mockClient); @@ -193,6 +197,8 @@ describe('MessageComposer', () => { expect(messageComposer.config).toStrictEqual({ attachments: { acceptedFiles: DEFAULT_COMPOSER_CONFIG.attachments.acceptedFiles, + customCdn: DEFAULT_COMPOSER_CONFIG.attachments.customCdn, + enabled: DEFAULT_COMPOSER_CONFIG.attachments.enabled, fileUploadFilter: DEFAULT_COMPOSER_CONFIG.attachments.fileUploadFilter, maxNumberOfFilesPerMessage: customConfig.attachments!.maxNumberOfFilesPerMessage, @@ -208,7 +214,9 @@ describe('MessageComposer', () => { location: { enabled: customConfig.location!.enabled, getDeviceId: DEFAULT_COMPOSER_CONFIG.location!.getDeviceId, + minShareDurationMs: DEFAULT_COMPOSER_CONFIG.location!.minShareDurationMs, }, + polls: DEFAULT_COMPOSER_CONFIG.polls, sendMessageRequestFn: customConfig.sendMessageRequestFn, text: { enabled: DEFAULT_COMPOSER_CONFIG.text.enabled, @@ -273,6 +281,120 @@ describe('MessageComposer', () => { }); }); + it.each([ + // `uploads` → `attachments.enabled` and `polls` → `polls.enabled` follow the same rule as + // `shared_locations` above: both sides are gates, so the stricter one wins whichever side it is on + // and an absent server flag leaves the request standing. Pinned per field rather than trusting the + // shared merge, because the bug these mirror was a consumer reading the *server* flag directly and + // therefore seeing only half the answer — the half that says yes. + { channel: undefined, expected: true, requested: undefined }, + { channel: undefined, expected: false, requested: false }, + { channel: false, expected: false, requested: undefined }, + { channel: false, expected: false, requested: true }, + { channel: true, expected: true, requested: undefined }, + { channel: true, expected: false, requested: false }, + { channel: true, expected: true, requested: true }, + ])( + 'ANDs the server flag with the request: requested=$requested channel=$channel -> $expected', + ({ channel, expected, requested }) => { + const { messageComposer } = setup({ + channelConfig: { polls: channel, uploads: channel }, + config: { attachments: { enabled: requested }, polls: { enabled: requested } }, + }); + + expect(messageComposer.config.attachments.enabled).toBe(expected); + expect(messageComposer.config.polls.enabled).toBe(expected); + }, + ); + + describe('link previews follow the server, not a client double-gate', () => { + // The default was `false`, which meant previews stayed off even where the channel type had + // `url_enrichment` on — the client half vetoed a feature the server had granted. `true` means "no + // opinion", so the server's answer decides, like every other server-gated feature. + it.each([ + { expected: true, server: true }, + { expected: false, server: false }, + ])('server=$server -> $expected with no client opinion', ({ expected, server }) => { + const { messageComposer } = setup({ channelConfig: { url_enrichment: server } }); + + expect(messageComposer.config.linkPreviews.enabled).toBe(expected); + expect(messageComposer.linkPreviewsManager.enabled).toBe(expected); + }); + + it('still lets the integrator switch them off against a permissive server', () => { + const { messageComposer } = setup({ + channelConfig: { url_enrichment: true }, + config: { linkPreviews: { enabled: false } }, + }); + + expect(messageComposer.config.linkPreviews.enabled).toBe(false); + }); + }); + + describe('storage outside Stream', () => { + // `uploads` is a statement about Stream's upload endpoint. An integrator storing files elsewhere is + // not using that endpoint, so requiring them to switch it on would make a Stream setting a + // precondition for storage Stream has nothing to do with. + const doUploadRequest = () => Promise.resolve({ file: 'https://cdn.example/f' }); + + it('ignores the server uploads flag when customCdn is declared', () => { + const { messageComposer } = setup({ + channelConfig: { uploads: false }, + config: { attachments: { customCdn: true, doUploadRequest } }, + }); + + expect(messageComposer.config.attachments.enabled).toBe(true); + }); + + it('still applies the server uploads flag to a custom request without customCdn', () => { + // The distinction the `customCdn` field exists for. A custom upload function says *how* files are + // sent, not *where* — wrapping the request or proxying it through your own backend still ends at + // Stream, and inferring otherwise waived Stream's rules for those integrators. + const { messageComposer } = setup({ + channelConfig: { uploads: false }, + config: { attachments: { doUploadRequest } }, + }); + + expect(messageComposer.config.attachments.enabled).toBe(false); + }); + + it('still lets the integrator turn attachments off themselves', () => { + // The escape hatch removes the *server's* say, not the client's — otherwise declaring a custom + // CDN would quietly make the feature unswitchable. + const { messageComposer } = setup({ + channelConfig: { uploads: false }, + config: { attachments: { customCdn: true, enabled: false } }, + }); + + expect(messageComposer.config.attachments.enabled).toBe(false); + }); + + it('picks up customCdn declared after construction', () => { + // The condition is evaluated on every resolution rather than captured once. + const { messageComposer } = setup({ channelConfig: { uploads: false } }); + expect(messageComposer.config.attachments.enabled).toBe(false); + + messageComposer.updateConfig({ attachments: { customCdn: true } }); + + expect(messageComposer.config.attachments.enabled).toBe(true); + }); + + it('can be switched back to Stream storage', () => { + // A boolean with a `false` default is reversible where an optional URL was not: the composer + // retains its patches and the merge skips `undefined`, so a field whose "off" value *is* + // `undefined` can never be turned off again. `false` is a real value, so this works. + const { messageComposer } = setup({ + channelConfig: { uploads: false }, + config: { attachments: { customCdn: true } }, + }); + expect(messageComposer.config.attachments.enabled).toBe(true); + + messageComposer.updateConfig({ attachments: { customCdn: false } }); + + expect(messageComposer.config.attachments.enabled).toBe(false); + }); + }); + it('should initialize with message', () => { const message = { id: 'test-message-id', @@ -616,7 +738,7 @@ describe('MessageComposer', () => { }, }); - vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + stubServerConfig(messageComposer.channel, { commands: [{ name: 'ban', description: 'Ban a user' }], }); @@ -648,7 +770,7 @@ describe('MessageComposer', () => { it('should apply the default ban command validator', () => { const { messageComposer } = setup(); - vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + stubServerConfig(messageComposer.channel, { commands: [{ name: 'ban', description: 'Ban a user' }], }); @@ -672,7 +794,7 @@ describe('MessageComposer', () => { it('should require mentions for default moderation target commands', () => { const { messageComposer } = setup(); - vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + stubServerConfig(messageComposer.channel, { commands: [ { name: 'mute', description: 'Mute a user' }, { name: 'unmute', description: 'Unmute a user' }, @@ -935,7 +1057,7 @@ describe('MessageComposer', () => { }, }); - vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + stubServerConfig(messageComposer.channel, { commands: [{ name: 'custom', description: 'Custom command' }], }); const customCommand = { description: 'Custom command', name: 'custom' }; @@ -964,7 +1086,7 @@ describe('MessageComposer', () => { }, }); - vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + stubServerConfig(messageComposer.channel, { commands: [{ name: 'ban', description: 'Ban a user' }], }); messageComposer.textComposer.state.partialNext({ @@ -2634,9 +2756,7 @@ describe('MessageComposer', () => { const { mockChannel, messageComposer } = setup({ config: { linkPreviews: { enabled: true } }, }); - mockChannel.getConfig = vi - .fn() - .mockImplementation(() => ({ url_enrichment: true })); + stubServerConfig(mockChannel, { url_enrichment: true }); const spy = vi.spyOn(messageComposer.linkPreviewsManager, 'findAndEnrichUrls'); messageComposer.registerSubscriptions(); diff --git a/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts b/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts index b68f4e3242..e421098ddd 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts @@ -18,6 +18,7 @@ import { MessageDraftComposerMiddlewareValueState } from '../../../../../src/mes import { LocalMessage, MessageResponse } from '../../../../../src'; import type { DeepPartial } from '../../../../../src/types.utility'; import { generateChannel } from '../../../test-utils/generateChannel'; +import { stubServerConfig } from '../../../test-utils/stubServerConfig'; const setupMiddleware = ( custom: { @@ -194,7 +195,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { const { messageComposer, validationMiddleware } = setupMiddleware({ editedMessage, }); - vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + stubServerConfig(messageComposer.channel, { commands: [{ name: 'ban', description: 'Ban a user' }], }); const addWarningSpy = vi.spyOn(messageComposer.client.notifications, 'addWarning'); @@ -219,7 +220,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { it('should discard raw moderation commands while replying', async () => { const { messageComposer, validationMiddleware } = setupMiddleware(); - vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + stubServerConfig(messageComposer.channel, { commands: [{ name: 'ban', description: 'Ban a user', set: 'moderation_set' }], }); vi.spyOn(messageComposer, 'quotedMessage', 'get').mockReturnValue({ @@ -264,7 +265,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { }, }, }); - vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + stubServerConfig(messageComposer.channel, { commands: [{ name: 'ban', description: 'Ban a user' }], }); const addWarningSpy = vi.spyOn(messageComposer.client.notifications, 'addWarning'); @@ -341,7 +342,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { }, }, }); - vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + stubServerConfig(messageComposer.channel, { commands: [{ name: 'custom', description: 'Custom command' }], }); @@ -360,7 +361,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { it('should discard ban commands without a reason by default', async () => { const { messageComposer, validationMiddleware } = setupMiddleware(); - vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + stubServerConfig(messageComposer.channel, { commands: [{ name: 'ban', description: 'Ban a user' }], }); vi.spyOn(messageComposer.textComposer, 'text', 'get').mockReturnValue('/ban @user1'); @@ -389,7 +390,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { it('should allow ban commands with mention and reason by default', async () => { const { messageComposer, validationMiddleware } = setupMiddleware(); - vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + stubServerConfig(messageComposer.channel, { commands: [{ name: 'ban', description: 'Ban a user' }], }); vi.spyOn(messageComposer.textComposer, 'text', 'get').mockReturnValue( @@ -411,7 +412,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { it('should discard mute, unmute and unban commands without a mention by default', async () => { for (const commandName of ['mute', 'unmute', 'unban'] as const) { const { messageComposer, validationMiddleware } = setupMiddleware(); - vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + stubServerConfig(messageComposer.channel, { commands: [{ name: commandName, description: `${commandName} a user` }], }); vi.spyOn(messageComposer.textComposer, 'text', 'get').mockReturnValue( @@ -441,7 +442,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { it('should allow raw known commands if command is not disabled', async () => { const { messageComposer, validationMiddleware } = setupMiddleware(); - vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + stubServerConfig(messageComposer.channel, { commands: [{ name: 'giphy', description: 'Post a random gif' }], }); vi.spyOn(messageComposer.textComposer, 'text', 'get').mockReturnValue('/giphy hello'); diff --git a/test/unit/MessageComposer/middleware/messageComposer/linkPreviews.test.ts b/test/unit/MessageComposer/middleware/messageComposer/linkPreviews.test.ts index aa1917f9ca..c5747ba5df 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/linkPreviews.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/linkPreviews.test.ts @@ -19,6 +19,7 @@ import { MiddlewareStatus, } from '../../../../../src'; import { getClientWithUser } from '../../../test-utils/getClient'; +import { stubServerConfig } from '../../../test-utils/stubServerConfig'; const enrichURLReturnValue = { asset_url: 'https://example.com/image.jpg', @@ -79,7 +80,7 @@ const setup = ({ const mockChannel = mockClient.channel('messaging', 'test-channel', { members: [], }); - mockChannel.getConfig = vi.fn().mockImplementation(() => ({ url_enrichment: true })); + stubServerConfig(mockChannel, { url_enrichment: true }); const messageComposer = new MessageComposer({ client: mockClient, composition, @@ -592,7 +593,7 @@ const setupForDraft = ({ const mockChannel = mockClient.channel('messaging', 'test-channel', { members: [], }); - mockChannel.getConfig = vi.fn().mockImplementation(() => ({ url_enrichment: true })); + stubServerConfig(mockChannel, { url_enrichment: true }); const messageComposer = new MessageComposer({ client: mockClient, composition, diff --git a/test/unit/MessageComposer/middleware/textComposer/CommandSearchSource.test.ts b/test/unit/MessageComposer/middleware/textComposer/CommandSearchSource.test.ts index 8dcfcee5bd..152c6980df 100644 --- a/test/unit/MessageComposer/middleware/textComposer/CommandSearchSource.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/CommandSearchSource.test.ts @@ -2,11 +2,12 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { CommandSearchSource } from '../../../../../src/messageComposer/middleware/textComposer/commands'; import { Channel } from '../../../../../src/channel'; import type { ChannelConfigWithInfo } from '../../../../../src/types'; +import { stubServerConfig } from '../../../test-utils/stubServerConfig'; describe('CommandSearchSource', () => { let channel: Channel; let mockCommands: any[]; - let getConfigMock: ReturnType; + let setServerConfig: (next: Record | undefined) => void; beforeEach(() => { mockCommands = [ @@ -16,10 +17,18 @@ describe('CommandSearchSource', () => { { name: 'unmute', description: 'Unmute a user' }, ]; - getConfigMock = vi.fn().mockReturnValue({ commands: mockCommands }); - channel = { - getConfig: getConfigMock, - } as any; + // A bare object with no client behind it, so there is no derivation to drive — the resolved shape + // is set directly. `availableCommands` is what the source reads; the server's `commands` list is + // mapped onto it by `Channel`'s own authority step, which does not exist here. + let availableCommands = mockCommands; + channel = { config: {} } as any; + Object.defineProperty(channel.config, 'availableCommands', { + configurable: true, + get: () => availableCommands, + }); + setServerConfig = ({ commands }: { commands: any[] }) => { + availableCommands = commands; + }; }); it('should initialize with correct type', () => { @@ -62,7 +71,7 @@ describe('CommandSearchSource', () => { expect(result.items).toHaveLength(1); expect(result.items[0].name).toBe('giphy'); - getConfigMock.mockReturnValueOnce({ + setServerConfig({ commands: mockCommands.map((command) => ({ ...command, name: command.name.toUpperCase(), @@ -108,7 +117,7 @@ describe('CommandSearchSource', () => { { name: 'alpha', description: '' }, { name: 'gamma', description: '' }, ]; - getConfigMock.mockReturnValue({ commands: mockCommands }); + setServerConfig({ commands: mockCommands }); const source = new CommandSearchSource(channel); source.activate(); @@ -137,7 +146,7 @@ describe('CommandSearchSource', () => { { name: 'mute', description: 'Mute a user', set: 'fun_set' }, { name: 'moderation_set', description: 'Moderate a user' }, ]; - getConfigMock.mockReturnValue({ commands: mockCommands }); + setServerConfig({ commands: mockCommands }); const source = new CommandSearchSource(channel); const result = await source.query(''); diff --git a/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts b/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts index 57eb7bd833..1dca143ae0 100644 --- a/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts @@ -11,6 +11,7 @@ import type { Command, DraftResponse, LocalMessage } from '../../../../../src/ty import { TextComposerMiddleware } from '../../../../../src'; import type { UserSuggestion } from '../../../../../src/messageComposer/middleware/textComposer/types'; import { getClientWithUser } from '../../../test-utils/getClient'; +import { stubServerConfig } from '../../../test-utils/stubServerConfig'; // Mock dependencies vi.mock('../../../src/utils', () => ({ @@ -49,7 +50,7 @@ const setup = ({ const channel = client.channel('channelType', 'channelId'); channel.keystroke = vi.fn().mockResolvedValue({}); channel.getClient = vi.fn().mockReturnValue(client); - channel.getConfig = vi.fn().mockReturnValue({ + stubServerConfig(channel, { commands: [ { name: 'ban', description: 'Ban a user' }, { name: 'mute', description: 'Mute a user' }, @@ -335,7 +336,7 @@ describe('TextComposerMiddlewareExecutor', () => { messageComposer, messageComposer: { textComposer }, } = setup(); - channel.getConfig = vi.fn().mockReturnValue({ + stubServerConfig(channel, { commands: [{ name: 'ban', description: 'Ban a user', set: 'moderation_set' }], }); messageComposer.setQuotedMessage({ diff --git a/test/unit/MessageComposer/middleware/textComposer/command.test.ts b/test/unit/MessageComposer/middleware/textComposer/command.test.ts index c5a2bfce64..44f4c87631 100644 --- a/test/unit/MessageComposer/middleware/textComposer/command.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/command.test.ts @@ -10,6 +10,7 @@ import { TextComposerMiddleware } from '../../../../../src'; import { createActiveCommandGuardMiddleware } from '../../../../../src/messageComposer/middleware/textComposer/activeCommandGuard'; import { createCommandStringExtractionMiddleware } from '../../../../../src/messageComposer/middleware/textComposer/commandStringExtraction'; import { getClientWithUser } from '../../../test-utils/getClient'; +import { stubServerConfig } from '../../../test-utils/stubServerConfig'; // Mock dependencies @@ -32,7 +33,7 @@ const setup = ({ const channel = client.channel('channelType', 'channelId'); channel.keystroke = vi.fn().mockResolvedValue({}); channel.getClient = vi.fn().mockReturnValue(client); - channel.getConfig = vi.fn().mockReturnValue({ + stubServerConfig(channel, { commands: [ { name: 'ban', description: 'Ban a user' }, { name: 'mute', description: 'Mute a user' }, diff --git a/test/unit/MessageComposer/textComposer.test.ts b/test/unit/MessageComposer/textComposer.test.ts index a9728cc496..4304b103f3 100644 --- a/test/unit/MessageComposer/textComposer.test.ts +++ b/test/unit/MessageComposer/textComposer.test.ts @@ -13,6 +13,7 @@ import { TextComposerConfig } from '../../../src/messageComposer/configuration'; import { LinkPreviewStatus } from '../../../src/messageComposer/linkPreviewsManager'; import type { LocalAttachment } from '../../../src/messageComposer/types'; import { getClientWithUser } from '../test-utils/getClient'; +import { stubServerConfig } from '../test-utils/stubServerConfig'; const textComposerMiddlewareExecuteOutput = { state: { @@ -555,7 +556,7 @@ describe('TextComposer', () => { messageComposer: { textComposer }, mockChannel, } = setup(); - mockChannel.getConfig = vi.fn().mockReturnValue({ + stubServerConfig(mockChannel, { commands: [{ name: 'ban', description: 'Ban a user' }], }); messageComposer.attachmentManager.state.partialNext({ attachments: [attachment] }); @@ -616,7 +617,7 @@ describe('TextComposer', () => { messageComposer: { textComposer }, mockChannel, } = setup(); - mockChannel.getConfig = vi.fn().mockReturnValue({ + stubServerConfig(mockChannel, { commands: [{ name: 'ban', description: 'Ban a user' }], }); messageComposer.attachmentManager.state.partialNext({ attachments: [attachment] }); @@ -666,7 +667,7 @@ describe('TextComposer', () => { messageComposer: { textComposer }, mockChannel, } = setup(); - mockChannel.getConfig = vi.fn().mockReturnValue({ + stubServerConfig(mockChannel, { commands: [{ name: 'ban', description: 'Ban a user' }], }); textComposer.setText('Hello world'); diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 8001cdcc28..5d150ad24e 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -2151,8 +2151,8 @@ describe('Channel _handleChannelEvent', function () { it('prevents reporting delivery just reported', () => { // enable delivery events client._addChannelConfig({ - cid: channel.cid, - config: { ...channel.getConfig(), delivery_events: true }, + type: channel.type, + config: { ...channel.serverConfig, delivery_events: true }, }); channel.state.read[user.id] = initialReadState; @@ -2177,8 +2177,8 @@ describe('Channel _handleChannelEvent', function () { it('keeps reporting delivery if having newer deliveries', () => { // enable delivery events client._addChannelConfig({ - cid: channel.cid, - config: { ...channel.getConfig(), delivery_events: true }, + type: channel.type, + config: { ...channel.serverConfig, delivery_events: true }, }); channel.state.read[user.id] = initialReadState; const newerMessage = generateMsg({ @@ -2208,8 +2208,8 @@ describe('Channel _handleChannelEvent', function () { it("does not sync the delivery buffer upon other user's delivery confirmation", () => { // enable delivery events client._addChannelConfig({ - cid: channel.cid, - config: { ...channel.getConfig(), delivery_events: true }, + type: channel.type, + config: { ...channel.serverConfig, delivery_events: true }, }); channel.state.read[user.id] = initialReadState; @@ -3124,7 +3124,7 @@ describe('Channel lastMessage', async () => { beforeEach(async () => { client = await getClientWithUser(); channel = client.channel('messaging', uuidv4()); - client._addChannelConfig({ cid: channel.cid, config: {} }); + client._addChannelConfig({ type: channel.type, config: {} }); }); it('should return last message - messages are in order', () => { @@ -3205,7 +3205,7 @@ describe('Channel last_message_at', () => { beforeEach(async () => { client = await getClientWithUser(); channel = client.channel('messaging', uuidv4()); - client._addChannelConfig({ cid: channel.cid, config: {} }); + client._addChannelConfig({ type: channel.type, config: {} }); channel.state = new ChannelState(channel); }); @@ -3403,11 +3403,15 @@ describe('Channel.query', async () => { expect(channel.messageComposer.config.location.enabled).toBe(true); const sendRequestStub = sinon.stub(client.api, 'sendRequest'); + // `cid`/`id` are overridden to the channel under test: the server config cache is keyed by cid, + // so a response describing a different channel would land under that channel's key instead. sendRequestStub.onFirstCall().resolves({ body: { ...mockChannelQueryResponse, channel: { ...mockChannelQueryResponse.channel, + cid: channel.cid, + id: channel.id, config: { ...mockChannelQueryResponse.channel.config, shared_locations: false }, }, }, @@ -3419,6 +3423,8 @@ describe('Channel.query', async () => { ...mockChannelQueryResponse, channel: { ...mockChannelQueryResponse.channel, + cid: channel.cid, + id: channel.id, config: { ...mockChannelQueryResponse.channel.config, shared_locations: true }, }, }, diff --git a/test/unit/client.construction.test.ts b/test/unit/client.construction.test.ts index a62e6f05af..48530c0bbe 100644 --- a/test/unit/client.construction.test.ts +++ b/test/unit/client.construction.test.ts @@ -77,7 +77,7 @@ describe('StreamChat construction', () => { expect(client.mutedChannels).to.deep.equal([]); expect(client.mutedUsers).to.deep.equal([]); expect(client.activeChannels).to.deep.equal({}); - expect(client.configs).to.deep.equal({}); + expect(client.channelServerConfigs).to.deep.equal({}); expect(client.wsConnection).to.be.null; expect(client.wsPromise).to.be.null; @@ -101,7 +101,7 @@ describe('StreamChat construction', () => { expect(a.mutedChannels).to.not.equal(b.mutedChannels); expect(a.mutedUsers).to.not.equal(b.mutedUsers); expect(a.activeChannels).to.not.equal(b.activeChannels); - expect(a.configs).to.not.equal(b.configs); + expect(a.channelServerConfigs).to.not.equal(b.channelServerConfigs); expect(a.blockedUsers).to.not.equal(b.blockedUsers); expect(a.options).to.not.equal(b.options); expect(a.axiosInstance).to.not.equal(b.axiosInstance); diff --git a/test/unit/client.test.js b/test/unit/client.test.js index 050f7bcf58..348039661d 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -122,44 +122,47 @@ describe('StreamChat getInstance', () => { }); describe('StreamChat config(s) store', () => { - it('initializes configsStore and keeps configs access backward compatible', () => { + it('initializes channelServerConfigsStore and keeps configs access backward compatible', () => { const client = new StreamChat('key', 'secret'); - expect(client.configs).to.eql({}); - expect(client.configsStore.getLatestValue()).to.eql({ configs: {} }); + expect(client.channelServerConfigs).to.eql({}); + expect(client.channelServerConfigsStore.getLatestValue()).to.eql({ configs: {} }); const nextConfigs = { 'messaging:next': { typing_events: true } }; - client.configs = nextConfigs; + client.channelServerConfigs = nextConfigs; - expect(client.configs).to.equal(nextConfigs); - expect(client.configsStore.getLatestValue()).to.eql({ configs: nextConfigs }); + expect(client.channelServerConfigs).to.equal(nextConfigs); + expect(client.channelServerConfigsStore.getLatestValue()).to.eql({ + configs: nextConfigs, + }); }); - it('updates configsStore through _addChannelConfig when cache is enabled', () => { + it('updates channelServerConfigsStore through _addChannelConfig when cache is enabled', () => { const client = new StreamChat('key', 'secret'); client._addChannelConfig({ - cid: 'messaging:channel-1', + cid: 'messaging:general', config: { replies: true }, }); - expect(client.configsStore.getLatestValue()).to.eql({ + expect(client.channelServerConfigsStore.getLatestValue()).to.eql({ configs: { - 'messaging:channel-1': { replies: true }, + // Keyed by cid: a channel's `config_overrides` can make it differ from its siblings. + 'messaging:general': { replies: true }, }, }); }); - it('does not update configsStore through _addChannelConfig when cache is disabled', () => { + it('does not update channelServerConfigsStore through _addChannelConfig when cache is disabled', () => { const client = new StreamChat('key', 'secret'); client._cacheEnabled = () => false; client._addChannelConfig({ - cid: 'messaging:channel-1', + cid: 'messaging:general', config: { replies: true }, }); - expect(client.configsStore.getLatestValue()).to.eql({ configs: {} }); + expect(client.channelServerConfigsStore.getLatestValue()).to.eql({ configs: {} }); }); }); @@ -869,7 +872,7 @@ describe('StreamChat.queryChannels', async () => { .resolves({ channels: mockedChannelsQueryResponse }); await client.queryChannelsAndHydrate(); expect(Object.keys(client.activeChannels).length).to.be.equal(0); - expect(Object.keys(client.configs).length).to.be.equal(0); + expect(Object.keys(client.channelServerConfigs).length).to.be.equal(0); sinon.restore(); }); diff --git a/test/unit/configuration/ConfigController.test.ts b/test/unit/configuration/ConfigController.test.ts new file mode 100644 index 0000000000..2d7e7fe2f7 --- /dev/null +++ b/test/unit/configuration/ConfigController.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ConfigController } from '../../../src/configuration/ConfigController'; + +type Config = { + debounceMs: number; + durations: { error: number; info: number }; + pageSize: number; +}; + +const DEFAULTS: Config = { + debounceMs: 300, + durations: { error: 3000, info: 3000 }, + pageSize: 10, +}; + +const make = (options: Partial>[0]> = {}) => + new ConfigController({ defaults: DEFAULTS, ...options }); + +describe('ConfigController', () => { + describe('defaults', () => { + it('freezes the defaults it is handed, even unfrozen ones', () => { + // Every default constant in the package freezes itself, so this looks redundant — it is not. It is + // what stops the *next* entity reintroducing the leak that was found three times (F3, the + // notification durations under G8, and the reminder offsets) by forgetting to freeze its own. + const unfrozen: Config = { + debounceMs: 1, + durations: { error: 1, info: 1 }, + pageSize: 1, + }; + + make({ defaults: unfrozen }); + + expect(Object.isFrozen(unfrozen)).toBe(true); + expect(Object.isFrozen(unfrozen.durations)).toBe(true); + }); + }); + + describe('layering', () => { + it('applies defaults, built-in defaults, the slice, then constructor options', () => { + // `docs/instance-configuration.md` §3 order. The integrator's construction argument is stage 3 and + // outranks the declarative tree at stage 2; anything the SDK supplies on the instance's behalf is + // stage 1 and loses to both. + const controller = make({ + builtInDefaults: { pageSize: 15, debounceMs: 15 }, + constructorOptions: { pageSize: 20 }, + }); + + controller.initialize({ pageSize: 30, debounceMs: 40 }); + + expect(controller.value.pageSize).toBe(20); // construction argument wins + expect(controller.value.debounceMs).toBe(40); // slice beats the built-in default + }); + + it('lets a declarative slice override a built-in default', () => { + const controller = make({ builtInDefaults: { pageSize: 15 } }); + + controller.initialize({ pageSize: 30 }); + + expect(controller.value.pageSize).toBe(30); + }); + + it('ignores an explicit undefined rather than writing it', () => { + // `Partial` admits `undefined`, and a plain spread would write it — turning "I did not set this" + // into "I set this to nothing" and wiping the default underneath. + const controller = make({ constructorOptions: { pageSize: undefined } }); + + expect(controller.value.pageSize).toBe(10); + + controller.initialize({ debounceMs: undefined }); + + expect(controller.value.debounceMs).toBe(300); + }); + + it('drops a previous slice on re-derivation but keeps constructor options', () => { + const controller = make({ constructorOptions: { pageSize: 20 } }); + controller.initialize({ pageSize: 30 }); + + controller.initialize(); + + expect(controller.value.pageSize).toBe(20); + }); + + it('keeps nested siblings when mergeSlice is deep', () => { + const controller = make({ mergeSlice: 'deep' }); + + controller.initialize({ durations: { error: 99 } } as Partial); + + expect(controller.value.durations).toEqual({ error: 99, info: 3000 }); + }); + + it('seeds from initialSlice without running getBehaviourOverrides', () => { + // The hook is an override on the owning class; running it from the controller's constructor would + // reach a subclass before its own fields exist. + const getBehaviourOverrides = vi.fn(() => ({ pageSize: 999 })); + + const controller = make({ getBehaviourOverrides, initialSlice: { pageSize: 30 } }); + + expect(getBehaviourOverrides).not.toHaveBeenCalled(); + expect(controller.value.pageSize).toBe(30); + }); + + it('lets behaviour overrides outrank everything on initialize', () => { + const controller = make({ + constructorOptions: { pageSize: 20 }, + getBehaviourOverrides: () => ({ pageSize: 999 }), + }); + + controller.initialize({ pageSize: 30 }); + + expect(controller.value.pageSize).toBe(999); + }); + }); + + describe('writes', () => { + it('skips a patch that changes nothing', () => { + const controller = make(); + const listener = vi.fn(); + controller.state.subscribe(listener); + listener.mockClear(); + + controller.patch({ pageSize: DEFAULTS.pageSize }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('skips a derivation that changes nothing', () => { + const controller = make(); + const listener = vi.fn(); + controller.state.subscribe(listener); + listener.mockClear(); + + controller.initialize(); + controller.initialize(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('publishes once when something moves', () => { + const controller = make(); + const listener = vi.fn(); + controller.state.subscribe(listener); + listener.mockClear(); + + controller.patch({ pageSize: 42 }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(controller.value.pageSize).toBe(42); + }); + }); + + /** + * `serverAuthority.ts` says the restrictions have to run on every route a configuration can change by, + * because one applied at construction alone stops holding the first time anything updates the config. + * `patch` was the exception: without `retainPatches` it spreads into the resolved value and writes it + * directly, which is the one path that does not go through `resolve`. + */ + describe('applyAuthority on the patch path', () => { + const cap = (config: Config) => ({ + ...config, + pageSize: Math.min(config.pageSize, 25), + }); + + it('applies authority to a patch when patches are not retained', () => { + const controller = make({ applyAuthority: cap }); + + controller.patch({ pageSize: 500 }); + + expect(controller.value.pageSize).toBe(25); + }); + + it('applies authority to a patch when patches are retained', () => { + const controller = make({ applyAuthority: cap, retainPatches: true }); + + controller.patch({ pageSize: 500 }); + + expect(controller.value.pageSize).toBe(25); + // Retained, so the request survives for a later resolution to honour if the ceiling lifts. + expect(controller.requested.pageSize).toBe(500); + }); + + it('does not retain the request without retainPatches', () => { + const controller = make({ applyAuthority: cap }); + + controller.patch({ pageSize: 500 }); + + // Refused outright rather than remembered — the documented consequence of leaving retainPatches off + // on a controller a server can narrow. + expect(controller.requested.pageSize).toBe(DEFAULTS.pageSize); + }); + + it('still lets an explicit undefined clear a field', () => { + const controller = make({ applyAuthority: cap }); + + controller.patch({ debounceMs: undefined as never }); + + expect(controller.value.debounceMs).toBeUndefined(); + }); + }); + + describe('onChanged', () => { + it('is not called for the initial value', () => { + const onChanged = vi.fn(); + + make({ onChanged, initialSlice: { pageSize: 30 } }); + + expect(onChanged).not.toHaveBeenCalled(); + }); + + it('receives the new and previous values', () => { + const onChanged = vi.fn(); + const controller = make({ onChanged }); + + controller.patch({ pageSize: 42 }); + + expect(onChanged).toHaveBeenCalledTimes(1); + const [next, previous] = onChanged.mock.calls[0]; + expect(next.pageSize).toBe(42); + expect(previous.pageSize).toBe(10); + }); + + it('is not called when the write was skipped', () => { + const onChanged = vi.fn(); + const controller = make({ onChanged }); + + controller.patch({ pageSize: DEFAULTS.pageSize }); + + expect(onChanged).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/unit/configuration/InstanceConfigurationRegistry.test.ts b/test/unit/configuration/InstanceConfigurationRegistry.test.ts new file mode 100644 index 0000000000..eaa1a24d17 --- /dev/null +++ b/test/unit/configuration/InstanceConfigurationRegistry.test.ts @@ -0,0 +1,345 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { InstanceConfigurationRegistry } from '../../../src/configuration/InstanceConfigurationRegistry'; +import { chatLoggerSystem } from '../../../src/logger'; + +const noop = () => undefined; + +describe('InstanceConfigurationRegistry', () => { + let service: InstanceConfigurationRegistry; + + beforeEach(() => { + service = new InstanceConfigurationRegistry(); + }); + + describe('stores', () => { + it('creates a store lazily and returns the same one for a key', () => { + const first = service.getSetupState('channel'); + expect(first).toBe(service.getSetupState('channel')); + expect(service.getConfigState('channel')).toBe(service.getConfigState('channel')); + }); + + it('creates stores for a key the SDK does not define', () => { + expect(service.getSetupState('myWidget').getLatestValue()).toEqual({ + setupFunction: null, + }); + expect(service.getConfigState('myWidget').getLatestValue()).toEqual({ + config: null, + }); + }); + + it('keeps the setup store and the config store independent', () => { + service.setSetupFunction('channel', noop); + expect(service.getConfig('channel')).toBeNull(); + + service.setConfig('thread', { messagePaginator: { pageSize: 5 } }); + expect(service.getSetupFunction('thread')).toBeNull(); + }); + }); + + describe('setup functions', () => { + it('round-trips and clears', () => { + service.setSetupFunction('channel', noop); + expect(service.getSetupFunction('channel')).toBe(noop); + + service.setSetupFunction('channel', null); + expect(service.getSetupFunction('channel')).toBeNull(); + }); + + it('does not disturb other keys', () => { + service.setSetupFunction('channel', noop); + service.setSetupFunction('thread', null); + expect(service.getSetupFunction('channel')).toBe(noop); + }); + }); + + describe('declarative configuration', () => { + it('deep-merges rather than replacing', () => { + service.setConfig('messageComposer', { + drafts: { enabled: true }, + text: { publishTypingEvents: true }, + }); + service.setConfig('messageComposer', { text: { publishTypingEvents: false } }); + + expect(service.getConfig('messageComposer')).toEqual({ + drafts: { enabled: true }, + text: { publishTypingEvents: false }, + }); + }); + + it('fans a tree out per key', () => { + service.set({ + channel: { messagePaginator: { pageSize: 50 } }, + messageComposer: { drafts: { enabled: true } }, + }); + + expect(service.getConfig('channel')).toEqual({ + messagePaginator: { pageSize: 50 }, + }); + expect(service.getConfig('messageComposer')).toEqual({ drafts: { enabled: true } }); + }); + + it('keeps going past an empty entry instead of dropping the rest of the tree', () => { + service.set({ + channel: undefined, + messageComposer: { drafts: { enabled: true } }, + }); + + expect(service.getConfig('messageComposer')).toEqual({ drafts: { enabled: true } }); + }); + + // The old `setSetupFunctions` used `return` where it meant `continue`, so one unrecognized key + // silently discarded every remaining valid key in the same call. + it('applies later keys even when an earlier one is unrecognized', () => { + service.set({ + // @ts-expect-error deliberately unrecognized + bogus: { nope: true }, + messageComposer: { drafts: { enabled: true } }, + }); + + expect(service.getConfig('messageComposer')).toEqual({ drafts: { enabled: true } }); + }); + }); + + describe('reset', () => { + it('clears both tiers for one key and leaves other keys alone', () => { + service.setConfig('channel', { messagePaginator: { pageSize: 50 } }); + service.setSetupFunction('channel', noop); + service.setConfig('thread', { messagePaginator: { pageSize: 25 } }); + + service.reset('channel'); + + expect(service.getConfig('channel')).toBeNull(); + expect(service.getSetupFunction('channel')).toBeNull(); + expect(service.getConfig('thread')).toEqual({ messagePaginator: { pageSize: 25 } }); + }); + + it('clears every touched key when called with no argument', () => { + service.setConfig('channel', { messagePaginator: { pageSize: 50 } }); + service.setSetupFunction('thread', noop); + + service.reset(); + + expect(service.getConfig('channel')).toBeNull(); + expect(service.getSetupFunction('thread')).toBeNull(); + }); + + it('invokes each live instance’s reinitializeConfig after clearing', () => { + const order: string[] = []; + service.setSetupFunction('channel', () => () => order.push('teardown')); + service.registerInstance('channel', { + reinitializeConfig: () => order.push('reinitialize'), + }); + + service.reset('channel'); + + // Re-derivation must come last, so a buggy teardown cannot undo it. + expect(order).toEqual(['reinitialize']); + expect(service.getSetupFunction('channel')).toBeNull(); + }); + + it('contains a throwing reinitializeConfig', () => { + service.registerInstance('channel', { + reinitializeConfig: () => { + throw new Error('boom'); + }, + }); + + expect(() => service.reset('channel')).not.toThrow(); + }); + + it('stops reaching a deregistered instance', () => { + const reinitializeConfig = vi.fn(); + const deregister = service.registerInstance('channel', { reinitializeConfig }); + deregister(); + + service.reset('channel'); + + expect(reinitializeConfig).not.toHaveBeenCalled(); + expect(service.hasLiveInstances('channel')).toBe(false); + }); + }); + + it('keeps two services independent, so configuration cannot leak between clients', () => { + const other = new InstanceConfigurationRegistry(); + service.setConfig('messageComposer', { drafts: { enabled: true } }); + + expect(other.getConfig('messageComposer')).toBeNull(); + }); + + describe('diagnostics', () => { + // The service captures its logger at module scope, so spying on `getLogger` after import has no + // effect. Route the scope through a sink instead — that is the supported seam. + let records: { level: string; message: string }[]; + + beforeEach(() => { + records = []; + chatLoggerSystem.configureLoggers({ + 'instance-configuration': { + level: 'debug', + sink: (level, message) => records.push({ level, message }), + }, + }); + }); + + afterEach(() => { + chatLoggerSystem.restoreDefaults(); + }); + + it('warns for an unknown key with no subscriber', () => { + // A misspelling is a compile error now, so this can only be reached from JavaScript or past a + // cast — which is exactly when a warning is worth the noise. + service.setSetupFunction('cahnnel' as never, noop); + + expect(records).toHaveLength(1); + expect(records[0].level).toBe('warn'); + expect(records[0].message).toContain('a key this package does not define'); + }); + + it('stays silent for a configuration-only key', () => { + // `messagePaginator` takes configuration but no setup function, so it is absent from + // `BUILT_IN_INSTANCE_KEYS` — the guard has to read the config tree instead, or this warns. + service.setConfig('messagePaginator', { pageSize: 10 }); + + expect(records).toEqual([]); + }); + + it('stays silent for a built-in key', () => { + service.setSetupFunction('channel', noop); + service.setConfig('messageComposer', { drafts: { enabled: true } }); + + expect(records).toEqual([]); + }); + + it('stays silent for an unknown key that already has a subscriber', () => { + // What a downstream SDK's declared key looks like from in here: this class cannot see the + // augmentation, so a live instance is the only evidence the key is real. + service.registerInstance('myWidget' as never, { reinitializeConfig: noop }); + + service.setSetupFunction('myWidget' as never, noop); + + expect(records).toEqual([]); + }); + + it('warns — not debug — when a construction-only path is set after instances exist', () => { + service.registerInstance('channel', { reinitializeConfig: noop }); + + service.setConfig('channel', { + messagePaginator: { pageSize: 10, unreadReferencePolicy: 'read-state-only' }, + }); + + const warnings = records.filter(({ level }) => level === 'warn'); + expect(warnings).toHaveLength(1); + expect(warnings[0].message).toContain('messagePaginator.unreadReferencePolicy'); + // The value is still stored; the warning is that it cannot reach the existing instances. + expect(service.getConfig('channel')).toMatchObject({ + messagePaginator: { unreadReferencePolicy: 'read-state-only' }, + }); + }); + + it('does not warn about construction-only paths before anything is constructed', () => { + service.setConfig('channel', { + messagePaginator: { unreadReferencePolicy: 'read-state-only' }, + }); + + expect(records.filter(({ level }) => level === 'warn')).toEqual([]); + }); + + it('does not warn about paths that are not construction-only', () => { + service.registerInstance('channel', { reinitializeConfig: noop }); + + service.setConfig('channel', { messagePaginator: { pageSize: 50 } }); + + expect(records.filter(({ level }) => level === 'warn')).toEqual([]); + }); + + it('warns once, not once per identical re-registration', () => { + // A settings UI applying on every keystroke, or any `set()` on a render path, otherwise produced one + // warning per call about a value that had not moved. Nothing failed to apply the second time — the + // registration is unchanged — so there is nothing to report. + service.registerInstance('channel', { reinitializeConfig: noop }); + const register = () => + service.setConfig('channel', { + messagePaginator: { unreadReferencePolicy: 'read-state-only' }, + }); + + register(); + register(); + register(); + + expect(records.filter(({ level }) => level === 'warn')).toHaveLength(1); + }); + + it('warns again when the construction-only value actually changes', () => { + // The other half: silence must come from the value being unchanged, not from having warned before. + service.registerInstance('channel', { reinitializeConfig: noop }); + + service.setConfig('channel', { + messagePaginator: { unreadReferencePolicy: 'read-state-only' }, + }); + service.setConfig('channel', { + messagePaginator: { unreadReferencePolicy: 'snapshot' }, + }); + + expect(records.filter(({ level }) => level === 'warn')).toHaveLength(2); + }); + }); + + describe('caller-owned patch objects', () => { + it('does not alias a nested object the caller passed in', () => { + // `mergeWith` reuses a source subtree verbatim where the target has nothing, and a first registration + // has an empty target — so the registry used to hold the caller's own object. Mutating it afterwards + // then changed resolved configuration behind every live instance's back, with no notification. + const patch = { text: { maxLengthOnSend: 100 } }; + + service.setConfig('messageComposer', patch); + + const stored = service.getConfig('messageComposer') as typeof patch; + expect(stored.text).not.toBe(patch.text); + expect(stored).toEqual(patch); + + patch.text.maxLengthOnSend = 5; + expect( + (service.getConfig('messageComposer') as typeof patch).text.maxLengthOnSend, + ).toBe(100); + }); + + it('copies arrays rather than sharing them', () => { + const patch = { scheduledOffsetsMs: [1, 2, 3] }; + + service.setConfig('client', { reminders: patch } as never); + patch.scheduledOffsetsMs.push(4); + + expect( + (service.getConfig('client') as { reminders: typeof patch }).reminders + .scheduledOffsetsMs, + ).toEqual([1, 2, 3]); + }); + + it('passes functions through by reference, since a copy would be a different handler', () => { + // Configuration is not JSON: request handlers, filters and comparators are functions, and the point of + // registering one is that the SDK calls *that* function. + const sendMessageRequest = () => undefined; + + service.setConfig('channel', { requestHandlers: { sendMessageRequest } } as never); + + expect( + ( + service.getConfig('channel') as { + requestHandlers: { sendMessageRequest: unknown }; + } + ).requestHandlers.sendMessageRequest, + ).toBe(sendMessageRequest); + }); + + it('passes a class instance through rather than walking its internals', () => { + class Sentinel { + constructor(readonly id = 'kept') {} + } + const instance = new Sentinel(); + + service.setConfig('myWidget', { index: instance } as never); + + expect((service.getConfig('myWidget') as { index: unknown }).index).toBe(instance); + }); + }); +}); diff --git a/test/unit/configuration/applyInstanceConfiguration.test.ts b/test/unit/configuration/applyInstanceConfiguration.test.ts new file mode 100644 index 0000000000..9dec81e2b2 --- /dev/null +++ b/test/unit/configuration/applyInstanceConfiguration.test.ts @@ -0,0 +1,386 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { InstanceConfigurationRegistry } from '../../../src/configuration/InstanceConfigurationRegistry'; +import { applyInstanceConfiguration } from '../../../src/configuration/utils/applyInstanceConfiguration'; + +/** Stands in for a keyed instance. `applyInstanceConfiguration` never inspects its argument. */ +const instance = () => ({ widget: {} }) as never; + +describe('applyInstanceConfiguration', () => { + let service: InstanceConfigurationRegistry; + + beforeEach(() => { + service = new InstanceConfigurationRegistry(); + }); + + describe('setup functions', () => { + it('applies a function that was registered before subscribing', () => { + const setup = vi.fn(); + service.setSetupFunction('myWidget', setup); + + applyInstanceConfiguration({ args: instance(), config: service, key: 'myWidget' }); + + expect(setup).toHaveBeenCalledTimes(1); + }); + + it('applies a function that is registered after subscribing', () => { + const setup = vi.fn(); + applyInstanceConfiguration({ args: instance(), config: service, key: 'myWidget' }); + + service.setSetupFunction('myWidget', setup); + + expect(setup).toHaveBeenCalledTimes(1); + }); + + it('applies exactly once at subscribe time, despite watching two stores', () => { + const setup = vi.fn(); + service.setSetupFunction('myWidget', setup); + service.setConfig('myWidget', { a: 1 }); + + applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + applyConfig: vi.fn(), + }); + + expect(setup).toHaveBeenCalledTimes(1); + }); + + it('runs the previous teardown before applying a replacement', () => { + const order: string[] = []; + service.setSetupFunction('myWidget', () => { + order.push('first'); + return () => order.push('first-teardown'); + }); + applyInstanceConfiguration({ args: instance(), config: service, key: 'myWidget' }); + + service.setSetupFunction('myWidget', () => { + order.push('second'); + return () => order.push('second-teardown'); + }); + + expect(order).toEqual(['first', 'first-teardown', 'second']); + }); + + it('runs the teardown when the function is cleared', () => { + const teardown = vi.fn(); + service.setSetupFunction('myWidget', () => teardown); + applyInstanceConfiguration({ args: instance(), config: service, key: 'myWidget' }); + + service.setSetupFunction('myWidget', null); + + expect(teardown).toHaveBeenCalledTimes(1); + }); + + it('runs the teardown on unsubscribe', () => { + const teardown = vi.fn(); + service.setSetupFunction('myWidget', () => teardown); + const unsubscribe = applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + }); + + unsubscribe(); + + expect(teardown).toHaveBeenCalledTimes(1); + }); + + it('stops reacting after unsubscribe', () => { + const setup = vi.fn(); + const unsubscribe = applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + }); + unsubscribe(); + + service.setSetupFunction('myWidget', setup); + + expect(setup).not.toHaveBeenCalled(); + }); + + it('ignores changes to a different key', () => { + const setup = vi.fn(); + applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + applyConfig: setup, + }); + setup.mockClear(); + + service.setConfig('otherWidget', { a: 1 }); + service.setSetupFunction('otherWidget', vi.fn()); + + expect(setup).not.toHaveBeenCalled(); + }); + }); + + describe('declarative configuration', () => { + it('passes the registered slice to applyConfig', () => { + const applyConfig = vi.fn(); + service.setConfig('myWidget', { pollIntervalMs: 10 }); + + applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + applyConfig, + }); + + expect(applyConfig).toHaveBeenCalledWith({ pollIntervalMs: 10 }); + }); + + // Called even with nothing registered, and that matters: an instance may derive from inputs other + // than its own key — the shared `messagePaginator` key, or the server's channel config — so it has + // to be told to re-derive rather than skipped. + it('calls applyConfig with undefined when nothing is registered', () => { + const applyConfig = vi.fn(); + + applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + applyConfig, + }); + + expect(applyConfig).toHaveBeenCalledWith(undefined); + }); + + it('is safe to omit applyConfig while configuration is registered', () => { + service.setConfig('myWidget', { pollIntervalMs: 10 }); + + expect(() => + applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + }), + ).not.toThrow(); + }); + + it('applies declarative configuration before the setup function', () => { + const order: string[] = []; + service.setConfig('myWidget', { pollIntervalMs: 10 }); + service.setSetupFunction('myWidget', () => { + order.push('setup'); + }); + + applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + applyConfig: () => order.push('config'), + }); + + // Tier 2 runs last, so it can override any value tier 1 set. + expect(order).toEqual(['config', 'setup']); + }); + + it('re-runs the setup function when only the configuration changes, keeping tier 2 on top', () => { + const order: string[] = []; + service.setSetupFunction('myWidget', () => { + order.push('setup'); + return () => order.push('teardown'); + }); + applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + applyConfig: () => order.push('config'), + }); + order.length = 0; + + service.setConfig('myWidget', { pollIntervalMs: 10 }); + + expect(order).toEqual(['teardown', 'config', 'setup']); + }); + }); + + describe('error containment', () => { + it('contains a throwing setup function', () => { + service.setSetupFunction('myWidget', () => { + throw new Error('boom'); + }); + + expect(() => + applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + }), + ).not.toThrow(); + }); + + it('contains a throwing teardown, and does not retry it', () => { + const teardown = vi.fn(() => { + throw new Error('boom'); + }); + service.setSetupFunction('myWidget', () => teardown); + const unsubscribe = applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + }); + + expect(() => service.setSetupFunction('myWidget', null)).not.toThrow(); + unsubscribe(); + + expect(teardown).toHaveBeenCalledTimes(1); + }); + + it('contains a throwing applyConfig and still applies the setup function', () => { + const setup = vi.fn(); + service.setConfig('myWidget', { pollIntervalMs: 10 }); + service.setSetupFunction('myWidget', setup); + + expect(() => + applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + applyConfig: () => { + throw new Error('boom'); + }, + }), + ).not.toThrow(); + expect(setup).toHaveBeenCalledTimes(1); + }); + + it('remains usable after a setup function threw', () => { + applyInstanceConfiguration({ args: instance(), config: service, key: 'myWidget' }); + service.setSetupFunction('myWidget', () => { + throw new Error('boom'); + }); + + const recovered = vi.fn(); + service.setSetupFunction('myWidget', recovered); + + expect(recovered).toHaveBeenCalledTimes(1); + }); + }); + + describe('reset integration', () => { + it('invokes reinitializeConfig on reset, after the teardown', () => { + const order: string[] = []; + service.setSetupFunction('myWidget', () => () => order.push('teardown')); + applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + reinitializeConfig: () => order.push('reinitialize'), + }); + + service.reset('myWidget'); + + expect(order).toEqual(['teardown', 'reinitialize']); + }); + + it('does not invoke reinitializeConfig after unsubscribe', () => { + const reinitializeConfig = vi.fn(); + const unsubscribe = applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + reinitializeConfig, + }); + unsubscribe(); + + service.reset('myWidget'); + + expect(reinitializeConfig).not.toHaveBeenCalled(); + }); + + // One instance registered under three keys must re-derive **once** per reset, not once per key. + // Registration used to allocate a fresh `{ reinitializeConfig }` handle per key, so `reset`'s + // identity-based Set could never collapse them. + it('re-derives once per reset, not once per registered key', () => { + const reinitializeConfig = vi.fn(); + applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + reinitializeConfig, + alsoWatch: ['sharedThing', 'otherSharedThing'], + }); + + service.reset(); + + expect(reinitializeConfig).toHaveBeenCalledTimes(1); + }); + + it('a global reset does not run a cycle per cleared-but-empty watched key', () => { + const applyConfig = vi.fn(); + applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + applyConfig, + alsoWatch: ['sharedThing', 'otherSharedThing'], + }); + applyConfig.mockClear(); + + // Nothing was ever registered on any of the three keys, so clearing them changes nothing — + // but `partialNext` always allocates, so a plain `subscribe` on a watched store still fired. + service.reset(); + + expect(applyConfig).not.toHaveBeenCalled(); + }); + }); + + describe('alsoWatch', () => { + it('runs the full cycle when a watched store changes, so the setup function stays on top', () => { + const order: string[] = []; + service.setSetupFunction('myWidget', () => { + order.push('setup'); + }); + applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + applyConfig: () => order.push('config'), + alsoWatch: ['sharedThing'], + }); + order.length = 0; + + service.setConfig('sharedThing', { a: 1 }); + + // Not just `applyConfig` — the setup function is re-applied after it, preserving precedence. + expect(order).toEqual(['config', 'setup']); + }); + + it('does not fire on subscribe, only on change', () => { + const applyConfig = vi.fn(); + service.setConfig('sharedThing', { a: 1 }); + + applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + applyConfig, + alsoWatch: ['sharedThing'], + }); + + // Exactly one apply at wiring time, despite three stores being watched. + expect(applyConfig).toHaveBeenCalledTimes(1); + }); + + it('stops watching after unsubscribe', () => { + const applyConfig = vi.fn(); + const unsubscribe = applyInstanceConfiguration({ + args: instance(), + config: service, + key: 'myWidget', + applyConfig, + alsoWatch: ['sharedThing'], + }); + unsubscribe(); + applyConfig.mockClear(); + + service.setConfig('sharedThing', { a: 1 }); + + expect(applyConfig).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/unit/configuration/channel.config.test.ts b/test/unit/configuration/channel.config.test.ts new file mode 100644 index 0000000000..4fa1d57272 --- /dev/null +++ b/test/unit/configuration/channel.config.test.ts @@ -0,0 +1,498 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getClientWithUser } from '../test-utils/getClient'; +import type { Channel } from '../../../src/channel'; +import type { StreamChat } from '../../../src/client'; + +describe("the 'channel' configuration key", () => { + let client: StreamChat; + + beforeEach(() => { + client = getClientWithUser({ id: 'user' }); + }); + + const openChannel = (id = 'channel-id'): Channel => client.channel('messaging', id); + + describe('declarative configuration', () => { + it('reaches a channel created after registration', () => { + client.config.set({ channel: { messagePaginator: { pageSize: 50 } } }); + + expect(openChannel().messagePaginator.config.pageSize).toBe(50); + }); + + it('reaches a channel that already exists', () => { + const channel = openChannel(); + + client.config.set({ channel: { messagePaginator: { pageSize: 50 } } }); + + expect(channel.messagePaginator.config.pageSize).toBe(50); + }); + + it('configures the pinned-message paginator independently of the main list', () => { + client.config.set({ + channel: { + messagePaginator: { pageSize: 50 }, + pinnedMessagesPaginator: { pageSize: 25 }, + }, + }); + const channel = openChannel(); + + expect(channel.messagePaginator.config.pageSize).toBe(50); + expect(channel.pinnedMessagesPaginator.config.pageSize).toBe(25); + }); + + it('installs request handlers into configState', () => { + const sendMessageRequest = vi.fn(); + client.config.set({ channel: { requestHandlers: { sendMessageRequest } } }); + + expect(openChannel().configState.getLatestValue().requestHandlers).toEqual({ + sendMessageRequest, + }); + }); + + it('changes observable throttling behaviour, not just the stored value', () => { + // `stateThrottleMs` is read once, when the throttles are built — a plain assignment would be + // silently discarded, so this asserts the rebuild setter was actually used. + client.config.set({ channel: { messagePaginator: { stateThrottleMs: 250 } } }); + + expect(openChannel().messagePaginator.config.stateThrottleMs).toBe(250); + }); + + it('changes observable debouncing behaviour', () => { + const channel = openChannel(); + const setDebounceOptions = vi.spyOn(channel.messagePaginator, 'setDebounceOptions'); + + client.config.set({ channel: { messagePaginator: { debounceMs: 900 } } }); + + expect(setDebounceOptions).toHaveBeenCalledWith({ debounceMs: 900 }); + expect(channel.messagePaginator.config.debounceMs).toBe(900); + }); + }); + + describe('construction-time injection', () => { + it('applies a read-once field when registered before the channel exists', () => { + client.config.set({ + channel: { messagePaginator: { unreadReferencePolicy: 'read-state-only' } }, + }); + + const channel = openChannel(); + + // Read once by the constructor — reachable only because the channel passes the declarative slice + // through as a constructor option. + expect( + (channel.messagePaginator as unknown as { unreadReferencePolicy: string }) + .unreadReferencePolicy, + ).toBe('read-state-only'); + }); + + it('leaves an already-built channel on the default for a read-once field', () => { + const channel = openChannel(); + + client.config.set({ + channel: { messagePaginator: { unreadReferencePolicy: 'read-state-only' } }, + }); + + // Order-dependent by design; the registry warns in this case rather than failing silently. + expect( + (channel.messagePaginator as unknown as { unreadReferencePolicy: string }) + .unreadReferencePolicy, + ).toBe('snapshot'); + }); + + it('does not accept composer configuration under the channel key', () => { + client.config.set({ messageComposer: { drafts: { enabled: true } } }); + + // Composer configuration is a top-level key, never nested under `channel` — one path only. + expect(openChannel().messageComposer.config.drafts.enabled).toBe(true); + }); + }); + + describe('setup functions', () => { + it('runs for a channel created afterwards', () => { + const seen: string[] = []; + client.config.setSetupFunction('channel', ({ channel }) => { + seen.push(channel.cid); + }); + + openChannel('later'); + + expect(seen).toEqual(['messaging:later']); + }); + + it('runs for every channel that already exists', () => { + const a = openChannel('a'); + const b = openChannel('b'); + const seen: string[] = []; + + client.config.setSetupFunction('channel', ({ channel }) => { + seen.push(channel.cid); + }); + + expect(seen.sort()).toEqual([a.cid, b.cid].sort()); + }); + + it('overrides a declarative value for the same field', () => { + client.config.set({ channel: { messagePaginator: { pageSize: 50 } } }); + client.config.setSetupFunction('channel', ({ channel }) => { + channel.messagePaginator.updateConfig({ pageSize: 200 }); + }); + + // Tier 2 is applied after tier 1, so it wins. + expect(openChannel().messagePaginator.config.pageSize).toBe(200); + }); + + it('cannot break client.channel() by throwing', () => { + client.config.setSetupFunction('channel', () => { + throw new Error('boom'); + }); + + expect(() => openChannel()).not.toThrow(); + }); + + it('is torn down by _disconnect, exactly once', () => { + const teardown = vi.fn(); + client.config.setSetupFunction('channel', () => teardown); + const channel = openChannel(); + + channel._disconnect(); + channel._disconnect(); + + expect(teardown).toHaveBeenCalledTimes(1); + }); + + it('stops reaching a disconnected channel', () => { + const channel = openChannel(); + channel._disconnect(); + const setup = vi.fn(); + + client.config.setSetupFunction('channel', setup); + + expect(setup).not.toHaveBeenCalled(); + }); + }); + + describe('reset', () => { + it('returns the paginators to their derived baseline', () => { + client.config.set({ channel: { messagePaginator: { pageSize: 50 } } }); + const channel = openChannel(); + + client.config.reset('channel'); + + expect(channel.messagePaginator.config.pageSize).toBe(100); // channel message list default + }); + + it('clears declaratively installed request handlers', () => { + client.config.set({ + channel: { requestHandlers: { sendMessageRequest: vi.fn() } }, + }); + const channel = openChannel(); + + client.config.reset('channel'); + + expect(channel.configState.getLatestValue().requestHandlers).toBeUndefined(); + }); + + it('recovers even when a setup function left no teardown', () => { + const channel = openChannel(); + const original = channel.messagePaginator.config.itemOrderComparator; + client.config.setSetupFunction('channel', ({ channel: c }) => { + c.messagePaginator.updateConfig({ itemOrderComparator: () => 0 }); + // deliberately no teardown + }); + + client.config.reset('channel'); + + // Re-derivation re-installs it; a snapshot of config values never could have. + // + // Asserted as `toBe(original)` rather than `not.toBe`. The old expectation pinned an + // implementation detail — the overlay used to rebuild its closures on every install, so the + // restored comparator was merely an equivalent one. The paginator now memoizes them, which the + // guard in `initializeConfig` needs to recognise an unchanged derivation, and which makes this the + // stronger claim: the reset restored *the* comparator, not a lookalike. + expect(channel.messagePaginator.config.itemOrderComparator).toBe(original); + expect(typeof channel.messagePaginator.config.itemOrderComparator).toBe('function'); + const older = { id: 'a', created_at: new Date('2020-01-01') } as never; + const newer = { id: 'b', created_at: new Date('2021-01-01') } as never; + expect( + channel.messagePaginator.config.itemOrderComparator?.(older, newer), + ).toBeLessThan(0); + }); + + // A `Channel` is a live instance of three keys — `channel` plus the shared `messagePaginator` and + // `messageOperations`. One reset must re-derive it once. It used to re-derive 5–6 times: three + // distinct handles in `reset`'s de-duplicating Set, plus a cycle per watched key whose store + // published a `null → null` clear. + it('re-derives a channel exactly once, despite three registered keys', () => { + const channel = openChannel(); + const initializeConfig = vi.spyOn(channel, 'initializeConfig'); + + client.config.reset(); + + expect(initializeConfig).toHaveBeenCalledTimes(1); + }); + + it('re-derives exactly once when all three keys carry configuration', () => { + client.config.set({ + channel: { messagePaginator: { pageSize: 11 } }, + messageOperations: { failedSendCacheMaxSize: 7 }, + messagePaginator: { retryCount: 4 }, + }); + const channel = openChannel(); + // the probe can see the bug: all three registrations landed + expect(channel.messagePaginator.config.pageSize).toBe(11); + expect(channel.messagePaginator.config.retryCount).toBe(4); + expect(channel.messageOperations.config.failedSendCacheMaxSize).toBe(7); + const initializeConfig = vi.spyOn(channel, 'initializeConfig'); + + client.config.reset(); + + expect(initializeConfig).toHaveBeenCalledTimes(1); + }); + }); + + /** + * `typing_events` and `read_events` were the last two channel-type flags with no declarative + * counterpart. The SDK already gated its *actions* on them (`keystroke`, `markRead`, `markUnread`), so + * this is not a correctness gap being closed — it is the two things that were missing: an off-switch + * for the integrator, and one reconciled value to read instead of the raw flag. + */ + /** + * `Readonly` rejects `channel.config.availableCommands = []` but is shallow, so it accepts + * the nested form — which is the one that escapes the instance. The five gates below are copied on every + * derivation (the server's restrictions name them), so the frozen package defaults never covered them. + */ + /** + * The `channel` slice also carries `messagePaginator`, `pinnedMessagesPaginator` and + * `messageOperations`. Those are handed to the sub-objects directly; the channel used to resolve them + * onto its own config as well, where nothing read them — `ChannelConfig` does not declare them — and a + * registration against one notified every `configState` subscriber for a change that did not concern + * the channel. + */ + describe('resolves only its own fields', () => { + it('keeps the sub-object keys off channel.config', () => { + client.config.set({ + channel: { + messageOperations: { optimisticUpdate: false } as never, + messagePaginator: { pageSize: 50 }, + pinnedMessagesPaginator: { pageSize: 5 }, + }, + }); + + const channel = openChannel(); + + // The scoped overrides still reach the objects they are for. + expect(channel.messagePaginator.config.pageSize).toBe(50); + expect(channel.pinnedMessagesPaginator.config.pageSize).toBe(5); + // Asserted as absences rather than an exact key list, so adding a field to `ChannelConfig` does + // not fail this test for an unrelated reason. + expect(channel.config).not.toHaveProperty('messagePaginator'); + expect(channel.config).not.toHaveProperty('pinnedMessagesPaginator'); + expect(channel.config).not.toHaveProperty('messageOperations'); + }); + + it('does not notify channel.configState when a sub-object key is registered', () => { + const channel = openChannel(); + const listener = vi.fn(); + channel.configState.subscribe(listener); + listener.mockClear(); + + client.config.set({ channel: { messagePaginator: { pageSize: 50 } } }); + + expect(channel.messagePaginator.config.pageSize).toBe(50); + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('the resolved config is frozen', () => { + it.each([ + 'deliveryEvents', + 'readEvents', + 'replies', + 'typingEvents', + 'userMessageReminders', + ] as const)('refuses a nested write to %s', (gate) => { + const channel = openChannel(); + + expect(() => { + (channel.config[gate] as { enabled: boolean }).enabled = false; + }).toThrow(TypeError); + expect(channel.config[gate].enabled).toBe(true); + }); + + it('refuses a write to availableCommands, and does not share the array with the cache', () => { + const channel = openChannel(); + client.channelServerConfigsStore.partialNext({ + configs: { + [channel.cid]: { commands: [{ name: 'giphy' }] } as never, + }, + }); + + expect(channel.config.availableCommands).toEqual([{ name: 'giphy' }]); + expect(() => channel.config.availableCommands.push({ name: 'ban' })).toThrow( + TypeError, + ); + // A copy, so freezing the resolved value cannot freeze the cache every channel of the type reads. + expect(channel.config.availableCommands).not.toBe( + client.channelServerConfigs[channel.cid]?.commands, + ); + expect(Object.isFrozen(client.channelServerConfigs[channel.cid]?.commands)).toBe( + false, + ); + }); + + it('leaves the declarative slice writable — only the resolved copy is frozen', () => { + // The order in `applyAuthority` matters: copy, then freeze. Freezing in place reached the subtree + // held by `client.config`, and a paginator resolving from that same object could no longer merge + // into it. + client.config.set({ channel: { messagePaginator: { pageSize: 50 } } }); + const channel = openChannel(); + + expect(channel.messagePaginator.config.pageSize).toBe(50); + expect(Object.isFrozen(client.config.getConfig('channel')?.messagePaginator)).toBe( + false, + ); + }); + }); + + describe('typing and read events', () => { + const withServerConfig = (config: Record, id = 'channel-id') => { + client.channelServerConfigsStore.partialNext({ + configs: { [`messaging:${id}`]: config as never }, + }); + return openChannel(id); + }; + + it('defaults both to enabled when the server states nothing', () => { + const { readEvents, typingEvents } = openChannel().configState.getLatestValue(); + + expect(typingEvents.enabled).toBe(true); + expect(readEvents.enabled).toBe(true); + }); + + it.each([ + { expected: true, requested: undefined, server: undefined }, + { expected: false, requested: false, server: undefined }, + { expected: false, requested: undefined, server: false }, + { expected: false, requested: true, server: false }, + { expected: true, requested: undefined, server: true }, + { expected: false, requested: false, server: true }, + { expected: true, requested: true, server: true }, + ])( + 'ANDs both gates: requested=$requested server=$server -> $expected', + ({ expected, requested, server }) => { + client.config.set({ + channel: { + readEvents: { enabled: requested }, + typingEvents: { enabled: requested }, + }, + }); + + const channel = withServerConfig({ + read_events: server, + typing_events: server, + }); + const { readEvents, typingEvents } = channel.configState.getLatestValue(); + + expect(typingEvents.enabled).toBe(expected); + expect(readEvents.enabled).toBe(expected); + }, + ); + + it('re-derives when the server config arrives after construction', () => { + // The case the subscription exists for: a channel built before it has been queried reads + // `serverConfig` as undefined, so the restriction states nothing and the defaults stand. Without + // re-deriving, an app that disables read events server-side keeps a channel that believes they + // are on. + const channel = openChannel(); + expect(channel.configState.getLatestValue().readEvents.enabled).toBe(true); + + client.channelServerConfigsStore.partialNext({ + configs: { [channel.cid]: { read_events: false } as never }, + }); + + expect(channel.configState.getLatestValue().readEvents.enabled).toBe(false); + }); + + describe('_isTypingIndicatorsEnabled', () => { + // The other two axes have to be satisfied or the gate short-circuits before reaching configuration + // and the assertions below pass for the wrong reason — which they did, until reverting the gate to + // the raw server flag failed to break anything. + beforeEach(() => { + client.wsConnection = { isHealthy: true } as never; + client.user = { id: 'user' } as never; + }); + + it('is true when both the server and the integrator allow it', () => { + const channel = withServerConfig({ typing_events: true }); + + expect(channel._isTypingIndicatorsEnabled()).toBe(true); + }); + + it('is false when the integrator disables them, with a permissive server', () => { + client.config.set({ channel: { typingEvents: { enabled: false } } }); + const channel = withServerConfig({ typing_events: true }); + + expect(channel._isTypingIndicatorsEnabled()).toBe(false); + }); + + it('is false when the server disables them, whatever the integrator asked', () => { + client.config.set({ channel: { typingEvents: { enabled: true } } }); + const channel = withServerConfig({ typing_events: false }); + + expect(channel._isTypingIndicatorsEnabled()).toBe(false); + }); + }); + + it('refuses markRead when the integrator disables read events', async () => { + client.config.set({ channel: { readEvents: { enabled: false } } }); + const channel = withServerConfig({ read_events: true }); + channel.initialized = true; + + await expect(channel.markRead()).rejects.toThrow('Read events are disabled'); + }); + + it('leaves the sibling group alone when only one is registered', () => { + // `mergeSlice: 'deep'` — naming one nested group must not drop the other. + client.config.set({ channel: { typingEvents: { enabled: false } } }); + + const { readEvents, typingEvents } = openChannel().configState.getLatestValue(); + + expect(typingEvents.enabled).toBe(false); + expect(readEvents.enabled).toBe(true); + }); + + it('mirrors the server command list, which the integrator cannot set', () => { + // A list, not a gate: nothing to AND and no intent to express, so the server's answer *is* the + // value. It lives on the resolved config anyway so consumers never need a second place to look. + const commands = [{ args: '', description: 'Ban', name: 'ban', set: 'moderation' }]; + const channel = withServerConfig({ commands }); + + expect(channel.config.availableCommands).toEqual(commands); + // absent from the declarative tree, so registering it is not offered and does not take + client.config.set({ channel: { availableCommands: [] } } as never); + expect(channel.config.availableCommands).toEqual(commands); + }); + + it('ANDs the replies gate like the others', () => { + client.config.set({ channel: { replies: { enabled: true } } }); + const channel = withServerConfig({ replies: false }); + + expect(channel.config.replies.enabled).toBe(false); + }); + + it('restores both on reset', () => { + client.config.set({ + channel: { + readEvents: { enabled: false }, + typingEvents: { enabled: false }, + }, + }); + const channel = openChannel(); + + client.config.reset(); + + const { readEvents, typingEvents } = channel.configState.getLatestValue(); + expect(typingEvents.enabled).toBe(true); + expect(readEvents.enabled).toBe(true); + }); + }); +}); diff --git a/test/unit/configuration/client.config.test.ts b/test/unit/configuration/client.config.test.ts new file mode 100644 index 0000000000..3478e58cda --- /dev/null +++ b/test/unit/configuration/client.config.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, it, vi } from 'vitest'; +import { StreamChat } from '../../../src/client'; +import { DEFAULT_NOTIFICATION_MANAGER_CONFIG } from '../../../src/notifications/configuration'; +import { DEFAULT_REMINDER_MANAGER_CONFIG } from '../../../src/reminders/ReminderManager'; +import { applyInstanceConfiguration } from '../../../src/configuration/utils/applyInstanceConfiguration'; + +describe('client.config', () => { + it('exposes config', () => { + const c = new StreamChat('k'); + expect(c.config).toBeDefined(); + }); + + it('applies a client setup function immediately, to an already-built client', () => { + const c = new StreamChat('k'); + const seen: string[] = []; + c.config.setSetupFunction('client', ({ client }) => { + seen.push(typeof client.reminders); + return () => seen.push('teardown'); + }); + expect(seen).toEqual(['object']); + c.config.setSetupFunction('client', null); + expect(seen).toEqual(['object', 'teardown']); + }); + + it('declarative reminders config reaches the manager', () => { + const c = new StreamChat('k'); + c.config.set({ client: { reminders: { scheduledOffsetsMs: [1234] } } }); + expect(c.reminders.configState.getLatestValue().scheduledOffsetsMs).toEqual([1234]); + }); + + it('options.config seeds before managers are built', () => { + const c = new StreamChat('k', { + config: { client: { reminders: { scheduledOffsetsMs: [42] } } }, + }); + expect(c.reminders.configState.getLatestValue().scheduledOffsetsMs).toEqual([42]); + }); + + it('setConfig deep-merges rather than replacing', () => { + const c = new StreamChat('k'); + c.config.setConfig('messageComposer', { drafts: { enabled: true } }); + c.config.setConfig('messageComposer', { text: { publishTypingEvents: false } }); + expect(c.config.getConfig('messageComposer')).toEqual({ + drafts: { enabled: true }, + text: { publishTypingEvents: false }, + }); + }); + + it('a throwing setup function is contained', () => { + const c = new StreamChat('k'); + expect(() => + c.config.setSetupFunction('client', () => { + throw new Error('boom'); + }), + ).not.toThrow(); + }); + + it('custom keys work in both tiers, in either order', () => { + const c = new StreamChat('k'); + const applied: unknown[] = []; + c.config.setConfig('myWidget', { pollIntervalMs: 10 }); + // subscriber arrives after the setter + const unsub = applyInstanceConfiguration({ + args: { widget: {} }, + config: c.config, + key: 'myWidget', + applyConfig: (cfg: unknown) => applied.push(cfg), + }); + expect(applied).toEqual([{ pollIntervalMs: 10 }]); + unsub(); + }); + + it('two clients do not share configuration', () => { + const a = new StreamChat('k'); + const b = new StreamChat('k2'); + a.config.setConfig('messageComposer', { drafts: { enabled: true } }); + expect(b.config.getConfig('messageComposer')).toBeNull(); + }); + + it('disconnectUser runs the client teardown exactly once', async () => { + const c = new StreamChat('k'); + const teardown = vi.fn(); + c.config.setSetupFunction('client', () => teardown); + await c.disconnectUser().catch(() => undefined); + await c.disconnectUser().catch(() => undefined); + expect(teardown).toHaveBeenCalledTimes(1); + }); + + // `initializeManagerConfig` is a *derivation*, not a patch. It used to be four + // `if (config?.x) manager.updateConfig(x)` guards, which made both of these fail: `reset` clears the + // declarative store before instances re-derive, so every guard was false and nothing was restored. + /** + * Every leaf owns its derivation, so the client only routes slices. Before, the client spread each + * manager's defaults itself — which is how `reset()` became a no-op for this key (F4) and how a + * registered `notifications.sortComparator` became unremovable (G8). + */ + describe('each manager derives its own configuration', () => { + it.each([ + ['reminders', (c: StreamChat) => c.reminders], + ['threads', (c: StreamChat) => c.threads], + ['messageDeliveryReporter', (c: StreamChat) => c.messageDeliveryReporter], + ['notifications', (c: StreamChat) => c.notifications], + ])('%s exposes initializeConfig', (_name, pick) => { + expect(typeof pick(new StreamChat('k')).initializeConfig).toBe('function'); + }); + + it('derives from defaults when called with nothing', () => { + const c = new StreamChat('k'); + const defaultThrottle = c.threads.config.connectionRecoveryThrottleMs; + c.threads.updateConfig({ connectionRecoveryThrottleMs: 999 }); + + c.threads.initializeConfig(); + + // A derivation, not a patch: the imperative value is gone rather than merged over. + expect(c.threads.config.connectionRecoveryThrottleMs).toBe(defaultThrottle); + }); + + it('applies a slice over the defaults', () => { + const c = new StreamChat('k'); + + c.reminders.initializeConfig({ stopTimerRefreshBoundaryMs: 4242 }); + + expect(c.reminders.config.stopTimerRefreshBoundaryMs).toBe(4242); + // Untouched fields come from the defaults, not from whatever was there before. + expect(c.reminders.config.scheduledOffsetsMs).toEqual( + DEFAULT_REMINDER_MANAGER_CONFIG.scheduledOffsetsMs, + ); + }); + + it('rebuilds the read throttle, not just the stored value', () => { + // The throttle captures its interval in a closure, so storing a new number is not enough. Asserted + // on the throttle's identity rather than on whichever method happens to rebuild it — the whole + // point of the `onChanged` hook is that no single route owns the pairing any more. + const c = new StreamChat('k'); + const reporter = c.messageDeliveryReporter as unknown as { + throttledMarkRead: unknown; + }; + const before = reporter.throttledMarkRead; + + c.messageDeliveryReporter.initializeConfig({ markAsReadThrottleTimeoutMs: 77 }); + + expect(c.messageDeliveryReporter.config.markAsReadThrottleTimeoutMs).toBe(77); + expect(reporter.throttledMarkRead).not.toBe(before); + }); + + it('rebuilds it on an imperative update too, which it did not always', () => { + const c = new StreamChat('k'); + const reporter = c.messageDeliveryReporter as unknown as { + throttledMarkRead: unknown; + }; + const before = reporter.throttledMarkRead; + + c.messageDeliveryReporter.updateConfig({ markAsReadThrottleTimeoutMs: 88 }); + + expect(reporter.throttledMarkRead).not.toBe(before); + }); + + it('still publishes nothing when the derivation has not moved', () => { + const c = new StreamChat('k'); + const listener = vi.fn(); + c.reminders.configState.subscribe(listener); + listener.mockClear(); + + c.reminders.initializeConfig(); + c.reminders.initializeConfig(); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('notifications — a field absent from the defaults', () => { + // Every other manager config has all-required fields, all present in its `DEFAULT_*_CONFIG`, so the + // derivation's spread overwrites each one and a reset lands. `NotificationManagerConfig` is the sole + // exception: `sortComparator` is optional and has no default, so there was nothing to overwrite it + // with — and `updateConfig` deep-merges, which cannot express a removal. Once registered it survived + // every reset for the client's lifetime. + it('reset clears a declaratively registered sortComparator', () => { + const c = new StreamChat('k'); + const sortComparator = () => 0; + + c.config.set({ client: { notifications: { sortComparator } } }); + // the probe has to be able to see the bug: assert the registration landed first + expect(c.notifications.config.sortComparator).toBe(sortComparator); + + c.config.reset(); + + expect(c.notifications.config.sortComparator).toBeUndefined(); + }); + + it('re-registering without it does not drop it — the registry merges', () => { + // Worth pinning, because it is the natural thing to assume and it is wrong. The *derivation* is a + // replacement, but the registry `setConfig` writes into is a deep merge, so the slice still + // carries `sortComparator` on the next read. `reset()` is what clears a registration. + const c = new StreamChat('k'); + const sortComparator = () => 0; + c.config.set({ client: { notifications: { sortComparator } } }); + + c.config.setConfig('client', { notifications: { durations: { error: 10 } } }); + + expect(c.notifications.config.sortComparator).toBe(sortComparator); + expect(c.notifications.config.durations.error).toBe(10); + }); + + it('still deep-merges durations rather than replacing them', () => { + const c = new StreamChat('k'); + const defaultInfo = c.notifications.config.durations.info; + + c.config.set({ client: { notifications: { durations: { error: 42 } } } }); + + expect(c.notifications.config.durations.error).toBe(42); + expect(c.notifications.config.durations.info).toBe(defaultInfo); + }); + + it('cannot corrupt the package default through config', () => { + // An untouched subtree *is* the module default, by reference — that is how the merge works and it + // is cheap. What makes it safe is the freeze, so the guarantee is asserted rather than the + // mechanism: an earlier version of this test compared identities, which said nothing about whether + // a write could get through. + const a = new StreamChat('k'); + const b = new StreamChat('k2'); + const before = { ...b.notifications.config.durations }; + + expect(() => { + (a.notifications.config.durations as { error: number }).error = 1; + }).toThrow(TypeError); + + expect(b.notifications.config.durations).toEqual(before); + expect(DEFAULT_NOTIFICATION_MANAGER_CONFIG.durations).toEqual(before); + }); + + it('stays safe after a derivation that actually publishes', () => { + // Dropping a `sortComparator` is the case that republishes *without* naming `durations`, so it is + // the one where an unfrozen default would slip into the store. + const c = new StreamChat('k'); + c.config.set({ client: { notifications: { sortComparator: () => 0 } } }); + + c.config.reset(); + + expect(c.notifications.config.sortComparator).toBeUndefined(); + expect(() => { + (c.notifications.config.durations as { error: number }).error = 1; + }).toThrow(TypeError); + }); + + it('an imperative updateConfig still merges, so a caller keeps patch semantics', () => { + const c = new StreamChat('k'); + const sortComparator = () => 0; + + c.notifications.updateConfig({ sortComparator }); + c.notifications.updateConfig({ durations: { error: 5 } }); + + expect(c.notifications.config.sortComparator).toBe(sortComparator); + expect(c.notifications.config.durations.error).toBe(5); + }); + }); + + describe('reset restores the managers to their defaults', () => { + it('reverts every manager the client key reaches', () => { + const c = new StreamChat('k'); + const defaults = { + reminders: c.reminders.config.scheduledOffsetsMs, + threads: c.threads.config.connectionRecoveryThrottleMs, + messageDelivery: + c.messageDeliveryReporter.config.maxDeliveredMessageCountInPayload, + notifications: c.notifications.config.durations.error, + }; + + c.config.set({ + client: { + messageDelivery: { maxDeliveredMessageCountInPayload: 7 }, + notifications: { durations: { error: 99_999 } }, + reminders: { scheduledOffsetsMs: [1, 2, 3] }, + threads: { connectionRecoveryThrottleMs: 5 }, + }, + }); + + // the probe has to be able to see the bug: assert the registration landed first + expect(c.reminders.config.scheduledOffsetsMs).toEqual([1, 2, 3]); + expect(c.threads.config.connectionRecoveryThrottleMs).toBe(5); + expect(c.messageDeliveryReporter.config.maxDeliveredMessageCountInPayload).toBe(7); + expect(c.notifications.config.durations.error).toBe(99_999); + + c.config.reset(); + + expect(c.reminders.config.scheduledOffsetsMs).toEqual(defaults.reminders); + expect(c.threads.config.connectionRecoveryThrottleMs).toBe(defaults.threads); + expect(c.messageDeliveryReporter.config.maxDeliveredMessageCountInPayload).toBe( + defaults.messageDelivery, + ); + expect(c.notifications.config.durations.error).toBe(defaults.notifications); + }); + + it('drops a field removed from the tree, which a merge cannot express', () => { + const c = new StreamChat('k'); + const defaultThrottle = c.threads.config.connectionRecoveryThrottleMs; + + c.config.set({ client: { threads: { connectionRecoveryThrottleMs: 5 } } }); + expect(c.threads.config.connectionRecoveryThrottleMs).toBe(5); + + // re-register the key without the field — the derivation must fall back to the default + c.config.reset('client'); + c.config.set({ client: { reminders: { scheduledOffsetsMs: [1] } } }); + + expect(c.threads.config.connectionRecoveryThrottleMs).toBe(defaultThrottle); + }); + + it('keeps sibling notification durations when only one is registered', () => { + const c = new StreamChat('k'); + const defaultInfo = c.notifications.config.durations.info; + + c.config.set({ client: { notifications: { durations: { error: 10_000 } } } }); + + expect(c.notifications.config.durations.error).toBe(10_000); + expect(c.notifications.config.durations.info).toBe(defaultInfo); + }); + }); +}); diff --git a/test/unit/configuration/configBoundaries.test.ts b/test/unit/configuration/configBoundaries.test.ts new file mode 100644 index 0000000000..68c36083ef --- /dev/null +++ b/test/unit/configuration/configBoundaries.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getClientWithUser } from '../test-utils/getClient'; +import { MessageComposer } from '../../../src/messageComposer/messageComposer'; + +/** + * Three boundaries, one rule each, all found by a second review pass over the same feature. + * + * The first two are the other half of the fix recorded as **F9**, which copied caller patches at + * `InstanceConfigurationRegistry.setConfig` on the reasoning that it was "the single boundary at which + * caller objects enter the SDK". It is not: `MessageComposer.updateConfig` and the composer's + * constructor argument are two more, and both are read on *every* resolution for the composer's whole + * life, so an aliased object there is longer-lived than one in the registry. + * + * The third is the freeze guarantee. `deepFreezeConfig(DEFAULT_COMPOSER_CONFIG)` only protects subtrees + * the merge never copies — and `serverRestrictions` names `location` while `serverUpperBounds` names + * `text` on every single resolution, so those two were always copied and always writable. They are also + * the two subtrees callers actually touch. + */ +describe('configuration boundaries', () => { + describe('caller-owned patches do not stay aliased', () => { + it('updateConfig copies the patch', () => { + const client = getClientWithUser({ id: 'user' }); + const composer = client.channel('messaging', 'c1').messageComposer; + + const patch = { text: { maxLengthOnSend: 100 } }; + composer.updateConfig(patch); + expect(composer.config.text.maxLengthOnSend).toBe(100); + + patch.text.maxLengthOnSend = 5; + composer.updateConfig({}); // any ordinary re-resolution + + expect(composer.config.text.maxLengthOnSend).toBe(100); + }); + + it('the constructor config argument is copied', () => { + const client = getClientWithUser({ id: 'user' }); + const channel = client.channel('messaging', 'c2'); + + const explicit = { text: { maxLengthOnSend: 100 } }; + const composer = new MessageComposer({ + client, + compositionContext: channel, + config: explicit, + }); + expect(composer.config.text.maxLengthOnSend).toBe(100); + + explicit.text.maxLengthOnSend = 7; + composer.updateConfig({}); + + expect(composer.config.text.maxLengthOnSend).toBe(100); + }); + + it('still passes functions through by reference', () => { + const client = getClientWithUser({ id: 'user' }); + const composer = client.channel('messaging', 'c3').messageComposer; + const findURLFn = () => []; + + composer.updateConfig({ linkPreviews: { findURLFn } }); + + expect(composer.config.linkPreviews.findURLFn).toBe(findURLFn); + }); + }); + + describe('the published composer config is frozen throughout', () => { + it('freezes every subtree, not only the ones the merge left untouched', () => { + const client = getClientWithUser({ id: 'user' }); + const composer = client.channel('messaging', 'c4').messageComposer; + + for (const [key, value] of Object.entries(composer.config)) { + if (value && typeof value === 'object') { + expect(Object.isFrozen(value), `config.${key} should be frozen`).toBe(true); + } + } + }); + + it('throws on a nested write to text, the subtree serverUpperBounds always copies', () => { + const client = getClientWithUser({ id: 'user' }); + const composer = client.channel('messaging', 'c5').messageComposer; + + expect(() => { + (composer.config.text as { maxLengthOnSend?: number }).maxLengthOnSend = 5; + }).toThrow(TypeError); + expect(composer.config.text.maxLengthOnSend).not.toBe(5); + }); + + it('stays frozen after a resolution that actually moves a value', () => { + const client = getClientWithUser({ id: 'user' }); + const composer = client.channel('messaging', 'c6').messageComposer; + + composer.updateConfig({ text: { maxLengthOnSend: 42 } }); + + expect(composer.config.text.maxLengthOnSend).toBe(42); + expect(Object.isFrozen(composer.config.text)).toBe(true); + }); + }); + + describe("the 'client' key survives a disconnect/connect cycle", () => { + // `disconnectUser` releases the subscription to run the setup function's teardown. It used to clear + // the handle and never re-arm, so on a client that reconnects — `getInstance` hands the same object + // back, and disconnect/connect is the documented multi-user flow — the key went permanently dead: + // `setConfig`, `setSetupFunction` and `reset` all stopped reaching any manager, silently. + const reconnect = async (client: ReturnType) => { + vi.spyOn(client, 'closeConnection').mockResolvedValue(undefined as never); + await client.disconnectUser().catch(() => undefined); + client._setUser({ id: 'user' }); + }; + + it('declarative configuration still reaches the managers', async () => { + const client = getClientWithUser({ id: 'user' }); + await reconnect(client); + + client.config.setConfig('client', { + reminders: { stopTimerRefreshBoundaryMs: 2222 }, + }); + + expect(client.reminders.config.stopTimerRefreshBoundaryMs).toBe(2222); + }); + + it('a setup function registered afterwards still applies', async () => { + const client = getClientWithUser({ id: 'user' }); + await reconnect(client); + + const setup = vi.fn(); + client.config.setSetupFunction('client', setup); + + expect(setup).toHaveBeenCalledTimes(1); + }); + + it('reset re-derives the managers again', async () => { + const client = getClientWithUser({ id: 'user' }); + const defaultBoundary = client.reminders.config.stopTimerRefreshBoundaryMs; + await reconnect(client); + + client.config.setConfig('client', { + reminders: { stopTimerRefreshBoundaryMs: 3333 }, + }); + // Asserted before the reset too, so this cannot pass by the value never having moved. + expect(client.reminders.config.stopTimerRefreshBoundaryMs).toBe(3333); + + client.config.reset(); + + expect(client.reminders.config.stopTimerRefreshBoundaryMs).toBe(defaultBoundary); + }); + + it('does not double-wire when connectUser follows the constructor', () => { + const client = getClientWithUser({ id: 'user' }); + const setup = vi.fn(); + client.config.setSetupFunction('client', setup); + setup.mockClear(); + + client._setUser({ id: 'user' }); + + expect(setup).not.toHaveBeenCalled(); + }); + }); +}); + +describe('the composer resolves through the shared controller', () => { + /** + * Nine of `MessageComposer`'s config members were the generic pipeline under different names. Two are + * genuinely extra, and are declared hooks the controller offers and only this entity passes: + * `retainPatches` and `applyAuthority`. + * + * A third, `finalizeRequest`, was added for `commands.sendValidator` and then deleted along with the + * `applyCommandValidatorOverride` it called: both reached the same answer as the plain deep merge on + * every layer shape, because a merge only writes keys that are present, so a silent later layer cannot + * erase an earlier choice. The validator case below is the guard that the *behaviour* still holds. + */ + it('retains an updateConfig request across a re-resolution (retainPatches)', () => { + const client = getClientWithUser({ id: 'user' }); + client._addChannelConfig({ + cid: 'messaging:c-layer', + config: { shared_locations: false } as never, + }); + const composer = client.channel('messaging', 'c-layer').messageComposer; + // Without this the composer never hears the server change — it is the subscription, not the + // controller, that decides *when* to re-resolve. + composer.registerSubscriptions(); + + composer.updateConfig({ location: { enabled: true } }); + // The server says no, so the effective value is false… + expect(composer.config.location.enabled).toBe(false); + // …but the request is retained, which is the whole point of DV-18. + expect(composer.requestedConfig.location.enabled).toBe(true); + + client._addChannelConfig({ + cid: 'messaging:c-layer', + config: { shared_locations: true } as never, + }); + + // The server changes its mind and the original request re-emerges, rather than having been + // overwritten by the server's earlier `false`. + expect(composer.config.location.enabled).toBe(true); + }); + + it('drops retained requests on reset, but not on a re-resolution', () => { + const client = getClientWithUser({ id: 'user' }); + const composer = client.channel('messaging', 'c-reset').messageComposer; + composer.registerSubscriptions(); + const defaultMax = composer.config.text.maxLengthOnSend; + composer.updateConfig({ text: { maxLengthOnSend: 7 } }); + + composer.applyServerRestrictions(); // a re-resolution — keeps the layer + expect(composer.config.text.maxLengthOnSend).toBe(7); + + client.config.reset(); // a reset — clears it + expect(composer.config.text.maxLengthOnSend).toBe(defaultMax); + }); + + it('picks a sendValidator from the most specific layer that names one', () => { + const client = getClientWithUser({ id: 'user' }); + const declarative = () => undefined; + const imperative = () => undefined; + client.config.set({ messageComposer: { commands: { sendValidator: declarative } } }); + const composer = client.channel('messaging', 'c-validator').messageComposer; + + expect(composer.config.commands.sendValidator).toBe(declarative); + + composer.updateConfig({ commands: { sendValidator: imperative } }); + + // A function is chosen, never merged — and the most specific layer naming one wins. + expect(composer.config.commands.sendValidator).toBe(imperative); + }); + + it.each([ + ['a later layer that says nothing about commands', { text: { enabled: true } }], + ['a later layer naming commands without a validator', { commands: {} }], + [ + 'a later layer setting the validator to undefined', + { commands: { sendValidator: undefined } }, + ], + ])('does not lose an earlier validator to %s', (_name, laterLayer) => { + // These three shapes are exactly what `applyCommandValidatorOverride` was written to protect against. + // The merge handles them on its own — it only writes keys that are present, and skips `undefined` — + // which is why the helper was deleted. Pinned here so the deletion cannot silently regress. + const client = getClientWithUser({ id: 'user' }); + const declarative = () => undefined; + client.config.set({ messageComposer: { commands: { sendValidator: declarative } } }); + const composer = client.channel( + 'messaging', + `c-silent-${_name.length}`, + ).messageComposer; + + composer.updateConfig(laterLayer as never); + + expect(composer.config.commands.sendValidator).toBe(declarative); + }); + + it('applies the server ceiling on every resolution (applyAuthority)', () => { + const client = getClientWithUser({ id: 'user' }); + client._addChannelConfig({ + cid: 'messaging:c-bounds', + config: { max_message_length: 100 } as never, + }); + const composer = client.channel('messaging', 'c-bounds').messageComposer; + + composer.updateConfig({ text: { maxLengthOnSend: 5000 } }); + + // Tightest wins, and it is re-applied rather than accumulated, so the request stays 5000. + expect(composer.config.text.maxLengthOnSend).toBe(100); + expect(composer.requestedConfig.text.maxLengthOnSend).toBe(5000); + }); + + it('still resolves the documented layer order — construction argument over declarative', () => { + const client = getClientWithUser({ id: 'user' }); + client.config.set({ messageComposer: { text: { maxLengthOnSend: 10 } } }); + + const composer = new MessageComposer({ + client, + compositionContext: client.channel('messaging', 'c-order'), + config: { text: { maxLengthOnSend: 20 } }, + }); + + // docs §3: the construction argument is stage 3, the declarative tree stage 2. + expect(composer.config.text.maxLengthOnSend).toBe(20); + }); +}); diff --git a/test/unit/configuration/configPublishing.test.ts b/test/unit/configuration/configPublishing.test.ts new file mode 100644 index 0000000000..8a02f57cfd --- /dev/null +++ b/test/unit/configuration/configPublishing.test.ts @@ -0,0 +1,334 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateChannel } from '../test-utils/generateChannel'; +import { getClientWithUser } from '../test-utils/getClient'; +import { generateMsg } from '../test-utils/generateMessage'; +import { generateThreadResponse } from '../test-utils/generateThreadResponse'; +import { Thread } from '../../../src/thread'; +import type { StreamChat } from '../../../src/client'; + +/** + * A configuration publish allocates a fresh object every time, so `StateStore.next`'s `===` no-op can never + * apply to one. Without a comparison somewhere, every publish notifies whether or not a value moved — and in + * the React SDK that is a re-render for any consumer whose selector returns part of the config rather than a + * scalar. + * + * The dominant source was a repeated channel query. The API returns a **fresh** config object on each + * response, so `_addChannelConfig` replaced the stored one, the by-cid selector in + * `MessageComposer.subscribeChannelConfigChanged` fired, and the channel's composer re-resolved. Measured on + * a 10-channel page with three open composers, back when the store was keyed by type and one write woke all + * three: **30 publishes and 30 subscriber runs, down to 3** — one per composer, for the config genuinely + * arriving the first time. Keying by cid narrows the fan-out further, but the guard is what makes a repeated + * query free. + * + * The guards below sit at three points, because each covers a route the others cannot: + * + * - **the source** — `_addChannelConfig` ignores a server config deep-equal to the stored one, so the work + * never happens; + * - **the sink** — `MessageComposer.publishConfig` skips a resolution equal to what is published; + * - **the derivation** — `Channel` / `Thread` `initializeConfig` skip a `requestHandlers` value that has + * not moved, which matters because both re-run on any `alsoWatch` key. + * + * A fourth now sits inside `ConfigController` and covers every entity that resolves through it; that one is + * unit-tested in `ConfigController.test.ts`. The three here are the end-to-end checks, and they are what + * would catch a regression that the controller's own guard cannot see. + */ +describe('configuration publishes skip no-ops', () => { + let client: StreamChat; + let channelResponse: ReturnType['channel']; + const serverConfig = { max_message_length: 5000, shared_locations: true }; + + beforeEach(() => { + client = getClientWithUser({ id: 'user' }); + channelResponse = generateChannel().channel; + }); + + describe('at the source — client._addChannelConfig', () => { + it('ignores a config deep-equal to the one already stored', () => { + client._addChannelConfig({ + cid: 'messaging:general', + config: { ...serverConfig } as never, + }); + const first = client.channelServerConfigs['messaging:general']; + + client._addChannelConfig({ + cid: 'messaging:general', + config: { ...serverConfig } as never, + }); + + // Same object kept, so the store never published and nothing downstream woke up. + expect(client.channelServerConfigs['messaging:general']).toBe(first); + }); + + it('does not notify the store for a repeated identical config', () => { + client._addChannelConfig({ + cid: 'messaging:general', + config: { ...serverConfig } as never, + }); + const listener = vi.fn(); + client.channelServerConfigsStore.subscribe(listener); + listener.mockClear(); + + for (let i = 0; i < 10; i++) { + client._addChannelConfig({ + cid: 'messaging:general', + config: { ...serverConfig } as never, + }); + } + + expect(listener).not.toHaveBeenCalled(); + }); + + it('still stores a config that genuinely changed', () => { + client._addChannelConfig({ + cid: 'messaging:general', + config: { ...serverConfig } as never, + }); + + client._addChannelConfig({ + cid: 'messaging:general', + config: { ...serverConfig, max_message_length: 120 } as never, + }); + + expect(client.channelServerConfigs['messaging:general']).toMatchObject({ + max_message_length: 120, + }); + }); + + it('keeps a repeated channel query from waking live composers', () => { + const channels = ['a', 'b', 'c'].map((id) => client.channel('messaging', id)); + const composers = channels.map((channel) => { + channel.messageComposer.registerSubscriptions(); + return channel.messageComposer; + }); + // The config arrives for the first time: each composer should hear about its own channel's. + channels.forEach((channel) => + client._addChannelConfig({ + cid: channel.cid, + config: { ...serverConfig } as never, + }), + ); + + let publishes = 0; + composers.forEach((composer) => composer.configState.subscribe(() => publishes++)); + publishes = 0; + + for (let i = 0; i < 10; i++) { + channels.forEach((channel) => + client._addChannelConfig({ + cid: channel.cid, + config: { ...serverConfig } as never, + }), + ); + } + + expect(publishes).toBe(0); + }); + + it('skips the re-resolution itself, not just the notification', () => { + // What the source guard buys over the sink guard, which would suppress the notification but only after + // every composer had resolved its configuration and thrown the result away. Removing the sink guard + // leaves this passing; removing the source guard is what turns it red. + const channel = client.channel('messaging', channelResponse.id); + const composer = channel.messageComposer; + composer.registerSubscriptions(); + client._addChannelConfig({ + cid: channel.cid, + config: { ...serverConfig } as never, + }); + + const reResolve = vi.spyOn(composer, 'applyServerRestrictions'); + + for (let i = 0; i < 10; i++) { + client._addChannelConfig({ + cid: channel.cid, + config: { ...serverConfig } as never, + }); + } + + expect(reResolve).not.toHaveBeenCalled(); + }); + + it('does wake them when the server config actually changes', () => { + const channel = client.channel('messaging', channelResponse.id); + const composer = channel.messageComposer; + composer.registerSubscriptions(); + client._addChannelConfig({ + cid: channel.cid, + config: { ...serverConfig } as never, + }); + expect(composer.config.text.maxLengthOnSend).toBe(5000); + + client._addChannelConfig({ + cid: channel.cid, + config: { ...serverConfig, max_message_length: 120 } as never, + }); + + expect(composer.config.text.maxLengthOnSend).toBe(120); + }); + }); + + describe('at the sink — MessageComposer.publishConfig', () => { + it('does not notify when a declarative re-registration changes nothing', () => { + const composer = client.channel('messaging', channelResponse.id).messageComposer; + composer.registerSubscriptions(); + client.config.set({ messageComposer: { drafts: { enabled: true } } }); + + const listener = vi.fn(); + composer.configState.subscribe(listener); + listener.mockClear(); + + // Same value again — the registry publishes, the composer re-resolves, the result is identical. + client.config.set({ messageComposer: { drafts: { enabled: true } } }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('does not notify for an empty updateConfig', () => { + const composer = client.channel('messaging', channelResponse.id).messageComposer; + const listener = vi.fn(); + composer.configState.subscribe(listener); + listener.mockClear(); + + composer.updateConfig({}); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('still notifies for a real change', () => { + const composer = client.channel('messaging', channelResponse.id).messageComposer; + const listener = vi.fn(); + composer.configState.subscribe(listener); + listener.mockClear(); + + composer.updateConfig({ drafts: { enabled: true } }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(composer.config.drafts.enabled).toBe(true); + }); + + it('still notifies when a server restriction lifts a value it had narrowed', () => { + // The guard must compare the *resolved* value, not the request — otherwise a restriction changing + // while the request stays put would be silently swallowed. + const channel = client.channel('messaging', channelResponse.id); + client._addChannelConfig({ + cid: channel.cid, + config: { shared_locations: false } as never, + }); + const composer = channel.messageComposer; + composer.registerSubscriptions(); + composer.updateConfig({ location: { enabled: true } }); + expect(composer.config.location.enabled).toBe(false); + + const listener = vi.fn(); + composer.configState.subscribe(listener); + listener.mockClear(); + + client._addChannelConfig({ + cid: channel.cid, + config: { shared_locations: true } as never, + }); + + expect(listener).toHaveBeenCalled(); + expect(composer.config.location.enabled).toBe(true); + }); + }); + /** + * `Channel.initializeConfig` and `Thread.initializeConfig` are derivations: they *replace* + * `configState.requestHandlers` rather than merging, and they build a fresh object every time, so + * `StateStore.next`'s `===` no-op can never apply. Every re-derivation therefore woke every subscriber + * with an identical value. + * + * They also run far more often than the `channel` / `thread` key changes: both register + * `alsoWatch: ['messagePaginator', 'messageOperations']`, so a registration against either shared key + * re-runs the whole cycle for every live channel and thread. The React SDK's request-handler + * coordinator subscribes to both stores, so those wake-ups reach components. + */ + describe('at the derivation — Channel / Thread initializeConfig', () => { + it('does not notify a channel whose derived requestHandlers have not moved', () => { + const channel = client.channel('messaging', channelResponse.id); + const listener = vi.fn(); + channel.configState.subscribe(listener); + listener.mockClear(); + + // An `alsoWatch` key, so this re-runs the full `channel` cycle without touching its slice. + client.config.setConfig('messagePaginator', { pageSize: 30 }); + client.config.setConfig('messageOperations', { failedSendCacheMaxSize: 7 }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('still notifies when a handler is registered', () => { + const channel = client.channel('messaging', channelResponse.id); + const listener = vi.fn(); + channel.configState.subscribe(listener); + listener.mockClear(); + + const sendMessageRequest = () => undefined; + client.config.setConfig('channel', { + requestHandlers: { sendMessageRequest }, + } as never); + + expect(listener).toHaveBeenCalledTimes(1); + expect( + channel.configState.getLatestValue().requestHandlers?.sendMessageRequest, + ).toBe(sendMessageRequest); + }); + + it('still notifies when a handler is dropped from the tree', () => { + const sendMessageRequest = () => undefined; + client.config.setConfig('channel', { + requestHandlers: { sendMessageRequest }, + } as never); + const channel = client.channel('messaging', channelResponse.id); + expect(channel.configState.getLatestValue().requestHandlers).toBeDefined(); + + const listener = vi.fn(); + channel.configState.subscribe(listener); + listener.mockClear(); + + // A derivation, so clearing the registration has to remove the handler. + client.config.reset('channel'); + + expect(listener).toHaveBeenCalledTimes(1); + expect(channel.configState.getLatestValue().requestHandlers).toBeUndefined(); + }); + + it('does not notify a thread whose derived requestHandlers have not moved', () => { + const thread = new Thread({ + client, + threadData: generateThreadResponse(channelResponse, generateMsg()), + }); + thread.registerSubscriptions(); + + const listener = vi.fn(); + thread.configState.subscribe(listener); + listener.mockClear(); + + client.config.setConfig('messagePaginator', { pageSize: 30 }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('still notifies a thread when its handler is registered', () => { + const thread = new Thread({ + client, + threadData: generateThreadResponse(channelResponse, generateMsg()), + }); + thread.registerSubscriptions(); + + const listener = vi.fn(); + thread.configState.subscribe(listener); + listener.mockClear(); + + const markReadRequest = () => undefined; + client.config.setConfig('thread', { + requestHandlers: { markReadRequest }, + } as never); + + expect(listener).toHaveBeenCalledTimes(1); + expect(thread.configState.getLatestValue().requestHandlers?.markReadRequest).toBe( + markReadRequest, + ); + }); + }); +}); diff --git a/test/unit/configuration/configShape.test.ts b/test/unit/configuration/configShape.test.ts new file mode 100644 index 0000000000..6ab77fd9a1 --- /dev/null +++ b/test/unit/configuration/configShape.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import { + CONSTRUCTION_ONLY_CONFIG_PATHS, + INSTANCE_CONFIG_TREE_KEYS, +} from '../../../src/configuration/keys'; +import { + flattenConfigShape, + INSTANCE_CONFIG_TREE_SHAPE, +} from '../../../src/configuration/shape'; +import type { ConfigNode } from '../../../src/configuration/shape'; + +/** + * `INSTANCE_CONFIG_TREE_SHAPE` is the answer to "what can I configure?" for every caller that cannot read + * the TypeScript types at the moment they need to — a settings UI, a JavaScript caller, a docs generator. + * That makes it useful exactly as long as it is complete and accurate. + * + * Completeness *within* a configuration type is already the compiler's job: each level is annotated + * `Record`, so a new field fails the build until it is described. These + * tests cover what the annotations cannot see — that the shape's top level tracks the tree's key list, + * that every node is actually usable by a caller, and that it agrees with the other runtime table + * describing the same paths. + */ +describe('configuration tree shape', () => { + const allNodes = flattenConfigShape(); + + it('describes exactly the keys of the configuration tree', () => { + // The `Record` annotation already forbids a missing or unknown key at + // compile time. This is the runtime half: `INSTANCE_CONFIG_TREE_KEYS` is derived separately, and two + // derivations of the same truth are worth pinning to each other. + expect(Object.keys(INSTANCE_CONFIG_TREE_SHAPE).sort()).toEqual( + [...INSTANCE_CONFIG_TREE_KEYS].sort(), + ); + }); + + it('describes `thread`, which no curated feature list remembered to include', () => { + // Named explicitly because this is the defect that prompted the shape: the example app's settings UI + // maintained its own list of what to show, and `thread` was simply absent from it. Anything reading + // the shape gets the key whether or not a Thread has ever been constructed. + expect(Object.keys(INSTANCE_CONFIG_TREE_SHAPE.thread.fields).sort()).toEqual([ + 'messageOperations', + 'messagePaginator', + 'requestHandlers', + ]); + expect(INSTANCE_CONFIG_TREE_SHAPE.thread.fields.messagePaginator).toMatchObject< + Partial + >({ kind: 'group' }); + }); + + it.each(allNodes)('$path is usable by a caller reading it', ({ node, path }) => { + // A node without a description is a path a UI can render but nobody can understand — the same + // dead end as not describing it at all, so an empty string is a failure rather than a gap. + expect(node.description.trim().length, `${path} has no description`).toBeGreaterThan( + 0, + ); + + if (node.kind === 'group') { + expect( + Object.keys(node.fields).length, + `${path} is an empty group`, + ).toBeGreaterThan(0); + return; + } + + if (node.type === 'enum') { + expect( + node.enumValues?.length, + `${path} is an enum with no values`, + ).toBeGreaterThan(0); + } else { + // `enumValues` on a non-enum would be rendered as a choice list for a free value. + expect( + node.enumValues, + `${path} is not an enum but lists enumValues`, + ).toBeUndefined(); + } + }); + + it('agrees with the construction-only paths table', () => { + // Two runtime tables describe the same paths from different angles: the shape says what exists, this + // one says which of those are read only at construction. A path listed in one and absent from the + // other means one of them is stale, and the UI would either warn about a path it cannot show or show + // a path without the warning that makes it comprehensible. + const described = new Set(allNodes.map(({ path }) => path)); + const missing: string[] = []; + + for (const [key, paths] of Object.entries(CONSTRUCTION_ONLY_CONFIG_PATHS)) { + for (const path of paths) { + if (!described.has(`${key}.${path}`)) missing.push(`${key}.${path}`); + } + } + + expect(missing).toEqual([]); + }); + + describe('flattenConfigShape', () => { + it('reaches leaves under nested groups, not just the top level', () => { + const paths = allNodes.map(({ path }) => path); + + expect(paths).toContain('thread.messagePaginator.pageSize'); + expect(paths).toContain('messageComposer.location.minShareDurationMs'); + expect(paths).toContain('client.threads.connectionRecoveryThrottleMs'); + // The shared keys carry the same fields as their per-parent overrides — both are real places to + // write, so both are listed rather than the shared one being treated as an alias. + expect(paths).toContain('messagePaginator.pageSize'); + expect(paths).toContain('channel.messagePaginator.pageSize'); + }); + + it('emits every path once, sorted, so callers can diff two runs', () => { + const paths = allNodes.map(({ path }) => path); + + expect(new Set(paths).size).toBe(paths.length); + // Sorted per level rather than globally: a group is emitted before its own children, so a plain + // sort of the whole list would not match. + const topLevel = paths.filter((path) => !path.includes('.')); + expect(topLevel).toEqual([...topLevel].sort()); + }); + + it('descends into a subtree when given one', () => { + const paths = flattenConfigShape(INSTANCE_CONFIG_TREE_SHAPE.client.fields).map( + ({ path }) => path, + ); + + expect(paths).toContain('reminders.scheduledOffsetsMs'); + expect(paths).not.toContain('client.reminders.scheduledOffsetsMs'); + }); + }); + + it('marks values the declarative tree cannot carry', () => { + // JSON has no functions (**DV-1**), so these paths exist but are reachable only through a setup + // function. A UI that does not distinguish them offers an edit box that silently does nothing. + const functions = allNodes + .filter(({ node }) => node.kind === 'value' && node.type === 'function') + .map(({ path }) => path); + + expect(functions).toContain('channel.requestHandlers'); + expect(functions).toContain('messageComposer.attachments.fileUploadFilter'); + expect(functions).toContain('client.notifications.sortComparator'); + }); +}); diff --git a/test/unit/configuration/configState.unification.test.ts b/test/unit/configuration/configState.unification.test.ts new file mode 100644 index 0000000000..83dd6d2b31 --- /dev/null +++ b/test/unit/configuration/configState.unification.test.ts @@ -0,0 +1,236 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateChannel } from '../test-utils/generateChannel'; +import { getClientWithUser } from '../test-utils/getClient'; +import { SearchController } from '../../../src/search'; +import { DEFAULT_COMPOSER_CONFIG } from '../../../src/messageComposer/configuration'; +import type { StreamChat } from '../../../src/client'; + +/** + * Every configurable class exposes its **resolved** configuration the same way: `configState` for the + * store, `config` for the current value, `updateConfig` to change it. + * + * `MessageComposer`, `ReminderManager`, `Channel` and `Thread` already did. `BasePaginator`, + * `NotificationManager` and `SearchController` held a plain object that changed silently, so anything + * displaying their settings had to poll to notice a `client.config.set()` or a `reset()`. These tests + * pin the notification, which is the entire point of the change — a plain-object regression would still + * satisfy every assertion about *values*. + * + * **Scope.** The three classes below are the ones that were converted; they are not the whole set. The + * class list above also predates two later changes: every configurable class now resolves through a + * `ConfigController` and exposes `configState` as a getter over its store, and `LiveLocationManager` + * joined the set. Reactivity for `LiveLocationManager` — and for `SearchController` reached through its + * own key — lives in `selfRegisteringEntities.test.ts`, because those two register themselves rather than + * being built by this package. + */ +describe('resolved configuration is reactive on the classes that were converted to a store', () => { + let client: StreamChat; + let channelResponse: ReturnType['channel']; + + beforeEach(() => { + client = getClientWithUser({ id: 'user' }); + channelResponse = generateChannel().channel; + }); + + describe('BasePaginator', () => { + it('notifies subscribers when declarative configuration is registered afterwards', () => { + const channel = client.channel('messaging', channelResponse.id); + const listener = vi.fn(); + + // `subscribe` fires immediately with the current value; ignore that first call. + channel.messagePaginator.configState.subscribe(listener); + listener.mockClear(); + + client.config.set({ messagePaginator: { pageSize: 7 } }); + + expect(listener).toHaveBeenCalled(); + expect(channel.messagePaginator.config.pageSize).toBe(7); + expect(listener.mock.calls.at(-1)?.[0]).toMatchObject({ pageSize: 7 }); + }); + + it('notifies on updateConfig and reflects it through the config getter', () => { + const channel = client.channel('messaging', channelResponse.id); + const listener = vi.fn(); + channel.messagePaginator.configState.subscribe(listener); + listener.mockClear(); + + channel.messagePaginator.updateConfig({ retryCount: 4 }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(channel.messagePaginator.config.retryCount).toBe(4); + }); + + it('notifies on reset, when the paginator falls back to defaults', () => { + const channel = client.channel('messaging', channelResponse.id); + client.config.set({ messagePaginator: { pageSize: 7 } }); + expect(channel.messagePaginator.config.pageSize).toBe(7); + + const listener = vi.fn(); + channel.messagePaginator.configState.subscribe(listener); + listener.mockClear(); + + client.config.reset(); + + expect(listener).toHaveBeenCalled(); + expect(channel.messagePaginator.config.pageSize).not.toBe(7); + }); + + it('settles on the final value, and every emission carries a complete config', () => { + // A re-derivation emits more than once by design: the base writes the derived config, then + // subclasses re-install structural wiring they own (`MessageIntervalPaginator` its `deriveCursor` + // and `itemOrderComparator`). Both emissions are complete configs, so a subscriber is never shown + // a half-applied state — asserting an exact count would just pin the subclass count in place. + const channel = client.channel('messaging', channelResponse.id); + const seen: number[] = []; + channel.messagePaginator.configState.subscribe((next) => seen.push(next.pageSize)); + seen.length = 0; + + client.config.set({ messagePaginator: { pageSize: 11 } }); + + expect(seen.length).toBeGreaterThan(0); + expect(new Set(seen)).toEqual(new Set([11])); + expect(channel.messagePaginator.config.pageSize).toBe(11); + expect(channel.messagePaginator.config.deriveCursor).toBeDefined(); + }); + }); + + describe('NotificationManager', () => { + it('notifies when notification configuration is registered through the client', () => { + const listener = vi.fn(); + client.notifications.configState.subscribe(listener); + listener.mockClear(); + + client.config.set({ client: { notifications: { durations: { error: 9000 } } } }); + + expect(listener).toHaveBeenCalled(); + expect(client.notifications.config.durations.error).toBe(9000); + }); + + it('deep-merges rather than replacing, so sibling durations survive', () => { + const before = client.notifications.config.durations.info; + + client.notifications.updateConfig({ durations: { error: 1234 } } as never); + + expect(client.notifications.config.durations.error).toBe(1234); + expect(client.notifications.config.durations.info).toBe(before); + }); + }); + + describe('SearchController', () => { + it('notifies on updateConfig', () => { + const controller = new SearchController(); + const listener = vi.fn(); + controller.configState.subscribe(listener); + listener.mockClear(); + + controller.updateConfig({ keepSingleActiveSource: false }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(controller.config.keepSingleActiveSource).toBe(false); + }); + }); + + it('exposes the same shape on the classes that already had it', () => { + const channel = client.channel('messaging', channelResponse.id); + + for (const configurable of [ + channel.messagePaginator, + channel.pinnedMessagesPaginator, + channel.messageComposer, + channel.messageOperations, + client.notifications, + client.reminders, + client.threads, + client.messageDeliveryReporter, + ]) { + expect(configurable.configState).toBeDefined(); + expect(configurable.configState.getLatestValue()).toBe( + (configurable as { config: unknown }).config, + ); + } + }); + + /** + * `Channel` was the one class that stopped at `configState`, because `channel.getConfig()` (now removed) already + * returned the channel *type*'s server configuration and a `channel.config` beside it would have read + * as the same thing in getter form while meaning something unrelated. + * + * That was a workaround for a name, so the name was fixed instead: the server side is now + * `channel.serverConfig` (with `getConfig()` deprecated), which frees `config` to mean what it means + * everywhere else. `Thread` follows the same shape. + */ + it('gives Channel the same shape as everything else, with the server config renamed out of the way', () => { + // This used to assert the opposite — `Channel` deliberately had no `config` getter, because + // `getConfig()` already meant the channel *type's server* configuration and the two names would + // have been indistinguishable. Renaming the server side to `serverConfig` removed the collision + // rather than working around it, so `Channel` no longer has to be the exception. + const channel = client.channel('messaging', channelResponse.id); + + expect(channel.configState).toBeDefined(); + expect(channel.config).toBe(channel.configState.getLatestValue()); + expect(channel.serverConfig).toBe(client.channelServerConfigs[channel.cid]); + }); + + /** + * `config` returns the store's live object, so a write through it changes state while notifying nobody. + * `Readonly` rejects the top-level form (`config.pageSize = 5`) but is shallow, and the *nested* form + * is the one that escapes the instance: the resolved config only copies a subtree some configuration + * layer actually touched, so a subtree nobody configured is identical by reference to the package + * default. `composer.config.drafts.enabled = true` therefore reached process-global state — it changed + * the default for every composer on every client in the process, including ones built afterwards. + * + * The invariant these pin is the one that matters: **no write through a resolved config can reach the + * package defaults.** A per-instance subtree (one some layer copied, such as `text`, which the + * `max_message_length` upper bound always touches) is still writable and still a mistake — that is what + * the `Readonly` type and `updateConfig` are for — but it cannot leak past the instance. + */ + describe('package defaults cannot be reached through a resolved config', () => { + const write = (target: unknown, key: string, value: unknown) => () => { + (target as Record)[key] = value; + }; + + it('rejects a write into an unconfigured subtree, which is the shared default', () => { + const composer = client.channel('messaging', channelResponse.id).messageComposer; + + expect(composer.config.drafts).toBe(DEFAULT_COMPOSER_CONFIG.drafts); + expect(write(composer.config.drafts, 'enabled', true)).toThrow(TypeError); + }); + + it('leaves the package default untouched, and later composers reading it', () => { + const composerA = client.channel('messaging', channelResponse.id).messageComposer; + const draftsDefault = DEFAULT_COMPOSER_CONFIG.drafts.enabled; + + expect(write(composerA.config.drafts, 'enabled', !draftsDefault)).toThrow(); + + expect(DEFAULT_COMPOSER_CONFIG.drafts.enabled).toBe(draftsDefault); + const other = getClientWithUser({ id: 'other' }); + const composerB = other.channel('messaging', channelResponse.id).messageComposer; + expect(composerB.config.drafts.enabled).toBe(draftsDefault); + }); + + it('freezes every subtree of the defaults, not only the ones read here', () => { + const frozen = Object.entries(DEFAULT_COMPOSER_CONFIG) + .filter(([, value]) => typeof value === 'object' && value !== null) + .map(([key, value]) => [key, Object.isFrozen(value)]); + + expect(Object.fromEntries(frozen)).toEqual({ + attachments: true, + commands: true, + drafts: true, + linkPreviews: true, + location: true, + polls: true, + text: true, + }); + }); + + it('still lets updateConfig change the value, by copying rather than mutating', () => { + const composer = client.channel('messaging', channelResponse.id).messageComposer; + + composer.updateConfig({ drafts: { enabled: true } }); + + expect(composer.config.drafts.enabled).toBe(true); + expect(composer.config.drafts).not.toBe(DEFAULT_COMPOSER_CONFIG.drafts); + expect(DEFAULT_COMPOSER_CONFIG.drafts.enabled).toBe(false); + }); + }); +}); diff --git a/test/unit/configuration/configurableInTree.test.ts b/test/unit/configuration/configurableInTree.test.ts new file mode 100644 index 0000000000..92ccbb9627 --- /dev/null +++ b/test/unit/configuration/configurableInTree.test.ts @@ -0,0 +1,227 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { generateChannel } from '../test-utils/generateChannel'; +import { generateMsg } from '../test-utils/generateMessage'; +import { generateThreadResponse } from '../test-utils/generateThreadResponse'; +import { getClientWithUser } from '../test-utils/getClient'; +import { Thread } from '../../../src/thread'; +import { LiveLocationManager } from '../../../src/LiveLocationManager'; +import { SearchController } from '../../../src/search/SearchController'; +import { INSTANCE_CONFIG_TREE_KEYS } from '../../../src/configuration/keys'; +import type { StreamChat } from '../../../src/client'; + +/** + * The invariant: **if it is configurable, it is in the tree.** + * + * The configuration tree is only trustworthy as a discovery surface if it is complete. Nothing enforces + * that by construction — a class can grow a `config` field and simply never be represented, and the only + * signal would be an integrator failing to find the setting. These tests are that signal. + * + * Scope: *plain-data* configuration. Functions and instances cannot travel through the declarative tier + * (**DV-1**), so their surface is the setup-function argument, not the tree. + */ +describe('every configurable object has a path in the configuration tree', () => { + let client: StreamChat; + let channelResponse: ReturnType['channel']; + + beforeEach(() => { + client = getClientWithUser({ id: 'user' }); + channelResponse = generateChannel().channel; + }); + + const openChannel = () => client.channel('messaging', channelResponse.id); + const openThread = () => { + const thread = new Thread({ + client, + threadData: generateThreadResponse(channelResponse, generateMsg()), + }); + thread.registerSubscriptions(); + return thread; + }; + + /** + * Every configurable object, with the tree path that reaches it and a plain-data field to prove the + * path actually lands. Adding a `configState` to a class without adding it here is the failure this + * suite exists to produce — the last test checks the inventory itself is complete. + */ + const CONFIGURABLE = [ + { + apply: () => client.config.set({ messagePaginator: { retryCount: 6 } }), + expected: 6, + name: 'channel.messagePaginator (shared key)', + read: () => openChannel().messagePaginator.config.retryCount, + }, + { + apply: () => client.config.set({ channel: { messagePaginator: { pageSize: 13 } } }), + expected: 13, + name: 'channel.messagePaginator (per-parent)', + read: () => openChannel().messagePaginator.config.pageSize, + }, + { + apply: () => + client.config.set({ channel: { pinnedMessagesPaginator: { pageSize: 14 } } }), + expected: 14, + name: 'channel.pinnedMessagesPaginator', + read: () => openChannel().pinnedMessagesPaginator.config.pageSize, + }, + { + apply: () => + client.config.set({ messageOperations: { failedSendCacheMaxSize: 9 } }), + expected: 9, + name: 'channel.messageOperations (shared key)', + read: () => openChannel().messageOperations.config.failedSendCacheMaxSize, + }, + { + apply: () => + client.config.set({ messageOperations: { failedSendCacheMaxSize: 9 } }), + expected: 9, + name: 'thread.messageOperations (shared key — a thread sends messages too)', + read: () => openThread().messageOperations.config.failedSendCacheMaxSize, + }, + { + apply: () => + client.config.set({ + channel: { messageOperations: { failedSendCacheMaxSize: 7 } }, + }), + expected: 7, + name: 'channel.messageOperations (per-parent override)', + read: () => openChannel().messageOperations.config.failedSendCacheMaxSize, + }, + { + apply: () => + client.config.set({ + thread: { messageOperations: { failedSendCacheMaxSize: 8 } }, + }), + expected: 8, + name: 'thread.messageOperations (per-parent override)', + read: () => openThread().messageOperations.config.failedSendCacheMaxSize, + }, + { + apply: () => client.config.set({ thread: { messagePaginator: { pageSize: 15 } } }), + expected: 15, + name: 'thread.messagePaginator', + read: () => openThread().messagePaginator.config.pageSize, + }, + { + // Neither of these is constructed by this package — an app or a downstream SDK builds them — so + // they register themselves against their key rather than being handed a slice by an owner. + apply: () => + client.config.set({ liveLocationManager: { minUpdateThrottleMs: 9_000 } }), + expected: 9_000, + name: 'liveLocationManager', + read: () => + new LiveLocationManager({ + client, + getDeviceId: () => 'device', + watchLocation: () => () => undefined, + }).config.minUpdateThrottleMs, + }, + { + apply: () => + client.config.set({ searchController: { keepSingleActiveSource: false } }), + expected: false, + name: 'searchController (constructed with a client)', + read: () => new SearchController({ client }).config.keepSingleActiveSource, + }, + { + apply: () => + client.config.set({ messageComposer: { text: { publishTypingEvents: false } } }), + expected: false, + name: 'messageComposer', + read: () => openChannel().messageComposer.config.text.publishTypingEvents, + }, + { + apply: () => + client.config.set({ + messageComposer: { location: { minShareDurationMs: 30_000 } }, + }), + expected: 30_000, + name: 'messageComposer.location (was a module constant)', + read: () => openChannel().messageComposer.config.location.minShareDurationMs, + }, + { + apply: () => + client.config.set({ client: { notifications: { durations: { error: 42 } } } }), + expected: 42, + name: 'client.notifications', + read: () => client.notifications.config.durations.error, + }, + { + apply: () => + client.config.set({ client: { reminders: { stopTimerRefreshBoundaryMs: 99 } } }), + expected: 99, + name: 'client.reminders', + read: () => client.reminders.config.stopTimerRefreshBoundaryMs, + }, + { + apply: () => + client.config.set({ client: { threads: { connectionRecoveryThrottleMs: 250 } } }), + expected: 250, + name: 'client.threads (was a module constant)', + read: () => client.threads.config.connectionRecoveryThrottleMs, + }, + { + apply: () => + client.config.set({ + client: { messageDelivery: { maxDeliveredMessageCountInPayload: 5 } }, + }), + expected: 5, + name: 'client.messageDelivery (was a module constant)', + read: () => client.messageDeliveryReporter.config.maxDeliveredMessageCountInPayload, + }, + ] as const; + + it.each(CONFIGURABLE)( + '$name is reachable through the tree', + ({ apply, expected, read }) => { + apply(); + expect(read()).toBe(expected); + }, + ); + + it('the tree reports back everything that was registered', () => { + client.config.set({ + channel: { messageOperations: { failedSendCacheTtlMs: 1 } }, + client: { messageDelivery: { markAsDeliveredBufferTimeoutMs: 2 } }, + messagePaginator: { pageSize: 3 }, + }); + + // `getTree()` is the enumeration primitive this suite — and any settings UI — depends on. Without it + // callers have to know the keys up front, which is exactly the discoverability gap being closed. + expect(client.config.getTree()).toEqual({ + channel: { messageOperations: { failedSendCacheTtlMs: 1 } }, + client: { messageDelivery: { markAsDeliveredBufferTimeoutMs: 2 } }, + messagePaginator: { pageSize: 3 }, + }); + }); + + it('omits keys with nothing registered, so an empty tree means nothing configured', () => { + expect(client.config.getTree()).toEqual({}); + + client.config.set({ messagePaginator: { pageSize: 3 } }); + + expect(Object.keys(client.config.getTree())).toEqual(['messagePaginator']); + }); + + it('includes custom keys, which are as real as the built-in ones', () => { + client.config.setConfig('myFeature', { enabled: true } as never); + + expect(client.config.getTree()).toEqual({ myFeature: { enabled: true } }); + }); + + /** + * The guard on the guard: every top-level key must be exercised above. A new key added to the tree + * without a case here would otherwise leave the invariant unverified for it. + */ + it('exercises every top-level key of the tree', () => { + const exercised = new Set(); + for (const { apply } of CONFIGURABLE) { + const before = new Set(Object.keys(client.config.getTree())); + apply(); + for (const key of Object.keys(client.config.getTree())) { + if (!before.has(key)) exercised.add(key); + } + } + + expect([...exercised].sort()).toEqual([...INSTANCE_CONFIG_TREE_KEYS].sort()); + }); +}); diff --git a/test/unit/configuration/copyConfigPatch.test.ts b/test/unit/configuration/copyConfigPatch.test.ts new file mode 100644 index 0000000000..f7a7cc15bf --- /dev/null +++ b/test/unit/configuration/copyConfigPatch.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { copyConfigPatch } from '../../../src/configuration/utils/copyConfigPatch'; + +/** + * `copyConfigPatch` walks an object an integrator built — `client.config.set()` and every `updateConfig` + * route through it — so it has to survive shapes the SDK did not construct. + */ +describe('copyConfigPatch', () => { + it('copies plain objects and arrays, and passes everything else by reference', () => { + const fn = () => undefined; + const date = new Date(0); + const source = { fn, date, nested: { list: [1, { deep: true }] } }; + + const copy = copyConfigPatch(source); + + expect(copy).toEqual(source); + expect(copy).not.toBe(source); + expect(copy.nested).not.toBe(source.nested); + expect(copy.nested.list).not.toBe(source.nested.list); + // Handed over, not merged into. + expect(copy.fn).toBe(fn); + expect(copy.date).toBe(date); + }); + + it('terminates on an object that points at itself', () => { + const source: Record = { pageSize: 10 }; + source.self = source; + + const copy = copyConfigPatch(source); + + expect(copy.pageSize).toBe(10); + // The copy's back-reference points at the copy, not at the original. + expect(copy.self).toBe(copy); + }); + + it('terminates on a longer cycle', () => { + const a: Record = { name: 'a' }; + const b: Record = { a, name: 'b' }; + a.b = b; + + const copy = copyConfigPatch(a); + + expect((copy.b as Record).name).toBe('b'); + expect(((copy.b as Record).a as unknown) === copy).toBe(true); + }); + + it('terminates on a cycle through an array', () => { + const list: unknown[] = [1]; + list.push(list); + + const copy = copyConfigPatch(list); + + expect(copy[0]).toBe(1); + expect(copy[1]).toBe(copy); + }); + + it('copies an object referenced twice exactly once', () => { + const shared = { enabled: true }; + const source = { left: shared, right: shared }; + + const copy = copyConfigPatch(source); + + expect(copy.left).not.toBe(shared); + // One object in, one object out — the two references still lead to the same place. + expect(copy.left).toBe(copy.right); + }); +}); diff --git a/test/unit/configuration/defaultConfigImmutability.test.ts b/test/unit/configuration/defaultConfigImmutability.test.ts new file mode 100644 index 0000000000..84f46955ce --- /dev/null +++ b/test/unit/configuration/defaultConfigImmutability.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { StreamChat } from '../../../src/client'; +import { DEFAULT_CHANNEL_CONFIG } from '../../../src/channel'; +import { DEFAULT_COMPOSER_CONFIG } from '../../../src/messageComposer/configuration'; +import { DEFAULT_LIVE_LOCATION_MANAGER_CONFIG } from '../../../src/LiveLocationManager'; +import { DEFAULT_MESSAGE_DELIVERY_REPORTER_CONFIG } from '../../../src/messageDelivery'; +import { DEFAULT_MESSAGE_OPERATIONS_CONFIG } from '../../../src/messageOperations/MessageOperations'; +import { DEFAULT_NOTIFICATION_MANAGER_CONFIG } from '../../../src/notifications/configuration'; +import { DEFAULT_PAGINATION_OPTIONS } from '../../../src/pagination/paginators/BasePaginator'; +import { DEFAULT_REMINDER_MANAGER_CONFIG } from '../../../src/reminders/ReminderManager'; +import { DEFAULT_THREAD_CONFIG } from '../../../src/thread'; +import { DEFAULT_THREAD_MANAGER_CONFIG } from '../../../src/thread_manager'; + +/** + * Resolved configuration is built by spreading or merging over these constants, and a spread only copies + * the top level — so any nested value no layer touches stays identical *by reference* to the module + * object, reachable through the entity's public `config` getter. A write through it changes the package + * default for every instance in the process, including ones created later. + * + * This has now been found three times in three places: `DEFAULT_COMPOSER_CONFIG` (**F3**), + * `DEFAULT_NOTIFICATION_MANAGER_CONFIG.durations` (**G8**) and + * `DEFAULT_REMINDER_MANAGER_CONFIG.scheduledOffsetsMs`. Each was fixed where it was found, which is why + * there was a third. The sweep below is the part that stops a fourth: a new default config constant is + * caught here rather than by whoever mutates it in production. + * + * Freezing is the guard rather than copying, because it makes the violation loud — in ESM, which is + * always strict, the offending line throws instead of quietly corrupting shared state somewhere else. + */ +describe('package default configurations are immutable', () => { + const DEFAULTS = { + DEFAULT_CHANNEL_CONFIG, + DEFAULT_COMPOSER_CONFIG, + DEFAULT_LIVE_LOCATION_MANAGER_CONFIG, + DEFAULT_MESSAGE_DELIVERY_REPORTER_CONFIG, + DEFAULT_MESSAGE_OPERATIONS_CONFIG, + DEFAULT_NOTIFICATION_MANAGER_CONFIG, + DEFAULT_PAGINATION_OPTIONS, + DEFAULT_REMINDER_MANAGER_CONFIG, + DEFAULT_THREAD_CONFIG, + DEFAULT_THREAD_MANAGER_CONFIG, + }; + + const deepFrozen = (value: unknown, path: string, out: string[]) => { + if (value === null || typeof value !== 'object') return; + if (!Object.isFrozen(value)) out.push(path); + for (const [key, nested] of Object.entries(value)) { + deepFrozen(nested, `${path}.${key}`, out); + } + }; + + it.each(Object.entries(DEFAULTS))('%s is deep-frozen', (_name, defaults) => { + const unfrozen: string[] = []; + deepFrozen(defaults, 'root', unfrozen); + expect(unfrozen).toEqual([]); + }); + + describe('the reminder offsets, which leaked in the working tree', () => { + it('does not hand the module-level array out through config', () => { + // Both seeding routes aliased it: the `ReminderManager` constructor reads + // `DEFAULT_REMINDER_MANAGER_CONFIG.scheduledOffsetsMs` directly, and the client's derivation + // spreads the defaults shallowly. So `client.reminders.config.scheduledOffsetsMs` *was* the module + // array, shared by every client in the process. + const client = new StreamChat('k'); + + expect(() => + (client.reminders.config.scheduledOffsetsMs as number[]).push(999), + ).toThrow(TypeError); + expect(client.reminders.config.scheduledOffsetsMs).toEqual( + DEFAULT_REMINDER_MANAGER_CONFIG.scheduledOffsetsMs, + ); + }); + + it('two clients cannot corrupt each other through it', () => { + const a = new StreamChat('k'); + const b = new StreamChat('k2'); + const before = [...b.reminders.config.scheduledOffsetsMs]; + + expect(() => + (a.reminders.config.scheduledOffsetsMs as number[]).splice(0, 1), + ).toThrow(TypeError); + + expect(b.reminders.config.scheduledOffsetsMs).toEqual(before); + }); + + it('still lets a caller replace the offsets through updateConfig', () => { + // The supported route has to keep working — freezing the defaults must not freeze the surface. + const client = new StreamChat('k'); + + client.reminders.updateConfig({ scheduledOffsetsMs: [1, 2] }); + + expect(client.reminders.config.scheduledOffsetsMs).toEqual([1, 2]); + }); + + it('still lets the declarative tree set and reset them', () => { + const client = new StreamChat('k'); + const defaults = DEFAULT_REMINDER_MANAGER_CONFIG.scheduledOffsetsMs; + + client.config.set({ client: { reminders: { scheduledOffsetsMs: [5] } } }); + expect(client.reminders.config.scheduledOffsetsMs).toEqual([5]); + + client.config.reset(); + expect(client.reminders.config.scheduledOffsetsMs).toEqual(defaults); + }); + }); +}); diff --git a/test/unit/configuration/instanceConfiguration.integration.test.ts b/test/unit/configuration/instanceConfiguration.integration.test.ts new file mode 100644 index 0000000000..1b5ae384e9 --- /dev/null +++ b/test/unit/configuration/instanceConfiguration.integration.test.ts @@ -0,0 +1,670 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateChannel } from '../test-utils/generateChannel'; +import { generateMsg } from '../test-utils/generateMessage'; +import { generateThreadResponse } from '../test-utils/generateThreadResponse'; +import { getClientWithUser } from '../test-utils/getClient'; +import { mockChannelQueryResponse } from '../test-utils/mockChannelQueryResponse'; +import { StreamChat } from '../../../src/client'; +import { Thread } from '../../../src/thread'; +import type { Channel } from '../../../src/channel'; + +/** + * Cross-instance coverage: all four built-in keys, both tiers, both registration orders, reset, the + * deprecated setters, and the server-authority invariant. + * + * The whole-tree test below is the important one. The per-key suites each cover their own paths; this is + * the only place that walks every path in the shipped `InstanceConfigTree` in one pass, which is what + * catches a declarative path that type-checks, stores its value, and lands nowhere. + */ +describe('instance configuration — cross-instance', () => { + let client: StreamChat; + let channelResponse: ReturnType['channel']; + let parentMessage: ReturnType; + + beforeEach(() => { + client = getClientWithUser({ id: 'user' }); + channelResponse = generateChannel().channel; + parentMessage = generateMsg(); + }); + + const openChannel = (id = channelResponse.id): Channel => + client.channel('messaging', id); + const openThread = () => + new Thread({ + client, + threadData: generateThreadResponse(channelResponse, parentMessage), + }); + /** Populate the channel's server-side config, as `query`/`watch` would. */ + const setServerConfig = (channel: Channel, config: Record) => + client._addChannelConfig({ cid: channel.cid, config } as never); + + describe('every path in the tree lands on its real target', () => { + it('applies the whole tree in one call', () => { + const shapeChanged = vi.fn(() => true); + const fileUploadFilter = vi.fn(() => true); + const findURLFn = vi.fn(() => []); + const getDeviceId = vi.fn(() => 'device'); + const sendValidator = vi.fn(); + const sortComparator = vi.fn(() => 0); + const sendMessageRequest = vi.fn(); + const markReadRequest = vi.fn(); + + client.config.set({ + channel: { + messagePaginator: { + debounceMs: 111, + hasPaginationQueryShapeChanged: shapeChanged, + initialOffset: 5, + lockItemOrder: true, + pageSize: 51, + retryCount: 2, + stateThrottleMs: 252, + throwErrors: true, + unreadReferencePolicy: 'read-state-only', + }, + pinnedMessagesPaginator: { + debounceMs: 222, + lockItemOrder: true, + pageSize: 26, + retryCount: 3, + stateThrottleMs: 333, + throwErrors: true, + }, + requestHandlers: { sendMessageRequest }, + }, + client: { + notifications: { durations: { error: 10_001, info: 3_001 }, sortComparator }, + reminders: { + scheduledOffsetsMs: [61_000], + stopTimerRefreshBoundaryMs: 999_000, + }, + }, + messageComposer: { + attachments: { + acceptedFiles: ['image/png'], + fileUploadFilter, + maxNumberOfFilesPerMessage: 5, + trackUploadProgress: false, + }, + commands: { sendValidator }, + drafts: { enabled: true }, + linkPreviews: { debounceURLEnrichmentMs: 801, enabled: true, findURLFn }, + location: { getDeviceId }, + text: { enabled: false, publishTypingEvents: false }, + }, + thread: { + messagePaginator: { debounceMs: 444, pageSize: 27, retryCount: 4 }, + requestHandlers: { markReadRequest }, + }, + }); + + const channel = openChannel(); + const thread = openThread(); + + // channel.messagePaginator — every field + expect(channel.messagePaginator.config).toMatchObject({ + debounceMs: 111, + hasPaginationQueryShapeChanged: shapeChanged, + initialOffset: 5, + lockItemOrder: true, + pageSize: 51, + retryCount: 2, + stateThrottleMs: 252, + throwErrors: true, + }); + expect( + (channel.messagePaginator as unknown as { unreadReferencePolicy: string }) + .unreadReferencePolicy, + ).toBe('read-state-only'); + + // channel.pinnedMessagesPaginator — configured independently + expect(channel.pinnedMessagesPaginator.config).toMatchObject({ + debounceMs: 222, + lockItemOrder: true, + pageSize: 26, + retryCount: 3, + stateThrottleMs: 333, + throwErrors: true, + }); + + // channel.configState + expect(channel.configState.getLatestValue().requestHandlers).toEqual({ + sendMessageRequest, + }); + + // thread + expect(thread.messagePaginator.config).toMatchObject({ + debounceMs: 444, + pageSize: 27, + retryCount: 4, + }); + expect(thread.configState.getLatestValue().requestHandlers).toEqual({ + markReadRequest, + }); + + // messageComposer — reached through the channel's own composer + expect(channel.messageComposer.config).toMatchObject({ + attachments: { + acceptedFiles: ['image/png'], + fileUploadFilter, + maxNumberOfFilesPerMessage: 5, + trackUploadProgress: false, + }, + commands: { sendValidator }, + drafts: { enabled: true }, + linkPreviews: { debounceURLEnrichmentMs: 801, enabled: true, findURLFn }, + text: { enabled: false, publishTypingEvents: false }, + }); + expect(channel.messageComposer.config.location.getDeviceId).toBe(getDeviceId); + + // client-owned managers + expect(client.reminders.configState.getLatestValue()).toMatchObject({ + scheduledOffsetsMs: [61_000], + stopTimerRefreshBoundaryMs: 999_000, + }); + expect(client.notifications.config.durations).toMatchObject({ + error: 10_001, + info: 3_001, + }); + expect(client.notifications.config.sortComparator).toBe(sortComparator); + // Untouched severities keep their defaults rather than being wiped by the merge. + expect(client.notifications.config.durations.warning).toBe(3_000); + }); + + it('changes observable behaviour for the read-once paginator fields', () => { + client.config.set({ channel: { messagePaginator: { stateThrottleMs: 250 } } }); + const channel = openChannel(); + const internals = channel.messagePaginator as unknown as { + _executeQueryDebounced: unknown; + _windowPublishThrottle: unknown; + }; + const debounceBefore = internals._executeQueryDebounced; + const throttleBefore = internals._windowPublishThrottle; + + client.config.setConfig('channel', { messagePaginator: { debounceMs: 900 } }); + + // The debounce is rebuilt, because a plain assignment would be discarded — it is captured in a + // closure. + expect(channel.messagePaginator.config.debounceMs).toBe(900); + expect(internals._executeQueryDebounced).not.toBe(debounceBefore); + // The throttle is *not*, because 250 did not move. The old code rebuilt it on every derivation + // regardless, flushing pending publishes each time for nothing. + expect(channel.messagePaginator.config.stateThrottleMs).toBe(250); + expect(internals._windowPublishThrottle).toBe(throttleBefore); + }); + }); + + describe('both registration orders, per key', () => { + it('reaches instances created after registration', () => { + client.config.set({ + channel: { messagePaginator: { pageSize: 41 } }, + client: { reminders: { scheduledOffsetsMs: [1] } }, + messageComposer: { drafts: { enabled: true } }, + thread: { messagePaginator: { pageSize: 42 } }, + }); + + const channel = openChannel(); + expect(channel.messagePaginator.config.pageSize).toBe(41); + expect(channel.messageComposer.config.drafts.enabled).toBe(true); + expect(openThread().messagePaginator.config.pageSize).toBe(42); + expect(client.reminders.configState.getLatestValue().scheduledOffsetsMs).toEqual([ + 1, + ]); + }); + + it('reaches instances that already exist', () => { + const channel = openChannel(); + const thread = openThread(); + thread.registerSubscriptions(); + channel.messageComposer.registerSubscriptions(); + + client.config.set({ + channel: { messagePaginator: { pageSize: 41 } }, + client: { reminders: { scheduledOffsetsMs: [1] } }, + messageComposer: { drafts: { enabled: true } }, + thread: { messagePaginator: { pageSize: 42 } }, + }); + + expect(channel.messagePaginator.config.pageSize).toBe(41); + expect(thread.messagePaginator.config.pageSize).toBe(42); + expect(channel.messageComposer.config.drafts.enabled).toBe(true); + expect(client.reminders.configState.getLatestValue().scheduledOffsetsMs).toEqual([ + 1, + ]); + }); + }); + + describe('setup functions', () => { + it('fires each key with the right argument shape', () => { + const seen: Record = {}; + client.config.setSetupFunction('client', ({ client: c }) => { + seen.client = Object.keys({ reminders: c.reminders }); + }); + client.config.setSetupFunction('channel', ({ channel }) => { + seen.channel = [channel.cid]; + }); + client.config.setSetupFunction('thread', ({ thread }) => { + seen.thread = [thread.id]; + }); + client.config.setSetupFunction('messageComposer', ({ composer }) => { + seen.messageComposer = [composer.channel.cid]; + }); + + const channel = openChannel(); + channel.messageComposer.registerSubscriptions(); + const thread = openThread(); + thread.registerSubscriptions(); + + expect(seen.client).toEqual(['reminders']); + expect(seen.channel).toEqual([channel.cid]); + expect(seen.thread).toEqual([thread.id]); + expect(seen.messageComposer).toEqual([channel.cid]); + }); + + it('reaches sub-objects the declarative tree does not name', () => { + const reached: string[] = []; + client.config.setSetupFunction('channel', ({ channel }) => { + reached.push(typeof channel.cooldownTimer, typeof channel.messageReceiptsTracker); + }); + client.config.setSetupFunction('messageComposer', ({ composer }) => { + reached.push(typeof composer.attachmentManager, typeof composer.textComposer); + }); + + openChannel().messageComposer.registerSubscriptions(); + + expect(reached).toEqual(['object', 'object', 'object', 'object']); + }); + + it('wins over a declarative value for the same field', () => { + client.config.set({ channel: { messagePaginator: { pageSize: 41 } } }); + client.config.setSetupFunction('channel', ({ channel }) => { + channel.messagePaginator.updateConfig({ pageSize: 202 }); + }); + + expect(openChannel().messagePaginator.config.pageSize).toBe(202); + }); + + it('leaves each instance usable when it throws', () => { + for (const key of ['client', 'channel', 'thread', 'messageComposer'] as const) { + client.config.setSetupFunction(key, () => { + throw new Error(`boom-${key}`); + }); + } + + const channel = openChannel(); + expect(() => channel.messageComposer.registerSubscriptions()).not.toThrow(); + expect(() => openThread().registerSubscriptions()).not.toThrow(); + expect(channel.messagePaginator.config.pageSize).toBe(100); + }); + + it('clearing one key does not disturb the others', () => { + const channelTeardown = vi.fn(); + const threadSetup = vi.fn(); + client.config.setSetupFunction('channel', () => channelTeardown); + client.config.setSetupFunction('thread', threadSetup); + openChannel(); + openThread().registerSubscriptions(); + threadSetup.mockClear(); + + client.config.setSetupFunction('channel', null); + + expect(channelTeardown).toHaveBeenCalledTimes(1); + expect(threadSetup).not.toHaveBeenCalled(); + expect(client.config.getSetupFunction('thread')).toBe(threadSetup); + }); + }); + + describe('teardown, per disposal path', () => { + it('channel — _disconnect', () => { + const teardown = vi.fn(); + client.config.setSetupFunction('channel', () => teardown); + openChannel()._disconnect(); + expect(teardown).toHaveBeenCalledTimes(1); + }); + + it('thread — unregisterSubscriptions', () => { + const teardown = vi.fn(); + client.config.setSetupFunction('thread', () => teardown); + const thread = openThread(); + thread.registerSubscriptions(); + thread.unregisterSubscriptions(); + expect(teardown).toHaveBeenCalledTimes(1); + }); + + it('messageComposer — unregisterSubscriptions', () => { + const teardown = vi.fn(); + client.config.setSetupFunction('messageComposer', () => teardown); + const composer = openChannel().messageComposer; + composer.registerSubscriptions(); + composer.unregisterSubscriptions(); + expect(teardown).toHaveBeenCalledTimes(1); + }); + + it('client — disconnectUser', async () => { + const teardown = vi.fn(); + client.config.setSetupFunction('client', () => teardown); + await client.disconnectUser().catch(() => undefined); + expect(teardown).toHaveBeenCalledTimes(1); + }); + }); + + /** + * Keyed by cid, not by channel type. Most of `ChannelConfigWithInfo` reads as a type-level setting, but + * a channel's own `config_overrides` narrow it for that channel alone — and this SDK can set them: + * `client.channel(type, id, { config_overrides })` sends them on `query`/`watch`, and + * `channel.update()` / `updatePartial()` reach the same state. `ConfigOverridesRequest` covers + * `shared_locations`, `uploads`, `typing_events`, `replies`, `max_message_length`, `commands` and more — + * exactly the fields `Channel.serverRestrictions` and `availableCommands` read. + * + * A type-keyed cache could not hold two disagreeing channels: they overwrote each other, and because + * every write woke every `Channel` and `MessageComposer` of the type, the whole set re-derived to a + * value correct for at most one of them. + */ + describe('server channel configuration is cached by cid', () => { + it('does not serve one channel the config of its sibling', () => { + const a = client.channel('messaging', 'a'); + const b = client.channel('messaging', 'b'); + + setServerConfig(a, { shared_locations: false }); + + // `b` was never queried. There is deliberately no type-level fallback: the only thing available to + // fall back on is `a`'s effective config, overrides included. + expect(b.serverConfig).toBeUndefined(); + expect(Object.keys(client.channelServerConfigs)).toEqual(['messaging:a']); + }); + + it('keeps two channels of one type independent', () => { + const a = client.channel('messaging', 'a'); + const b = client.channel('messaging', 'b'); + + setServerConfig(a, { shared_locations: false }); + setServerConfig(b, { shared_locations: true }); + + expect(a.serverConfig?.shared_locations).toBe(false); + expect(b.serverConfig?.shared_locations).toBe(true); + expect(a.config.availableCommands).toEqual([]); + }); + + it('does not leak across types', () => { + const messaging = client.channel('messaging', 'a'); + const livestream = client.channel('livestream', 'b'); + + setServerConfig(messaging, { shared_locations: false }); + + expect(livestream.serverConfig).toBeUndefined(); + }); + + it('reaches a composer built before its own channel config arrived', () => { + const channel = client.channel('messaging', 'a'); + channel.messageComposer.registerSubscriptions(); + + setServerConfig(channel, { shared_locations: false }); + + expect(channel.messageComposer.config.location.enabled).toBe(false); + }); + + it('does not narrow a composer from a sibling channel config', () => { + const a = client.channel('messaging', 'a'); + const b = client.channel('messaging', 'b'); + b.messageComposer.registerSubscriptions(); + + // `a`'s override must not reach `b`'s composer — the leak this keying exists to prevent. + setServerConfig(a, { shared_locations: false }); + + expect(b.messageComposer.config.location.enabled).toBe(true); + }); + + it('resolves different channel configs for two channels of one type', () => { + // The assertion that matters to consumers: not the raw cache, but the *resolved* gates they read. + // `typing_events` and `read_events` are both overridable per channel. + const a = client.channel('messaging', 'a'); + const b = client.channel('messaging', 'b'); + + setServerConfig(a, { read_events: false, typing_events: false }); + setServerConfig(b, { read_events: true, typing_events: true }); + + expect(a.config.typingEvents.enabled).toBe(false); + expect(a.config.readEvents.enabled).toBe(false); + expect(b.config.typingEvents.enabled).toBe(true); + expect(b.config.readEvents.enabled).toBe(true); + }); + + it('keeps two live composers of one type on their own server configs', () => { + const a = client.channel('messaging', 'a'); + const b = client.channel('messaging', 'b'); + a.messageComposer.registerSubscriptions(); + b.messageComposer.registerSubscriptions(); + + setServerConfig(a, { max_message_length: 100, shared_locations: false }); + setServerConfig(b, { max_message_length: 5000, shared_locations: true }); + + expect(a.messageComposer.config.location.enabled).toBe(false); + expect(a.messageComposer.config.text.maxLengthOnSend).toBe(100); + expect(b.messageComposer.config.location.enabled).toBe(true); + expect(b.messageComposer.config.text.maxLengthOnSend).toBe(5000); + }); + + it('keeps them apart when each is queried over HTTP', async () => { + // The same disagreement over the real transport, one `channel.query()` each: the cid the config is + // filed under is the one on the response, and each channel reads back only its own. + const restricted = client.channel('messaging', 'http-restricted'); + const permissive = client.channel('messaging', 'http-permissive'); + + const responseFor = (channel: Channel, typing_events: boolean) => ({ + body: { + ...mockChannelQueryResponse, + channel: { + ...mockChannelQueryResponse.channel, + cid: channel.cid, + id: channel.id, + type: channel.type, + config: { ...mockChannelQueryResponse.channel.config, typing_events }, + }, + }, + metadata: {}, + }); + + vi.spyOn(client.api, 'sendRequest') + .mockResolvedValueOnce(responseFor(restricted, false) as never) + .mockResolvedValueOnce(responseFor(permissive, true) as never); + + await restricted.query(); + await permissive.query(); + + expect(restricted.serverConfig?.typing_events).toBe(false); + expect(permissive.serverConfig?.typing_events).toBe(true); + expect(restricted.config.typingEvents.enabled).toBe(false); + expect(permissive.config.typingEvents.enabled).toBe(true); + }); + + it('keeps them apart through the queryChannels hydration path', () => { + // The realistic route, where the cid comes off `ChannelResponse.cid` rather than being handed in: + // one page of two same-type channels whose configs disagree. Keyed by type, the second entry + // overwrote the first and both channels ended up reporting the last one seen. + const restricted = generateChannel({ + channel: { id: 'restricted', config: { typing_events: false } as never }, + }); + const permissive = generateChannel({ + channel: { id: 'permissive', config: { typing_events: true } as never }, + }); + + client.hydrateActiveChannels([restricted, permissive]); + + expect(client.channel('messaging', 'restricted').config.typingEvents.enabled).toBe( + false, + ); + expect(client.channel('messaging', 'permissive').config.typingEvents.enabled).toBe( + true, + ); + }); + }); + + describe('server authority — client configuration narrows, never widens', () => { + it('cannot re-enable a feature the server disabled (mechanism 1: the ctor merge)', () => { + const channel = openChannel(); + setServerConfig(channel, { shared_locations: false }); + + client.config.set({ messageComposer: { location: { enabled: true } } }); + channel.messageComposer.registerSubscriptions(); + + // The merge customizer keeps the server value authoritative — the one silent no-op in this API. + expect(channel.messageComposer.config.location.enabled).toBe(false); + }); + + it('honours a server flag that arrives after construction (D9)', () => { + const channel = openChannel(); + channel.messageComposer.registerSubscriptions(); + // Before the config lands, the composer has only its defaults to go on. + expect(channel.messageComposer.config.location.enabled).toBe(true); + + setServerConfig(channel, { shared_locations: false }); + + // Previously this stayed `true` forever: the composer read `getConfig()` exactly once, in its + // constructor, which for `client.channel()` runs before `watch()` populates it. + expect(channel.messageComposer.config.location.enabled).toBe(false); + }); + + it('cannot bypass a point-of-use guard (mechanism 2: typing events)', async () => { + const channel = openChannel(); + setServerConfig(channel, { typing_events: false }); + const sendEvent = vi.spyOn(channel, 'sendEvent').mockResolvedValue({} as never); + + client.config.set({ messageComposer: { text: { publishTypingEvents: true } } }); + channel.messageComposer.registerSubscriptions(); + await channel.keystroke(); + + // The composer config says yes; `channel.keystroke` checks the server flag itself and emits + // nothing. Safe without the declarative tier doing anything. + expect(channel.messageComposer.config.text.publishTypingEvents).toBe(true); + expect(sendEvent).not.toHaveBeenCalled(); + }); + }); + + describe('reset', () => { + it('returns every key to its derived baseline', () => { + client.config.set({ + channel: { + messagePaginator: { pageSize: 41 }, + requestHandlers: { sendMessageRequest: vi.fn() }, + }, + client: { reminders: { scheduledOffsetsMs: [1] } }, + messageComposer: { drafts: { enabled: true } }, + thread: { messagePaginator: { pageSize: 42 } }, + }); + const channel = openChannel(); + channel.messageComposer.registerSubscriptions(); + const thread = openThread(); + thread.registerSubscriptions(); + + client.config.reset(); + + expect(channel.messagePaginator.config.pageSize).toBe(100); + expect(channel.configState.getLatestValue().requestHandlers).toBeUndefined(); + expect(thread.messagePaginator.config.pageSize).toBe(50); + expect(channel.messageComposer.config.drafts.enabled).toBe(false); + expect(client.config.getConfig('channel')).toBeNull(); + expect(client.config.getConfig('messageComposer')).toBeNull(); + }); + + it('per-key reset leaves the other keys configured', () => { + client.config.set({ + channel: { messagePaginator: { pageSize: 41 } }, + thread: { messagePaginator: { pageSize: 42 } }, + }); + const channel = openChannel(); + const thread = openThread(); + thread.registerSubscriptions(); + + client.config.reset('channel'); + + expect(channel.messagePaginator.config.pageSize).toBe(100); + expect(thread.messagePaginator.config.pageSize).toBe(42); + }); + + it('recovers even when a setup function left no teardown', () => { + const channel = openChannel(); + client.config.setSetupFunction('channel', ({ channel: c }) => { + c.messagePaginator.updateConfig({ pageSize: 999 }); + c.messagePaginator.updateConfig({ itemOrderComparator: () => 0 }); + // deliberately returns nothing + }); + expect(channel.messagePaginator.config.pageSize).toBe(999); + + client.config.reset('channel'); + + // Re-derivation, not a snapshot: this is the property that makes reset trustworthy when + // teardowns are integrator-written. + expect(channel.messagePaginator.config.pageSize).toBe(100); + const older = { id: 'a', created_at: new Date('2020-01-01') } as never; + const newer = { id: 'b', created_at: new Date('2021-01-01') } as never; + expect( + channel.messagePaginator.config.itemOrderComparator?.(older, newer), + ).toBeLessThan(0); + }); + + it('re-installs a PinnedMessagePaginator’s own behaviour', () => { + const channel = openChannel(); + client.config.setSetupFunction('channel', ({ channel: c }) => { + c.pinnedMessagesPaginator.updateConfig({ + doRequest: async () => ({ items: [] }), + }); + }); + + client.config.reset('channel'); + + // A config snapshot could never have restored this — it is a closure over the paginator. + expect(String(channel.pinnedMessagesPaginator.config.doRequest)).toContain( + 'getPinnedMessages', + ); + }); + + it('re-reads current server config rather than a construction-time copy', () => { + const channel = openChannel(); + channel.messageComposer.registerSubscriptions(); + setServerConfig(channel, { shared_locations: false }); + + client.config.reset(); + + // A snapshot taken at construction would have restored the pre-query `true`. + expect(channel.messageComposer.config.location.enabled).toBe(false); + }); + }); + + describe('deprecated setters still work', () => { + it('setMessageComposerSetupFunction reaches a composer', () => { + const setup = vi.fn(); + client.setMessageComposerSetupFunction(setup); + + openChannel().messageComposer.registerSubscriptions(); + + expect(setup).toHaveBeenCalledTimes(1); + }); + + // `setInstanceConfigurationFunction` and its `SetInstanceConfigurationFunctions` type are gone: + // they only ever existed on the v10 line (never in a stable release), three of their four keys never + // functioned, and the one that did duplicates `setMessageComposerSetupFunction` above — which *did* + // ship in v9.9.0 and is therefore kept. Its `return`-instead-of-`continue` + // batch bug went with it — `set(tree)` is now the only multi-key path, and it uses `continue`. + // `instanceConfigurationService` and `configsStore` are gone for the same reason: both were + // v10-RC-only aliases, so there was no released code for the deprecation to protect (DEC-29). + }); + + it('keeps two clients independent', () => { + const other = getClientWithUser({ id: 'other' }); + client.config.set({ channel: { messagePaginator: { pageSize: 41 } } }); + + expect(other.config.getConfig('channel')).toBeNull(); + expect(other.channel('messaging', 'x').messagePaginator.config.pageSize).toBe(100); + }); + + it('seeds the client key through StreamChatOptions.config', () => { + // Constructed directly rather than via the test helper, because this is specifically about the + // constructor option — the only construction-time route for `client`, whose configuration registry + // is born inside that constructor. + const seeded = new StreamChat('', { + config: { client: { reminders: { scheduledOffsetsMs: [7] } } }, + }); + + expect(seeded.reminders.configState.getLatestValue().scheduledOffsetsMs).toEqual([7]); + }); +}); diff --git a/test/unit/configuration/messagePaginator.config.test.ts b/test/unit/configuration/messagePaginator.config.test.ts new file mode 100644 index 0000000000..fb7b78c88d --- /dev/null +++ b/test/unit/configuration/messagePaginator.config.test.ts @@ -0,0 +1,376 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateChannel } from '../test-utils/generateChannel'; +import { generateMsg } from '../test-utils/generateMessage'; +import { generateThreadResponse } from '../test-utils/generateThreadResponse'; +import { getClientWithUser } from '../test-utils/getClient'; +import { MessagePaginator } from '../../../src/pagination/paginators/MessagePaginator'; +import { Thread } from '../../../src/thread'; +import { + mergeDeclarativeMessageOperationsConfig, + mergeDeclarativePaginatorConfig, +} from '../../../src/configuration/utils/declarativeSlices'; +import type { StreamChat } from '../../../src/client'; + +/** + * The shared `messagePaginator` key exists because a `MessagePaginator` has two parent types — it backs + * the channel message list *and* thread replies — while `stateThrottleMs`, `retryCount` and friends have + * no reason to differ between them. Per-parent slices still override it, because `pageSize` legitimately + * does differ. + */ +describe("the shared 'messagePaginator' configuration key", () => { + let client: StreamChat; + let channelResponse: ReturnType['channel']; + + beforeEach(() => { + client = getClientWithUser({ id: 'user' }); + channelResponse = generateChannel().channel; + }); + + const openChannel = () => client.channel('messaging', channelResponse.id); + const openThread = () => { + const thread = new Thread({ + client, + threadData: generateThreadResponse(channelResponse, generateMsg()), + }); + thread.registerSubscriptions(); + return thread; + }; + + /** + * `unreadReferencePolicy` is read once, when `MessagePaginator` copies it into a private field, and it + * is offered on the shared key *and* both per-parent slices — so the late-registration warning has to + * fire on all three routes. It originally fired only for the parents: the warning is gated on + * `hasLiveInstances(key)`, and a key reached through `alsoWatch` had no instances registered against it. + */ + it('warns about construction-only paths registered through the shared key', () => { + const warned: string[] = []; + const spy = vi.spyOn(console, 'warn').mockImplementation((...args) => { + warned.push(args.map(String).join(' ')); + }); + + // A consumer has to exist: the warning is about instances that already missed the value. + openChannel(); + + warned.length = 0; + client.config.set({ messagePaginator: { unreadReferencePolicy: 'read-state-only' } }); + + spy.mockRestore(); + + expect(warned.filter((m) => /read once during construction/.test(m))).toHaveLength(1); + }); + + /** + * `unreadReferencePolicy` rides in the `messagePaginator` subtree but is **not** a + * `BasePaginatorConfig` field — the paginator reads it once into a private member. It used to be passed + * straight through to `initializeConfig`, which spreads the slice, so it landed in the published config + * as an untyped key nothing reads. Worse on a late registration: the config reported the new value while + * the paginator kept behaving on the constructed one, so resolved configuration contradicted behaviour. + */ + /** + * `docs/instance-configuration.md` §3 puts the declarative tree at stage 2 and the construction + * argument at stage 3. `MessageComposer` always followed that; `BasePaginator` layered them the other + * way round, so the same registration answered differently depending on which object read it. + * + * The order was never the problem — the layer contents were. A paginator built with no configuration + * at all already carried `pageSize`, `stateThrottleMs`, `initialCursor` and + * `hasPaginationQueryShapeChanged` as "construction arguments", because its subclasses spread their own + * defaults into `super({…})`. Promoting that layer wholesale broke 33 tests. Only what an *integrator* + * passes is stage 3 now; what the SDK supplies on the instance's behalf is stage 1. + */ + describe('the documented layer order', () => { + it('lets a declarative registration beat an SDK-supplied default', () => { + client.config.set({ messagePaginator: { pageSize: 41 } }); + + expect(openChannel().messagePaginator.config.pageSize).toBe(41); + // Thread replies default to 50, supplied by `Thread` rather than by the integrator — so the + // registration wins there too. + expect(openThread().messagePaginator.config.pageSize).toBe(41); + }); + + it('lets an integrator construction argument beat a declarative registration', () => { + client.config.set({ messagePaginator: { pageSize: 41 } }); + + const paginator = new MessagePaginator({ + channel: openChannel(), + paginatorOptions: { + declarativeConfig: { pageSize: 41 }, + pageSize: 7, + }, + }); + + expect(paginator.config.pageSize).toBe(7); + }); + + it('keeps the SDK default when nothing else names the field', () => { + expect(openChannel().messagePaginator.config.pageSize).toBe(100); + expect(openThread().messagePaginator.config.pageSize).toBe(50); + expect(openChannel().messagePaginator.config.stateThrottleMs).toBe(500); + }); + }); + + describe('the construction-only argument does not leak into resolved config', () => { + it('is absent from config even when registered before construction', () => { + client.config.set({ + messagePaginator: { unreadReferencePolicy: 'read-state-only' }, + }); + + const paginator = openChannel().messagePaginator; + + expect('unreadReferencePolicy' in paginator.config).toBe(false); + // …and the policy still arrived, through the constructor argument where it belongs. + expect( + (paginator as unknown as { unreadReferencePolicy: string }).unreadReferencePolicy, + ).toBe('read-state-only'); + }); + + it('never reports a value the paginator is not using', () => { + const paginator = openChannel().messagePaginator; + + // Registered too late to apply — the SDK warns about exactly this. + client.config.set({ + messagePaginator: { unreadReferencePolicy: 'read-state-only' }, + }); + + expect( + (paginator as unknown as { unreadReferencePolicy: string }).unreadReferencePolicy, + ).toBe('snapshot'); + expect('unreadReferencePolicy' in paginator.config).toBe(false); + }); + + it('keeps carrying the paginator fields that share the subtree', () => { + // The split must take only the construction-only key with it. + client.config.set({ + messagePaginator: { pageSize: 21, unreadReferencePolicy: 'read-state-only' }, + }); + + expect(openChannel().messagePaginator.config.pageSize).toBe(21); + }); + }); + + it('still reaches both parents when set through the shared key', () => { + client.config.set({ messagePaginator: { unreadReferencePolicy: 'read-state-only' } }); + + const channel = openChannel(); + const thread = openThread(); + + // Read through the private field the paginator copies it into — the only observable of the policy. + for (const paginator of [channel.messagePaginator, thread.messagePaginator]) { + expect( + (paginator as unknown as { unreadReferencePolicy: string }).unreadReferencePolicy, + ).toBe('read-state-only'); + } + }); + + it('reaches both the channel list and thread replies from one call', () => { + client.config.set({ messagePaginator: { stateThrottleMs: 250, retryCount: 3 } }); + + const channel = openChannel(); + const thread = openThread(); + + expect(channel.messagePaginator.config).toMatchObject({ + retryCount: 3, + stateThrottleMs: 250, + }); + expect(thread.messagePaginator.config).toMatchObject({ + retryCount: 3, + stateThrottleMs: 250, + }); + }); + + it('reaches instances that already exist', () => { + const channel = openChannel(); + const thread = openThread(); + + client.config.set({ messagePaginator: { retryCount: 4 } }); + + expect(channel.messagePaginator.config.retryCount).toBe(4); + expect(thread.messagePaginator.config.retryCount).toBe(4); + }); + + it('leaves the pinned-message paginator alone — it has a single parent', () => { + client.config.set({ messagePaginator: { retryCount: 3 } }); + + // Not a MessagePaginator, and reachable only through `channel`, so by the rule it stays nested. + expect(openChannel().pinnedMessagesPaginator.config.retryCount).toBe(0); + }); + + describe('per-parent overrides', () => { + it('lets each parent override a shared value', () => { + client.config.set({ + messagePaginator: { pageSize: 30, retryCount: 3 }, + channel: { messagePaginator: { pageSize: 60 } }, + thread: { messagePaginator: { pageSize: 15 } }, + }); + + const channel = openChannel(); + const thread = openThread(); + + expect(channel.messagePaginator.config.pageSize).toBe(60); + expect(thread.messagePaginator.config.pageSize).toBe(15); + // The un-overridden shared value still reaches both. + expect(channel.messagePaginator.config.retryCount).toBe(3); + expect(thread.messagePaginator.config.retryCount).toBe(3); + }); + + it('keeps shared values a partial per-parent slice does not mention', () => { + client.config.set({ + messagePaginator: { pageSize: 30, retryCount: 3, stateThrottleMs: 100 }, + channel: { messagePaginator: { stateThrottleMs: 400 } }, + }); + + expect(openChannel().messagePaginator.config).toMatchObject({ + pageSize: 30, + retryCount: 3, + stateThrottleMs: 400, + }); + }); + + it('falls back to the shared value when a per-parent slice is cleared', () => { + client.config.set({ + messagePaginator: { retryCount: 3 }, + channel: { messagePaginator: { retryCount: 9 } }, + }); + const channel = openChannel(); + expect(channel.messagePaginator.config.retryCount).toBe(9); + + client.config.reset('channel'); + + expect(channel.messagePaginator.config.retryCount).toBe(3); + }); + + /** + * The layering rule at its own level, because the tests above reach it only through the store — and the + * store cannot hold an explicit `undefined` (`mergeWith` skips undefined source values), so they exercise + * the *absent-key* path and leave the `undefined` path unguarded. Removing the skip from + * `mergeDeclarativeSlice` left all 318 configuration tests green, which is how this gap surfaced. + * + * It matters because the failure is silent: a per-parent slice carrying `retryCount: undefined` would + * erase the shared value rather than defer to it. Both helpers share one implementation, so this covers + * `messageOperations` too. + */ + it('defers to the shared value for a field the slice sets to undefined', () => { + expect( + mergeDeclarativePaginatorConfig( + { pageSize: 30, retryCount: 3 }, + { pageSize: 50, retryCount: undefined }, + ), + ).toEqual({ pageSize: 50, retryCount: 3 }); + + expect( + mergeDeclarativeMessageOperationsConfig( + { failedSendCacheMaxSize: 100, failedSendCacheTtlMs: 5_000 }, + { failedSendCacheTtlMs: undefined }, + ), + ).toEqual({ failedSendCacheMaxSize: 100, failedSendCacheTtlMs: 5_000 }); + }); + + it('returns whichever side is present when the other is absent', () => { + const shared = { pageSize: 30 }; + const specific = { pageSize: 50 }; + + expect(mergeDeclarativePaginatorConfig(undefined, specific)).toBe(specific); + expect(mergeDeclarativePaginatorConfig(shared, undefined)).toBe(shared); + expect(mergeDeclarativePaginatorConfig(undefined, undefined)).toBeUndefined(); + }); + }); + + it('applies read-once fields at construction', () => { + client.config.set({ messagePaginator: { unreadReferencePolicy: 'read-state-only' } }); + + for (const paginator of [ + openChannel().messagePaginator, + openThread().messagePaginator, + ]) { + expect( + (paginator as unknown as { unreadReferencePolicy: string }).unreadReferencePolicy, + ).toBe('read-state-only'); + } + }); + + it('rebuilds the read-once fields when set late', () => { + // Asserted on the throttle itself rather than on whichever method rebuilds it. The pairing of "store + // the value" with "make it take effect" is no longer owned by one setter — it hangs off the config + // controller's change hook, so every route gets it. + const channel = openChannel(); + const internals = channel.messagePaginator as unknown as { + _windowPublishThrottle: unknown; + }; + const before = internals._windowPublishThrottle; + + client.config.set({ messagePaginator: { stateThrottleMs: 350 } }); + + expect(channel.messagePaginator.config.stateThrottleMs).toBe(350); + expect(internals._windowPublishThrottle).not.toBe(before); + }); + + it('does not rebuild a read-once field whose value did not move', () => { + const channel = openChannel(); + const internals = channel.messagePaginator as unknown as { + _windowPublishThrottle: unknown; + }; + const before = internals._windowPublishThrottle; + + client.config.set({ messagePaginator: { pageSize: 33 } }); + + expect(channel.messagePaginator.config.pageSize).toBe(33); + // The old code re-ran the rebuild on every derivation regardless; flushing and swapping a throttle + // that nothing changed is pure churn on a path that runs per channel, per registration. + expect(internals._windowPublishThrottle).toBe(before); + }); + + describe('interaction with setup functions', () => { + // A change to the shared key must run the *whole* apply cycle for the owning key, not just a + // re-derivation — otherwise tier 2 loses its overrides. + it('keeps a channel setup function on top of a shared change', () => { + client.config.setSetupFunction('channel', ({ channel }) => { + channel.messagePaginator.updateConfig({ retryCount: 7 }); + }); + const channel = openChannel(); + + client.config.set({ messagePaginator: { retryCount: 3 } }); + + expect(channel.messagePaginator.config.retryCount).toBe(7); + }); + + it('keeps a thread setup function on top of a shared change', () => { + client.config.setSetupFunction('thread', ({ thread }) => { + thread.messagePaginator.updateConfig({ retryCount: 8 }); + }); + const thread = openThread(); + + client.config.set({ messagePaginator: { retryCount: 3 } }); + + expect(thread.messagePaginator.config.retryCount).toBe(8); + }); + }); + + describe('teardown', () => { + it('stops reaching a disconnected channel', () => { + const channel = openChannel(); + channel._disconnect(); + + client.config.set({ messagePaginator: { retryCount: 5 } }); + + expect(channel.messagePaginator.config.retryCount).toBe(0); + }); + + it('stops reaching an unsubscribed thread', () => { + const thread = openThread(); + thread.unregisterSubscriptions(); + + client.config.set({ messagePaginator: { retryCount: 5 } }); + + expect(thread.messagePaginator.config.retryCount).toBe(0); + }); + }); + + it('is cleared by a global reset', () => { + client.config.set({ messagePaginator: { retryCount: 3 } }); + const channel = openChannel(); + + client.config.reset(); + + expect(client.config.getConfig('messagePaginator')).toBeNull(); + expect(channel.messagePaginator.config.retryCount).toBe(0); + }); +}); diff --git a/test/unit/configuration/resolutionOrder.test.ts b/test/unit/configuration/resolutionOrder.test.ts new file mode 100644 index 0000000000..007d32e2d0 --- /dev/null +++ b/test/unit/configuration/resolutionOrder.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { generateChannel } from '../test-utils/generateChannel'; +import { getClientWithUser } from '../test-utils/getClient'; +import { MessageComposer } from '../../../src/messageComposer'; +import type { StreamChat } from '../../../src/client'; + +/** + * The stage table in `docs/instance-configuration.md` §3, asserted rather than described. + * + * Later stages win: defaults, the declarative tree, the construction argument, a setup function, imperative + * changes, and the server last. Only the server row had tests; the rest was documentation, and one row was + * simply false — a declarative change arriving after an imperative one used to overwrite it, because the + * declarative slice was copied in through `updateConfig` and so filed under imperative changes. + * + * Ordering is worth pinning per pair rather than in one big case: a single scenario touching all six stages + * passes as long as the *last* one wins, and would miss an inversion in the middle. + */ +describe('configuration resolution order (MessageComposer)', () => { + let client: StreamChat; + let channelId: string; + + beforeEach(() => { + client = getClientWithUser({ id: 'user' }); + channelId = generateChannel().channel.id; + }); + + const composerFor = (config?: Parameters[0]['config']) => { + const channel = client.channel('messaging', channelId); + if (!config) { + channel.messageComposer.registerSubscriptions(); + return channel.messageComposer; + } + // A construction argument only exists for a composer somebody builds deliberately — `channel + // .messageComposer` is built by the SDK without one, which is stage 3's whole caveat in the docs. + const composer = new MessageComposer({ + client, + composition: undefined, + compositionContext: channel, + config, + }); + composer.registerSubscriptions(); + return composer; + }; + + it('1 → 2: the declarative tree beats package defaults', () => { + expect(composerFor().config.text.publishTypingEvents).toBe(true); + + client.config.set({ messageComposer: { text: { publishTypingEvents: false } } }); + + expect(composerFor().config.text.publishTypingEvents).toBe(false); + }); + + it('2 → 3: the construction argument beats the declarative tree', () => { + client.config.set({ messageComposer: { text: { publishTypingEvents: false } } }); + + const composer = composerFor({ text: { publishTypingEvents: true } }); + + expect(composer.config.text.publishTypingEvents).toBe(true); + }); + + it('3 → 5: an imperative change beats the construction argument', () => { + const composer = composerFor({ text: { publishTypingEvents: true } }); + + composer.updateConfig({ text: { publishTypingEvents: false } }); + + expect(composer.config.text.publishTypingEvents).toBe(false); + }); + + it('5 over a later 2: a declarative change does not overwrite an imperative one', () => { + // The row that was false. The declarative slice is read live when the configuration is resolved, so it + // stays in its own layer instead of being copied into the imperative one. + const composer = composerFor(); + composer.updateConfig({ text: { publishTypingEvents: false } }); + + client.config.set({ messageComposer: { text: { publishTypingEvents: true } } }); + + expect(composer.config.text.publishTypingEvents).toBe(false); + }); + + it('a later declarative change still lands on a field nobody claimed imperatively', () => { + // The other side of the previous test: staying in its own layer must not mean being ignored. + const composer = composerFor(); + composer.updateConfig({ text: { publishTypingEvents: false } }); + + client.config.set({ messageComposer: { drafts: { enabled: true } } }); + + expect(composer.config.drafts.enabled).toBe(true); + expect(composer.config.text.publishTypingEvents).toBe(false); + }); + + describe('reset', () => { + it('discards imperative changes and keeps what is registered', () => { + client.config.set({ messageComposer: { text: { publishTypingEvents: false } } }); + const composer = composerFor(); + composer.updateConfig({ + text: { publishTypingEvents: true }, + drafts: { enabled: true }, + }); + + client.config.reset(); + + // Both imperative fields go; the declarative one is gone too, because `reset` clears the tree — what + // remains is stage 1. + expect(composer.config.text.publishTypingEvents).toBe(true); // package default + expect(composer.config.drafts.enabled).toBe(false); // package default + }); + + it('keeps a construction argument, which a reset does not own', () => { + const composer = composerFor({ text: { publishTypingEvents: false } }); + composer.updateConfig({ drafts: { enabled: true } }); + + client.config.reset(); + + expect(composer.config.text.publishTypingEvents).toBe(false); + expect(composer.config.drafts.enabled).toBe(false); + }); + }); +}); + +/** + * The asymmetry the stage table does *not* describe, pinned so it is visible rather than folded. + * + * Only `MessageComposer` stores the stages separately (**DEC-38**), because only it has a server restriction + * to re-apply without destroying the request underneath. Every other configurable object re-derives from its + * registered inputs, so an imperative `updateConfig()` lasts until the next cycle and no longer. The docs + * asserted both behaviours as general rules at one point (**DV-22**); these tests are what would catch that + * again, and what will fail — informatively — if **FU-35** ever extends the composer's model to the rest. + */ +describe('imperative changes through a cycle: composer vs everything else', () => { + let client: StreamChat; + let channelId: string; + + beforeEach(() => { + client = getClientWithUser({ id: 'asymmetry' }); + channelId = generateChannel().channel.id; + }); + + it('the composer keeps one', () => { + const channel = client.channel('messaging', channelId); + channel.messageComposer.registerSubscriptions(); + channel.messageComposer.updateConfig({ text: { maxLengthOnSend: 77 } }); + + // A cycle, triggered by a declarative change to an unrelated field under the same key. + client.config.set({ messageComposer: { drafts: { enabled: true } } }); + + expect(channel.messageComposer.config.text.maxLengthOnSend).toBe(77); + }); + + it('a paginator drops one', () => { + const channel = client.channel('messaging', channelId); + channel.messagePaginator.updateConfig({ retryCount: 7 }); + + client.config.set({ channel: { messagePaginator: { pageSize: 33 } } }); + + // Back to the package default: the re-derivation reads the registered inputs, and 7 was not among them. + expect(channel.messagePaginator.config.retryCount).toBe(0); + expect(channel.messagePaginator.config.pageSize).toBe(33); + }); + + it('a setup function persists for the paginator, which is the documented way round it', () => { + const channel = client.channel('messaging', channelId); + client.config.setSetupFunction('channel', ({ channel: target }) => { + target.messagePaginator.updateConfig({ retryCount: 7 }); + }); + + client.config.set({ channel: { messagePaginator: { pageSize: 33 } } }); + + // Re-run as part of the cycle rather than remembered, so the effect is reapplied. + expect(channel.messagePaginator.config.retryCount).toBe(7); + expect(channel.messagePaginator.config.pageSize).toBe(33); + }); +}); diff --git a/test/unit/configuration/selfRegisteringEntities.test.ts b/test/unit/configuration/selfRegisteringEntities.test.ts new file mode 100644 index 0000000000..7478b9eaef --- /dev/null +++ b/test/unit/configuration/selfRegisteringEntities.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getClientWithUser } from '../test-utils/getClient'; +import { LiveLocationManager } from '../../../src/LiveLocationManager'; +import { SearchController } from '../../../src/search/SearchController'; +import { DEFAULT_LIVE_LOCATION_MANAGER_CONFIG } from '../../../src/LiveLocationManager'; +import { DEFAULT_SEARCH_CONTROLLER_CONFIG } from '../../../src/search/SearchController'; +import type { StreamChat } from '../../../src/client'; + +/** + * `LiveLocationManager` and `SearchController` are the two configurable classes this package never + * constructs — an app or a downstream SDK does (`useLiveLocationSharingManager` and `` in + * `stream-chat-react`). There is no owner to hand them a declarative slice, so they register themselves + * against their own key, the way a `MessageComposer` does. + * + * That is also why they were absent from the tree until now: not an oversight about *whether* they were + * configurable, but no route by which configuration could reach them. + */ +describe('entities that register themselves', () => { + let client: StreamChat; + + beforeEach(() => { + client = getClientWithUser({ id: 'user' }); + }); + + const makeLiveLocation = () => + new LiveLocationManager({ + client, + getDeviceId: () => 'device', + watchLocation: () => () => undefined, + }); + + describe('LiveLocationManager', () => { + it('reads a registration made before it was constructed', () => { + client.config.set({ liveLocationManager: { minUpdateThrottleMs: 9_000 } }); + + expect(makeLiveLocation().config.minUpdateThrottleMs).toBe(9_000); + }); + + it('reacts to a registration made afterwards', () => { + const manager = makeLiveLocation(); + + client.config.set({ liveLocationManager: { minUpdateThrottleMs: 7_000 } }); + + expect(manager.config.minUpdateThrottleMs).toBe(7_000); + }); + + it('takes part in reset', () => { + const manager = makeLiveLocation(); + client.config.set({ liveLocationManager: { minUpdateThrottleMs: 7_000 } }); + + client.config.reset(); + + expect(manager.config.minUpdateThrottleMs).toBe( + DEFAULT_LIVE_LOCATION_MANAGER_CONFIG.minUpdateThrottleMs, + ); + }); + + it('runs a setup function, and its teardown on dispose', () => { + const teardown = vi.fn(); + const setup = vi.fn(() => teardown); + client.config.setSetupFunction('liveLocationManager', setup); + + const manager = makeLiveLocation(); + expect(setup).toHaveBeenCalledWith({ liveLocationManager: manager }); + + manager.registerSubscriptions(); + manager.unregisterSubscriptions(); + // Ref-counted event subscriptions are a separate lifetime from configuration — an unregister does + // not end the manager, so the setup function's teardown has not run yet. + expect(teardown).not.toHaveBeenCalled(); + + manager.dispose(); + + expect(teardown).toHaveBeenCalledTimes(1); + }); + + it('keeps hearing changes after unregistering, and stops after dispose', () => { + const manager = makeLiveLocation(); + manager.registerSubscriptions(); + manager.unregisterSubscriptions(); + + client.config.set({ liveLocationManager: { minUpdateThrottleMs: 7_000 } }); + expect(manager.config.minUpdateThrottleMs).toBe(7_000); + + manager.dispose(); + client.config.set({ liveLocationManager: { minUpdateThrottleMs: 5_000 } }); + + expect(manager.config.minUpdateThrottleMs).toBe(7_000); + }); + }); + + describe('SearchController', () => { + it('reads a registration when constructed with a client', () => { + client.config.set({ searchController: { keepSingleActiveSource: false } }); + + expect(new SearchController({ client }).config.keepSingleActiveSource).toBe(false); + }); + + it('reacts to a registration made afterwards', () => { + const controller = new SearchController({ client }); + + client.config.set({ searchController: { keepSingleActiveSource: false } }); + + expect(controller.config.keepSingleActiveSource).toBe(false); + }); + + it('lets a construction argument outrank the declarative tree', () => { + // docs §3: stage 3 beats stage 2, the same rule every other entity follows. + client.config.set({ searchController: { keepSingleActiveSource: false } }); + + const controller = new SearchController({ + client, + config: { keepSingleActiveSource: true }, + }); + + expect(controller.config.keepSingleActiveSource).toBe(true); + }); + + it('without a client, still works but hears nothing', () => { + // The documented caveat. `updateConfig` keeps working; only the declarative key goes unheard. + const controller = new SearchController(); + + client.config.set({ searchController: { keepSingleActiveSource: false } }); + expect(controller.config.keepSingleActiveSource).toBe( + DEFAULT_SEARCH_CONTROLLER_CONFIG.keepSingleActiveSource, + ); + + controller.updateConfig({ keepSingleActiveSource: false }); + expect(controller.config.keepSingleActiveSource).toBe(false); + }); + + it('stops hearing changes after dispose', () => { + const controller = new SearchController({ client }); + controller.dispose(); + + client.config.set({ searchController: { keepSingleActiveSource: false } }); + + expect(controller.config.keepSingleActiveSource).toBe(true); + }); + }); +}); diff --git a/test/unit/configuration/serverAuthority.test.ts b/test/unit/configuration/serverAuthority.test.ts new file mode 100644 index 0000000000..3f2618fe70 --- /dev/null +++ b/test/unit/configuration/serverAuthority.test.ts @@ -0,0 +1,512 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateChannel } from '../test-utils/generateChannel'; +import { getClientWithUser } from '../test-utils/getClient'; +import { mockChannelQueryResponse } from '../test-utils/mockChannelQueryResponse'; +import { mergeServerRestrictions } from '../../../src/configuration/utils/serverAuthority'; +import type { StreamChat } from '../../../src/client'; + +/** + * The invariant: **client configuration can only narrow what the server grants, never widen it.** + * + * It used to hold only at construction. `deriveConfig` applied the restrictions, but every + * *later* route — a declarative slice registered once the composer exists, or a setup function — landed in + * `updateConfig`, which merged without re-asserting them. So a running app could widen past the server and + * end up offering a feature the API rejects. Each route is pinned separately below, because they reach the + * config through different code and only one of them was ever covered. + */ +describe('the server has the last word, on every route', () => { + let client: StreamChat; + let channelResponse: ReturnType['channel']; + + beforeEach(() => { + client = getClientWithUser({ id: 'user' }); + channelResponse = generateChannel({ + channel: { config: { shared_locations: false } }, + }).channel; + client._addChannelConfig(channelResponse); + }); + + const openRegisteredComposer = () => { + const composer = client.channel('messaging', channelResponse.id).messageComposer; + // The composer only wires its configuration once subscriptions are registered — before that, the + // constructor's derivation is the only thing that has run. + composer.registerSubscriptions(); + return composer; + }; + + it('keeps a server-disabled feature off at construction', () => { + expect(openRegisteredComposer().config.location.enabled).toBe(false); + }); + + it('keeps it off when declarative configuration arrives afterwards', () => { + const composer = openRegisteredComposer(); + + client.config.set({ messageComposer: { location: { enabled: true } } }); + + expect(composer.config.location.enabled).toBe(false); + }); + + it('keeps it off when a setup function tries to enable it', () => { + const composer = openRegisteredComposer(); + + client.config.setSetupFunction('messageComposer', ({ composer: c }) => { + c.updateConfig({ location: { enabled: true } }); + }); + + expect(composer.config.location.enabled).toBe(false); + }); + + it('keeps it off for a direct imperative call', () => { + const composer = openRegisteredComposer(); + + composer.updateConfig({ location: { enabled: true } }); + + expect(composer.config.location.enabled).toBe(false); + }); + + it('does not invent restrictions the server has not stated', () => { + // No `shared_locations` in the channel config at all — the client's own value must stand. + const other = generateChannel({ channel: { config: {} } }).channel; + client._addChannelConfig(other); + const composer = client.channel('messaging', other.id).messageComposer; + composer.registerSubscriptions(); + + composer.updateConfig({ location: { enabled: true } }); + + expect(composer.config.location.enabled).toBe(true); + }); + + it('leaves unrelated fields alone while narrowing', () => { + const composer = openRegisteredComposer(); + + composer.updateConfig({ + location: { enabled: true }, + text: { publishTypingEvents: false }, + }); + + expect(composer.config.location.enabled).toBe(false); + expect(composer.config.text.publishTypingEvents).toBe(false); + }); +}); + +/** + * The same invariant at the level of the rule itself. + * + * The two-rule merge was extracted from `MessageComposer` (**DEC-37**) so the policy has one home and a + * name, findable by whoever writes the next entity with server-gated configuration. The composer tests + * above prove it holds for the one entity using it today; these prove the rule in isolation, including the + * cases no current entity exercises — a composer's restrictions carry a single boolean, so nothing else + * would notice if the scalar or nesting behaviour changed. + */ +describe('mergeServerRestrictions', () => { + it('lets the server turn a feature off', () => { + expect( + mergeServerRestrictions( + { location: { enabled: true } }, + { location: { enabled: false } }, + ), + ).toEqual({ location: { enabled: false } }); + }); + + it('keeps a client-disabled feature off even when the server allows it', () => { + // Rule 1. Asking for less than you are granted is always legitimate, so `enabled: false` on the + // requested side is not something the server gets to overturn. + expect( + mergeServerRestrictions( + { location: { enabled: false } }, + { location: { enabled: true } }, + ), + ).toEqual({ location: { enabled: false } }); + }); + + it('applies rule 1 to every boolean, not only to `enabled`', () => { + // Rule 1 used to be keyed on `key === 'enabled'`, which was correct only because `location.enabled` was + // the sole boolean restriction. Any other gate — `text.publishTypingEvents` for `typing_events`, say — + // would have fallen through to rule 2, and a client's deliberate opt-out would have been overwritten by + // a permissive server. That is the widening DV-16 was about, arriving one field at a time. + expect( + mergeServerRestrictions( + { trackUploadProgress: false }, + { trackUploadProgress: true }, + ), + ).toEqual({ trackUploadProgress: false }); + }); + + it('still lets the server turn a boolean off that the client asked to have on', () => { + // The other direction of rule 1, and the half that makes it a restriction rather than a client veto. + expect( + mergeServerRestrictions( + { location: { enabled: true } }, + { location: { enabled: false } }, + ), + ).toEqual({ location: { enabled: false } }); + }); + + it('lands a server subtree where the request has nothing', () => { + // `null` is not an interior, so the leaf rules would answer with the absent request and drop the + // server's subtree. It has to be descended into instead. + expect( + mergeServerRestrictions( + { location: null } as never, + { + location: { enabled: false }, + } as never, + ), + ).toEqual({ location: { enabled: false } }); + }); + + it('lets the server replace non-boolean scalars', () => { + expect( + mergeServerRestrictions( + { maxLengthOnSend: 5000, name: 'client' }, + { maxLengthOnSend: 120, name: 'server' }, + ), + ).toEqual({ maxLengthOnSend: 120, name: 'server' }); + }); + + it('leaves a field the restrictions do not mention alone', () => { + // The restrictions are a *partial* configuration. Treating an absent field as a server "no" would turn + // a silent server into a total lockdown. + expect( + mergeServerRestrictions( + { location: { enabled: true, minShareDurationMs: 60_000 } }, + { location: { enabled: true } }, + ), + ).toEqual({ location: { enabled: true, minShareDurationMs: 60_000 } }); + }); + + it('treats an undefined restriction as "the server did not say"', () => { + // What `channel.serverConfig?.shared_locations` returns before the channel config is known. Reading it + // as `false` would disable a feature the server never objected to. + // + // Note where this guarantee comes from: `mergeWith` already keeps the target when the source value is + // `undefined`, with no customizer involved. Pinned here anyway, because the behaviour matters to + // callers whatever produces it — but it is *not* evidence that the scalar check below works, which is + // what the next test is for. + expect( + mergeServerRestrictions( + { location: { enabled: true } }, + { location: { enabled: undefined } }, + ), + ).toEqual({ location: { enabled: true } }); + }); + + it('does not let a non-scalar restriction replace a scalar value', () => { + // The actual job of the "is it a scalar?" check in rule 2, and the only case that distinguishes it from + // no check at all. A restrictions object is typed `DeepPartial`, so a structure where a scalar + // belongs is already a type error — but the check is what stops it becoming a *silent* one at runtime, + // substituting a container for the flag a caller asked about. + expect( + mergeServerRestrictions({ location: { enabled: true } }, { + location: { enabled: ['nonsense'] }, + } as never), + ).toEqual({ location: { enabled: true } }); + }); + + it('decides leaf by leaf rather than replacing whole objects', () => { + // Objects are handed back to the deep merge. If they were not, a restriction naming one leaf would + // wipe out its siblings. + expect( + mergeServerRestrictions( + { + location: { enabled: true, minShareDurationMs: 60_000 }, + text: { enabled: true }, + }, + { location: { enabled: false } }, + ), + ).toEqual({ + location: { enabled: false, minShareDurationMs: 60_000 }, + text: { enabled: true }, + }); + }); +}); + +/** + * The same invariant read in the other direction: **a client may always ask for less than the server + * grants, and the server changing its mind must still land.** + * + * Both halves are pinned here because they used to be mutually exclusive (**DV-18**). Restrictions were + * applied to the previously *published* configuration, where a `false` the server wrote is + * indistinguishable from a `false` the client asked for — so `Channel.query` either recorded the server's + * permission as a client request (losing an integrator's opt-out) or made the server's `false` permanent + * (losing a later permissive answer). A fix for one broke the other, and each is a single test. + * + * They coexist now because restrictions are re-applied to the *requested* configuration rather than + * accumulated into the result, which makes the operation idempotent — see `MessageComposer.requestedConfig`. + */ +describe('narrowing and recovery, together', () => { + const queryWith = async ( + client: StreamChat, + sharedLocations: boolean, + channelId?: string, + ) => { + const generated = generateChannel({ + channel: { + config: { shared_locations: sharedLocations }, + ...(channelId ? { id: channelId } : {}), + }, + }); + const channel = client.channel('messaging', channelId ?? generated.channel.id); + + vi.spyOn(client.api, 'sendRequest').mockResolvedValue({ + body: { + ...mockChannelQueryResponse, + channel: { + ...mockChannelQueryResponse.channel, + ...generated.channel, + config: { + ...mockChannelQueryResponse.channel.config, + shared_locations: sharedLocations, + }, + }, + }, + metadata: {}, + } as never); + + await channel.query(); + return channel; + }; + + it('keeps a declarative opt-out through a query that reports the feature as allowed', async () => { + const client = getClientWithUser({ id: 'declarative-optout' }); + client.config.set({ messageComposer: { location: { enabled: false } } }); + + // Deliberately a composer with **no** registered subscriptions: that is the case `Channel.query` exists + // to cover, and the one whose fix regressed the other direction. + const channel = await queryWith(client, true); + + expect(channel.messageComposer.config.location.enabled).toBe(false); + }); + + it('keeps an imperative opt-out through the same query', async () => { + const client = getClientWithUser({ id: 'imperative-optout' }); + const channel = client.channel('messaging', 'imperative-channel'); + // An imperative request is as much a request as a declarative one, and used to be the more fragile of + // the two: it lived only in the published configuration, so anything that re-resolved discarded it. + channel.messageComposer.updateConfig({ location: { enabled: false } }); + + await queryWith(client, true, 'imperative-channel'); + + expect(channel.messageComposer.config.location.enabled).toBe(false); + }); + + it('lets a server that stops restricting the feature restore what was asked for', async () => { + const client = getClientWithUser({ id: 'recovery' }); + const channel = client.channel('messaging', 'recovery-channel'); + + vi.spyOn(client.api, 'sendRequest') + .mockResolvedValueOnce({ + body: { + ...mockChannelQueryResponse, + channel: { + ...mockChannelQueryResponse.channel, + cid: 'messaging:recovery-channel', + id: 'recovery-channel', + config: { + ...mockChannelQueryResponse.channel.config, + shared_locations: false, + }, + }, + }, + metadata: {}, + } as never) + .mockResolvedValueOnce({ + body: { + ...mockChannelQueryResponse, + channel: { + ...mockChannelQueryResponse.channel, + cid: 'messaging:recovery-channel', + id: 'recovery-channel', + config: { + ...mockChannelQueryResponse.channel.config, + shared_locations: true, + }, + }, + }, + metadata: {}, + } as never); + + await channel.query(); + expect(channel.messageComposer.config.location.enabled).toBe(false); + + // Nothing on the client ever asked for `false`, so the default request stands once the server allows it. + await channel.query(); + expect(channel.messageComposer.config.location.enabled).toBe(true); + }); + + it('still lets the server turn the feature off', async () => { + const client = getClientWithUser({ id: 'narrowing-server' }); + const channel = await queryWith(client, false); + + expect(channel.messageComposer.config.location.enabled).toBe(false); + }); +}); + +/** + * The third rule: a numeric ceiling narrows, it does not replace. + * + * Kept apart from {@link ServerRestrictions} deliberately (**FU-34**). Routing `max_message_length` through + * the restriction rules would have *widened* a composer that asked for something stricter — rule 2 replaces + * the requested scalar with the server's, so a deliberate limit of 200 against a server maximum of 5000 + * would have become 5000. Putting a ceiling in the wrong bucket is a silent bug, not a type error, so both + * directions are pinned. + */ +describe('mergeServerRestrictions — upper bounds', () => { + it('lowers a request that exceeds the ceiling', () => { + expect( + mergeServerRestrictions({ maxLengthOnSend: 10_000 }, {}, { maxLengthOnSend: 5000 }), + ).toEqual({ maxLengthOnSend: 5000 }); + }); + + it('keeps a request that is already stricter', () => { + // The case the restriction rules would have got wrong. + expect( + mergeServerRestrictions({ maxLengthOnSend: 200 }, {}, { maxLengthOnSend: 5000 }), + ).toEqual({ maxLengthOnSend: 200 }); + }); + + it('applies in full when nothing was requested', () => { + // The default, and the reason to read the server maximum at all: "no limit" means the server's limit. + // + // As with the scalar check above, note what does the work: for `undefined` the deep merge already takes + // the source value, so this passes with or without the explicit branch. The next test is the one that + // covers the branch. + expect( + mergeServerRestrictions( + { maxLengthOnSend: undefined } as { maxLengthOnSend?: number }, + {}, + { maxLengthOnSend: 5000 }, + ), + ).toEqual({ maxLengthOnSend: 5000 }); + }); + + it('prefers the ceiling over a requested value that is not a number at all', () => { + // Only reachable from JavaScript, or through a cast — but the choice matters: keeping the nonsense would + // drop the ceiling silently, leaving the composer effectively unlimited for the field it was meant to cap. + expect( + mergeServerRestrictions({ maxLengthOnSend: 'lots' } as never, {}, { + maxLengthOnSend: 5000, + } as never), + ).toEqual({ maxLengthOnSend: 5000 }); + }); + + it('leaves the request alone when the server states no ceiling', () => { + expect( + mergeServerRestrictions( + { maxLengthOnSend: 200 }, + {}, + { maxLengthOnSend: undefined }, + ), + ).toEqual({ maxLengthOnSend: 200 }); + }); + + it('narrows leaf by leaf, without disturbing siblings', () => { + expect( + mergeServerRestrictions( + { text: { enabled: true, maxLengthOnSend: 10_000 } }, + {}, + { text: { maxLengthOnSend: 5000 } }, + ), + ).toEqual({ text: { enabled: true, maxLengthOnSend: 5000 } }); + }); + + it('applies both rule sets together, each to its own field', () => { + expect( + mergeServerRestrictions( + { location: { enabled: true }, text: { maxLengthOnSend: 10_000 } }, + { location: { enabled: false } }, + { text: { maxLengthOnSend: 5000 } }, + ), + ).toEqual({ location: { enabled: false }, text: { maxLengthOnSend: 5000 } }); + }); +}); + +/** + * The same rule reaching the composer, which is what **FU-34** was actually about: `max_message_length` was + * a real server field that nothing in `src/` read, so a composer with no limit of its own accepted text the + * send endpoint would reject. + */ +describe("the channel type's max_message_length caps the composer", () => { + const composerOn = (client: StreamChat, channelId: string) => + client.channel('messaging', channelId).messageComposer; + + it('supplies the limit when the composer asked for none', () => { + const client = getClientWithUser({ id: 'capped' }); + const response = generateChannel({ + channel: { config: { max_message_length: 400 } }, + }).channel; + client._addChannelConfig(response); + + expect(composerOn(client, response.id).config.text.maxLengthOnSend).toBe(400); + expect(composerOn(client, response.id).config.text.maxLengthOnEdit).toBe(400); + }); + + it('keeps a stricter limit the integrator asked for', () => { + const client = getClientWithUser({ id: 'stricter' }); + client.config.set({ messageComposer: { text: { maxLengthOnSend: 100 } } }); + const response = generateChannel({ + channel: { config: { max_message_length: 400 } }, + }).channel; + client._addChannelConfig(response); + + expect(composerOn(client, response.id).config.text.maxLengthOnSend).toBe(100); + }); + + it('lowers a limit the integrator set above the server maximum', () => { + const client = getClientWithUser({ id: 'looser' }); + client.config.set({ messageComposer: { text: { maxLengthOnSend: 9000 } } }); + const response = generateChannel({ + channel: { config: { max_message_length: 400 } }, + }).channel; + client._addChannelConfig(response); + + expect(composerOn(client, response.id).config.text.maxLengthOnSend).toBe(400); + }); + + it('leaves the composer unlimited when the channel type states no maximum', () => { + const client = getClientWithUser({ id: 'uncapped' }); + const response = generateChannel().channel; + delete (response.config as { max_message_length?: number }).max_message_length; + client._addChannelConfig(response); + + expect(composerOn(client, response.id).config.text.maxLengthOnSend).toBeUndefined(); + }); +}); + +/** + * `ChannelResponse.config` is optional — the `notification.message_new` payload is one route that can omit + * it — and `_addChannelConfig` stored whatever it was handed, voiding a config already known for the + * channel. Since the composer reads `serverConfig` for `shared_locations` and `max_message_length`, the + * result is a restriction silently lifted rather than a cache miss. + */ +describe('an absent server config cannot un-learn a known one', () => { + it('ignores a response with no config instead of storing undefined', () => { + const client = getClientWithUser({ id: 'unlearn' }); + const response = generateChannel({ + channel: { config: { max_message_length: 400, shared_locations: false } }, + }).channel; + client._addChannelConfig(response); + + client._addChannelConfig({ cid: response.cid, config: undefined }); + + expect(client.channelServerConfigs[response.cid]).toEqual(response.config); + }); + + it('keeps the server restriction in force on a live composer', () => { + const client = getClientWithUser({ id: 'unlearn-composer' }); + const response = generateChannel({ + channel: { config: { max_message_length: 400, shared_locations: false } }, + }).channel; + client._addChannelConfig(response); + const composer = client.channel('messaging', response.id).messageComposer; + composer.registerSubscriptions(); + composer.updateConfig({ location: { enabled: true } }); + // the probe can see the bug: the restriction is in force before the empty response arrives + expect(composer.config.location.enabled).toBe(false); + + client._addChannelConfig({ type: response.type, config: undefined }); + + expect(composer.config.location.enabled).toBe(false); + expect(composer.config.text.maxLengthOnSend).toBe(400); + }); +}); diff --git a/test/unit/configuration/thread.config.test.ts b/test/unit/configuration/thread.config.test.ts new file mode 100644 index 0000000000..0f1c58fd6e --- /dev/null +++ b/test/unit/configuration/thread.config.test.ts @@ -0,0 +1,255 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateChannel } from '../test-utils/generateChannel'; +import { generateMsg } from '../test-utils/generateMessage'; +import { generateThreadResponse } from '../test-utils/generateThreadResponse'; +import { getClientWithUser } from '../test-utils/getClient'; +import { Thread } from '../../../src/thread'; +import type { StreamChat } from '../../../src/client'; + +describe("the 'thread' configuration key", () => { + let client: StreamChat; + let channelResponse: ReturnType['channel']; + let parentMessage: ReturnType; + + beforeEach(() => { + client = getClientWithUser({ id: 'user' }); + channelResponse = generateChannel().channel; + parentMessage = generateMsg(); + }); + + const openThread = () => + new Thread({ + client, + threadData: generateThreadResponse(channelResponse, parentMessage), + }); + + /** + * `Thread` was the last entity resolving configuration by hand — a bare `StateStore`, an open-coded + * no-op guard, no frozen defaults and no `updateConfig`. It now goes through `ConfigController` like + * everything else, so these pin the surface that migration is supposed to provide. + */ + describe('the shared configuration surface', () => { + it('exposes configState, config and updateConfig', () => { + const thread = openThread(); + + expect(thread.configState.getLatestValue()).toBe(thread.config); + expect(typeof thread.updateConfig).toBe('function'); + }); + + it('applies an imperative updateConfig', () => { + const thread = openThread(); + const markReadRequest = vi.fn(); + + thread.updateConfig({ requestHandlers: { markReadRequest } }); + + expect(thread.config.requestHandlers?.markReadRequest).toBe(markReadRequest); + }); + + it('lets an imperative change outrank the declarative slice', () => { + const declarative = vi.fn(); + const imperative = vi.fn(); + client.config.set({ + thread: { requestHandlers: { markReadRequest: declarative } }, + }); + const thread = openThread(); + expect(thread.config.requestHandlers?.markReadRequest).toBe(declarative); + + thread.updateConfig({ requestHandlers: { markReadRequest: imperative } }); + + expect(thread.config.requestHandlers?.markReadRequest).toBe(imperative); + }); + + it('skips the write when nothing moved', () => { + const markReadRequest = vi.fn(); + client.config.set({ thread: { requestHandlers: { markReadRequest } } }); + const thread = openThread(); + const listener = vi.fn(); + thread.configState.subscribe(listener); + listener.mockClear(); + + // Re-registering the same handler re-runs the derivation with a freshly allocated object, which + // `StateStore`'s `===` check cannot suppress on its own. + client.config.set({ thread: { requestHandlers: { markReadRequest } } }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('carries only its own slice, not the keys it hands to its sub-objects', () => { + client.config.set({ thread: { messagePaginator: { pageSize: 25 } } }); + + const thread = openThread(); + + expect(thread.messagePaginator.config.pageSize).toBe(25); + expect(thread.config).not.toHaveProperty('messagePaginator'); + }); + }); + + describe('declarative configuration', () => { + it('reaches a thread created after registration', () => { + client.config.set({ thread: { messagePaginator: { pageSize: 25 } } }); + + expect(openThread().messagePaginator.config.pageSize).toBe(25); + }); + + it('installs request handlers into configState', () => { + const markReadRequest = vi.fn(); + client.config.set({ thread: { requestHandlers: { markReadRequest } } }); + + expect(openThread().configState.getLatestValue().requestHandlers).toEqual({ + markReadRequest, + }); + }); + + it('applies without registerSubscriptions — the constructor derives it directly', () => { + client.config.set({ thread: { messagePaginator: { pageSize: 25 } } }); + + const thread = openThread(); + + // Only the *setup function* needs a subscription; declarative configuration does not. + expect(thread.hasSubscriptions).toBe(false); + expect(thread.messagePaginator.config.pageSize).toBe(25); + }); + + it('reaches a subscribed thread that already exists', () => { + const thread = openThread(); + thread.registerSubscriptions(); + + client.config.set({ thread: { messagePaginator: { pageSize: 25 } } }); + + expect(thread.messagePaginator.config.pageSize).toBe(25); + }); + + it('leaves the thread page-size default alone when unconfigured', () => { + expect(openThread().messagePaginator.config.pageSize).toBe(50); + }); + }); + + describe('construction-time injection', () => { + it('applies a read-once field registered before the thread exists', () => { + client.config.set({ + thread: { messagePaginator: { unreadReferencePolicy: 'read-state-only' } }, + }); + + const thread = openThread(); + + expect( + (thread.messagePaginator as unknown as { unreadReferencePolicy: string }) + .unreadReferencePolicy, + ).toBe('read-state-only'); + }); + + it('does not accept composer configuration under the thread key', () => { + client.config.set({ messageComposer: { drafts: { enabled: true } } }); + + expect(openThread().messageComposer.config.drafts.enabled).toBe(true); + }); + }); + + describe('setup functions', () => { + it('runs on registerSubscriptions with every sub-object present', () => { + const seen: string[] = []; + client.config.setSetupFunction('thread', ({ thread }) => { + seen.push( + [ + typeof thread.messagePaginator, + typeof thread.messageComposer, + typeof thread.messageOperations, + ].join(','), + ); + }); + + openThread().registerSubscriptions(); + + expect(seen).toEqual(['object,object,object']); + }); + + it('does not run for a thread that never subscribes', () => { + const setup = vi.fn(); + client.config.setSetupFunction('thread', setup); + + openThread(); + + expect(setup).not.toHaveBeenCalled(); + }); + + it('tears down on unregisterSubscriptions', () => { + const teardown = vi.fn(); + client.config.setSetupFunction('thread', () => teardown); + const thread = openThread(); + thread.registerSubscriptions(); + + thread.unregisterSubscriptions(); + + expect(teardown).toHaveBeenCalledTimes(1); + }); + + it('tears down and re-applies when the function is replaced', () => { + const order: string[] = []; + client.config.setSetupFunction('thread', () => { + order.push('first'); + return () => order.push('first-teardown'); + }); + openThread().registerSubscriptions(); + + client.config.setSetupFunction('thread', () => { + order.push('second'); + }); + + expect(order).toEqual(['first', 'first-teardown', 'second']); + }); + + it('overrides a declarative value for the same field', () => { + client.config.set({ thread: { messagePaginator: { pageSize: 25 } } }); + client.config.setSetupFunction('thread', ({ thread }) => { + thread.messagePaginator.updateConfig({ pageSize: 75 }); + }); + + const thread = openThread(); + thread.registerSubscriptions(); + + expect(thread.messagePaginator.config.pageSize).toBe(75); + }); + + it('cannot break Thread construction by throwing', () => { + client.config.setSetupFunction('thread', () => { + throw new Error('boom'); + }); + + expect(() => openThread().registerSubscriptions()).not.toThrow(); + }); + }); + + describe('reset', () => { + it('returns the reply paginator to its derived baseline', () => { + client.config.set({ thread: { messagePaginator: { pageSize: 25 } } }); + const thread = openThread(); + thread.registerSubscriptions(); + + client.config.reset('thread'); + + expect(thread.messagePaginator.config.pageSize).toBe(50); + }); + + it('clears declaratively installed request handlers', () => { + client.config.set({ thread: { requestHandlers: { markReadRequest: vi.fn() } } }); + const thread = openThread(); + thread.registerSubscriptions(); + + client.config.reset('thread'); + + expect(thread.configState.getLatestValue().requestHandlers).toBeUndefined(); + }); + + it('does not disturb the channel key', () => { + client.config.set({ + channel: { messagePaginator: { pageSize: 50 } }, + thread: { messagePaginator: { pageSize: 25 } }, + }); + const channel = client.channel('messaging', channelResponse.id); + + client.config.reset('thread'); + + expect(channel.messagePaginator.config.pageSize).toBe(50); + }); + }); +}); diff --git a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts index 5a8384055c..8417327c6a 100644 --- a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts +++ b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts @@ -15,6 +15,7 @@ import { Thread, } from '../../../src'; import type { AxiosResponse } from 'axios'; +import { stubServerConfig } from '../test-utils/stubServerConfig'; const channelType = 'messaging'; const channelId = 'channelId'; @@ -56,7 +57,7 @@ describe('MessageDeliveryReporter', () => { channel = client.channel(channelType, channelId); channel.initialized = true; - client.configs[channel.cid] = { + client.channelServerConfigs[channel.cid] = { created_at: '', delivery_events: true, read_events: false, @@ -111,7 +112,7 @@ describe('MessageDeliveryReporter', () => { return channel; }); channels.forEach((ch) => { - client.configs[ch.cid] = { + client.channelServerConfigs[ch.cid] = { created_at: '', delivery_events: true, read_events: false, @@ -156,13 +157,16 @@ describe('MessageDeliveryReporter', () => { }); it('does nothing when delievry events are disabled in channel config', async () => { - client.configs[channel.cid] = { + // Through the store, not by mutating `channelServerConfigs`: the flag is reconciled into + // `channel.config.deliveryEvents` by the channel's own derivation, and the store write is what + // triggers it. A direct mutation changes the raw record and nothing else. + stubServerConfig(channel, { created_at: '', delivery_events: false, read_events: false, reminders: false, updated_at: '', - }; + }); const markDeliveredSpy = vi .spyOn(client, 'markDelivered') .mockResolvedValue({ ok: true } as any); @@ -209,7 +213,7 @@ describe('MessageDeliveryReporter', () => { thread.channel.initialized = true; // Grant delivery permission so we exercise the thread branch of // `getNextDeliveryReportCandidate`, not the earlier permission gate. - client.configs[thread.channel.cid] = { + client.channelServerConfigs[thread.channel.cid] = { created_at: '', delivery_events: true, read_events: false, @@ -302,7 +306,7 @@ describe('MessageDeliveryReporter', () => { const ch2 = client.channel('messaging', 'ch2'); ch2.initialized = true; - client.configs[ch1.cid] = { + client.channelServerConfigs[ch1.cid] = { created_at: '', delivery_events: true, read_events: false, @@ -310,7 +314,7 @@ describe('MessageDeliveryReporter', () => { updated_at: '', }; - client.configs[ch2.cid] = { + client.channelServerConfigs[ch2.cid] = { created_at: '', delivery_events: true, read_events: false, @@ -453,7 +457,7 @@ describe('MessageDeliveryReporter', () => { return channel; }); channels.forEach((ch) => { - client.configs[ch.cid] = { + client.channelServerConfigs[ch.cid] = { created_at: '', delivery_events: true, read_events: false, diff --git a/test/unit/pagination/BasePaginator.stateThrottle.test.ts b/test/unit/pagination/BasePaginator.stateThrottle.test.ts new file mode 100644 index 0000000000..f06d16045d --- /dev/null +++ b/test/unit/pagination/BasePaginator.stateThrottle.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MessagePaginator } from '../../../src/pagination/paginators/MessagePaginator'; +import { PinnedMessagePaginator } from '../../../src/pagination/paginators/PinnedMessagePaginator'; +import { setStateThrottlingEnabled } from '../../../src/pagination/paginators/stateThrottling'; +import type { Channel } from '../../../src/channel'; + +const stubChannel = () => + ({ + cid: 'messaging:channel-id', + getPinnedMessages: vi.fn().mockResolvedValue({ messages: [] }), + getReplies: vi.fn(), + query: vi.fn(), + }) as unknown as Channel; + +/** + * `setStateThrottleOptions` exists because `stateThrottleMs` is read exactly once, in the constructor: + * the throttles capture the interval in their closures, so assigning `config.stateThrottleMs` later + * does nothing at all. These tests pin that the setter is the only thing that changes it. + */ +describe('BasePaginator.setStateThrottleOptions', () => { + let channel: Channel; + + beforeEach(() => { + vi.useFakeTimers(); + // Throttling is auto-disabled under test runners so the existing suites stay synchronous + // (see `stateThrottling.ts`); these tests are about the throttle itself, so opt in. + setStateThrottlingEnabled(true); + channel = stubChannel(); + }); + + afterEach(() => { + setStateThrottlingEnabled(false); + vi.useRealTimers(); + }); + + it('is what actually changes the interval — a plain assignment is not', () => { + const paginator = new MessagePaginator({ channel }); + + paginator.setStateThrottleOptions({ stateThrottleMs: 250 }); + + expect(paginator.config.stateThrottleMs).toBe(250); + }); + + it('enables throttling on a paginator that started unthrottled', () => { + const paginator = new PinnedMessagePaginator({ channel }); + expect(paginator.config.stateThrottleMs).toBeUndefined(); + + paginator.setStateThrottleOptions({ stateThrottleMs: 300 }); + + expect(paginator.config.stateThrottleMs).toBe(300); + // Proves a throttle object now exists: the protected getter is only true when one was built. + expect((paginator as unknown as { isStateThrottled: boolean }).isStateThrottled).toBe( + true, + ); + }); + + it('disables throttling when the interval is cleared', () => { + const paginator = new MessagePaginator({ channel }); + expect((paginator as unknown as { isStateThrottled: boolean }).isStateThrottled).toBe( + true, + ); + + paginator.setStateThrottleOptions({ stateThrottleMs: undefined }); + + expect(paginator.config.stateThrottleMs).toBeUndefined(); + expect((paginator as unknown as { isStateThrottled: boolean }).isStateThrottled).toBe( + false, + ); + }); + + it('flushes a pending publish rather than swallowing it on rebuild', () => { + const paginator = new MessagePaginator({ channel }); + const internals = paginator as unknown as { + flushPendingPublishes: () => void; + scheduleWindowPublish: () => void; + }; + const flush = vi.spyOn(internals, 'flushPendingPublishes'); + + paginator.setStateThrottleOptions({ stateThrottleMs: 100 }); + + // A scheduled trailing-edge emit would be lost if the throttles were replaced without flushing. + expect(flush).toHaveBeenCalled(); + }); + + it('is idempotent — repeated calls do not accumulate throttles', () => { + const paginator = new MessagePaginator({ channel }); + + for (let i = 0; i < 5; i += 1) + paginator.setStateThrottleOptions({ stateThrottleMs: 50 }); + + expect(paginator.config.stateThrottleMs).toBe(50); + expect((paginator as unknown as { isStateThrottled: boolean }).isStateThrottled).toBe( + true, + ); + }); + + it('leaves a paginator untouched when never called', () => { + expect(new MessagePaginator({ channel }).config.stateThrottleMs).toBe(500); + expect( + new PinnedMessagePaginator({ channel }).config.stateThrottleMs, + ).toBeUndefined(); + }); +}); diff --git a/test/unit/pagination/paginator.initializeConfig.test.ts b/test/unit/pagination/paginator.initializeConfig.test.ts new file mode 100644 index 0000000000..f113c8caef --- /dev/null +++ b/test/unit/pagination/paginator.initializeConfig.test.ts @@ -0,0 +1,317 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MessagePaginator } from '../../../src/pagination/paginators/MessagePaginator'; +import { PinnedMessagePaginator } from '../../../src/pagination/paginators/PinnedMessagePaginator'; +import type { Channel } from '../../../src/channel'; + +/** Matches the lightweight channel stub the sibling paginator suites use. */ +const stubChannel = () => + ({ + cid: 'messaging:channel-id', + getPinnedMessages: vi.fn().mockResolvedValue({ messages: [] }), + getReplies: vi.fn(), + query: vi.fn(), + }) as unknown as Channel; + +describe('paginator initializeConfig', () => { + let channel: Channel; + + beforeEach(() => { + channel = stubChannel(); + }); + + describe('BasePaginator', () => { + it('produces the same config from the constructor and from initializeConfig', () => { + const paginator = new MessagePaginator({ channel }); + const fromConstructor = { ...paginator.config }; + + paginator.initializeConfig(); + + // Same keys — a missing one would mean a re-derivation silently dropped configuration. + expect(Object.keys(paginator.config).sort()).toEqual( + Object.keys(fromConstructor).sort(), + ); + + // Value fields must be identical. + const valueFields = [ + 'debounceMs', + 'initialOffset', + 'lockItemOrder', + 'pageSize', + 'retryCount', + 'stateThrottleMs', + 'throwErrors', + ] as const; + for (const field of valueFields) { + expect(paginator.config[field]).toEqual(fromConstructor[field]); + } + + // Behavioural fields come from the memoized subclass overlay, so a re-derivation reinstates the + // very same functions — which is what lets `initializeConfig` recognise an unchanged derivation + // and skip the publish. + for (const field of ['deriveCursor', 'itemOrderComparator'] as const) { + expect(typeof fromConstructor[field]).toBe('function'); + expect(paginator.config[field]).toBe(fromConstructor[field]); + } + }); + + it('applies a declarative slice', () => { + const paginator = new MessagePaginator({ channel }); + + paginator.initializeConfig({ pageSize: 50, retryCount: 3 }); + + expect(paginator.config.pageSize).toBe(50); + expect(paginator.config.retryCount).toBe(3); + }); + + it('reads a declarative slice passed at construction', () => { + const paginator = new MessagePaginator({ + channel, + paginatorOptions: { declarativeConfig: { pageSize: 77 } }, + }); + + expect(paginator.config.pageSize).toBe(77); + }); + + it('drops a previous declarative slice when re-derived without one', () => { + const paginator = new MessagePaginator({ channel }); + paginator.initializeConfig({ pageSize: 50 }); + + paginator.initializeConfig(); + + // Back to the subclass's own construction default rather than the base's 10 — that value came + // through the constructor, so it is preserved while the declarative slice is dropped. + expect(paginator.config.pageSize).toBe(100); + }); + + it('preserves constructor-injected options across a re-derivation', () => { + const paginator = new MessagePaginator({ + channel, + paginatorOptions: { pageSize: 33 }, + }); + + paginator.initializeConfig(); + + expect(paginator.config.pageSize).toBe(33); + }); + + it('never swaps the item index — loaded items would be lost', () => { + // Asserted on the live index rather than on `config.itemIndex`. That field was typed as part of + // the resolved config but never written to it — the constructor destructures `itemIndex` and + // `createItemIndex` out of its options and resolves them once into `_itemIndex` — so the previous + // version of this test compared `undefined` to `undefined` and passed for any implementation, + // including one with the preservation branch deleted. The field is gone from the type now. + const paginator = new MessagePaginator({ channel }); + const before = paginator._itemIndex; + paginator.ingestItem({ id: 'm1', created_at: new Date() } as never); + + paginator.initializeConfig({ pageSize: 50 }); + + expect(paginator._itemIndex).toBe(before); + expect(paginator.getItem('m1')).toBeDefined(); + }); + + it('rebuilds the debounced query rather than only assigning debounceMs', () => { + const paginator = new MessagePaginator({ channel }); + const setDebounceOptions = vi.spyOn(paginator, 'setDebounceOptions'); + + paginator.initializeConfig({ debounceMs: 900 }); + + expect(setDebounceOptions).toHaveBeenCalledWith({ debounceMs: 900 }); + expect(paginator.config.debounceMs).toBe(900); + }); + }); + + describe('MessagePaginator', () => { + it('defaults stateThrottleMs to 500', () => { + expect(new MessagePaginator({ channel }).config.stateThrottleMs).toBe(500); + }); + + it('keeps its 500ms default across a bare re-derivation', () => { + const paginator = new MessagePaginator({ channel }); + + paginator.initializeConfig(); + + // A bare `super.initializeConfig` would fall back to the base's `undefined` and silently drop + // the message list's render coalescing. + expect(paginator.config.stateThrottleMs).toBe(500); + }); + + it('lets a declarative slice override the subclass default', () => { + const paginator = new MessagePaginator({ channel }); + + paginator.initializeConfig({ stateThrottleMs: 250 }); + + expect(paginator.config.stateThrottleMs).toBe(250); + }); + + it('honours an explicit stateThrottleMs from construction on re-derivation', () => { + const paginator = new MessagePaginator({ + channel, + paginatorOptions: { stateThrottleMs: 120 }, + }); + + paginator.initializeConfig(); + + expect(paginator.config.stateThrottleMs).toBe(120); + }); + }); + + describe('PinnedMessagePaginator', () => { + it('re-installs doRequest after a re-derivation', () => { + const paginator = new PinnedMessagePaginator({ channel }); + const original = paginator.config.doRequest; + expect(original).toBeDefined(); + + paginator.initializeConfig(); + + expect(paginator.config.doRequest).toBeDefined(); + }); + + it('restores doRequest that a setup function replaced without a teardown', () => { + const paginator = new PinnedMessagePaginator({ channel }); + paginator.updateConfig({ doRequest: async () => ({ items: [] }) }); + + paginator.initializeConfig(); + + // Proves re-derivation beats a snapshot: the original is a closure over `this`, which no + // captured config object could have restored. + const restored = paginator.config.doRequest; + expect(restored).toBeDefined(); + expect(String(restored)).toContain('getPinnedMessages'); + }); + + it('re-installs the pinned_at item order comparator', () => { + const paginator = new PinnedMessagePaginator({ channel }); + paginator.updateConfig({ itemOrderComparator: () => 0 }); + + paginator.initializeConfig(); + + const older = { id: 'a', pinned_at: new Date('2020-01-01') } as never; + const newer = { id: 'b', pinned_at: new Date('2021-01-01') } as never; + expect(paginator.config.itemOrderComparator?.(older, newer)).toBeLessThan(0); + }); + + it('does not acquire a state throttle from the message paginator default', () => { + const paginator = new PinnedMessagePaginator({ channel }); + + paginator.initializeConfig(); + + expect(paginator.config.stateThrottleMs).toBeUndefined(); + }); + }); + describe('read-once fields take effect however they are set', () => { + // `updateConfig` used to store these and rebuild nothing, because the rebuild lived only in + // `initializeConfig`. So `paginator.updateConfig({ debounceMs: 900 })` reported 900 while the + // debounce kept running at 300 — resolved configuration contradicting behaviour, the same shape as + // the `unreadReferencePolicy` leak. Pairing the write with the rebuild is now the controller's job, + // so it holds for every route rather than the one someone remembered. + it('rebuilds the debounced query on a plain updateConfig', () => { + const paginator = new MessagePaginator({ channel }); + const internals = paginator as unknown as { _executeQueryDebounced: unknown }; + const before = internals._executeQueryDebounced; + + paginator.updateConfig({ debounceMs: 900 }); + + expect(paginator.config.debounceMs).toBe(900); + expect(internals._executeQueryDebounced).not.toBe(before); + }); + + it('rebuilds the publish throttles on a plain updateConfig', () => { + const paginator = new MessagePaginator({ channel }); + const internals = paginator as unknown as { _windowPublishThrottle: unknown }; + const before = internals._windowPublishThrottle; + + paginator.updateConfig({ stateThrottleMs: 111 }); + + expect(paginator.config.stateThrottleMs).toBe(111); + expect(internals._windowPublishThrottle).not.toBe(before); + }); + + it('drops the throttles when the interval is cleared', () => { + const paginator = new MessagePaginator({ channel }); + + paginator.updateConfig({ stateThrottleMs: undefined }); + + expect( + (paginator as unknown as { _windowPublishThrottle: unknown }) + ._windowPublishThrottle, + ).toBeUndefined(); + }); + }); + + describe('one re-derivation is one complete publish', () => { + // The subclass overlay used to be a *second* write from an `initializeConfig` override. The base + // derivation knows nothing of that overlay, so its publish carried the config with `doRequest`, + // `deriveCursor` and `itemOrderComparator` stripped, and the subclass then put them back — three + // notifications for a pinned paginator, the first with no request function at all. The JSDoc claimed + // "both carry a complete config, so no subscriber sees a half-applied state"; it did not hold. + it('never publishes a pinned config missing its request function or comparators', () => { + const paginator = new PinnedMessagePaginator({ channel }); + const publishes: { + deriveCursor: string; + doRequest: string; + itemOrderComparator: string; + }[] = []; + paginator.configState.subscribe((config) => + publishes.push({ + deriveCursor: typeof config.deriveCursor, + doRequest: typeof config.doRequest, + itemOrderComparator: typeof config.itemOrderComparator, + }), + ); + publishes.length = 0; + + paginator.initializeConfig({ pageSize: 42 }); + + expect(publishes).toEqual([ + { + deriveCursor: 'function', + doRequest: 'function', + itemOrderComparator: 'function', + }, + ]); + expect(paginator.config.pageSize).toBe(42); + }); + + it('does not publish at all when the derivation has not moved', () => { + const paginator = new PinnedMessagePaginator({ channel }); + const listener = vi.fn(); + paginator.configState.subscribe(listener); + listener.mockClear(); + + paginator.initializeConfig(); + paginator.initializeConfig(); + + expect(listener).not.toHaveBeenCalled(); + // …and the overlay is still installed, so the skip is a genuine no-op rather than a lost write. + expect(typeof paginator.config.doRequest).toBe('function'); + }); + + it('keeps the overlay winning over a constructor-supplied doRequest', () => { + // Precedence used to come from the overlay being written *after* the base derivation. It now comes + // from being spread last inside it — same result, and worth pinning since the mechanism changed. + const ownDoRequest = vi.fn(); + const paginator = new PinnedMessagePaginator({ + channel, + paginatorOptions: { doRequest: ownDoRequest }, + }); + + paginator.initializeConfig(); + + expect(paginator.config.doRequest).not.toBe(ownDoRequest); + }); + + it('updateConfig skips a patch whose every field is already equal', () => { + const paginator = new MessagePaginator({ channel }); + const listener = vi.fn(); + paginator.configState.subscribe(listener); + listener.mockClear(); + + paginator.updateConfig({ pageSize: paginator.config.pageSize }); + expect(listener).not.toHaveBeenCalled(); + + paginator.updateConfig({ pageSize: paginator.config.pageSize + 1 }); + expect(listener).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 1f8955457c..c2ce0352f8 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -3233,7 +3233,7 @@ describe('MessagePaginator', () => { const buildPaginator = (parentMessageId?: string) => { trackingChannel = { cid: 'channel-id', - getConfig: () => ({ skip_last_msg_update_for_system_msgs: skipSystemMessages }), + serverConfig: { skip_last_msg_update_for_system_msgs: skipSystemMessages }, getReplies: vi.fn(), query: vi.fn(), } as unknown as Channel; diff --git a/test/unit/pagination/utility.normalization.dotPath.test.ts b/test/unit/pagination/utility.normalization.dotPath.test.ts new file mode 100644 index 0000000000..4aebae23ed --- /dev/null +++ b/test/unit/pagination/utility.normalization.dotPath.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { resolveDotPathValue } from '../../../src/pagination/utility.normalization'; +import { makeComparator } from '../../../src/pagination/sortCompiler'; + +/** + * The dot-path accessor behind every filter and sort comparator. + * + * It used to stop descending at any **falsy** intermediate rather than at a nullish one, which made a result + * depend on a value's contents instead of its shape: `name.length` resolved to `2` for `'ab'` and to + * `undefined` for `''`. Falsy values at the *end* of a path were never affected — the guard only ran against + * intermediates — so scalar filtering and sorting were never wrong, which is why this went unnoticed. + */ +describe('resolveDotPathValue', () => { + describe('descends to the end of the path', () => { + it('reads a nested plain-object value', () => { + expect(resolveDotPathValue({ a: { b: { c: 7 } } }, 'a.b.c')).toBe(7); + }); + + it('reads through arrays, by index and by property', () => { + // A filter path legitimately reaches into arrays, which is one reason this is not `getPath` from + // `src/utils/objectPath.ts`. + expect( + resolveDotPathValue({ items: [{ id: 'x' }, { id: 'y' }] }, 'items.1.id'), + ).toBe('y'); + expect(resolveDotPathValue({ items: [1, 2, 3] }, 'items.length')).toBe(3); + }); + + it('reads through class instances', () => { + // The other reason: `Reminder`, `Poll` and friends are class instances, and `getPath` walks plain + // records only. + class Reminder { + readonly remind_at = 'soon'; + readonly user = { id: 'u1' }; + } + + expect(resolveDotPathValue(new Reminder(), 'remind_at')).toBe('soon'); + expect(resolveDotPathValue(new Reminder(), 'user.id')).toBe('u1'); + }); + }); + + describe('falsy values', () => { + it('returns a falsy value at the end of a path', () => { + // Never broken, and the case that actually matters for filtering and sorting — asserted so a future + // change to the guard cannot quietly start swallowing these. + expect(resolveDotPathValue({ count: 0 }, 'count')).toBe(0); + expect(resolveDotPathValue({ name: '' }, 'name')).toBe(''); + expect(resolveDotPathValue({ flag: false }, 'flag')).toBe(false); + expect(resolveDotPathValue({ a: { b: 0 } }, 'a.b')).toBe(0); + }); + + it('does not let a falsy intermediate change the answer for the same path', () => { + // The defect. Both are strings, so both should answer with a length. + expect(resolveDotPathValue({ name: 'ab' }, 'name.length')).toBe(2); + expect(resolveDotPathValue({ name: '' }, 'name.length')).toBe(0); + }); + }); + + describe('stops only where it cannot descend', () => { + it('returns undefined for an absent path', () => { + expect(resolveDotPathValue({}, 'a.b')).toBeUndefined(); + }); + + it('returns undefined rather than throwing on a nullish intermediate', () => { + expect(resolveDotPathValue({ a: null }, 'a.b')).toBeUndefined(); + expect(resolveDotPathValue({ a: undefined }, 'a.b')).toBeUndefined(); + expect(resolveDotPathValue(undefined, 'a.b')).toBeUndefined(); + }); + }); + + it('sorts an empty string by length alongside the others', () => { + // The reachable consequence: with the old guard the empty-string row resolved to `undefined` and sorted + // as though the field were missing, while every other row sorted by its length. + const comparator = makeComparator<{ cid: string; name: string }>({ + sort: [{ direction: 1, field: 'name.length' }] as never, + }); + const rows = [ + { cid: 'c', name: 'abc' }, + { cid: 'a', name: '' }, + { cid: 'b', name: 'ab' }, + ]; + + expect([...rows].sort(comparator).map(({ cid }) => cid)).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/test/unit/test-utils/stubServerConfig.ts b/test/unit/test-utils/stubServerConfig.ts new file mode 100644 index 0000000000..7e3c08aa50 --- /dev/null +++ b/test/unit/test-utils/stubServerConfig.ts @@ -0,0 +1,40 @@ +import type { Channel } from '../../../src/channel'; + +/** + * Sets a channel's server configuration in tests. + * + * Writes through `client.channelServerConfigsStore` — the real place — rather than stubbing the + * accessor, because the flags no longer reach consumers directly. They are reconciled into + * `channel.config` by the entity's `applyAuthority`, and the store write is what triggers that + * derivation. Faking `serverConfig` alone would leave every resolved value untouched, so tests that + * looked like they were disabling a feature would silently assert nothing. + * + * Falls back to defining both accessors for plain object mocks with no client behind them. + * + * Returns a setter for the cases that need the value to change mid-test. + */ +export const stubServerConfig = ( + channel: Partial | Record, + initial: Record | undefined, +) => { + const client = (channel as Channel).getClient?.(); + const cid = (channel as Channel).cid; + + if (client && cid) { + const write = (next: Record | undefined) => { + client.channelServerConfigsStore.partialNext({ + configs: { ...client.channelServerConfigs, [cid]: next } as never, + }); + }; + write(initial); + return write; + } + + let current = initial; + for (const key of ['serverConfig', 'config']) { + Object.defineProperty(channel, key, { configurable: true, get: () => current }); + } + return (next: Record | undefined) => { + current = next; + }; +}; diff --git a/test/unit/utils/objectPath.test.ts b/test/unit/utils/objectPath.test.ts new file mode 100644 index 0000000000..0d37bc10a9 --- /dev/null +++ b/test/unit/utils/objectPath.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { getPath, hasPath } from '../../../src/utils/objectPath'; + +/** + * The pair's reason to exist is that `getPath` alone cannot answer "was this registered?": a configuration + * patch may carry an explicit `undefined`, and that reads back the same as an absent key. Three other + * dot-path walkers in this package return only a value, so none of them can express `hasPath` — which is + * what the first test here pins. + */ +describe('objectPath', () => { + describe('hasPath', () => { + it('distinguishes an explicit undefined from an absent key', () => { + // The property no value-returning accessor can provide, and the one the construction-only + // diagnostic depends on to report a late registration. + expect( + hasPath( + { messagePaginator: { initialCursor: undefined } }, + 'messagePaginator.initialCursor', + ), + ).toBe(true); + expect(hasPath({ messagePaginator: {} }, 'messagePaginator.initialCursor')).toBe( + false, + ); + }); + + it('walks nested plain objects', () => { + const tree = { a: { b: { c: 1 } } }; + + expect(hasPath(tree, 'a')).toBe(true); + expect(hasPath(tree, 'a.b')).toBe(true); + expect(hasPath(tree, 'a.b.c')).toBe(true); + expect(hasPath(tree, 'a.b.d')).toBe(false); + expect(hasPath(tree, 'x.y')).toBe(false); + }); + + it('refuses to descend into anything that is not a plain object', () => { + // A config tree holds class instances, arrays and functions as leaf *values*. Indexing into their + // internals would be meaningless — `itemIndex.length` is not a configuration path. + class ItemIndex { + readonly length = 3; + } + + expect(hasPath({ itemIndex: new ItemIndex() }, 'itemIndex.length')).toBe(false); + expect(hasPath({ list: [1, 2, 3] }, 'list.length')).toBe(false); + expect(hasPath({ findURLFn: () => [] }, 'findURLFn.name')).toBe(false); + // …but each is still present as a leaf in its own right. + expect(hasPath({ list: [1, 2, 3] }, 'list')).toBe(true); + }); + }); + + describe('getPath', () => { + it('returns the value at a nested path', () => { + expect(getPath({ a: { b: { c: 7 } } }, 'a.b.c')).toBe(7); + expect(getPath({ a: { b: 0 } }, 'a.b')).toBe(0); + expect(getPath({ a: { b: '' } }, 'a.b')).toBe(''); + }); + + it('does not short-circuit on a falsy intermediate value', () => { + // `resolveDotPathValue` in the pagination utilities does, which is the behaviour difference that + // stops these being interchangeable. + expect(getPath({ a: { b: { c: 1 } } }, 'a.b.c')).toBe(1); + expect(getPath({ a: 0 }, 'a.b')).toBeUndefined(); + }); + + it('returns undefined for an absent path rather than throwing', () => { + expect(getPath({}, 'a.b.c')).toBeUndefined(); + expect(getPath({ a: null }, 'a.b')).toBeUndefined(); + }); + }); +}); diff --git a/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md index ae56ae1f59..98c8815970 100644 --- a/v9-to-v10-migration-guide-methods.md +++ b/v9-to-v10-migration-guide-methods.md @@ -656,7 +656,7 @@ Webhook verification is inherently server-side work: it needs the API secret, wh ### Constructor and lifecycle -`getClient()`, `getConfig()`, `clean()`, `_checkInitialized()`, `_initializeState(...)`, `_disconnect()`, and `create(options?)` are unchanged. +`getClient()`, `clean()`, `_checkInitialized()`, `_initializeState(...)`, `_disconnect()`, and `create(options?)` are unchanged. `channel._channelURL()` — **REMOVED after `10.0.0-rc.4`**, no replacement. It built a `{baseURL}/channels/{type}/{id}` string for the hand-rolled request layer that no longer exists; every request now goes through the generated API client, which resolves its own paths. Nothing in the SDK called it. If you were using it to build a URL yourself, construct it inline. @@ -666,6 +666,8 @@ Webhook verification is inherently server-side work: it needs the API secret, wh - `channel.updateMemberPartial(updates, options?: { userId? })` — REMOVED (v9 wrapper). Use the inherited `channel.updateMemberPartial(request?)` — same name, generated shape. - `channel.partialUpdateMember(user_id, updates)` — REMOVED. Use `channel.updateMemberPartial({ user_id, ...updates })`. - `channel.sendEvent(event)` — replaced by `channel.sendEvent(request: { event })` (override). +- `channel.getConfig()` — **REMOVED**. Use the `channel.serverConfig` **getter**, which returns the same value: this channel's server configuration (`ChannelConfigWithInfo`) — mostly type-level, but narrowed by the channel's own `config_overrides` where it has any. It is a property now, not a call — `channel.getConfig()?.uploads` becomes `channel.serverConfig?.uploads`. If you mock it in tests, note that `vi.fn()` cannot stand in for a getter. + - **Not** to be confused with `channel.config`, which is new and different: the channel's _resolved_ configuration, where a handful of server flags have been combined with what you registered through `client.config`. See the table under "Composer & attachment shape" for which fields live where. ### Signature-changed methods diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md index 93b5401585..17854f709e 100644 --- a/v9-to-v10-migration-guide-other.md +++ b/v9-to-v10-migration-guide-other.md @@ -28,6 +28,7 @@ - Filter payloads now carry **per-endpoint operator constraints** (inline `Filters<{ … }>` on each request type) — previously-permissive filter objects may stop type-checking. Only one operator per field is allowed, and `null` is no longer a valid `$in` element. `QueryPollsFilters`, `QueryVotesFilters`, and `ReminderFilters` were the last hand-written holdouts and now derive from their request types too. - `ChannelState.membership` initializes to `undefined` (was `{}`); `ChannelState.typing` values are now `EventPayload<'typing.start' | 'typing.stop'>` (were `Event`); read receipts merged with the generated `ReadStateResponse`. - Composer attachments now nest `mime_type` / `file_size` / `duration` under `.custom`; `LocationComposer` preview `end_at` is a `Date` (was ISO string). +- Composer configuration gained required `polls`, `attachments.enabled` and `attachments.customCdn` (all defaulted — only full-literal annotations break). The channel type's `uploads` / `polls` flags now resolve **into** that configuration, so read `composer.config` rather than `channel.serverConfig`. **Silent behaviour change:** a custom `doUploadRequest` no longer waives the `upload-file` capability — set `attachments.customCdn: true` if you upload to storage Stream does not host. - `Role` type renamed to `RoleName`. - Assorted small tightenings: `TokenManager.setTokenOrProvider` user param narrowed, `revokeTokens(before)` no longer accepts `string`, `UserGroupPaginator` cursor field is a `Date`. @@ -480,6 +481,168 @@ If your app called `preview.end_at.toISOString()` or passed `end_at` directly to Reference to a `user_id` getter on the poll composer is removed. Consumers that read `pollComposer.user_id` should use `client.userId` directly. +### Composer configuration — three new required fields + +`MessageComposerConfig` gained `polls`, and `AttachmentManagerConfig` gained `enabled` and `customCdn`. All have defaults, so **callers passing partials need no change** — `client.config.set()`, `composer.updateConfig()` and the `config` construction option all take a `DeepPartial`. + +| Type | Field | Default | Gates | +| ------------------------- | ----------- | ------------------- | ---------------------------- | +| `MessageComposerConfig` | `polls` | `{ enabled: true }` | Poll composition | +| `AttachmentManagerConfig` | `enabled` | `true` | File attachments | +| `AttachmentManagerConfig` | `customCdn` | `false` | Whether uploads reach Stream | + +Only code that annotates a variable as the **complete** config type and builds it as an object literal breaks — TypeScript asks for the new keys: + +```ts +// v9 — compiles +const config: AttachmentManagerConfig = { + acceptedFiles: [], + fileUploadFilter: () => true, + maxNumberOfFilesPerMessage: 10, + trackUploadProgress: true, +}; + +// v10 — add the two new keys +const config: AttachmentManagerConfig = { + acceptedFiles: [], + customCdn: false, + enabled: true, + fileUploadFilter: () => true, + maxNumberOfFilesPerMessage: 10, + trackUploadProgress: true, +}; +``` + +### Channel-type `uploads` / `polls` now resolve into composer configuration + +They join `shared_locations`: the server flag is ANDed with the client's `attachments.enabled` / `polls.enabled`, so either side can switch a feature off and neither can widen. + +**Read the resolved value, not the raw flag.** UI that gates on `channel.serverConfig?.uploads` sees only the server's half and will offer features the composer has already disabled: + +```ts +// v9 — the only available answer +if (channel.serverConfig?.uploads) showAttachmentButton(); + +// v10 — the whole answer +if (composer.attachmentManager.isUploadEnabled) showAttachmentButton(); +// or, for the configured value alone: +if (composer.config.attachments.enabled) … +``` + +`commands` is deliberately **not** mirrored — the server sends a list, not a gate. It is carried on **`channel.config.availableCommands`** so there is one place to read from. + +Named for availability, not enablement: whether a given command can be _used_ right now is +`messageComposer.isCommandDisabled(command)`, which depends on the message context — editing and quoting +disable different ones. The name also keeps it distinct from `messageComposer.config.commands`, which is +unrelated (it holds `{ sendValidator }`). + +### `doUploadRequest` no longer waives the `upload-file` capability — use `customCdn` + +**Behaviour change; no compile error will point at it.** `AttachmentManager` used to skip Stream's `upload-file` capability whenever a custom `doUploadRequest` was supplied. That conflated _how_ files are sent with _where_ they land: wrapping the request to add retries or headers, or proxying it through your own backend, still ends at Stream. + +The waiver now keys on `attachments.customCdn`: + +| You have | v9 | v10 | +| ------------------------------------------------- | ----------------------- | -------------------------------------------- | +| `doUploadRequest` that still posts to Stream | capability **bypassed** | capability **enforced** — the correction | +| `doUploadRequest` to storage Stream does not host | capability bypassed | **set `customCdn: true`** to keep the bypass | + +```ts +client.config.set({ + messageComposer: { attachments: { customCdn: true } }, +}); +``` + +Miss it and uploads to your own storage are refused for users without `upload-file`, and the attachment action disappears from the UI. `customCdn` also decides whether the channel type's `uploads` flag applies, for the same reason. + +Related: `AttachmentManager.isUploadEnabled` and `uploadFiles` now enforce the **same** predicate (they had drifted — `uploadFiles` carried the bypass, the getter did not), and the `usesStreamStorage` getter is public. + +See `docs/instance-configuration.md` for the reasoning behind all three. + +### Config setters no longer skip a write when the server is masking the field + +**Bug fix, worth knowing if you set configuration imperatively.** Setters such as +`linkPreviewsManager.enabled`, `textComposer.maxLengthOnSend` and +`attachmentManager.maxNumberOfFilesPerMessage` used to return early when the new value equalled the +current one. The value they compared was the **effective** one — after the server's restrictions — while +the write they skipped records what you **requested**. + +With a server that disables the feature, the effective value is always `false`, so: + +```ts +linkPreviewsManager.enabled = true; // server says no → still false +linkPreviewsManager.enabled = false; // your final answer… but the guard skipped the write +// server later enables url_enrichment → previews turn ON +``` + +The last instruction was "off" and the earlier "on" survived in the retained request layer. The guards are +removed; `ConfigController` already declines to publish when the resolved value does not move, which is +the same check applied to the right value. A request made while the server is masking the field is still +recorded and honoured if the server later relents — that part is deliberate and unchanged. + +### `linkPreviews.enabled` now defaults to `true` + +**Behaviour change.** Link previews were off unless you switched them on. They are now on wherever the channel type has `url_enrichment` enabled. + +The old default double-gated the feature: the server flag said yes, and the client default said no, so previews stayed off in apps that had enabled them server-side and never knew there was a second switch. Every other server-gated setting defaults to `true`, meaning "no opinion — let the server decide", and this one now matches. + +To keep them off, say so: + +```ts +client.config.set({ messageComposer: { linkPreviews: { enabled: false } } }); +``` + +`drafts.enabled` is **unchanged** at `false`. It has no server flag, so there is nothing to defer to — that default is a product decision, not a double-gate. + +### Channel-type `typing_events` / `read_events` now resolve into channel configuration + +`Channel` gained a resolved configuration of its own, carrying two new gates that AND the channel type's flags with what the integrator registered: + +```ts +client.config.set({ + channel: { + typingEvents: { enabled: false }, // stop publishing typing events + readEvents: { enabled: false }, // stop marking read/unread + }, +}); +``` + +`keystroke()`, `stopTyping()`, `markRead()` and `markUnread()` were already gated on the server flags — that has not changed. What is new is that they now read the **resolved** value, so a client-side `false` is honoured too, and UI can read one answer instead of the raw flag: + +```ts +// v9 — the server's half only +if (channel.serverConfig?.read_events) showReadReceipts(); + +// v10 — the whole answer, and reactive +useStateStore(channel.configState, ({ readEvents }) => ({ enabled: readEvents.enabled })); +``` + +`markRead` / `markUnread` still throw when read events are off; the message now names both possible causes. + +`Channel` now exposes the same shape as every other configurable class — `configState`, `config`, `initializeConfig` — which required renaming the member it would have collided with: + +| Before | After | Returns | +| --------------------- | ---------------------- | ----------------------------------------------------------------------------------- | +| `channel.getConfig()` | `channel.serverConfig` | This channel's server configuration — `ChannelConfigWithInfo`, 37 fields | +| — | `channel.config` | This channel's **resolved** configuration — 7 fields, server combined with your own | + +**`getConfig()` is removed, not deprecated.** `serverConfig` is a getter returning exactly what it returned, so migrating is dropping the parentheses. + +Most of `ChannelConfigWithInfo` is a channel-_type_ setting, but not all of it: a channel's own `config_overrides` narrow `uploads`, `url_enrichment`, `typing_events`, `replies`, `quotes`, `reactions`, `shared_locations`, `max_message_length`, `commands` and `user_message_reminders` for that channel alone. `serverConfig` therefore answers for **this channel**, and the cache behind it (`client.channelServerConfigs`, v9's `client.configs`) stays keyed by cid. It is `undefined` until the channel has been queried or watched — there is deliberately no type-level fallback, because the only thing available to fall back on is a sibling channel's overrides. `channel.config` covers that window with its defaults. + +The two are **not** interchangeable. Only six flags have a resolved counterpart on `config`: `typing_events`, `read_events`, `replies`, `user_message_reminders`, `delivery_events` and `commands`. Read those from `config` — it is the whole answer, server and client combined. Everything else (`automod`, `max_message_length`, `mutes`, `quotes`, `search`, …) is server-only and stays on `serverConfig`. + +`DEFAULT_CHANNEL_CONFIG` is exported and deep-frozen, like every other default config constant. + +**`channel.config` holds only the channel's own fields.** The `channel` slice you register can also carry +`messagePaginator`, `pinnedMessagesPaginator` and `messageOperations` — those are scoped overrides for the +objects the channel owns, and they still work: + +```ts +client.config.set({ channel: { messagePaginator: { pageSize: 50 } } }); +channel.messagePaginator.config.pageSize; // 50 +``` + --- ## Reminders — `messageId` → `message_id` @@ -605,13 +768,20 @@ For each source file that touches the SDK: 4. **Guard `channel.state.membership` reads** with `?.` — it's `undefined` on freshly constructed channels. 5. **Fix filter objects that used undeclared operators** for constrained endpoints (`queryChannels`, `queryUsers`, `queryReactions`, `queryThreads`, `queryMembers`, `queryBannedUsers`, `queryMessageFlags`, `search`). If the filter must stay as-is, cast; otherwise use a declared operator. 6. **Move composer attachment metadata reads** from `attachment.mime_type` / `attachment.file_size` / `attachment.duration` to `attachment.custom?.`. -7. **Format `LocationComposer` preview `end_at` at read sites** — it's a `Date` now. -8. **Rename `ReminderManager` call-site keys** `messageId` → `message_id`. Same for any place you were shaping a reminder-event body. -9. **Delete any code that used `client.secret`, `client._isUsingServerAuth()`, `client.setAnonymousUser`, `client.markAllRead`, or assigned to `client.userID`.** Move server-side callers to `@stream-io/node-sdk`. -10. **Rewrite `client.revokeTokens(isoString)`** to `client.revokeTokens(new Date(isoString))`. -11. **Fix upload call sites.** `channel.sendFile` / `sendImage` are now `channel.uploadFile` / `uploadImage`, and take a request object: `{ file }`, where `file` is a `File`, a `Blob`, or a React-Native `{ uri, name, type }` descriptor — no `Buffer`, no readable streams. The MIME type still has to be explicit on the React-Native path, it just lives on the descriptor rather than in a separate `contentType` argument. `axiosRequestConfig` becomes `requestOptions` (`{ onUploadProgress, signal }`), and the routes moved to `/api/v2/…`. -12. **Delete bundler shims** added for `stream-chat`'s Node-only deps (`crypto`, `https`, `zlib`, `jsonwebtoken`, `ws`) — `package.json#browser` is gone because nothing imports them anymore. -13. **Handle the new connect hello event.** Anything keyed on the _first_ `health.check` (seeding `client.user`, unread counts, "connected" UI state) should listen for `connection.ok` instead; periodic `health.check` events are unchanged. Narrow on `event.type` before reading fields off the resolved `ConnectionOpen`. -14. **Drop long-poll fallback code.** Remove `enableWSFallback` from client options, delete `transport.changed` listeners, and delete reads of `client.defaultWSTimeoutWithFallback`. -15. **Replace `client.setLocalDevice(device)` / the `device` client option** with an explicit `await client.createDevice({ id, push_provider, push_provider_name? })` after connecting. -16. **Polyfill `atob`** if your React Native / Hermes target lacks it (`typeof atob === 'undefined'`); `UserFromToken` depends on it during `connectUser`. +7. **Add `enabled` / `customCdn` / `polls`** to any variable annotated as a complete `AttachmentManagerConfig` or `MessageComposerConfig` and built as an object literal. Partials are unaffected. +8. **Decide whether you want link previews.** They now default to on wherever `url_enrichment` is enabled server-side; set `linkPreviews.enabled: false` to keep the old behaviour. Nothing will fail to compile. +9. **Set `attachments.customCdn: true`** if you supply a `doUploadRequest` that stores files outside Stream — otherwise uploads are refused for users without the `upload-file` capability. Nothing will fail to compile; this one is silent. +10. **Drop the parentheses on every `channel.getConfig()` call** — it is removed; `channel.serverConfig` is a getter returning the same thing. Test mocks need `Object.defineProperty`, not `vi.fn()`. +11. **Replace raw `serverConfig?.uploads` / `?.polls` / `?.shared_locations` reads** used to gate UI with the resolved composer values (`attachmentManager.isUploadEnabled`, `composer.config.polls.enabled`, `composer.config.location.enabled`). +12. **Replace raw `serverConfig?.typing_events` / `?.read_events` / `?.replies` / `?.user_message_reminders` / `?.delivery_events` / `?.commands` reads** with `channel.config`'s equivalents (`commands` becomes `availableCommands`), which are also reactive through `channel.configState`. +13. **Format `LocationComposer` preview `end_at` at read sites** — it's a `Date` now. +14. **Rename `ReminderManager` call-site keys** `messageId` → `message_id`. Same for any place you were shaping a reminder-event body. +15. **Delete any code that used `client.secret`, `client._isUsingServerAuth()`, `client.setAnonymousUser`, `client.markAllRead`, or assigned to `client.userID`.** Move server-side callers to `@stream-io/node-sdk`. +16. **Rewrite `client.revokeTokens(isoString)`** to `client.revokeTokens(new Date(isoString))`. +17. **Fix upload call sites.** `channel.sendFile` / `sendImage` are now `channel.uploadFile` / `uploadImage`, and take a request object: `{ file }`, where `file` is a `File`, a `Blob`, or a React-Native `{ uri, name, type }` descriptor — no `Buffer`, no readable streams. The MIME type still has to be explicit on the React-Native path, it just lives on the descriptor rather than in a separate `contentType` argument. `axiosRequestConfig` becomes `requestOptions` (`{ onUploadProgress, signal }`), and the routes moved to `/api/v2/…`. +18. **Delete bundler shims** added for `stream-chat`'s Node-only deps (`crypto`, `https`, `zlib`, `jsonwebtoken`, `ws`) — `package.json#browser` is gone because nothing imports them anymore. +19. **Handle the new connect hello event.** Anything keyed on the _first_ `health.check` (seeding `client.user`, unread counts, "connected" UI state) should listen for `connection.ok` instead; periodic `health.check` events are unchanged. Narrow on `event.type` before reading fields off the resolved `ConnectionOpen`. +20. **Drop long-poll fallback code.** Remove `enableWSFallback` from client options, delete `transport.changed` listeners, and delete reads of `client.defaultWSTimeoutWithFallback`. +21. **Replace `client.setLocalDevice(device)` / the `device` client option** with an explicit `await client.createDevice({ id, push_provider, push_provider_name? })` after connecting. +22. **Polyfill `atob`** if your React Native / Hermes target lacks it (`typeof atob === 'undefined'`); `UserFromToken` depends on it during `connectUser`. +23. **Call `liveLocationManager.dispose()`** when you are finished with a manager you constructed, alongside whatever `unregisterSubscriptions()` you already call. Nothing will fail to compile: `dispose()` is the _configuration_ teardown, and until it runs the client's configuration registry holds a handle to the manager — a long-lived client and many short-lived managers will accumulate them. `unregisterSubscriptions()` is unchanged and stays ref-counted, so it deliberately no longer releases configuration; it never should have, since with two callers sharing a manager the first to leave stopped a still-live instance from tracking `client.config`. `SearchController` already worked this way. diff --git a/v9-to-v10-migration-guide-type-renames.md b/v9-to-v10-migration-guide-type-renames.md index c3b1683bc9..207552432f 100644 --- a/v9-to-v10-migration-guide-type-renames.md +++ b/v9-to-v10-migration-guide-type-renames.md @@ -4,8 +4,9 @@ > > This document is written for AI agents doing mechanical rewrites. Each entry lists the v9 name, the v10 name, and the file(s) where the type is exported from. All v10 names are still importable from the package root (`stream-chat`) or from `stream-chat/dist/types` — nothing has moved outside the package surface. > -> If a codebase imports one of these names it will fail to resolve in v10; apply the table below as a find/replace. For most rows behavior is unchanged — the underlying type is identical to what the removed alias resolved to in v9. +> If a codebase imports one of these names it will fail to resolve in v10; apply the table below as a find/replace. Behavior is unchanged — the underlying type is identical to what the removed alias resolved to in v9. > +> One exception to "it will fail to resolve": the three `MessageComposer*` setup types were never exported from the package root, so no v9 code can be importing them. They are listed so the table is a complete record of removed type names, not because a rewrite is expected to find them. > **Three rows are the exception.** `APIErrorResponse`, `DraftMessagePayload`, and `EventAPIResponse` were hand-rolled object types in v9, not aliases of a generated type, and their v10 targets differ field-by-field. A mechanical find/replace on those three will compile in some places and silently change meaning in others — stop at them and read [Rows that are shape changes, not renames](#rows-that-are-shape-changes-not-renames) below. ## How to apply @@ -27,50 +28,55 @@ v10 exposes two generated types whose names collide with v9 aliases that pointed ## Rename table -| v9 (removed) | v10 (use this) | Notes | -| ------------------------------ | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `APIErrorResponse` | `APIError` | ⚠️ **Shape change, not an alias** — `StatusCode` becomes `status_code`, `code` becomes required, `details` becomes `Array`. See [below](#apierrorresponse--apierror). | -| `AppSettings` | `AppResponseFields` | | -| `AppSettingsAPIResponse` | `GetApplicationResponse` | Return type of `client.getAppSettings()`. | -| `AutomodDetails` | `AutomodDetailsResponse` | | -| `ChannelAPIResponse` | `ChannelStateResponseFields` | The per-channel entry inside a `queryChannels` response (fields only, no top-level `duration`). | -| `ChannelConfigAutomod` | `Automod` | ⚠️ **Narrowed after `rc.4`.** `Automod` is now `ChannelConfigWithInfo['automod']` — exactly `'disabled' \| 'simple' \| 'AI'`. It previously carried a `\| (string & {})` tail that let any string through. | -| `ChannelConfigAutomodBehavior` | `AutomodBehavior` | ⚠️ **Narrowed after `rc.4`.** Now `ChannelConfigWithInfo['automod_behavior']` — exactly `'flag' \| 'block' \| 'shadow_block'`, without the `\| (string & {})` tail. | -| `ChannelQueryOptions` | `ChannelGetOrCreateRequest` | Payload for `channel.watch()`, `channel.create()`, and `channel.query()`. The v9 alias masked the OpenAPI name; v10 uses the generated name directly. | -| `CommandResponse` | `Command` | Slash-command descriptor — matches the shape stored under `channel.getConfig().commands`. | -| `CreatePollData` | `CreatePollRequest` | Payload for `client.createPoll()` / `PollManager.createPoll()`. | -| `DraftMessagePayload` | `MessageRequest` | ⚠️ **Shape change, and the payload is now nested** — `channel.createDraft` takes `{ message: MessageRequest }`. See [below](#draftmessagepayload--messagerequest). | -| `ErrorFromResponse` | `StreamAPIError` | **Runtime value**, not just a type — was `export const ErrorFromResponse = StreamAPIError;`. Rewrite `instanceof ErrorFromResponse` and `new ErrorFromResponse(...)` call sites too. | -| `EventAPIResponse` | depends on the endpoint | ⚠️ **One v9 alias became three generated response types.** HTTP endpoints still return an event — only `markDelivered` lost it. See [below](#eventapiresponse--one-type-per-endpoint). | -| `EventTypes` | `EventType` | Simple singular/plural rename. | -| `MarkDeliveredOptions` | `MarkDeliveredRequest` | | -| `MarkReadOptions` | `MarkReadRequest` | | -| `MarkUnreadOptions` | `MarkUnreadRequest` | | -| `Message` | `MessageRequest` | The v9 `Message` alias resolved to the generated `MessageRequest` (send-message payload). Rewrite in type positions only — `Message` is a common English noun and appears in JSDoc, permission names, and error strings; leave those alone. | -| `Mute` | `UserMuteResponse` | Both v9 `Mute` and v9 `MuteResponse` aliased `UserMuteResponse` — the v10 name is the same for both. | -| `MuteResponse` | `UserMuteResponse` | See collision note above — v10 also exports a different `MuteResponse` from the server-side `mute` endpoint. Use `UserMuteResponse` when replacing the v9 alias. | -| `PartialUpdateChannel` | `UpdateChannelPartialRequest` | Payload for `channel.updatePartial`. | -| `PartialUserUpdate` | `UpdateUserPartialRequest` | Payload for the partial-user-update endpoint. | -| `PollAnswer` | `PollVoteResponseData` | v9 modeled answers as a separate type; v10 treats them uniformly with vote responses. | -| `PollData` | `UpdatePollRequest` | Payload for `client.updatePoll()`. Also used internally by `PartialPollUpdate` (its `set`/`unset` are keyed on this type). | -| `PollOption` | `PollOptionResponseData` | The v9 alias pointed at the generated `PollOptionResponseData`. Not to be confused with `PollOptionData` (the update-poll-option request payload), which is a different local type and is **not** renamed. | -| `PollVote` | `PollVoteResponseData` | Applied to type positions only. Do **not** rewrite method names such as `castPollVote`, `deletePollVote`, `queryPollVotes` or event guards like `isPollVoteCastedEvent`. | -| `PrivacySettings` | `PrivacySettingsResponse` | | -| `PushPreference` | `PushPreferenceInput` | | -| `QueryChannelAPIResponse` | `ChannelStateResponse` | The full response of `client.getOrCreateChannel` (has top-level `duration`). | -| `QueryChannelsAPIResponse` | `QueryChannelsResponse` | Return type of the raw `client.queryChannels` inherited from the generated `ChatApi`. | -| `QueryThreadsOptions` | `QueryThreadsRequest` | Payload for `client.queryThreadsAndHydrate` / `ThreadManager.queryThreads`. | -| `QueryUserGroupsOptions` | `ListUserGroupsOptions` | Same underlying shape (`NonNullable[0]>`) — pure rename to match the new `client.listUserGroups` method. See the methods guide. | -| `QueryUserGroupsResponse` | `StreamResponse` | Was a hand-rolled `APIResponse & { user_groups: UserGroupResponse[] }`; v10 uses the generated `ListUserGroupsResponse` wrapped in `StreamResponse<...>`, which adds a `metadata: RequestMetadata` field alongside `duration` and `user_groups`. Callers that only destructure `user_groups` are unaffected. | -| `ReadResponse` | `ReadStateResponse` | Per-user read state on a channel/thread. | -| `ReminderResponse` | `ReminderResponseData` | The single-reminder entry returned by the reminders paginator. Note: this is only the type; helper names like `generateReminderResponse` in test utilities should stay as-is. | -| `RequestOptions` | `StreamRequestOptions` | Per-request options that are never serialized into the payload — still `{ signal?: AbortSignal }`, so the type itself is a 1:1 rename. **The positions that accept it changed**, see [below](#requestoptions--streamrequestoptions). | -| `SharedLocationResponse` | `SharedLocationResponseData` | See collision note above — v10 also exports a different `SharedLocationResponse` from the generated shared-location endpoint. Use `SharedLocationResponseData` when replacing the v9 alias. | -| `StaticLocationPayload` | `SharedLocation` | Payload for static (non-live) shared-location attachments. | -| `ThreadResponse` | `ThreadStateResponse` | The v9 alias wrapped the generated `ThreadStateResponse` with a `custom` overlay. In v10 the custom-overlay pattern is dropped and `ThreadResponse` in `stream-chat` refers to the minimal generated shape — which is missing `read`, `latest_replies`, and `draft`. Anything using those fields must switch to `ThreadStateResponse`. (`thread_participants` and `parent_message` are on both shapes.) `generateThreadResponse` in test-utils keeps its name. | -| `TranslationLanguages` | `TranslationLanguage` | Renamed from plural to singular. The v9 literal union is gone; the v10 alias is `TranslateMessageRequest['language']` — a hand-defined alias in `src/types.ts` that reads the `language` field type off the generated `TranslateMessageRequest` model (the underlying `client.translateMessage` endpoint is not exposed by this SDK; the request/language model is still generated). | -| `UpdateLocationPayload` | `UpdateLiveLocationRequest` | Payload for `channel.stopLiveLocationSharing`. | -| `User_old` | `UserResponse` | Trivial 1:1 alias. | +| v9 (removed) | v10 (use this) | Notes | +| --------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- | ---------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `APIErrorResponse` | `APIError` | ⚠️ **Shape change, not an alias** — `StatusCode` becomes `status_code`, `code` becomes required, `details` becomes `Array`. See [below](#apierrorresponse--apierror). | +| `AppSettings` | `AppResponseFields` | | +| `AppSettingsAPIResponse` | `GetApplicationResponse` | Return type of `client.getAppSettings()`. | +| `AutomodDetails` | `AutomodDetailsResponse` | | +| `ChannelAPIResponse` | `ChannelStateResponseFields` | The per-channel entry inside a `queryChannels` response (fields only, no top-level `duration`). | +| `ChannelConfigAutomod` | `Automod` | ⚠️ **Narrowed after `rc.4`.** `Automod` is now `ChannelConfigWithInfo['automod']` — exactly `'disabled' \| 'simple' \| 'AI'`. It previously carried a `\| (string & {})` tail that let any string through. | +| `ChannelConfigAutomodBehavior` | `AutomodBehavior` | ⚠️ **Narrowed after `rc.4`.** Now `ChannelConfigWithInfo['automod_behavior']` — exactly `'flag' \| 'block' \| 'shadow_block'`, without the `\| (string & {})` tail. | +| `ChannelQueryOptions` | `ChannelGetOrCreateRequest` | Payload for `channel.watch()`, `channel.create()`, and `channel.query()`. The v9 alias masked the OpenAPI name; v10 uses the generated name directly. | +| `CommandResponse` | `Command` | Slash-command descriptor — matches the shape stored under `channel.serverConfig.commands` (and `channel.config.availableCommands`). | +| `CreatePollData` | `CreatePollRequest` | Payload for `client.createPoll()` / `PollManager.createPoll()`. | +| `DraftMessagePayload` | `MessageRequest` | ⚠️ **Shape change, and the payload is now nested** — `channel.createDraft` takes `{ message: MessageRequest }`. See [below](#draftmessagepayload--messagerequest). | +| `ErrorFromResponse` | `StreamAPIError` | **Runtime value**, not just a type — was `export const ErrorFromResponse = StreamAPIError;`. Rewrite `instanceof ErrorFromResponse` and `new ErrorFromResponse(...)` call sites too. | +| `EventAPIResponse` | depends on the endpoint | ⚠️ **One v9 alias became three generated response types.** HTTP endpoints still return an event — only `markDelivered` lost it. See [below](#eventapiresponse--one-type-per-endpoint). | +| `EventTypes` | `EventType` | Simple singular/plural rename. | +| `MarkDeliveredOptions` | `MarkDeliveredRequest` | | +| `MarkReadOptions` | `MarkReadRequest` | | +| `MarkUnreadOptions` | `MarkUnreadRequest` | | +| `Message` | `MessageRequest` | The v9 `Message` alias resolved to the generated `MessageRequest` (send-message payload). Rewrite in type positions only — `Message` is a common English noun and appears in JSDoc, permission names, and error strings; leave those alone. | +| `MessageComposerSetupFunction` | `InstanceSetupFunction<'messageComposer'>` | Configuration setup types, generalized when the key space stopped being composer-only. **Not reachable in v9** — these lived in `src/configuration/types.ts` and were never exported from the package root, and `package.json#exports` routes consumers to the bundles rather than to source, so no v9 import can break. Listed for completeness; if a rewrite pass finds no occurrences, that is the expected result. The deprecated _method_ they typed, `client.setMessageComposerSetupFunction`, is unaffected. | +| `MessageComposerSetupState` | `InstanceSetupState<'messageComposer'>` | See `MessageComposerSetupFunction` above — same story. | +| `MessageComposerTearDownFunction` | `InstanceSetupTearDownFunction` | See `MessageComposerSetupFunction` above — same story. The v10 name is not composer-specific, because a teardown is the same shape for every key. | +| `ChannelInstanceConfig` | `ChannelConfig` | A channel's resolved instance configuration, behind the new `channel.config` getter. The `Instance` infix disambiguated a collision that never existed — plain `ChannelConfig` was free; the generated _server_ type is `ChannelConfigWithInfo`, behind `channel.serverConfig`. Renamed to match `MessageComposerConfig` and every other `Config`. **Reachable in v9** (exported from the package root), so this one is a real break — no alias, to keep it loud. | +| `ThreadInstanceConfig` | `ThreadConfig` | Same story, same reason; renamed together so the pair stays consistent. | +| `InstanceConfigurationService` | `InstanceConfigurationRegistry` | The class behind `client.config`. `Service` said only "a class"; the object is a **registry** — it stores what you registered (declarative values and setup functions) and which live instances listen on each key, and applies nothing. Applying is `applyInstanceConfiguration`; resolving is each instance's `ConfigController`. The old name read as "the configuration applied to instances", which is the one thing it does not hold. Reachable from the package root, so a real break — but integrators use `client.config` and rarely name the class. | +| `Mute` | `UserMuteResponse` | Both v9 `Mute` and v9 `MuteResponse` aliased `UserMuteResponse` — the v10 name is the same for both. | +| `MuteResponse` | `UserMuteResponse` | See collision note above — v10 also exports a different `MuteResponse` from the server-side `mute` endpoint. Use `UserMuteResponse` when replacing the v9 alias. | +| `PartialUpdateChannel` | `UpdateChannelPartialRequest` | Payload for `channel.updatePartial`. | +| `PartialUserUpdate` | `UpdateUserPartialRequest` | Payload for the partial-user-update endpoint. | +| `PollAnswer` | `PollVoteResponseData` | v9 modeled answers as a separate type; v10 treats them uniformly with vote responses. | +| `PollData` | `UpdatePollRequest` | Payload for `client.updatePoll()`. Also used internally by `PartialPollUpdate` (its `set`/`unset` are keyed on this type). | +| `PollOption` | `PollOptionResponseData` | The v9 alias pointed at the generated `PollOptionResponseData`. Not to be confused with `PollOptionData` (the update-poll-option request payload), which is a different local type and is **not** renamed. | +| `PollVote` | `PollVoteResponseData` | Applied to type positions only. Do **not** rewrite method names such as `castPollVote`, `deletePollVote`, `queryPollVotes` or event guards like `isPollVoteCastedEvent`. | +| `PrivacySettings` | `PrivacySettingsResponse` | | +| `PushPreference` | `PushPreferenceInput` | | +| `QueryChannelAPIResponse` | `ChannelStateResponse` | The full response of `client.getOrCreateChannel` (has top-level `duration`). | +| `QueryChannelsAPIResponse` | `QueryChannelsResponse` | Return type of the raw `client.queryChannels` inherited from the generated `ChatApi`. | +| `QueryThreadsOptions` | `QueryThreadsRequest` | Payload for `client.queryThreadsAndHydrate` / `ThreadManager.queryThreads`. | +| `QueryUserGroupsOptions` | `ListUserGroupsOptions` | Same underlying shape (`NonNullable[0]>`) — pure rename to match the new `client.listUserGroups` method. See the methods guide. | +| `QueryUserGroupsResponse` | `StreamResponse` | Was a hand-rolled `APIResponse & { user_groups: UserGroupResponse[] }`; v10 uses the generated `ListUserGroupsResponse` wrapped in `StreamResponse<...>`, which adds a `metadata: RequestMetadata` field alongside `duration` and `user_groups`. Callers that only destructure `user_groups` are unaffected. | +| `ReadResponse` | `ReadStateResponse` | Per-user read state on a channel/thread. | +| `ReminderResponse` | `ReminderResponseData` | The single-reminder entry returned by the reminders paginator. Note: this is only the type; helper names like `generateReminderResponse` in test utilities should stay as-is. | | `RequestOptions` | `StreamRequestOptions` | Per-request options that are never serialized into the payload — still `{ signal?: AbortSignal }`, so the type itself is a 1:1 rename. **The positions that accept it changed**, see [below](#requestoptions--streamrequestoptions). | +| `SharedLocationResponse` | `SharedLocationResponseData` | See collision note above — v10 also exports a different `SharedLocationResponse` from the generated shared-location endpoint. Use `SharedLocationResponseData` when replacing the v9 alias. | +| `StaticLocationPayload` | `SharedLocation` | Payload for static (non-live) shared-location attachments. | +| `ThreadResponse` | `ThreadStateResponse` | The v9 alias wrapped the generated `ThreadStateResponse` with a `custom` overlay. In v10 the custom-overlay pattern is dropped and `ThreadResponse` in `stream-chat` refers to the minimal generated shape — which is missing `read`, `latest_replies`, and `draft`. Anything using those fields must switch to `ThreadStateResponse`. (`thread_participants` and `parent_message` are on both shapes.) `generateThreadResponse` in test-utils keeps its name. | +| `TranslationLanguages` | `TranslationLanguage` | Renamed from plural to singular. The v9 literal union is gone; the v10 alias is `TranslateMessageRequest['language']` — a hand-defined alias in `src/types.ts` that reads the `language` field type off the generated `TranslateMessageRequest` model (the underlying `client.translateMessage` endpoint is not exposed by this SDK; the request/language model is still generated). | +| `UpdateLocationPayload` | `UpdateLiveLocationRequest` | Payload for `channel.stopLiveLocationSharing`. | +| `User_old` | `UserResponse` | Trivial 1:1 alias. | ## Rows that are shape changes, not renames