From 88ea425fa867e46ec7b80e585d56f1d886174d44 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 5 Aug 2026 12:03:29 +0200 Subject: [PATCH 01/22] feat: handle predefined filters with ChannelPaginator --- src/pagination/paginators/BasePaginator.ts | 10 +- src/pagination/paginators/ChannelPaginator.ts | 241 ++++++++++++- .../paginators/MessageIntervalPaginator.ts | 2 +- .../paginators/PinnedMessagePaginator.ts | 2 +- .../ChannelPaginatorsOrchestrator.test.ts | 6 +- test/unit/client.test.js | 55 ++- .../paginators/BasePaginator.test.ts | 32 +- .../paginators/ChannelPaginator.test.ts | 318 +++++++++++++++++- .../paginators/MessagePaginator.test.ts | 4 +- 9 files changed, 613 insertions(+), 57 deletions(-) diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 69e9dff0a6..485a0ff6a8 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -773,12 +773,18 @@ export abstract class BasePaginator { throw new Error('Paginator.getNextQueryShape() is not implemented'); } - protected buildFilters(): object | null { + /** + * Filters an item is matched against locally (`matchesFilter`) — NOT the filters sent to the server. + * A paginator whose backend query is filtered has to build the request filters separately (see + * `ChannelPaginator.buildQueryFilters`), because the two can differ: the backend may resolve a + * server-side stored filter of its own, and some paginators filter locally without sending anything. + */ + protected buildMatchFilters(): object | null { return null; // === no filters } matchesFilter(item: T): boolean { - const filters = this.buildFilters(); + const filters = this.buildMatchFilters(); if (filters == null) return true; return itemMatchesFilter(item, filters, { resolvers: this._filterFieldToDataResolvers, diff --git a/src/pagination/paginators/ChannelPaginator.ts b/src/pagination/paginators/ChannelPaginator.ts index f63e4065e3..73b24081f8 100644 --- a/src/pagination/paginators/ChannelPaginator.ts +++ b/src/pagination/paginators/ChannelPaginator.ts @@ -4,6 +4,7 @@ import type { PaginationQueryShapeChangeIdentifier, PaginatorOptions, PaginatorState, + PostQueryReconcileParams, SetPaginatorItemsParams, } from './BasePaginator'; import { BasePaginator } from './BasePaginator'; @@ -11,6 +12,7 @@ import { chatLoggerSystem } from '../../logger'; import type { FilterBuilderOptions } from '../FilterBuilder'; import { FilterBuilder } from '../FilterBuilder'; import { makeComparator } from '../sortCompiler'; +import { itemMatchesFilter } from '../filterCompiler'; import { ItemIndex } from '../ItemIndex'; import { generateUUIDv4 } from '../../utils'; import type { StreamChat } from '../../client'; @@ -20,6 +22,7 @@ import type { ChannelOptions, ChannelSort, ChannelStateOptions, + ParsedPredefinedFilterResponse, } from '../../types'; import type { FieldToDataResolver, PathResolver } from '../types.normalization'; import { resolveDotPathValue } from '../utility.normalization'; @@ -43,6 +46,25 @@ export type ChannelPaginatorRequestOptions = Partial< Omit >; +export type ChannelSortComparatorFactoryParams = { + /** Sort the comparator is being built for — the effective sort, so a backend-resolved sort template. */ + sort: ChannelSort; + /** + * The comparator `ChannelPaginator` would use for this sort. Delegate to it for the fields you do not + * want to handle yourself instead of reimplementing channel field resolution and the cid tiebreaker. + */ + defaultComparator: (a: Channel, b: Channel) => number; +}; + +/** + * Produces the comparator that orders this paginator's channels. Consulted on **every** comparator + * rebuild (construction, `sort` change, backend-resolved predefined sort), so unlike assigning + * `sortComparator` directly, a factory is not overwritten by later sort changes. + */ +export type ChannelSortComparatorFactory = ( + params: ChannelSortComparatorFactoryParams, +) => (a: Channel, b: Channel) => number; + export type ChannelPaginatorOptions = { client: StreamChat; channelStateOptions?: ChannelStateOptions; @@ -52,6 +74,7 @@ export type ChannelPaginatorOptions = { paginatorOptions?: PaginatorOptions; requestOptions?: ChannelPaginatorRequestOptions; sort?: ChannelSort; + sortComparatorFactory?: ChannelSortComparatorFactory; }; const getQueryShapeRelevantChannelOptions = (options: ChannelOptions) => { @@ -111,6 +134,14 @@ const hasUnreadFilterResolver: FieldToDataResolver = { }, }; +const hiddenFilterResolver: FieldToDataResolver = { + matchesField: (field) => field === 'hidden', + // `hidden` is optional on ChannelResponse, so a response that omits it (and a channel that was never + // hidden or shown) leaves `channel.data.hidden` undefined. Coerce to a boolean so `{ hidden: false }` + // matches those channels instead of comparing undefined against false. + resolve: (channel) => !!channel.data?.hidden, +}; + const lastUpdatedFilterResolver: FieldToDataResolver = { matchesField: (field) => field === 'last_updated', resolve: (channel) => { @@ -194,6 +225,25 @@ const channelSortPathResolver: PathResolver = (channel, path) => { // todo: maybe items could be just an array of {cid: string} and the data would be retrieved from client.activeChannels // todo: maybe we should introduce client._cache.channels that would be reactive and orchestrator would subscribe to client._cache.channels state to keep all the dependent state in sync +/** + * A paginated channel list. Filters are described along three independent axes — the names follow them: + * + * - origin: `staticFilters` are supplied at construction and fixed; `filterBuilder` generates the + * dynamic ones from its reactive context on every build. Every build merges dynamic over static. + * - authority: `staticFilters` are what this client asked for; `predefinedFilter` is what the backend + * reports it actually applied for a `predefined_filter` query. `effectiveFilters` is the winner of the + * two (and `effectiveSort` likewise for ordering). + * - purpose: `buildQueryFilters()` produces the filters sent to the server (and the offline-db query + * key), built from the local filters only; `buildMatchFilters()` produces the filters items are + * matched against locally, built from `effectiveFilters`. They are not interchangeable: sending the + * resolved filter back changes the query shape mid-pagination, and matching against the local filter + * admits channels the queried list excludes. + * + * Ordering follows the same authority rule: `sortComparator` is derived from `effectiveSort` and is + * rebuilt whenever that changes, so it must not be assigned to. Customize it through + * `sortComparatorFactory` (constructor option or setter, consulted on every rebuild) or by overriding + * `buildSortComparator` in a subclass. + */ export class ChannelPaginator extends BasePaginator { private readonly _id: string; private client: StreamChat; @@ -202,6 +252,14 @@ export class ChannelPaginator extends BasePaginator protected _options: ChannelPaginatorRequestOptions | undefined; protected _channelStateOptions: ChannelStateOptions | undefined; protected _nextQueryShape: ChannelQueryShape | undefined; + /** Backend-reported metadata of the last `predefined_filter` query (see `predefinedFilter`). */ + protected _predefinedFilter: ParsedPredefinedFilterResponse | undefined; + /** + * Predefined-filter metadata from the response of the query currently in flight. Held until + * `postQueryReconcile` decides whether it should be committed (first page only). + */ + private _pendingPredefinedFilter: ParsedPredefinedFilterResponse | undefined; + protected _sortComparatorFactory: ChannelSortComparatorFactory | undefined; sortComparator: (a: Channel, b: Channel) => number; filterBuilder: FilterBuilder; @@ -214,6 +272,7 @@ export class ChannelPaginator extends BasePaginator paginatorOptions, requestOptions, sort, + sortComparatorFactory, }: ChannelPaginatorOptions) { super({ hasPaginationQueryShapeChanged, @@ -228,19 +287,13 @@ export class ChannelPaginator extends BasePaginator this._options = requestOptions; this._channelStateOptions = channelStateOptions; this.filterBuilder = new FilterBuilder(filterBuilderOptions); - this.sortComparator = makeComparator({ - sort: definedSort, - resolvePathValue: channelSortPathResolver, - tiebreaker: (l, r) => { - const leftId = this.getItemId(l); - const rightId = this.getItemId(r); - return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; - }, - }); + this._sortComparatorFactory = sortComparatorFactory; + this.sortComparator = this.buildSortComparator(definedSort); this.setFilterResolvers([ archivedFilterResolver, appBannedFilterResolver, hasUnreadFilterResolver, + hiddenFilterResolver, lastUpdatedFilterResolver, pinnedFilterResolver, mutedFilterResolver, @@ -250,6 +303,33 @@ export class ChannelPaginator extends BasePaginator ]); } + /** + * Builds the comparator for the given sort — the single construction point, so every rebuild (the + * `sort` setter, a backend-resolved predefined sort) keeps the channel-specific path resolver and the + * cid tiebreaker; omitting either makes ordering fall back to raw `channel.data` lookups and become + * non-deterministic for equal sort values. + * + * `sortComparator` is derived state and is therefore reassigned on every rebuild — assigning to it + * directly does not survive a sort change or a predefined-filter response. To customize ordering, + * supply `sortComparatorFactory` (it is consulted on every rebuild and may delegate to + * `defaultComparator`), or override this method in a subclass. + */ + protected buildSortComparator(sort: ChannelSort) { + const defaultComparator = makeComparator({ + sort, + resolvePathValue: channelSortPathResolver, + tiebreaker: (l, r) => { + const leftId = this.getItemId(l); + const rightId = this.getItemId(r); + return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; + }, + }); + + return this._sortComparatorFactory + ? this._sortComparatorFactory({ sort, defaultComparator }) + : defaultComparator; + } + get id() { return this._id; } @@ -266,6 +346,36 @@ export class ChannelPaginator extends BasePaginator return this._sort ?? DEFAULT_BACKEND_SORT; } + /** + * What the backend reported it actually filtered and sorted by for the last first-page query — + * `QueryChannelsResponse.predefined_filter` (`name`, `filter`, and `sort` when the stored filter + * carries its own sort template). Set only for `predefined_filter` queries: the raw `filter_conditions` + * of a normal query are not echoed back. + */ + get predefinedFilter(): ParsedPredefinedFilterResponse | undefined { + return this._predefinedFilter; + } + + /** + * Filters this paginator matches items against client-side. A predefined filter resolved by the backend + * wins over the locally configured one: for such a query the local `filters` are not what the server + * applied, so matching against them would admit items the queried list excludes. + */ + get effectiveFilters(): ChannelFilters | undefined { + return ( + (this._predefinedFilter?.filter as ChannelFilters | undefined) ?? this.staticFilters + ); + } + + /** + * Sort this paginator orders items by. Mirrors `effectiveFilters`: a sort template carried by a + * backend-resolved predefined filter takes precedence over the requested sort, matching backend + * precedence rules. + */ + get effectiveSort(): ChannelSort { + return (this._predefinedFilter?.sort as ChannelSort | undefined) ?? this.sort; + } + get options(): ChannelOptions | undefined { return this._options; } @@ -280,9 +390,7 @@ export class ChannelPaginator extends BasePaginator set sort(sort: ChannelSort | undefined) { this._sort = sort; - this.sortComparator = makeComparator({ - sort: this.sort ?? DEFAULT_BACKEND_SORT, - }); + this.sortComparator = this.buildSortComparator(this.effectiveSort); } set options(options: ChannelPaginatorRequestOptions | undefined) { @@ -293,19 +401,113 @@ export class ChannelPaginator extends BasePaginator this._channelStateOptions = options; } + get sortComparatorFactory(): ChannelSortComparatorFactory | undefined { + return this._sortComparatorFactory; + } + + /** + * Take over channel ordering at any point in the paginator's life. The comparator is rebuilt + * immediately and the factory is consulted again on every later rebuild, so — unlike assigning + * `sortComparator` — a sort change or a backend-resolved predefined sort will not discard it. Set to + * `undefined` to go back to the built-in ordering. + */ + set sortComparatorFactory(factory: ChannelSortComparatorFactory | undefined) { + this._sortComparatorFactory = factory; + this.sortComparator = this.buildSortComparator(this.effectiveSort); + } + getItemId(item: Channel): string { return item.cid; } - buildFilters = (): ChannelFilters => + /** + * Filters sent to the server with the next query (and used as the offline-db query key). Deliberately + * built from the locally configured filters only — feeding the backend-resolved filter back in would + * change the query shape after the first response and make the following page look like a new first + * page to `hasPaginationQueryShapeChanged`. + */ + buildQueryFilters = (): ChannelFilters => this.filterBuilder.buildFilters({ baseFilters: { ...this.staticFilters }, }); + /** + * Filters items are matched against locally — the `BasePaginator.buildMatchFilters` hook consumed by + * `matchesFilter`. Built from `effectiveFilters`, so a `predefined_filter` list matches what the + * backend applied rather than the local filters, which are not what produced the list. + */ + buildMatchFilters = (): ChannelFilters => + this.filterBuilder.buildFilters({ + baseFilters: { ...this.effectiveFilters }, + }); + + matchesFilter(channel: Channel): boolean { + const filters = this.buildMatchFilters(); + + const LOGICAL_FILTER_OPERATORS = ['$and', '$or', '$nor'] as const; + + /** + * Whether a filter constrains `hidden` anywhere — at the top level or nested inside a logical + * operator, both of which the filter compiler evaluates. A filter that mentions `hidden` at all opts + * out of the "exclude hidden channels" default (see `ChannelPaginator.matchesFilter`), so + * `{ $or: [{ hidden: true }, …] }` is not silently overruled by it. + * + * Recursion is limited to logical operators on purpose: a `hidden` key anywhere else (e.g. inside + * `{ custom: { hidden: … } }`) is a different field, not a constraint on the channel's hidden state. + */ + const filterConstrainsHidden = (filters: unknown): boolean => { + if (!filters || typeof filters !== 'object') return false; + if (Array.isArray(filters)) return filters.some(filterConstrainsHidden); + const node = filters as Record; + if ('hidden' in node) return true; + return LOGICAL_FILTER_OPERATORS.some((operator) => + filterConstrainsHidden(node[operator]), + ); + }; + // Mirror `queryChannels`, which excludes hidden channels unless the filter asks for them — + // otherwise a channel hidden while the list is open keeps matching and stays visible until the next + // query. Applied here rather than as a synthetic `hidden: false` filter entry so the default also + // holds for a paginator whose filter resolvers were replaced (`setFilterResolvers`), which would + // otherwise leave `hidden` unresolvable and reject every channel. + if (channel.data?.hidden && !filterConstrainsHidden(filters)) return false; + return itemMatchesFilter(channel, filters, { + resolvers: this._filterFieldToDataResolvers, + }); + } + + /** + * Commits the `predefined_filter` metadata of the response, or clears it when the response carried + * none (a plain `filter_conditions` query), so a switch away from a predefined filter cannot leave + * stale matching/ordering semantics behind. + */ + protected applyPredefinedFilterResponse( + predefinedFilter: ParsedPredefinedFilterResponse | undefined, + ) { + this._predefinedFilter = predefinedFilter; + this.sortComparator = this.buildSortComparator(this.effectiveSort); + } + + /** + * The response metadata describes the query as a whole, so it is (re)committed only when a first page + * lands — matching the legacy `ChannelManager`, which kept it untouched while paginating. A failed + * query (`results === null`) must not drop the metadata of the already loaded list either. + * + * Committing before `super` matters: the base implementation filters and ingests the page using + * `matchesFilter` and the comparators this metadata feeds. + */ + postQueryReconcile(params: PostQueryReconcileParams) { + const pendingPredefinedFilter = this._pendingPredefinedFilter; + this._pendingPredefinedFilter = undefined; + if (params.isFirstPage && params.results) { + this.applyPredefinedFilterResponse(pendingPredefinedFilter); + } + return super.postQueryReconcile(params); + } + // invoked inside BasePaginator.executeQuery() to keep it as a query descriptor; protected getNextQueryShape(): ChannelQueryShape { const shape: ChannelQueryShape = { - filters: this.buildFilters(), + filters: this.buildQueryFilters(), options: { ...this.options, limit: this.pageSize, @@ -396,10 +598,14 @@ export class ChannelPaginator extends BasePaginator if (this.config.doRequest) { items = (await this.config.doRequest(this._nextQueryShape)).items; } else { - items = await this.client.queryChannelsAndHydrate( + // withResponse gives access to response-level metadata (`predefined_filter`) which a + // predefined-filter list needs to know what the backend actually filtered and sorted by. + const response = await this.client.queryChannelsAndHydrate( { filter_conditions: filters, sort, ...options }, - stateOptions, + { ...stateOptions, withResponse: true }, ); + items = response.channels; + this._pendingPredefinedFilter = response.predefined_filter; } return { items }; }; @@ -412,7 +618,8 @@ export class ChannelPaginator extends BasePaginator if (!this.client.offlineDb) return; const { items: channels = [], sort } = this; - const filters = this.buildFilters(); + // the offline cache is keyed by the query that produced the list, not by local matching filters + const filters = this.buildQueryFilters(); this.client.offlineDb?.executeQuerySafely( (db) => diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 536c2fee9f..1066a36c4f 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -242,7 +242,7 @@ export class MessageIntervalPaginator extends BasePaginator< /** * Even though we do not send filters object to the server, we need to have filters for client-side item ingestion logic. */ - buildFilters = (): MessagePaginatorFilter => ({ + buildMatchFilters = (): MessagePaginatorFilter => ({ cid: this.channel.cid, ...(this.parentMessageId ? { parent_id: this.parentMessageId } : {}), }); diff --git a/src/pagination/paginators/PinnedMessagePaginator.ts b/src/pagination/paginators/PinnedMessagePaginator.ts index 97fdbb3047..7f04240cb5 100644 --- a/src/pagination/paginators/PinnedMessagePaginator.ts +++ b/src/pagination/paginators/PinnedMessagePaginator.ts @@ -94,7 +94,7 @@ export class PinnedMessagePaginator extends MessageIntervalPaginator { }; } - buildFilters = (): PinnedMessagePaginatorFilter => ({ + buildMatchFilters = (): PinnedMessagePaginatorFilter => ({ cid: this.channel.cid, pinned: true, }); diff --git a/test/unit/ChannelPaginatorsOrchestrator.test.ts b/test/unit/ChannelPaginatorsOrchestrator.test.ts index 72192894a6..3adf631427 100644 --- a/test/unit/ChannelPaginatorsOrchestrator.test.ts +++ b/test/unit/ChannelPaginatorsOrchestrator.test.ts @@ -171,7 +171,9 @@ describe('ChannelPaginatorsOrchestrator', () => { const ch2 = makeChannel('messaging:102'); const queryChannelSpy = vi .spyOn(client, 'queryChannelsAndHydrate') - .mockResolvedValue([ch1]); + // ChannelPaginator queries with `withResponse: true` to read `predefined_filter` metadata, + // so the mock has to resolve the full response shape. + .mockResolvedValue({ channels: [ch1], duration: '0.1ms' }); const p1 = new ChannelPaginator({ client, filters: { type: 'messaging' }, @@ -201,7 +203,7 @@ describe('ChannelPaginatorsOrchestrator', () => { expect(p2.hasMoreTail).toBe(true); }); - queryChannelSpy.mockResolvedValue([ch2]); + queryChannelSpy.mockResolvedValue({ channels: [ch2], duration: '0.1ms' }); await Promise.all([p1, p2].map((p) => p.toTail())); await vi.waitFor(() => { diff --git a/test/unit/client.test.js b/test/unit/client.test.js index b58aad385e..fa851f4e0c 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -792,6 +792,35 @@ describe('Client WSFallback', () => { }); describe('StreamChat.queryChannels', async () => { + /** + * A queryChannels response entry for a DISTINCT channel with messages carrying distinct, + * deterministic timestamps. + * + * Both matter for the paginator assertions: spreading `mockChannelQueryResponse` repeatedly yields + * N entries sharing one cid, so the same channel gets re-seeded N times with disjoint "newest" + * pages — and because `generateMsg()` stamps `created_at` with `new Date()`, all those messages land + * in the same millisecond, leaving the sort order to the random-uuid tiebreaker. A later page whose + * head then happens to sort above the head interval force-merges into it (a first-page seed marks + * the interval `isHead`), welding every page together, which made the item-count assertions fail in + * roughly 5% of runs. + */ + const generateQueriedChannel = ({ index, messageCount }) => { + const id = `queried-channel-${index}`; + return { + ...mockChannelQueryResponse, + channel: { + ...mockChannelQueryResponse.channel, + id, + cid: `messaging:${id}`, + }, + messages: Array.from({ length: messageCount }, (_, messageIndex) => + generateMsg({ + date: new Date(Date.UTC(2024, 0, 1, 0, 0, messageIndex)), + }), + ), + }; + }; + it('should not hydrate activeChannels and channel configs when disableCache is true', async () => { const client = await getClientWithUser(); client._cacheEnabled = () => false; @@ -921,13 +950,12 @@ describe('StreamChat.queryChannels', async () => { it('seeds each queried channel paginator with its full message page', async () => { const client = await getClientWithUser(); - const mockedChannelsQueryResponse = Array.from({ length: 10 }, () => ({ - ...mockChannelQueryResponse, - messages: Array.from( - { length: DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE }, - generateMsg, - ), - })); + const mockedChannelsQueryResponse = Array.from({ length: 10 }, (_, index) => + generateQueriedChannel({ + index, + messageCount: DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE, + }), + ); sinon .stub(client, 'queryChannels') .resolves({ channels: mockedChannelsQueryResponse }); @@ -943,13 +971,12 @@ describe('StreamChat.queryChannels', async () => { it('seeds each queried channel paginator with its partial message page', async () => { const client = await getClientWithUser(); - const mockedChannelQueryResponse = Array.from({ length: 10 }, () => ({ - ...mockChannelQueryResponse, - messages: Array.from( - { length: DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE - 1 }, - generateMsg, - ), - })); + const mockedChannelQueryResponse = Array.from({ length: 10 }, (_, index) => + generateQueriedChannel({ + index, + messageCount: DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE - 1, + }), + ); sinon .stub(client, 'queryChannels') .resolves({ channels: mockedChannelQueryResponse }); diff --git a/test/unit/pagination/paginators/BasePaginator.test.ts b/test/unit/pagination/paginators/BasePaginator.test.ts index e793a5f2dd..d20dc599a3 100644 --- a/test/unit/pagination/paginators/BasePaginator.test.ts +++ b/test/unit/pagination/paginators/BasePaginator.test.ts @@ -939,7 +939,7 @@ describe('BasePaginator', () => { it('returns false if does not match the filter', async () => { const paginator = new Paginator(); // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ + paginator.buildMatchFilters = () => ({ name: { $eq: 'test1' }, }); expect(paginator.matchesFilter(item1)).toBeFalsy(); @@ -947,7 +947,7 @@ describe('BasePaginator', () => { it('returns true if item matches the filter', async () => { const paginator = new Paginator(); // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ + paginator.buildMatchFilters = () => ({ $or: [{ name: { $eq: 'test1' } }, { teams: { $contains: 'abc' } }], }); expect(paginator.matchesFilter(item1)).toBeTruthy(); @@ -2049,7 +2049,7 @@ describe('BasePaginator', () => { const paginator = new Paginator({ itemIndex, lockItemOrder }); // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ + paginator.buildMatchFilters = () => ({ teams: { $eq: ['abc', 'efg'] }, // required membership in these two teams }); @@ -2105,7 +2105,7 @@ describe('BasePaginator', () => { setActive: true, }); // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ + paginator.buildMatchFilters = () => ({ age: { $gt: 100 }, }); @@ -2166,7 +2166,7 @@ describe('BasePaginator', () => { }); // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ + paginator.buildMatchFilters = () => ({ age: { $gt: 100 }, }); @@ -2205,7 +2205,7 @@ describe('BasePaginator', () => { }); // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ + paginator.buildMatchFilters = () => ({ teams: { $contains: 'abc' }, }); @@ -2238,7 +2238,7 @@ describe('BasePaginator', () => { }); // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ + paginator.buildMatchFilters = () => ({ teams: { $contains: 'abc' }, }); paginator.sortComparator = makeComparator({ @@ -2275,7 +2275,7 @@ describe('BasePaginator', () => { }); // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ + paginator.buildMatchFilters = () => ({ teams: { $contains: 'abc' }, }); paginator.sortComparator = makeComparator({ @@ -2316,7 +2316,7 @@ describe('BasePaginator', () => { (_, lockItemOrder) => { const paginator = new Paginator({ itemIndex, lockItemOrder }); // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ + paginator.buildMatchFilters = () => ({ teams: { $contains: 'abc' }, }); paginator.sortComparator = makeComparator({ @@ -2410,7 +2410,7 @@ describe('BasePaginator', () => { (_, lockItemOrder) => { const paginator = new Paginator({ itemIndex, lockItemOrder }); // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ + paginator.buildMatchFilters = () => ({ teams: { $contains: 'abc' }, }); paginator.sortComparator = makeComparator({ @@ -2504,7 +2504,7 @@ describe('BasePaginator', () => { (_, __, lockItemOrder) => { const paginator = new Paginator({ itemIndex, lockItemOrder }); // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ + paginator.buildMatchFilters = () => ({ teams: { $contains: 'abc' }, }); paginator.sortComparator = makeComparator({ @@ -2601,7 +2601,7 @@ describe('BasePaginator', () => { setActive: true, }); // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ + paginator.buildMatchFilters = () => ({ teams: { $contains: 'abc' }, }); @@ -2632,7 +2632,7 @@ describe('BasePaginator', () => { setActive: true, }); // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ + paginator.buildMatchFilters = () => ({ teams: { $contains: 'abc' }, }); @@ -2705,7 +2705,7 @@ describe('BasePaginator', () => { }); // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ + paginator.buildMatchFilters = () => ({ age: { $gt: 100 }, }); @@ -2737,7 +2737,7 @@ describe('BasePaginator', () => { }); // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ + paginator.buildMatchFilters = () => ({ age: { $gt: 100 }, }); @@ -2766,7 +2766,7 @@ describe('BasePaginator', () => { paginator.ingestPage({ page: [item3, item1], setActive: true }); // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ + paginator.buildMatchFilters = () => ({ teams: { $contains: 'abc' }, }); diff --git a/test/unit/pagination/paginators/ChannelPaginator.test.ts b/test/unit/pagination/paginators/ChannelPaginator.test.ts index 84bafd674c..c4358e0428 100644 --- a/test/unit/pagination/paginators/ChannelPaginator.test.ts +++ b/test/unit/pagination/paginators/ChannelPaginator.test.ts @@ -75,7 +75,7 @@ describe('ChannelPaginator', () => { paginator.filterBuilder.buildFilters({ baseFilters: paginator.staticFilters }), ).toStrictEqual({}); // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toHaveLength(9); + expect(paginator._filterFieldToDataResolvers).toHaveLength(10); expect(paginator.config.doRequest).toBeUndefined(); }); @@ -139,7 +139,7 @@ describe('ChannelPaginator', () => { ...initialFilterBuilderContext, }); // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toHaveLength(9); + expect(paginator._filterFieldToDataResolvers).toHaveLength(10); expect(paginator.config.debounceMs).toStrictEqual(paginatorOptions.debounceMs); expect(paginator.config.doRequest).toStrictEqual(doRequest); expect(paginator.config.hasPaginationQueryShapeChanged).toStrictEqual( @@ -357,6 +357,81 @@ describe('ChannelPaginator', () => { expect(paginator.matchesFilter(channel1)).toBeFalsy(); }); + it('resolves field "hidden"', () => { + const paginator = new ChannelPaginator({ client, filters: { hidden: false } }); + const hiddenChannelsPaginator = new ChannelPaginator({ + client, + filters: { hidden: true }, + }); + + // `hidden` is optional on ChannelResponse — undefined when the response omits it + expect(channel1.data!.hidden).toBeUndefined(); + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + expect(hiddenChannelsPaginator.matchesFilter(channel1)).toBeFalsy(); + + channel1.data!.hidden = true; + + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + expect(hiddenChannelsPaginator.matchesFilter(channel1)).toBeTruthy(); + }); + + it('excludes hidden channels by default, as the backend query does', () => { + const paginator = new ChannelPaginator({ client, filters: { muted: false } }); + + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + channel1.data!.hidden = true; + + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + // the default is a local matching rule, not a filter: the server already excludes hidden + // channels, so the request must stay untouched + expect(paginator.buildQueryFilters()).toEqual({ muted: false }); + }); + + it('keeps the hidden-by-default rule out of the way of replaced filter resolvers', () => { + const paginator = new ChannelPaginator({ client, filters: { muted: false } }); + paginator.setFilterResolvers([ + { matchesField: (field) => field === 'muted', resolve: () => false }, + ]); + + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + }); + + it('does not apply the hidden default when the filter constrains "hidden"', () => { + const paginator = new ChannelPaginator({ client, filters: { hidden: true } }); + + channel1.data!.hidden = true; + + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + }); + + it('detects a "hidden" constraint nested in a logical operator', () => { + const paginator = new ChannelPaginator({ + client, + filters: { $or: [{ hidden: true }, { muted: true }] }, + }); + + channel1.data!.hidden = true; + + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + }); + + it('applies the hidden default when "hidden" only appears as an unrelated field path', () => { + const paginator = new ChannelPaginator({ + client, + // @ts-expect-error using undeclared custom property + filters: { 'custom.hidden': { $eq: true } }, + }); + + // @ts-expect-error using undeclared custom property + channel1.data!.custom = { hidden: true }; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + // `custom.hidden` is a different field, so it does not opt out of excluding hidden channels + channel1.data!.hidden = true; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + it('resolves field "app_banned"', () => { const paginator = new ChannelPaginator({ client, @@ -666,6 +741,80 @@ describe('ChannelPaginator', () => { expect(paginator.sortComparator).not.toEqual(originalComparator); }); + it('keeps consulting sortComparatorFactory on every comparator rebuild', async () => { + // orders by cid descending, ignoring the sort entirely + const factory = vi.fn(() => (a: Channel, b: Channel) => b.cid.localeCompare(a.cid)); + const paginator = new ChannelPaginator({ + client, + sort: [{ field: 'last_message_at', direction: 1 }], + sortComparatorFactory: factory, + }); + const channelA = new Channel(client, 'type', 'aaa', {}); + const channelZ = new Channel(client, 'type', 'zzz', {}); + + expect(paginator.sortComparator(channelA, channelZ)).toBeGreaterThan(0); + expect(factory).toHaveBeenCalledTimes(1); + + // a sort change must not discard the custom ordering + paginator.sort = [{ field: 'last_message_at', direction: -1 }]; + expect(factory).toHaveBeenCalledTimes(2); + expect(paginator.sortComparator(channelA, channelZ)).toBeGreaterThan(0); + + // neither may a backend-resolved sort template + vi.spyOn(client, 'queryChannelsAndHydrate').mockResolvedValue({ + channels: [], + duration: '0.1ms', + predefined_filter: { + name: 'x', + filter: {}, + sort: [{ field: 'last_message_at', direction: -1 }], + }, + }); + await paginator.toTail(); + + expect(factory).toHaveBeenCalledTimes(3); + expect(paginator.sortComparator(channelA, channelZ)).toBeGreaterThan(0); + }); + + it('passes the default comparator to sortComparatorFactory so it can delegate', () => { + const paginator = new ChannelPaginator({ + client, + sort: [{ field: 'last_message_at', direction: 1 }], + // a factory that only delegates must preserve the built-in channel ordering + sortComparatorFactory: + ({ defaultComparator }) => + (a, b) => + defaultComparator(a, b), + }); + + // ascending: the older channel2 precedes channel1 + expect(paginator.sortComparator(channel1, channel2)).toBeGreaterThan(0); + }); + + it('adopts a sortComparatorFactory assigned after construction', () => { + const paginator = new ChannelPaginator({ client }); + + paginator.sortComparatorFactory = () => (a, b) => b.cid.localeCompare(a.cid); + const channelA = new Channel(client, 'type', 'aaa', {}); + const channelZ = new Channel(client, 'type', 'zzz', {}); + + expect(paginator.sortComparator(channelA, channelZ)).toBeGreaterThan(0); + + paginator.sortComparatorFactory = undefined; + + expect(paginator.sortComparator(channelA, channelZ)).toBeLessThan(0); + }); + + it('rebuilt comparator keeps resolving channel-specific sort paths', () => { + const paginator = new ChannelPaginator({ client, sort: [{ name: 1 }] }); + + paginator.sort = [{ field: 'last_message_at', direction: 1 }]; + + // `last_message_at` lives on the message paginator, not on channel.data, so a comparator built + // without the channel path resolver would read undefined for both and report a tie + expect(paginator.sortComparator(channel1, channel2)).toBeGreaterThan(0); + }); + it('options reset does not reset the paginator state', () => { const paginator = new ChannelPaginator({ client }); const before = seed(paginator); @@ -753,6 +902,171 @@ describe('ChannelPaginator', () => { }); }); + describe('predefined filter response metadata', () => { + const PREDEFINED_FILTER = { + name: 'unarchived', + filter: { archived: false }, + }; + + const mockQueryResponse = ( + channels: Channel[], + predefinedFilter?: { name: string; filter: object; sort?: ChannelSort }, + ) => + vi.spyOn(client, 'queryChannelsAndHydrate').mockResolvedValue({ + channels, + duration: '0.1ms', + ...(predefinedFilter ? { predefined_filter: predefinedFilter } : {}), + }); + + const archive = (channel: Channel) => { + channel.state.membership = { user, archived_at: '2025-09-03T12:19:39.101089Z' }; + }; + + it('matches items against the backend-resolved filter', async () => { + // no local filters -> everything matches until the backend tells us what it filtered by + const paginator = new ChannelPaginator({ client }); + archive(channel1); + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + mockQueryResponse([], PREDEFINED_FILTER); + await paginator.toTail(); + + expect(paginator.predefinedFilter?.filter).toEqual({ archived: false }); + expect(paginator.effectiveFilters).toEqual({ archived: false }); + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + + it('does not ingest a live item excluded by the backend-resolved filter', async () => { + const paginator = new ChannelPaginator({ client }); + const channelA = new Channel(client, 'type', 'pf-a', {}); + + mockQueryResponse([channelA], PREDEFINED_FILTER); + await paginator.toTail(); + expect(paginator.items).toStrictEqual([channelA]); + + const archivedChannel = new Channel(client, 'type', 'pf-archived', {}); + archive(archivedChannel); + + expect(paginator.ingestItem(archivedChannel)).toBe(false); + expect(paginator.items).toStrictEqual([channelA]); + }); + + it('orders items by the backend-resolved sort', async () => { + const paginator = new ChannelPaginator({ + client, + sort: [{ field: 'last_message_at', direction: 1 }], + }); + // ascending: the older channel2 precedes channel1 + expect(paginator.sortComparator(channel1, channel2)).toBeGreaterThan(0); + + mockQueryResponse([], { + ...PREDEFINED_FILTER, + sort: [{ field: 'last_message_at', direction: -1 }], + }); + await paginator.toTail(); + + expect(paginator.predefinedFilter?.sort).toEqual([ + { field: 'last_message_at', direction: -1 }, + ]); + expect(paginator.effectiveSort).toEqual([ + { field: 'last_message_at', direction: -1 }, + ]); + expect(paginator.sortComparator(channel1, channel2)).toBeLessThan(0); + }); + + it('does not send the predefined filter back to the server', async () => { + const channelA = new Channel(client, 'type', 'pf-a', {}); + const channelB = new Channel(client, 'type', 'pf-b', {}); + const paginator = new ChannelPaginator({ + client, + filters: { type: 'type' }, + sort: [{ field: 'last_message_at', direction: -1 }], + paginatorOptions: { pageSize: 1 }, + }); + + const spy = mockQueryResponse([channelA], PREDEFINED_FILTER); + await paginator.toTail(); + spy.mockResolvedValue({ + channels: [channelB], + duration: '0.1ms', + predefined_filter: PREDEFINED_FILTER, + }); + await paginator.toTail(); + + // the request still carries the locally configured filters, and offset 1 proves the query shape + // did not change under us (a changed shape would restart pagination from offset 0) + expect(spy).toHaveBeenLastCalledWith( + { + filter_conditions: { type: 'type' }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 1, + offset: 1, + }, + { withResponse: true }, + ); + }); + + it('keeps the predefined-filter metadata while paginating, even if a later page omits it', async () => { + const channelA = new Channel(client, 'type', 'pf-a', {}); + const channelB = new Channel(client, 'type', 'pf-b', {}); + const paginator = new ChannelPaginator({ + client, + paginatorOptions: { pageSize: 1 }, + }); + + const spy = mockQueryResponse([channelA], PREDEFINED_FILTER); + await paginator.toTail(); + spy.mockResolvedValue({ channels: [channelB], duration: '0.1ms' }); + await paginator.toTail(); + + expect(paginator.predefinedFilter?.filter).toEqual({ archived: false }); + }); + + it('clears the predefined-filter metadata when a first-page query is not a predefined-filter query', async () => { + const paginator = new ChannelPaginator({ client }); + archive(channel1); + + const spy = mockQueryResponse([], { + ...PREDEFINED_FILTER, + sort: [{ field: 'last_message_at', direction: -1 }], + }); + await paginator.toTail(); + expect(paginator.predefinedFilter?.filter).toEqual({ archived: false }); + + spy.mockResolvedValue({ channels: [], duration: '0.1ms' }); + await paginator.reload(); + + expect(paginator.predefinedFilter).toBeUndefined(); + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + }); + + it('keeps the predefined-filter metadata when a query fails', async () => { + const paginator = new ChannelPaginator({ client }); + + const spy = mockQueryResponse([], PREDEFINED_FILTER); + await paginator.toTail(); + + spy.mockRejectedValue(new Error('query failed')); + await paginator.reload(); + + expect(paginator.lastQueryError).toBeDefined(); + expect(paginator.predefinedFilter?.filter).toEqual({ archived: false }); + }); + + it('ignores the response metadata when items are fetched through doRequest', async () => { + const spy = mockQueryResponse([], PREDEFINED_FILTER); + const paginator = new ChannelPaginator({ + client, + paginatorOptions: { doRequest: async () => ({ items: [] }) }, + }); + + await paginator.toTail(); + + expect(spy).not.toHaveBeenCalled(); + expect(paginator.predefinedFilter).toBeUndefined(); + }); + }); + describe('interval storage', () => { it('is index-addressable by cid, populates headItems, and dedupes across pages', async () => { const a = new Channel(client, 'type', 'iv-a', {}); diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 87f0ddebe7..ac841ac14e 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -144,7 +144,7 @@ describe('MessagePaginator', () => { it('builds filters using the channel cid', () => { const paginator = new MessagePaginator({ channel, itemIndex }); - expect(paginator.buildFilters()).toEqual({ cid: 'channel-id' }); + expect(paginator.buildMatchFilters()).toEqual({ cid: 'channel-id' }); }); it('builds thread-scoped filters when parentMessageId is provided', () => { @@ -153,7 +153,7 @@ describe('MessagePaginator', () => { itemIndex, parentMessageId: 'parent-1', }); - expect(paginator.buildFilters()).toEqual({ + expect(paginator.buildMatchFilters()).toEqual({ cid: 'channel-id', parent_id: 'parent-1', }); From 3f305d814a6c6f650d2a5f941df43dd32ad9ac2b Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 5 Aug 2026 12:50:17 +0200 Subject: [PATCH 02/22] feat(ChannelPaginator): adjust offline DB parity --- src/pagination/paginators/ChannelPaginator.ts | 212 ++++++++++++------ .../paginators/ChannelPaginator.test.ts | 203 +++++++++++++++++ 2 files changed, 347 insertions(+), 68 deletions(-) diff --git a/src/pagination/paginators/ChannelPaginator.ts b/src/pagination/paginators/ChannelPaginator.ts index 73b24081f8..8b6355848c 100644 --- a/src/pagination/paginators/ChannelPaginator.ts +++ b/src/pagination/paginators/ChannelPaginator.ts @@ -1,4 +1,5 @@ import type { + ItemCoordinates, PaginationQueryParams, PaginationQueryReturnValue, PaginationQueryShapeChangeIdentifier, @@ -23,6 +24,7 @@ import type { ChannelSort, ChannelStateOptions, ParsedPredefinedFilterResponse, + QueryChannelsRequest, } from '../../types'; import type { FieldToDataResolver, PathResolver } from '../types.normalization'; import { resolveDotPathValue } from '../utility.normalization'; @@ -33,10 +35,16 @@ const DEFAULT_BACKEND_SORT: ChannelSort = [ { direction: -1, field: 'updated_at' }, ]; -export type ChannelQueryShape = { - filters: ChannelFilters; - sort?: ChannelSort; - options?: ChannelOptions; +/** + * The `queryChannels` request this paginator will send, plus the client-only `stateOptions`. + * + * Deliberately the request's own shape (`filter_conditions`, `sort`, `limit`, `predefined_filter`, …) + * rather than a `{ filters, sort, options }` wrapper: the same object goes on the wire, keys the + * offline-db cache and is compared for query-shape changes, so any field mapping in between would be a + * place for those three to drift apart. `MessageQueryShape` is likewise the request params themselves. + */ +export type ChannelQueryShape = QueryChannelsRequest & { + /** Not part of the request — controls how the response is applied to client state. */ stateOptions?: ChannelStateOptions; }; @@ -77,31 +85,27 @@ export type ChannelPaginatorOptions = { sortComparatorFactory?: ChannelSortComparatorFactory; }; -const getQueryShapeRelevantChannelOptions = (options: ChannelOptions) => { +/** + * What identifies the query itself, as opposed to which page of it is being requested. Two shapes with + * the same identity continue one pagination; a different identity restarts it from the first page. + */ +const getQueryIdentity = (queryShape: ChannelQueryShape | undefined) => { + if (!queryShape) return queryShape; const { limit: _, member_limit: __, message_limit: ___, offset: ____, - ...relevantShape - } = options; - return relevantShape; + ...identity + } = queryShape; + return identity; }; const hasPaginationQueryShapeChanged: PaginationQueryShapeChangeIdentifier< ChannelQueryShape > = (prevQueryShape, nextQueryShape) => - !isEqual( - { - ...prevQueryShape, - options: getQueryShapeRelevantChannelOptions(prevQueryShape?.options ?? {}), - }, - { - ...nextQueryShape, - options: getQueryShapeRelevantChannelOptions(nextQueryShape?.options ?? {}), - }, - ); + !isEqual(getQueryIdentity(prevQueryShape), getQueryIdentity(nextQueryShape)); const archivedFilterResolver: FieldToDataResolver = { matchesField: (field) => field === 'archived', @@ -507,12 +511,10 @@ export class ChannelPaginator extends BasePaginator // invoked inside BasePaginator.executeQuery() to keep it as a query descriptor; protected getNextQueryShape(): ChannelQueryShape { const shape: ChannelQueryShape = { - filters: this.buildQueryFilters(), - options: { - ...this.options, - limit: this.pageSize, - offset: this.offset, - }, + filter_conditions: this.buildQueryFilters(), + ...this.options, + limit: this.pageSize, + offset: this.offset, }; if (this.sort) { @@ -525,10 +527,58 @@ export class ChannelPaginator extends BasePaginator return shape; } + /** The query that produced the currently loaded list, i.e. the cache key its cids belong under. */ + protected get loadedQueryRequest(): QueryChannelsRequest { + const { stateOptions: _, ...request } = + this._lastQueryShape ?? this.getNextQueryShape(); + return request; + } + + /** + * Writes a cid order into the offline cache under the query that produced it. + * + * `filters` and `sort` are passed separately even though `options` already contains them: the DB + * derives the cache row key from those two top-level arguments (see the TODO at + * `channel_manager.ts:276`), while `options` carries the full request — the only place + * `predefined_filter` / `filter_values` / `sort_values` appear, without which two predefined-filter + * lists cannot be told apart. The duplication goes away once `convertFilterSortToQuery` derives the + * key from `options`, which is a change in the concrete (RN) DB implementation plus a schema bump. + */ + protected cacheCidsForQuery({ + cids, + request, + }: { + cids: string[]; + request: QueryChannelsRequest; + }) { + this.client.offlineDb?.executeQuerySafely( + (db) => + db.upsertCidsForQuery({ + cids, + filters: request.filter_conditions, + options: request, + sort: request.sort, + }), + { method: 'upsertCidsForQuery' }, + ); + } + + /** + * Persists the current cid order under the query that produced it. Called after every mutation of the + * loaded list — including live WS-driven inserts/removals, which reorder the list without any query + * running; skipping those would leave the cached order stale until the next full re-query. + */ + protected persistLoadedCids() { + if (!this.client.offlineDb) return; + + this.cacheCidsForQuery({ + cids: (this.items ?? []).map((channel) => channel.cid), + request: this.loadedQueryRequest, + }); + } + preloadFirstPageFromOfflineDb = async ({ - direction, queryShape, - reset, }: PaginationQueryParams) => { if ( !this.client.offlineDb?.getChannelsForQuery || @@ -537,29 +587,19 @@ export class ChannelPaginator extends BasePaginator ) return undefined; + const { stateOptions: _, ...request } = queryShape; + try { const channelsFromDB = await this.client.offlineDb.getChannelsForQuery({ userId: this.client.user.id, - options: { filter_conditions: queryShape.filters, sort: queryShape.sort }, + options: request, }); if (channelsFromDB) { - const offlineChannels = this.client.hydrateActiveChannels(channelsFromDB, { + return this.client.hydrateActiveChannels(channelsFromDB, { offlineMode: true, skipInitialization: [], // passing empty array will clear out the existing messages from channel state, this removes the possibility of duplicate messages }); - - return offlineChannels; - } - - if (!this.client.offlineDb.syncManager.syncStatus) { - this.client.offlineDb.syncManager.scheduleSyncStatusChangeCallback( - this.id, - async () => { - await this.executeQuery({ direction, queryShape, reset }); - }, - ); - return; } } catch (error) { chatLoggerSystem.getLogger('channel').error((error as Error).message); @@ -576,34 +616,71 @@ export class ChannelPaginator extends BasePaginator queryShape?: ChannelQueryShape; }) => { if (!items || !queryShape) return undefined; + const { stateOptions: _, ...request } = queryShape; - this.client.offlineDb?.executeQuerySafely( - (db) => - db.upsertCidsForQuery({ - cids: items.map((channel) => channel.cid), - filters: queryShape.filters, - sort: queryShape.sort, - }), - { method: 'upsertCidsForQuery' }, - ); + this.cacheCidsForQuery({ + cids: items.map((channel) => channel.cid), + request, + }); }; + /** + * Postpones a first-page query while the offline sync is still in progress: the cached page is + * surfaced right away and the network query is handed to the sync manager, which re-runs it once + * reconciliation finished — querying (and persisting cids) against unsynced local state would race + * with the replay of pending local mutations. Mirrors `ChannelManager.queryChannels`, which deferred + * on every unsynced call and read the cache only when it had nothing loaded yet. + * + * Only first-page queries defer; paginating an already loaded list is unaffected (as was the legacy + * `loadNext`). + */ + async executeQuery(params: PaginationQueryParams = {}) { + const { offlineDb } = this.client; + const queryShape = params.queryShape ?? this.getNextQueryShape(); + const shouldDeferUntilSynced = + !!offlineDb?.getChannelsForQuery && + !!this.client.user?.id && + !offlineDb.syncManager.syncStatus && + this.isFirstPageQuery({ queryShape, reset: params.reset }); + + if (!shouldDeferUntilSynced) return await super.executeQuery(params); + + if (!this.isInitialized) { + const state = this.getStateBeforeFirstQuery(); + const cachedChannels = await this.preloadFirstPageFromOfflineDb({ + ...params, + queryShape, + }); + // `isLoading: false` — nothing is in flight while we wait for the sync, and leaving it set would + // make `canExecuteQuery` reject the query this schedules below. + this.state.next({ + ...state, + isLoading: false, + items: cachedChannels ?? state.items, + }); + } + + offlineDb.syncManager.scheduleSyncStatusChangeCallback(this.id, async () => { + await this.executeQuery(params); + }); + } + query = async (): Promise> => { // get the params only if they were not generated previously if (!this._nextQueryShape) { this._nextQueryShape = this.getNextQueryShape(); } - const { filters, sort, options, stateOptions } = this._nextQueryShape; + const { stateOptions, ...request } = this._nextQueryShape; let items: Channel[]; if (this.config.doRequest) { items = (await this.config.doRequest(this._nextQueryShape)).items; } else { // withResponse gives access to response-level metadata (`predefined_filter`) which a // predefined-filter list needs to know what the backend actually filtered and sorted by. - const response = await this.client.queryChannelsAndHydrate( - { filter_conditions: filters, sort, ...options }, - { ...stateOptions, withResponse: true }, - ); + const response = await this.client.queryChannelsAndHydrate(request, { + ...stateOptions, + withResponse: true, + }); items = response.channels; this._pendingPredefinedFilter = response.predefined_filter; } @@ -614,21 +691,20 @@ export class ChannelPaginator extends BasePaginator setItems(params: SetPaginatorItemsParams) { super.setItems(params); + this.persistLoadedCids(); + } - if (!this.client.offlineDb) return; - - const { items: channels = [], sort } = this; - // the offline cache is keyed by the query that produced the list, not by local matching filters - const filters = this.buildQueryFilters(); + ingestItem(channel: Channel): boolean { + const changed = super.ingestItem(channel); + if (changed) this.persistLoadedCids(); + return changed; + } - this.client.offlineDb?.executeQuerySafely( - (db) => - db.upsertCidsForQuery({ - cids: channels.map((channel) => channel.cid), - filters, - sort, - }), - { method: 'upsertCidsForQuery' }, - ); + removeItem(params: { id?: string; item?: Channel }): ItemCoordinates { + const coordinates = super.removeItem(params); + if ((coordinates.state?.currentIndex ?? -1) > -1 || coordinates.interval) { + this.persistLoadedCids(); + } + return coordinates; } } diff --git a/test/unit/pagination/paginators/ChannelPaginator.test.ts b/test/unit/pagination/paginators/ChannelPaginator.test.ts index c4358e0428..bcd963c41f 100644 --- a/test/unit/pagination/paginators/ChannelPaginator.test.ts +++ b/test/unit/pagination/paginators/ChannelPaginator.test.ts @@ -856,11 +856,214 @@ describe('ChannelPaginator', () => { ).toHaveBeenCalledWith({ cids: [channel1.cid], filters, + options: expect.objectContaining({ filter_conditions: filters, sort }), sort, }); }); }); + describe('offline support', () => { + const requestOptions: ChannelOptions = { + predefined_filter: 'user_messaging', + filter_values: { user_id: 'dan' }, + sort_values: { sort_field: 'last_message_at' }, + }; + let offlineDb: MockOfflineDB; + let upsertCidsForQuery: MockInstance; + let getChannelsForQuery: MockInstance; + let scheduleSyncStatusChangeCallback: MockInstance; + + const setUpOfflineDb = async ({ syncStatus }: { syncStatus: boolean }) => { + offlineDb = new MockOfflineDB({ client }); + client.setOfflineDBApi(offlineDb); + (client.offlineDb!.initializeDB as unknown as MockInstance).mockReturnValue(true); + await client.offlineDb!.init(client.userID as string); + client.offlineDb!.syncManager.syncStatus = syncStatus; + upsertCidsForQuery = client.offlineDb! + .upsertCidsForQuery as unknown as MockInstance; + upsertCidsForQuery.mockImplementation(() => Promise.resolve(true)); + getChannelsForQuery = client.offlineDb! + .getChannelsForQuery as unknown as MockInstance; + getChannelsForQuery.mockResolvedValue(null); + scheduleSyncStatusChangeCallback = vi.spyOn( + client.offlineDb!.syncManager, + 'scheduleSyncStatusChangeCallback', + ); + }; + + const makePaginator = ({ + filters = { type: 'type' } as ChannelFilters | undefined, + } = {}) => + new ChannelPaginator({ + client, + filters, + requestOptions, + sort: [{ field: 'last_message_at', direction: -1 }], + }); + + it('reads the cache with the full query request, including predefined-filter options', async () => { + await setUpOfflineDb({ syncStatus: true }); + vi.spyOn(client, 'queryChannelsAndHydrate').mockResolvedValue({ + channels: [], + duration: '0.1ms', + }); + const paginator = makePaginator(); + + await paginator.toTail(); + + expect(getChannelsForQuery).toHaveBeenCalledWith({ + userId: client.userID, + options: expect.objectContaining({ + filter_conditions: { type: 'type' }, + sort: [{ field: 'last_message_at', direction: -1 }], + ...requestOptions, + }), + }); + }); + + it('persists cids under the full query request after a query', async () => { + await setUpOfflineDb({ syncStatus: true }); + const channelA = new Channel(client, 'type', 'offline-a', {}); + vi.spyOn(client, 'queryChannelsAndHydrate').mockResolvedValue({ + channels: [channelA], + duration: '0.1ms', + }); + const paginator = makePaginator(); + + await paginator.toTail(); + + expect(upsertCidsForQuery).toHaveBeenCalledWith({ + cids: [channelA.cid], + filters: { type: 'type' }, + options: expect.objectContaining(requestOptions), + sort: [{ field: 'last_message_at', direction: -1 }], + }); + }); + + it('persists the new cid order after a live ingest and after a removal', async () => { + await setUpOfflineDb({ syncStatus: true }); + const channelA = new Channel(client, 'type', 'offline-a', {}); + setLastMessageAt(channelA, new Date('1971-01-01T00:00:00.000Z')); + const channelB = new Channel(client, 'type', 'offline-b', {}); + setLastMessageAt(channelB, new Date('1970-01-01T00:00:00.000Z')); + vi.spyOn(client, 'queryChannelsAndHydrate').mockResolvedValue({ + channels: [channelA, channelB], + duration: '0.1ms', + }); + // no filters: these bare channels carry no `data.type`, and a live ingest of an item the filter + // rejects would (correctly) remove it instead of reordering + const paginator = makePaginator({ filters: {} }); + await paginator.toTail(); + upsertCidsForQuery.mockClear(); + + // channelB receives a newer message and moves to the top - a reorder no query performed + setLastMessageAt(channelB, new Date('1972-01-01T00:00:00.000Z')); + paginator.ingestItem(channelB); + + expect(upsertCidsForQuery).toHaveBeenCalledWith( + expect.objectContaining({ cids: [channelB.cid, channelA.cid] }), + ); + + upsertCidsForQuery.mockClear(); + paginator.removeItem({ item: channelA }); + + expect(upsertCidsForQuery).toHaveBeenCalledWith( + expect.objectContaining({ cids: [channelB.cid] }), + ); + }); + + it('does not persist when a removal changed nothing', async () => { + await setUpOfflineDb({ syncStatus: true }); + vi.spyOn(client, 'queryChannelsAndHydrate').mockResolvedValue({ + channels: [], + duration: '0.1ms', + }); + const paginator = makePaginator(); + await paginator.toTail(); + upsertCidsForQuery.mockClear(); + + paginator.removeItem({ item: new Channel(client, 'type', 'not-in-list', {}) }); + + expect(upsertCidsForQuery).not.toHaveBeenCalled(); + }); + + describe('while the offline sync is in progress', () => { + it('surfaces the cached page and defers the query until the sync completes', async () => { + await setUpOfflineDb({ syncStatus: false }); + const cachedChannel = new Channel(client, 'type', 'cached', {}); + getChannelsForQuery.mockResolvedValue([{ channel: cachedChannel.data }]); + vi.spyOn(client, 'hydrateActiveChannels').mockReturnValue([cachedChannel]); + const queryChannels = vi + .spyOn(client, 'queryChannelsAndHydrate') + .mockResolvedValue({ channels: [], duration: '0.1ms' }); + const paginator = makePaginator(); + + await paginator.toTail(); + + expect(paginator.items).toStrictEqual([cachedChannel]); + expect(queryChannels).not.toHaveBeenCalled(); + expect(scheduleSyncStatusChangeCallback).toHaveBeenCalledTimes(1); + expect(scheduleSyncStatusChangeCallback.mock.calls[0][0]).toBe(paginator.id); + + // the scheduled callback runs the deferred query once the sync manager reports completion + client.offlineDb!.syncManager.syncStatus = true; + await scheduleSyncStatusChangeCallback.mock.calls[0][1](); + + expect(queryChannels).toHaveBeenCalledTimes(1); + }); + + it('defers even when nothing is cached and the list is already loaded', async () => { + await setUpOfflineDb({ syncStatus: true }); + vi.spyOn(client, 'queryChannelsAndHydrate').mockResolvedValue({ + channels: [new Channel(client, 'type', 'offline-a', {})], + duration: '0.1ms', + }); + const paginator = makePaginator(); + await paginator.toTail(); + + client.offlineDb!.syncManager.syncStatus = false; + getChannelsForQuery.mockClear(); + const queryChannels = vi + .spyOn(client, 'queryChannelsAndHydrate') + .mockResolvedValue({ channels: [], duration: '0.1ms' }); + queryChannels.mockClear(); + + await paginator.reload(); + + expect(queryChannels).not.toHaveBeenCalled(); + // the cache is only read when nothing is loaded yet + expect(getChannelsForQuery).not.toHaveBeenCalled(); + expect(scheduleSyncStatusChangeCallback).toHaveBeenCalledTimes(1); + }); + + it('does not defer a next-page query', async () => { + await setUpOfflineDb({ syncStatus: true }); + const paginator = new ChannelPaginator({ + client, + filters: { type: 'type' }, + paginatorOptions: { pageSize: 1 }, + }); + const queryChannels = vi + .spyOn(client, 'queryChannelsAndHydrate') + .mockResolvedValue({ + channels: [new Channel(client, 'type', 'offline-a', {})], + duration: '0.1ms', + }); + await paginator.toTail(); + + client.offlineDb!.syncManager.syncStatus = false; + queryChannels.mockResolvedValue({ + channels: [new Channel(client, 'type', 'offline-b', {})], + duration: '0.1ms', + }); + await paginator.toTail(); + + expect(queryChannels).toHaveBeenCalledTimes(2); + expect(scheduleSyncStatusChangeCallback).not.toHaveBeenCalled(); + }); + }); + }); + describe('query', () => { it('is called with correct parameters', async () => { const queryChannelsSpy = vi.spyOn(client, 'queryChannels').mockResolvedValue([]); From 008495654d44c58f7f48d5ba5bd8661772a280f0 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 5 Aug 2026 13:22:18 +0200 Subject: [PATCH 03/22] feat(ChannelPaginatorsOrchestrator): add `channel.hidden` routing and event cid fallback --- src/ChannelPaginatorsOrchestrator.ts | 40 ++-- .../ChannelPaginatorsOrchestrator.test.ts | 189 ++++++++++++++---- 2 files changed, 170 insertions(+), 59 deletions(-) diff --git a/src/ChannelPaginatorsOrchestrator.ts b/src/ChannelPaginatorsOrchestrator.ts index d3a6ff3a80..dc9106ff2e 100644 --- a/src/ChannelPaginatorsOrchestrator.ts +++ b/src/ChannelPaginatorsOrchestrator.ts @@ -69,22 +69,27 @@ export const createPriorityOwnershipResolver = ( }; }; +/** + * The cid the event refers to. Events are inconsistent about how they identify their channel: some carry + * a top-level `cid`, some only `channel_type` + `channel_id`, and some (e.g. + * `notification.added_to_channel`) have all three optional and identify the channel solely through the + * required `event.channel`. + */ +const getCidFromEvent = (event: PipelineEvent): string | undefined => { + if (event.cid) return event.cid; + // todo: is there a central method to construct the cid from type and channel id? + if (event.channel_id && event.channel_type) { + return `${event.channel_type}:${event.channel_id}`; + } + return event.channel?.cid; +}; + const getCachedChannelFromEvent = ( event: PipelineEvent, cache: Record, ): Channel | undefined => { - let channel: Channel | undefined = undefined; - if (event.cid) { - channel = cache[event.cid]; - } else if (event.channel_id && event.channel_type) { - // todo: is there a central method to construct the cid from type and channel id? - channel = cache[`${event.channel_type}:${event.channel_id}`]; - } else if (event.channel) { - channel = cache[event.channel.cid]; - } else { - return; - } - return channel; + const cid = getCidFromEvent(event); + return cid ? cache[cid] : undefined; }; const reEmit: EventHandlerPipelineHandler = ({ @@ -136,9 +141,8 @@ const updateLists: EventHandlerPipelineHandler = async ({ ); if (!channel) { - const [type, id] = event.cid - ? event.cid.split(':') - : [event.channel_type, event.channel_id]; + const [type, id] = getCidFromEvent(event)?.split(':') ?? []; + if (!type) return; channel = await getChannel({ client: orchestrator.client, @@ -212,6 +216,11 @@ const channelVisibleHandler: LabeledEventHandler = { id: 'ChannelPaginatorsOrchestrator:default-handler:channel.visible', }; +const channelHiddenHandler: LabeledEventHandler = { + handle: updateLists, + id: 'ChannelPaginatorsOrchestrator:default-handler:channel.hidden', +}; + // members filter - should not be impacted as id is stable - cannot be updated // member.user.name - can be impacted const memberUpdatedHandler: LabeledEventHandler = { @@ -304,6 +313,7 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { 'channel.deleted': [channelDeletedHandler], 'channel.updated': [channelUpdatedHandler], 'channel.truncated': [channelTruncatedHandler], + 'channel.hidden': [channelHiddenHandler], 'channel.visible': [channelVisibleHandler], 'member.updated': [memberUpdatedHandler], 'message.new': [messageNewHandler], diff --git a/test/unit/ChannelPaginatorsOrchestrator.test.ts b/test/unit/ChannelPaginatorsOrchestrator.test.ts index 3adf631427..ee3b222ccb 100644 --- a/test/unit/ChannelPaginatorsOrchestrator.test.ts +++ b/test/unit/ChannelPaginatorsOrchestrator.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getClientWithUser } from './test-utils/getClient'; import { + type Channel, ChannelPaginator, ChannelResponse, EventTypes, @@ -579,63 +580,163 @@ describe('ChannelPaginatorsOrchestrator', () => { return channel; } - describe.each(['channel.deleted', 'channel.hidden'] as EventTypes[])( - 'event %s', - (eventType) => { - it('removes the channel from all paginators', async () => { - const cid = 'messaging:1'; - const ch = makeChannel(cid); + // `channel.hidden` used to be parameterized in here, but every case dispatched a hardcoded + // `channel.deleted`, so the hidden variant was never exercised. It is not a removal either — see the + // `event channel.hidden` block below. + describe.each(['channel.deleted'] as EventTypes[])('event %s', (eventType) => { + it('removes the channel from all paginators', async () => { + const cid = 'messaging:1'; + const ch = makeChannel(cid); - const p1 = new ChannelPaginator({ client }); - const p2 = new ChannelPaginator({ client }); - const r1 = vi.spyOn(p1, 'removeItem'); - const r2 = vi.spyOn(p2, 'removeItem'); + const p1 = new ChannelPaginator({ client }); + const p2 = new ChannelPaginator({ client }); + const r1 = vi.spyOn(p1, 'removeItem'); + const r2 = vi.spyOn(p2, 'removeItem'); - const orchestrator = new ChannelPaginatorsOrchestrator({ - client, - paginators: [p1, p2], - }); - client.activeChannels[cid] = ch; + const orchestrator = new ChannelPaginatorsOrchestrator({ + client, + paginators: [p1, p2], + }); + client.activeChannels[cid] = ch; - orchestrator.registerSubscriptions(); - client.dispatchEvent({ type: 'channel.deleted', cid } as const); + orchestrator.registerSubscriptions(); + client.dispatchEvent({ type: eventType, cid } as const); - await vi.waitFor(() => { - // client.activeChannels does not contain the deleted channel, therefore the search is performed with id - expect(r1).toHaveBeenCalledWith({ id: ch.cid, item: undefined }); - expect(r2).toHaveBeenCalledWith({ id: ch.cid, item: undefined }); - }); + await vi.waitFor(() => { + // client.activeChannels does not contain the deleted channel, therefore the search is performed with id + expect(r1).toHaveBeenCalledWith({ id: ch.cid, item: undefined }); + expect(r2).toHaveBeenCalledWith({ id: ch.cid, item: undefined }); }); + }); - it('is a no-op when cid is missing', async () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); - const p = new ChannelPaginator({ client }); - const r = vi.spyOn(p, 'removeItem'); + it('is a no-op when cid is missing', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const p = new ChannelPaginator({ client }); + const r = vi.spyOn(p, 'removeItem'); - orchestrator.insertPaginator({ paginator: p }); - orchestrator.registerSubscriptions(); + orchestrator.insertPaginator({ paginator: p }); + orchestrator.registerSubscriptions(); - client.dispatchEvent({ type: 'channel.deleted' } as const); // no cid - await vi.waitFor(() => { - expect(r).not.toHaveBeenCalled(); - }); + client.dispatchEvent({ type: eventType } as const); // no cid + await vi.waitFor(() => { + expect(r).not.toHaveBeenCalled(); }); + }); - it('tries to remove non-existent channel from all paginators', async () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); - const p = new ChannelPaginator({ client }); - const r = vi.spyOn(p, 'removeItem'); + it('tries to remove non-existent channel from all paginators', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const p = new ChannelPaginator({ client }); + const r = vi.spyOn(p, 'removeItem'); - orchestrator.insertPaginator({ paginator: p }); - orchestrator.registerSubscriptions(); + orchestrator.insertPaginator({ paginator: p }); + orchestrator.registerSubscriptions(); - client.dispatchEvent({ type: 'channel.deleted', cid: 'messaging:404' }); // no such channel - await vi.waitFor(() => { - expect(r).toHaveBeenCalledWith({ id: 'messaging:404', item: undefined }); - }); + client.dispatchEvent({ type: eventType, cid: 'messaging:404' }); // no such channel + await vi.waitFor(() => { + expect(r).toHaveBeenCalledWith({ id: 'messaging:404', item: undefined }); }); - }, - ); + }); + }); + + describe('event channel.hidden', () => { + const seed = (paginator: ChannelPaginator, channels: Channel[]) => + paginator.setItems({ + valueOrFactory: channels, + isFirstPage: true, + isLastPage: true, + }); + + it('drops the channel from lists that exclude hidden channels, keeping it in a hidden-only list', async () => { + const cid = 'messaging:hidden-1'; + const channel = makeChannel(cid); + client.activeChannels[cid] = channel; + + const regular = new ChannelPaginator({ client }); + const hiddenOnly = new ChannelPaginator({ client, filters: { hidden: true } }); + seed(regular, [channel]); + seed(hiddenOnly, [channel]); + + const orchestrator = new ChannelPaginatorsOrchestrator({ + client, + paginators: [regular, hiddenOnly], + }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ type: 'channel.hidden', cid } as const); + + await vi.waitFor(() => { + // Channel._handleChannelEvent runs before the client listeners, so the filters see the new value + expect(channel.data?.hidden).toBe(true); + expect(regular.items).toEqual([]); + expect(hiddenOnly.items?.map((c) => c.cid)).toEqual([cid]); + }); + }); + + it('re-adds the channel on channel.visible', async () => { + const cid = 'messaging:hidden-2'; + const channel = makeChannel(cid); + client.activeChannels[cid] = channel; + + const regular = new ChannelPaginator({ client }); + const orchestrator = new ChannelPaginatorsOrchestrator({ + client, + paginators: [regular], + }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ type: 'channel.hidden', cid } as const); + await vi.waitFor(() => expect(regular.items ?? []).toEqual([])); + + client.dispatchEvent({ type: 'channel.visible', cid } as const); + + await vi.waitFor(() => { + expect(channel.data?.hidden).toBe(false); + expect(regular.items?.map((c) => c.cid)).toEqual([cid]); + }); + }); + }); + + describe('channel resolution from the event', () => { + it('falls back to event.channel.cid when the event carries no top-level identifiers', async () => { + const cid = 'messaging:added-1'; + const paginator = new ChannelPaginator({ client }); + const orchestrator = new ChannelPaginatorsOrchestrator({ + client, + paginators: [paginator], + }); + orchestrator.registerSubscriptions(); + + // notification.added_to_channel has optional cid / channel_type / channel_id — only + // event.channel is guaranteed + client.dispatchEvent({ + type: 'notification.added_to_channel', + channel: { cid, id: 'added-1', type: 'messaging' } as ChannelResponse, + }); + + await vi.waitFor(() => { + expect(mockGetChannel).toHaveBeenCalledWith( + expect.objectContaining({ id: 'added-1', type: 'messaging' }), + ); + expect(paginator.items?.map((c) => c.cid)).toEqual([cid]); + }); + }); + + it('does not query a channel it cannot identify', async () => { + const paginator = new ChannelPaginator({ client }); + const orchestrator = new ChannelPaginatorsOrchestrator({ + client, + paginators: [paginator], + }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ type: 'notification.added_to_channel' } as never); + + await vi.waitFor(() => { + expect(mockGetChannel).not.toHaveBeenCalled(); + expect(paginator.items).toBeUndefined(); + }); + }); + }); describe.each(['notification.removed_from_channel'] as EventTypes[])( 'event %s', From ab83f55d0130a4176fb58b5b446f7c87f912b180 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 5 Aug 2026 13:31:49 +0200 Subject: [PATCH 04/22] feat(ChannelPaginator): add configurable default retry count --- src/pagination/paginators/BasePaginator.ts | 21 ++++-- src/pagination/paginators/ChannelPaginator.ts | 2 + .../paginators/BasePaginator.test.ts | 75 +++++++++++++++++++ .../paginators/ChannelPaginator.test.ts | 59 +++++++++++++++ 4 files changed, 151 insertions(+), 6 deletions(-) diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 485a0ff6a8..58643fac09 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -190,7 +190,10 @@ export type PaginationQueryParams = { queryShape?: Q; /** Per-call override of the reset behavior. */ reset?: StateResetPolicy; - /** Should retry the failed request given number of times. Default is 0. */ + /** + * How many times to **retry** a failed request, i.e. `retryCount + 1` attempts in total. Per-call + * override of `PaginatorOptions.retryCount`, which defaults to 0 (no retry). + */ retryCount?: number; /** * Suppress `isLoading` transitions for this query (a silent, background refresh). When falsy @@ -348,6 +351,12 @@ export type PaginatorOptions = { lockItemOrder?: boolean; /** The item page size to be requested from the server. */ pageSize?: number; + /** + * How many times to **retry** a failed request before giving up, i.e. `retryCount + 1` attempts in + * total, with `DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES` between them. Defaults to 0 (no retry); + * `PaginationQueryParams.retryCount` overrides it per call. + */ + retryCount?: number; /** Prevent silencing the errors thrown during the pagination execution. Default is false. */ throwErrors?: boolean; }; @@ -376,6 +385,7 @@ export const DEFAULT_PAGINATION_OPTIONS: BasePaginatorConfig = { lockItemOrder: false, pageSize: 10, hasPaginationQueryShapeChanged: baseHasPaginationQueryShapeChanged, + retryCount: 0, throwErrors: false, } as const; @@ -2061,7 +2071,7 @@ export abstract class BasePaginator { protected async runQueryRetryable( params: PaginationQueryParams = {}, ): Promise | null> { - const { retryCount } = params; + const remainingRetries = params.retryCount ?? 0; try { return await this.query(params); } catch (e) { @@ -2071,12 +2081,11 @@ export abstract class BasePaginator { this.state.partialNext({ lastQueryError: e as Error }); } - const nextRetryCount = (retryCount ?? 0) - 1; - if (nextRetryCount > 0) { + if (remainingRetries > 0) { await sleep(DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES); return await this.runQueryRetryable({ ...params, - retryCount: nextRetryCount, + retryCount: remainingRetries - 1, }); } if (this.config.throwErrors) { @@ -2104,7 +2113,7 @@ export abstract class BasePaginator { keepPreviousItems, queryShape: forcedQueryShape, reset, - retryCount = 0, + retryCount = this.config.retryCount, silent, updateState = true, }: PaginationQueryParams = {}): Promise | void> { diff --git a/src/pagination/paginators/ChannelPaginator.ts b/src/pagination/paginators/ChannelPaginator.ts index 8b6355848c..2a871ddb14 100644 --- a/src/pagination/paginators/ChannelPaginator.ts +++ b/src/pagination/paginators/ChannelPaginator.ts @@ -9,6 +9,7 @@ import type { SetPaginatorItemsParams, } from './BasePaginator'; import { BasePaginator } from './BasePaginator'; +import { DEFAULT_QUERY_CHANNELS_RETRY_COUNT } from '../../constants'; import { chatLoggerSystem } from '../../logger'; import type { FilterBuilderOptions } from '../FilterBuilder'; import { FilterBuilder } from '../FilterBuilder'; @@ -281,6 +282,7 @@ export class ChannelPaginator extends BasePaginator super({ hasPaginationQueryShapeChanged, itemIndex: new ItemIndex({ getId: (channel) => channel.cid }), + retryCount: DEFAULT_QUERY_CHANNELS_RETRY_COUNT, ...paginatorOptions, }); const definedSort = sort ?? DEFAULT_BACKEND_SORT; diff --git a/test/unit/pagination/paginators/BasePaginator.test.ts b/test/unit/pagination/paginators/BasePaginator.test.ts index d20dc599a3..6851ebe1e9 100644 --- a/test/unit/pagination/paginators/BasePaginator.test.ts +++ b/test/unit/pagination/paginators/BasePaginator.test.ts @@ -18,6 +18,7 @@ import { ZERO_PAGE_CURSOR, } from '../../../../src'; import { sleep } from '../../../../src/utils'; +import * as utils from '../../../../src/utils'; import { makeComparator } from '../../../../src/pagination/sortCompiler'; import { DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES } from '../../../../src/constants'; import { ItemIndex } from '../../../../src/pagination/ItemIndex'; @@ -867,6 +868,80 @@ describe('BasePaginator', () => { vi.useRealTimers(); }); + // `retryCount` counts *retries*, so N retries mean N + 1 attempts. Failing every attempt and + // counting the query calls is the only way to pin that down — the test above resolves on the second + // attempt, which passes for either interpretation. + const failEveryAttempt = async (paginator: Paginator, attempts: number) => { + for (let attempt = 0; attempt < attempts; attempt++) { + await toNextTick(); + paginator.queryReject(new Error('Failed')); + await toNextTick(); + vi.advanceTimersByTime(DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES); + } + }; + + it('does not retry by default', async () => { + vi.useFakeTimers(); + const paginator = new Paginator({ initialCursor: ZERO_PAGE_CURSOR }); + const promise = paginator.toTail(); + + await failEveryAttempt(paginator, 1); + await promise; + + expect(paginator.config.retryCount).toBe(0); + expect(paginator.mockClientQuery).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it('retries config.retryCount times, then gives up', async () => { + vi.useFakeTimers(); + const retryCount = 3; + const paginator = new Paginator({ initialCursor: ZERO_PAGE_CURSOR, retryCount }); + const sleepSpy = vi.spyOn(utils, 'sleep'); + const promise = paginator.toTail(); + + // one attempt more than the configured retries; the extra iteration proves it stops there + await failEveryAttempt(paginator, retryCount + 2); + await promise; + + expect(paginator.mockClientQuery).toHaveBeenCalledTimes(retryCount + 1); + // `toNextTick` sleeps too, so count only the waits between attempts + const retryWaits = sleepSpy.mock.calls.filter( + ([ms]) => ms === DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES, + ); + expect(retryWaits).toHaveLength(retryCount); + expect(paginator.lastQueryError).toEqual(new Error('Failed')); + vi.useRealTimers(); + }); + + it('stops retrying as soon as an attempt succeeds', async () => { + vi.useFakeTimers(); + const paginator = new Paginator({ initialCursor: ZERO_PAGE_CURSOR, retryCount: 3 }); + const promise = paginator.toTail(); + + await failEveryAttempt(paginator, 1); + await toNextTick(); + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await promise; + + expect(paginator.mockClientQuery).toHaveBeenCalledTimes(2); + expect(paginator.lastQueryError).toBeUndefined(); + expect(paginator.items).toEqual([{ id: 'id1' }]); + vi.useRealTimers(); + }); + + it('lets a per-call retryCount override the configured one', async () => { + vi.useFakeTimers(); + const paginator = new Paginator({ initialCursor: ZERO_PAGE_CURSOR, retryCount: 3 }); + const promise = paginator.toTail({ retryCount: 0 }); + + await failEveryAttempt(paginator, 2); + await promise; + + expect(paginator.mockClientQuery).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + it('executeQuery uses explicit queryShape and does not call getNextQueryShape', async () => { const paginator = new Paginator(); const forcedShape: QueryShape = { diff --git a/test/unit/pagination/paginators/ChannelPaginator.test.ts b/test/unit/pagination/paginators/ChannelPaginator.test.ts index bcd963c41f..e3c25c53b8 100644 --- a/test/unit/pagination/paginators/ChannelPaginator.test.ts +++ b/test/unit/pagination/paginators/ChannelPaginator.test.ts @@ -15,6 +15,11 @@ import { getClientWithUser } from '../../test-utils/getClient'; import { generateMsg } from '../../test-utils/generateMessage'; import type { FieldToDataResolver } from '../../../../src/pagination/types.normalization'; import { MockOfflineDB } from '../../offline-support/MockOfflineDB'; +import * as utils from '../../../../src/utils'; +import { + DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES, + DEFAULT_QUERY_CHANNELS_RETRY_COUNT, +} from '../../../../src/constants'; const user = { id: 'custom-id' }; @@ -862,6 +867,60 @@ describe('ChannelPaginator', () => { }); }); + describe('retries', () => { + it('retries a failing channel query DEFAULT_QUERY_CHANNELS_RETRY_COUNT times by default', async () => { + const paginator = new ChannelPaginator({ client }); + expect(paginator.config.retryCount).toBe(DEFAULT_QUERY_CHANNELS_RETRY_COUNT); + + const sleepSpy = vi.spyOn(utils, 'sleep').mockResolvedValue(undefined); + const queryChannels = vi + .spyOn(client, 'queryChannelsAndHydrate') + .mockRejectedValue(new Error('fail')); + + await paginator.toTail(); + + // initial attempt + however many retries are configured (matches the legacy ChannelManager) + expect(queryChannels).toHaveBeenCalledTimes(DEFAULT_QUERY_CHANNELS_RETRY_COUNT + 1); + expect( + sleepSpy.mock.calls.filter( + ([ms]) => ms === DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES, + ), + ).toHaveLength(DEFAULT_QUERY_CHANNELS_RETRY_COUNT); + expect(paginator.lastQueryError).toEqual(new Error('fail')); + }); + + it('stops retrying once a query succeeds', async () => { + vi.spyOn(utils, 'sleep').mockResolvedValue(undefined); + const channelA = new Channel(client, 'type', 'retry-a', {}); + const queryChannels = vi + .spyOn(client, 'queryChannelsAndHydrate') + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValue({ channels: [channelA], duration: '0.1ms' }); + + const paginator = new ChannelPaginator({ client }); + await paginator.toTail(); + + expect(queryChannels).toHaveBeenCalledTimes(2); + expect(paginator.lastQueryError).toBeUndefined(); + expect(paginator.items).toStrictEqual([channelA]); + }); + + it('accepts an explicit retryCount through paginatorOptions', async () => { + const paginator = new ChannelPaginator({ + client, + paginatorOptions: { retryCount: 0 }, + }); + vi.spyOn(utils, 'sleep').mockResolvedValue(undefined); + const queryChannels = vi + .spyOn(client, 'queryChannelsAndHydrate') + .mockRejectedValue(new Error('fail')); + + await paginator.toTail(); + + expect(queryChannels).toHaveBeenCalledTimes(1); + }); + }); + describe('offline support', () => { const requestOptions: ChannelOptions = { predefined_filter: 'user_messaging', From 49547a3d8860f687ff6f0ee9c9b5aa465c811a12 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 5 Aug 2026 13:52:48 +0200 Subject: [PATCH 05/22] refactor: rename to ChannelPaginatorsOrchestrator to `ChannelManager` and delete the legacy manager --- ...atorsOrchestrator.ts => ChannelManager.ts} | 118 +- src/channel_manager.ts | 834 ----- src/client.ts | 40 +- src/index.ts | 3 +- src/pagination/paginators/ChannelPaginator.ts | 8 +- ...strator.test.ts => ChannelManager.test.ts} | 311 +- test/unit/channel_manager.test.ts | 2801 ----------------- 7 files changed, 246 insertions(+), 3869 deletions(-) rename src/{ChannelPaginatorsOrchestrator.ts => ChannelManager.ts} (82%) delete mode 100644 src/channel_manager.ts rename test/unit/{ChannelPaginatorsOrchestrator.test.ts => ChannelManager.test.ts} (78%) delete mode 100644 test/unit/channel_manager.test.ts diff --git a/src/ChannelPaginatorsOrchestrator.ts b/src/ChannelManager.ts similarity index 82% rename from src/ChannelPaginatorsOrchestrator.ts rename to src/ChannelManager.ts index dc9106ff2e..278cd7a916 100644 --- a/src/ChannelPaginatorsOrchestrator.ts +++ b/src/ChannelManager.ts @@ -15,11 +15,11 @@ import type { import { getChannel } from './pagination/utility.queryChannel'; import type { Channel } from './channel'; -export type ChannelPaginatorsOrchestratorEventHandlerContext = { - orchestrator: ChannelPaginatorsOrchestrator; +export type ChannelManagerEventHandlerContext = { + channelManager: ChannelManager; }; -type EventHandlerContext = ChannelPaginatorsOrchestratorEventHandlerContext; +type EventHandlerContext = ChannelManagerEventHandlerContext; type SupportedEventType = EventType | (string & {}); @@ -94,12 +94,12 @@ const getCachedChannelFromEvent = ( const reEmit: EventHandlerPipelineHandler = ({ event, - ctx: { orchestrator }, + ctx: { channelManager }, }) => { if (!event.cid) return; - const channel = orchestrator.client.activeChannels[event.cid]; + const channel = channelManager.client.activeChannels[event.cid]; if (!channel) return; - orchestrator.paginators.forEach((paginator) => { + channelManager.paginators.forEach((paginator) => { const items = paginator.items; const { state } = paginator.locateByItem(channel); if ((state?.currentIndex ?? -1) > -1 && items) { @@ -110,11 +110,11 @@ const reEmit: EventHandlerPipelineHandler = ({ const removeItem: EventHandlerPipelineHandler = ({ event, - ctx: { orchestrator }, + ctx: { channelManager }, }) => { if (!event.cid) return; - const channel = orchestrator.client.activeChannels[event.cid]; - orchestrator.paginators.forEach((paginator) => { + const channel = channelManager.client.activeChannels[event.cid]; + channelManager.paginators.forEach((paginator) => { paginator.removeItem({ id: event.cid, item: channel }); }); }; @@ -123,21 +123,21 @@ const removeItem: EventHandlerPipelineHandler = ({ // at the start of the handler pipeline and filter out events for unknown channels export const ignoreEventsForUnknownChannels: EventHandlerPipelineHandler< EventHandlerContext -> = ({ event, ctx: { orchestrator } }) => { +> = ({ event, ctx: { channelManager } }) => { const channel: Channel | undefined = getCachedChannelFromEvent( event, - orchestrator.client.activeChannels, + channelManager.client.activeChannels, ); if (!channel) return { action: 'stop' }; }; const updateLists: EventHandlerPipelineHandler = async ({ event, - ctx: { orchestrator }, + ctx: { channelManager }, }) => { let channel: Channel | undefined = getCachedChannelFromEvent( event, - orchestrator.client.activeChannels, + channelManager.client.activeChannels, ); if (!channel) { @@ -145,7 +145,7 @@ const updateLists: EventHandlerPipelineHandler = async ({ if (!type) return; channel = await getChannel({ - client: orchestrator.client, + client: channelManager.client, id, type, }); @@ -153,14 +153,14 @@ const updateLists: EventHandlerPipelineHandler = async ({ if (!channel) return; - const matchingPaginators = orchestrator.paginators.filter((p) => + const matchingPaginators = channelManager.paginators.filter((p) => p.matchesFilter(channel), ); const matchingIds = new Set(matchingPaginators.map((p) => p.id)); - const ownerIds = orchestrator.resolveOwnership(channel, matchingPaginators); + const ownerIds = channelManager.resolveOwnership(channel, matchingPaginators); - orchestrator.paginators.forEach((paginator) => { + channelManager.paginators.forEach((paginator) => { if (!matchingIds.has(paginator.id)) { // remove if it does not match the filter anymore paginator.removeItem({ item: channel }); @@ -194,66 +194,66 @@ const updateLists: EventHandlerPipelineHandler = async ({ // we have to make sure that client.activeChannels is always up-to-date const channelDeletedHandler: LabeledEventHandler = { handle: removeItem, - id: 'ChannelPaginatorsOrchestrator:default-handler:channel.deleted', + id: 'ChannelManager:default-handler:channel.deleted', }; -// fixme: this handler should not be handled by the orchestrator but as Channel does not have reactive state, +// fixme: this handler should not be handled by the channel manager but as Channel does not have reactive state, // we need to re-emit the whole list to reflect the changes const channelUpdatedHandler: LabeledEventHandler = { handle: reEmit, - id: 'ChannelPaginatorsOrchestrator:default-handler:channel.updated', + id: 'ChannelManager:default-handler:channel.updated', }; -// fixme: this handler should not be handled by the orchestrator but as Channel does not have reactive state, +// fixme: this handler should not be handled by the channel manager but as Channel does not have reactive state, // we need to re-emit the whole list to reflect the changes const channelTruncatedHandler: LabeledEventHandler = { handle: reEmit, - id: 'ChannelPaginatorsOrchestrator:default-handler:channel.truncated', + id: 'ChannelManager:default-handler:channel.truncated', }; const channelVisibleHandler: LabeledEventHandler = { handle: updateLists, - id: 'ChannelPaginatorsOrchestrator:default-handler:channel.visible', + id: 'ChannelManager:default-handler:channel.visible', }; const channelHiddenHandler: LabeledEventHandler = { handle: updateLists, - id: 'ChannelPaginatorsOrchestrator:default-handler:channel.hidden', + id: 'ChannelManager:default-handler:channel.hidden', }; // members filter - should not be impacted as id is stable - cannot be updated // member.user.name - can be impacted const memberUpdatedHandler: LabeledEventHandler = { handle: updateLists, - id: 'ChannelPaginatorsOrchestrator:default-handler:member.updated', + id: 'ChannelManager:default-handler:member.updated', }; const messageNewHandler: LabeledEventHandler = { handle: updateLists, - id: 'ChannelPaginatorsOrchestrator:default-handler:message.new', + id: 'ChannelManager:default-handler:message.new', }; const notificationAddedToChannelHandler: LabeledEventHandler = { handle: updateLists, - id: 'ChannelPaginatorsOrchestrator:default-handler:notification.added_to_channel', + id: 'ChannelManager:default-handler:notification.added_to_channel', }; const notificationMessageNewHandler: LabeledEventHandler = { handle: updateLists, - id: 'ChannelPaginatorsOrchestrator:default-handler:notification.message_new', + id: 'ChannelManager:default-handler:notification.message_new', }; const notificationRemovedFromChannelHandler: LabeledEventHandler = { handle: removeItem, - id: 'ChannelPaginatorsOrchestrator:default-handler:notification.removed_from_channel', + id: 'ChannelManager:default-handler:notification.removed_from_channel', }; // fixme: updates users for member object in all the channels which are loaded with that member - normalization would be beneficial const userPresenceChangedHandler: LabeledEventHandler = { - handle: ({ event, ctx: { orchestrator } }) => { + handle: ({ event, ctx: { channelManager } }) => { const eventUser = event.user; if (!eventUser?.id) return; - orchestrator.paginators.forEach((paginator) => { + channelManager.paginators.forEach((paginator) => { const paginatorItems = paginator.items; if (!paginatorItems) return; let updated = false; @@ -273,21 +273,21 @@ const userPresenceChangedHandler: LabeledEventHandler = { } }); }, - id: 'ChannelPaginatorsOrchestrator:default-handler:user.presence.changed', + id: 'ChannelManager:default-handler:user.presence.changed', }; -export type ChannelPaginatorsOrchestratorState = { +export type ChannelManagerState = { paginators: ChannelPaginator[]; }; -export type ChannelPaginatorsOrchestratorEventHandlers = Partial< +export type ChannelManagerEventHandlers = Partial< Record[]> >; -export type ChannelPaginatorsOrchestratorOptions = { +export type ChannelManagerOptions = { client: StreamChat; paginators?: ChannelPaginator[]; - eventHandlers?: ChannelPaginatorsOrchestratorEventHandlers; + eventHandlers?: ChannelManagerEventHandlers; /** * Decide which paginator(s) should own a channel when multiple match. * Defaults to keeping the channel in all matching paginators. @@ -297,9 +297,9 @@ export type ChannelPaginatorsOrchestratorOptions = { ownershipResolver?: PaginatorOwnershipResolver | string[]; }; -export class ChannelPaginatorsOrchestrator extends WithSubscriptions { +export class ChannelManager extends WithSubscriptions { client: StreamChat; - state: StateStore; + state: StateStore; protected _pipelines = new Map< SupportedEventType, EventHandlerPipeline @@ -308,27 +308,26 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { /** Track paginators already wrapped with ownership-aware filtering */ protected ownershipFilterAppliedPaginators = new WeakSet(); - protected static readonly defaultEventHandlers: ChannelPaginatorsOrchestratorEventHandlers = - { - 'channel.deleted': [channelDeletedHandler], - 'channel.updated': [channelUpdatedHandler], - 'channel.truncated': [channelTruncatedHandler], - 'channel.hidden': [channelHiddenHandler], - 'channel.visible': [channelVisibleHandler], - 'member.updated': [memberUpdatedHandler], - 'message.new': [messageNewHandler], - 'notification.added_to_channel': [notificationAddedToChannelHandler], - 'notification.message_new': [notificationMessageNewHandler], - 'notification.removed_from_channel': [notificationRemovedFromChannelHandler], - 'user.presence.changed': [userPresenceChangedHandler], - }; + protected static readonly defaultEventHandlers: ChannelManagerEventHandlers = { + 'channel.deleted': [channelDeletedHandler], + 'channel.updated': [channelUpdatedHandler], + 'channel.truncated': [channelTruncatedHandler], + 'channel.hidden': [channelHiddenHandler], + 'channel.visible': [channelVisibleHandler], + 'member.updated': [memberUpdatedHandler], + 'message.new': [messageNewHandler], + 'notification.added_to_channel': [notificationAddedToChannelHandler], + 'notification.message_new': [notificationMessageNewHandler], + 'notification.removed_from_channel': [notificationRemovedFromChannelHandler], + 'user.presence.changed': [userPresenceChangedHandler], + }; constructor({ client, eventHandlers, paginators, ownershipResolver, - }: ChannelPaginatorsOrchestratorOptions) { + }: ChannelManagerOptions) { super(); this.client = client; this.state = new StateStore({ paginators: paginators ?? [] }); @@ -338,8 +337,7 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { : ownershipResolver; } - const finalEventHandlers = - eventHandlers ?? ChannelPaginatorsOrchestrator.getDefaultHandlers(); + const finalEventHandlers = eventHandlers ?? ChannelManager.getDefaultHandlers(); for (const [type, handlers] of Object.entries(finalEventHandlers)) { if (handlers) this.ensurePipeline(type).replaceAll(handlers); } @@ -356,16 +354,16 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { } private get ctx(): EventHandlerContext { - return { orchestrator: this }; + return { channelManager: this }; } /** * Returns deep copy of default handlers mapping. * The defaults can be enriched with custom handlers or the custom handlers can be replaced. */ - static getDefaultHandlers(): ChannelPaginatorsOrchestratorEventHandlers { - const src = ChannelPaginatorsOrchestrator.defaultEventHandlers; - const out: ChannelPaginatorsOrchestratorEventHandlers = {}; + static getDefaultHandlers(): ChannelManagerEventHandlers { + const src = ChannelManager.defaultEventHandlers; + const out: ChannelManagerEventHandlers = {}; for (const [type, handlers] of Object.entries(src)) { if (!handlers) continue; out[type as SupportedEventType] = [...handlers]; @@ -539,7 +537,7 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { let pipe = this._pipelines.get(eventType); if (!pipe) { pipe = new EventHandlerPipeline({ - id: `ChannelPaginatorsOrchestrator:${eventType}`, + id: `ChannelManager:${eventType}`, }); this._pipelines.set(eventType, pipe); } diff --git a/src/channel_manager.ts b/src/channel_manager.ts deleted file mode 100644 index f7bfed4aaa..0000000000 --- a/src/channel_manager.ts +++ /dev/null @@ -1,834 +0,0 @@ -import type { QueryChannelsResponseWithChannels, StreamChat } from './client'; -import type { - ChannelFilters, - ChannelSort, - ChannelStateOptions, - Event, - EventPayload, - QueryChannelsRequest, - QueryChannelsResponse, -} from './types'; -import { chatLoggerSystem } from './logger'; -import type { ValueOrPatch } from './store'; -import { isPatch, StateStore } from './store'; -import type { Channel } from './channel'; -import { - extractSortValue, - findLastPinnedChannelIndex, - getAndWatchChannel, - isChannelArchived, - isChannelPinned, - promoteChannel, - shouldConsiderArchivedChannels, - shouldConsiderPinnedChannels, - sleep, - uniqBy, -} from './utils'; -import { generateUUIDv4 } from './utils'; -import { - DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES, - DEFAULT_QUERY_CHANNELS_RETRY_COUNT, -} from './constants'; -import { WithSubscriptions } from './utils/WithSubscriptions'; - -const logger = chatLoggerSystem.getLogger('channel-manager'); - -export type ChannelManagerPagination = { - hasNext: boolean; - isLoading: boolean; - isLoadingNext: boolean; - options?: QueryChannelsRequest; - responseFilters?: QueryChannelsRequest['filter_conditions']; - responseSort?: QueryChannelsRequest['sort']; -}; - -export type ChannelManagerState = { - channels: Channel[]; - /** - * This value will become true the first time queryChannels is successfully executed and - * will remain false otherwise. It's used as a control property regarding whether the list - * has been initialized yet (i.e a query has already been done at least once) or not. We do - * this to prevent state.channels from being forced to be nullable. - */ - initialized: boolean; - pagination: ChannelManagerPagination; - error: Error | undefined; -}; - -export type ChannelSetterParameterType = ValueOrPatch; -export type ChannelSetterType = (arg: ChannelSetterParameterType) => void; - -export type GenericEventHandlerType = (...args: T) => any; -export type EventHandlerType = GenericEventHandlerType<[Event]>; -export type EventHandlerOverrideType = GenericEventHandlerType< - [ChannelSetterType, Event] ->; - -export type ChannelManagerEventTypes = - | 'notification.added_to_channel' - | 'notification.message_new' - | 'notification.removed_from_channel' - | 'message.new' - | 'member.updated' - | 'channel.deleted' - | 'channel.hidden' - | 'channel.truncated' - | 'channel.visible' - | 'channel.updated'; - -export type ChannelManagerEventHandlerNames = - | 'channelDeletedHandler' - | 'channelHiddenHandler' - | 'channelTruncatedHandler' - | 'channelUpdatedHandler' - | 'channelVisibleHandler' - | 'newMessageHandler' - | 'memberUpdatedHandler' - | 'notificationAddedToChannelHandler' - | 'notificationNewMessageHandler' - | 'notificationRemovedFromChannelHandler'; - -export type ChannelManagerEventHandlerOverrides = Partial< - Record ->; - -export type ExecuteChannelsQueryPayload = Pick & { - stateOptions: ChannelStateOptions; -}; - -export const channelManagerEventToHandlerMapping: { - [key in ChannelManagerEventTypes]: ChannelManagerEventHandlerNames; -} = { - 'channel.deleted': 'channelDeletedHandler', - 'channel.hidden': 'channelHiddenHandler', - 'channel.truncated': 'channelTruncatedHandler', - 'channel.updated': 'channelUpdatedHandler', - 'channel.visible': 'channelVisibleHandler', - 'message.new': 'newMessageHandler', - 'member.updated': 'memberUpdatedHandler', - 'notification.added_to_channel': 'notificationAddedToChannelHandler', - 'notification.message_new': 'notificationNewMessageHandler', - 'notification.removed_from_channel': 'notificationRemovedFromChannelHandler', -}; - -export type ChannelManagerOptions = { - /** - * Aborts a channels query that is already in progress and runs the new one. - */ - abortInFlightQuery?: boolean; - /** - * Allows channel promotion to be applied where applicable for channels that are - * currently not part of the channel list within the state. A good example of - * this would be a channel that is being watched and it receives a new message, - * but is not part of the list initially. - */ - allowNotLoadedChannelPromotionForEvent?: { - 'channel.visible': boolean; - 'message.new': boolean; - 'notification.added_to_channel': boolean; - 'notification.message_new': boolean; - }; - /** - * Allows us to lock the order of channels within the list. Any event that would - * change the order of channels within the list will do nothing. - */ - lockChannelOrder?: boolean; -}; - -export type QueryChannelsRequestOutput = Channel[] | QueryChannelsResponseWithChannels; - -export type QueryChannelsRequestType = ( - options?: QueryChannelsRequest, - stateOptions?: ChannelStateOptions, -) => Promise; - -export const DEFAULT_CHANNEL_MANAGER_OPTIONS = { - abortInFlightQuery: false, - allowNotLoadedChannelPromotionForEvent: { - 'channel.visible': true, - 'message.new': true, - 'notification.added_to_channel': true, - 'notification.message_new': true, - }, - lockChannelOrder: false, -}; - -export const DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS = { - offset: 0, -}; - -const getResponsePaginationParams = ({ - queryChannelsResponse, - sort, -}: { - queryChannelsResponse?: Pick; - sort: ChannelSort; -}): Pick => { - const predefinedFilter = queryChannelsResponse?.predefined_filter; - - if (!predefinedFilter) { - return {}; - } - - return { - responseFilters: predefinedFilter.filter as ChannelFilters, - responseSort: predefinedFilter.sort ?? sort, - }; -}; - -const getResponseFiltersAndSort = (pagination: ChannelManagerPagination) => ({ - filters: pagination.responseFilters ?? pagination.options?.filter_conditions, - sort: pagination.responseSort ?? pagination.options?.sort, -}); - -const omitResponsePaginationParams = (pagination: ChannelManagerPagination) => { - const paginationWithoutResponseParams = { ...pagination }; - delete paginationWithoutResponseParams.responseFilters; - delete paginationWithoutResponseParams.responseSort; - - return paginationWithoutResponseParams; -}; - -const isQueryChannelsResponseWithChannels = ( - response: QueryChannelsRequestOutput, -): response is QueryChannelsResponseWithChannels => !Array.isArray(response); - -/** - * A class that manages a list of channels and changes it based on configuration and WS events. The - * list of channels is reactive as well as the pagination and it can be subscribed to for state updates. - * - * @internal - */ -export class ChannelManager extends WithSubscriptions { - public readonly state: StateStore; - private client: StreamChat; - private eventHandlers: Map = new Map(); - private eventHandlerOverrides: Map = new Map(); - private queryChannelsRequest: QueryChannelsRequestType; - private options: ChannelManagerOptions = {}; - private stateOptions: ChannelStateOptions = {}; - private id: string; - - constructor({ - client, - eventHandlerOverrides = {}, - options = {}, - queryChannelsOverride, - }: { - client: StreamChat; - eventHandlerOverrides?: ChannelManagerEventHandlerOverrides; - options?: ChannelManagerOptions; - queryChannelsOverride?: QueryChannelsRequestType; - }) { - super(); - - this.id = `channel-manager-${generateUUIDv4()}`; - this.client = client; - this.state = new StateStore({ - channels: [], - pagination: { - isLoading: false, - isLoadingNext: false, - hasNext: false, - options: DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS, - }, - initialized: false, - error: undefined, - }); - this.setEventHandlerOverrides(eventHandlerOverrides); - this.setOptions(options); - this.queryChannelsRequest = - queryChannelsOverride ?? - ((...params) => this.client.queryChannelsAndHydrate(...params)); - this.eventHandlers = new Map( - Object.entries({ - channelDeletedHandler: this.channelDeletedHandler, - channelHiddenHandler: this.channelHiddenHandler, - channelVisibleHandler: this.channelVisibleHandler, - memberUpdatedHandler: this.memberUpdatedHandler, - newMessageHandler: this.newMessageHandler, - notificationAddedToChannelHandler: this.notificationAddedToChannelHandler, - notificationNewMessageHandler: this.notificationNewMessageHandler, - notificationRemovedFromChannelHandler: this.notificationRemovedFromChannelHandler, - }), - ); - } - - public setChannels = (valueOrFactory: ChannelSetterParameterType) => { - this.state.next((current) => { - const { channels: currentChannels } = current; - const newChannels = isPatch(valueOrFactory) - ? valueOrFactory(currentChannels) - : valueOrFactory; - - // If the references between the two values are the same, just return the - // current state; otherwise trigger a state change. - if (currentChannels === newChannels) { - return current; - } - - return { ...current, channels: newChannels }; - }); - const { - channels, - pagination: { options }, - } = this.state.getLatestValue(); - this.client.offlineDb?.executeQuerySafely( - (db) => - // TODO: filters/sort must be passed explicitly (even though they live inside `options`) - // because `convertFilterSortToQuery` keys the offline query off the top-level filters/sort - // args, not `options.filter_conditions`/`options.sort`. Omitting them here writes reorders - // to a mismatched cache key, so the persisted channel order goes stale until a full requery. - // Proper fix: make `convertFilterSortToQuery` derive filters/sort from `options` so no call - // site has to pass them redundantly (requires a DB schema-version bump to flush stale - // `channelQueries` rows keyed by the old format). - db.upsertCidsForQuery({ - cids: channels.map((channel) => channel.cid), - filters: options?.filter_conditions, - options, - sort: options?.sort, - }), - { method: 'upsertCidsForQuery' }, - ); - }; - - public setEventHandlerOverrides = ( - eventHandlerOverrides: ChannelManagerEventHandlerOverrides = {}, - ) => { - const truthyEventHandlerOverrides = Object.entries(eventHandlerOverrides).reduce< - Partial - >((acc, [key, value]) => { - if (value) { - acc[key as keyof ChannelManagerEventHandlerOverrides] = value; - } - return acc; - }, {}); - this.eventHandlerOverrides = new Map( - Object.entries(truthyEventHandlerOverrides), - ); - }; - - public setQueryChannelsRequest = (queryChannelsRequest: QueryChannelsRequestType) => { - this.queryChannelsRequest = queryChannelsRequest; - }; - - public setOptions = (options: ChannelManagerOptions = {}) => { - this.options = { ...DEFAULT_CHANNEL_MANAGER_OPTIONS, ...options }; - }; - - private executeChannelsQuery = async ( - payload: ExecuteChannelsQueryPayload, - retryCount = 0, - ): Promise => { - const { options, stateOptions } = payload; - const { offset, limit } = { - ...DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS, - ...options, - }; - try { - const queryChannelsResponse = await this.queryChannelsRequest(options, { - ...stateOptions, - withResponse: true, - }); - const channels = isQueryChannelsResponseWithChannels(queryChannelsResponse) - ? queryChannelsResponse.channels - : queryChannelsResponse; - const newOffset = offset + (channels?.length ?? 0); - const newOptions = { ...options, offset: newOffset }; - const { pagination } = this.state.getLatestValue(); - const responsePaginationParams = getResponsePaginationParams({ - queryChannelsResponse: isQueryChannelsResponseWithChannels(queryChannelsResponse) - ? queryChannelsResponse - : undefined, - sort: options?.sort ?? [], - }); - const paginationWithoutResponseParams = omitResponsePaginationParams(pagination); - - this.state.partialNext({ - channels, - pagination: { - // Drop response derived filter/sort from the previous query before applying - // the current response. Non predefined queries do not return this metadata, - // so keeping the old values would make later WS mutations use stale - // predefined filter semantics. Also the predefined_filter might change, producing - // a different combination as well so we always need to first clean up. - ...paginationWithoutResponseParams, - hasNext: (channels?.length ?? 0) >= (limit ?? 1), - isLoading: false, - options: newOptions, - ...responsePaginationParams, - }, - initialized: true, - error: undefined, - }); - this.client.offlineDb?.executeQuerySafely( - (db) => - db.upsertCidsForQuery({ - cids: channels.map((channel) => channel.cid), - filters: pagination.options?.filter_conditions, - options, - sort: pagination.options?.sort, - }), - { method: 'upsertCidsForQuery' }, - ); - } catch (error) { - if (retryCount >= DEFAULT_QUERY_CHANNELS_RETRY_COUNT) { - logger - .withExtraTags('executeChannelsQuery') - .error('Failed to query channels after the maximum number of retries.', { - error, - }); - - const wrappedError = new Error( - `Maximum number of retries reached in queryChannels. Last error message is: ${error}`, - ); - - const state = this.state.getLatestValue(); - // If the offline support is enabled, and there are channels in the DB, we should not error out. - const isOfflineSupportEnabledWithChannels = - this.client.offlineDb && state.channels.length > 0; - - this.state.partialNext({ - error: isOfflineSupportEnabledWithChannels ? undefined : wrappedError, - pagination: { - ...state.pagination, - isLoading: false, - isLoadingNext: false, - }, - }); - return; - } - - await sleep(DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES); - - return this.executeChannelsQuery(payload, retryCount + 1); - } - }; - - public queryChannels = async ( - request?: QueryChannelsRequest, - stateOptions: ChannelStateOptions = {}, - ) => { - const { - pagination: { isLoading, options: optionsFromState }, - initialized, - } = this.state.getLatestValue(); - - if ( - isLoading && - !this.options.abortInFlightQuery && - // TODO: Figure a proper way to either deeply compare these or - // create hashes from each. - JSON.stringify(optionsFromState?.filter_conditions) === - JSON.stringify(request?.filter_conditions) - ) { - return; - } - - const executeChannelsQueryPayload = { - filters: request?.filter_conditions, - sort: request?.sort, - options: request, - stateOptions, - }; - - try { - this.stateOptions = stateOptions; - this.state.next((currentState) => ({ - ...currentState, - pagination: { - ...omitResponsePaginationParams(currentState.pagination), - isLoading: true, - isLoadingNext: false, - options: request, - }, - error: undefined, - })); - - if (this.client.offlineDb?.getChannelsForQuery && this.client.user?.id) { - if (!initialized) { - const channelsFromDB = await this.client.offlineDb.getChannelsForQuery({ - userId: this.client.user.id, - options: request, - }); - - if (channelsFromDB) { - const offlineChannels = this.client.hydrateActiveChannels(channelsFromDB, { - offlineMode: true, - skipInitialization: [], // passing empty array will clear out the existing messages from channel state, this removes the possibility of duplicate messages - }); - - this.state.partialNext({ channels: offlineChannels }); - } - } - - if (!this.client.offlineDb.syncManager.syncStatus) { - this.client.offlineDb.syncManager.scheduleSyncStatusChangeCallback( - this.id, - async () => { - await this.executeChannelsQuery(executeChannelsQueryPayload); - }, - ); - return; - } - } - await this.executeChannelsQuery(executeChannelsQueryPayload); - } catch (error) { - logger.withExtraTags('queryChannels').error('Failed to query channels.', { error }); - this.state.next((currentState) => ({ - ...currentState, - pagination: { ...currentState.pagination, isLoading: false }, - })); - throw error; - } - }; - - public loadNext = async () => { - const { pagination, initialized } = this.state.getLatestValue(); - const { options, isLoadingNext, hasNext } = pagination; - - if (!initialized || isLoadingNext || !hasNext) { - return; - } - - try { - const { offset, limit } = { - ...DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS, - ...options, - }; - this.state.partialNext({ - pagination: { ...pagination, isLoading: false, isLoadingNext: true }, - }); - const queryChannelsResponse = await this.queryChannelsRequest( - options, - this.stateOptions, - ); - const nextChannels = isQueryChannelsResponseWithChannels(queryChannelsResponse) - ? queryChannelsResponse.channels - : queryChannelsResponse; - const { channels } = this.state.getLatestValue(); - const newOffset = offset + (nextChannels?.length ?? 0); - const newOptions = { ...options, offset: newOffset }; - - this.state.partialNext({ - channels: uniqBy([...(channels || []), ...nextChannels], 'cid'), - pagination: { - ...pagination, - hasNext: (nextChannels?.length ?? 0) >= (limit ?? 1), - isLoading: false, - isLoadingNext: false, - options: newOptions, - }, - }); - } catch (error) { - logger - .withExtraTags('loadNext') - .error('Failed to load the next page of channels.', { error }); - this.state.next((currentState) => ({ - ...currentState, - pagination: { - ...currentState.pagination, - isLoadingNext: false, - isLoading: false, - }, - })); - throw error; - } - }; - - private notificationAddedToChannelHandler = async (event_: Event) => { - const event = event_ as EventPayload<'notification.added_to_channel'>; - const { id, type, members } = event?.channel ?? {}; - - if ( - !type || - !this.options.allowNotLoadedChannelPromotionForEvent?.[ - 'notification.added_to_channel' - ] - ) { - return; - } - - const channel = await getAndWatchChannel({ - client: this.client, - id, - members: members?.reduce((acc, { user, user_id }) => { - const userId = user_id || user?.id; - if (userId) { - acc.push(userId); - } - return acc; - }, []), - type, - }); - - const { pagination, channels } = this.state.getLatestValue(); - if (!channels) { - return; - } - - const { sort = [] } = getResponseFiltersAndSort(pagination); - - this.setChannels( - promoteChannel({ - channels, - channelToMove: channel, - sort, - }), - ); - }; - - private channelDeletedHandler = (event_: Event) => { - const event = event_ as EventPayload< - 'channel.deleted' | 'channel.hidden' | 'notification.removed_from_channel' - >; - - const { channels } = this.state.getLatestValue(); - if (!channels) { - return; - } - - const newChannels = [...channels]; - const channelIndex = newChannels.findIndex( - (channel) => channel.cid === (event.cid || event.channel?.cid), - ); - - if (channelIndex < 0) { - return; - } - - newChannels.splice(channelIndex, 1); - this.setChannels(newChannels); - }; - - private channelHiddenHandler = this.channelDeletedHandler; - - private newMessageHandler = (event_: Event) => { - const event = event_ as EventPayload<'message.new'>; - - const { pagination, channels } = this.state.getLatestValue(); - if (!channels) { - return; - } - const { filters, sort = [] } = getResponseFiltersAndSort(pagination); - - const channelType = event.channel_type; - const channelId = event.channel_id; - - if (!channelType || !channelId) { - return; - } - - const targetChannel = this.client.channel(channelType, channelId); - const targetChannelIndex = channels.indexOf(targetChannel); - const targetChannelExistsWithinList = targetChannelIndex >= 0; - - const isTargetChannelPinned = isChannelPinned(targetChannel); - const isTargetChannelArchived = isChannelArchived(targetChannel); - - const considerArchivedChannels = shouldConsiderArchivedChannels(filters); - const considerPinnedChannels = shouldConsiderPinnedChannels(sort); - - if ( - // filter is defined, target channel is archived and filter option is set to false - (considerArchivedChannels && isTargetChannelArchived && !filters?.archived) || - // filter is defined, target channel isn't archived and filter option is set to true - (considerArchivedChannels && !isTargetChannelArchived && filters?.archived) || - // sort option is defined, target channel is pinned - (considerPinnedChannels && isTargetChannelPinned) || - // list order is locked - this.options.lockChannelOrder || - // target channel is not within the loaded list and loading from cache is disallowed - (!targetChannelExistsWithinList && - !this.options.allowNotLoadedChannelPromotionForEvent?.['message.new']) - ) { - return; - } - - this.setChannels( - promoteChannel({ - channels, - channelToMove: targetChannel, - channelToMoveIndexWithinChannels: targetChannelIndex, - sort, - }), - ); - }; - - private notificationNewMessageHandler = async (event_: Event) => { - const event = event_ as EventPayload<'notification.message_new'>; - - const { id, type } = event?.channel ?? {}; - - if (!id || !type) { - return; - } - - const channel = await getAndWatchChannel({ - client: this.client, - id, - type, - }); - - const { channels, pagination } = this.state.getLatestValue(); - const { filters, sort = [] } = getResponseFiltersAndSort(pagination); - - const considerArchivedChannels = shouldConsiderArchivedChannels(filters); - const isTargetChannelArchived = isChannelArchived(channel); - - if ( - !channels || - (considerArchivedChannels && isTargetChannelArchived && !filters?.archived) || - (considerArchivedChannels && !isTargetChannelArchived && filters?.archived) || - !this.options.allowNotLoadedChannelPromotionForEvent?.['notification.message_new'] - ) { - return; - } - - this.setChannels( - promoteChannel({ - channels, - channelToMove: channel, - sort, - }), - ); - }; - - private channelVisibleHandler = async (event_: Event) => { - const event = event_ as EventPayload<'channel.visible' | 'channel.hidden'>; - const { channel_type: channelType, channel_id: channelId } = event; - - if (!channelType || !channelId) { - return; - } - - const channel = await getAndWatchChannel({ - client: this.client, - id: event.channel_id, - type: event.channel_type, - }); - - const { channels, pagination } = this.state.getLatestValue(); - const { filters, sort = [] } = getResponseFiltersAndSort(pagination); - - const considerArchivedChannels = shouldConsiderArchivedChannels(filters); - const isTargetChannelArchived = isChannelArchived(channel); - - if ( - !channels || - (considerArchivedChannels && isTargetChannelArchived && !filters?.archived) || - (considerArchivedChannels && !isTargetChannelArchived && filters?.archived) || - !this.options.allowNotLoadedChannelPromotionForEvent?.['channel.visible'] - ) { - return; - } - - this.setChannels( - promoteChannel({ - channels, - channelToMove: channel, - sort, - }), - ); - }; - - private notificationRemovedFromChannelHandler = this.channelDeletedHandler; - - private memberUpdatedHandler = (event_: Event) => { - const event = event_ as EventPayload<'member.updated'>; - const { pagination, channels } = this.state.getLatestValue(); - const { filters, sort = [] } = getResponseFiltersAndSort(pagination); - if ( - !event.member?.user || - event.member.user.id !== this.client.userId || - !event.channel_type || - !event.channel_id - ) { - return; - } - const channelType = event.channel_type; - const channelId = event.channel_id; - - const considerPinnedChannels = shouldConsiderPinnedChannels(sort); - const considerArchivedChannels = shouldConsiderArchivedChannels(filters); - const pinnedAtSort = extractSortValue({ atIndex: 0, sort, targetKey: 'pinned_at' }); - - if ( - !channels || - (!considerPinnedChannels && !considerArchivedChannels) || - this.options.lockChannelOrder - ) { - return; - } - - const targetChannel = this.client.channel(channelType, channelId); - // assumes that channel instances are not changing - const targetChannelIndex = channels.indexOf(targetChannel); - const targetChannelExistsWithinList = targetChannelIndex >= 0; - - const isTargetChannelPinned = isChannelPinned(targetChannel); - const isTargetChannelArchived = isChannelArchived(targetChannel); - - const newChannels = [...channels]; - - if (targetChannelExistsWithinList) { - newChannels.splice(targetChannelIndex, 1); - } - - // handle archiving (remove channel) - if ( - // When archived filter true, and channel is unarchived - (considerArchivedChannels && !isTargetChannelArchived && filters?.archived) || - // When archived filter false, and channel is archived - (considerArchivedChannels && isTargetChannelArchived && !filters?.archived) - ) { - this.setChannels(newChannels); - return; - } - - // handle pinning - let lastPinnedChannelIndex: number | null = null; - - if (pinnedAtSort === 1 || (pinnedAtSort === -1 && !isTargetChannelPinned)) { - lastPinnedChannelIndex = findLastPinnedChannelIndex({ channels: newChannels }); - } - const newTargetChannelIndex = - typeof lastPinnedChannelIndex === 'number' ? lastPinnedChannelIndex + 1 : 0; - - // skip state update if the position of the channel does not change - if (channels[newTargetChannelIndex] === targetChannel) { - return; - } - - newChannels.splice(newTargetChannelIndex, 0, targetChannel); - this.setChannels(newChannels); - }; - - private subscriptionOrOverride = (event: Event) => { - const handlerName = - channelManagerEventToHandlerMapping[event.type as ChannelManagerEventTypes]; - const defaultEventHandler = this.eventHandlers.get(handlerName); - const eventHandlerOverride = this.eventHandlerOverrides.get(handlerName); - if (eventHandlerOverride && typeof eventHandlerOverride === 'function') { - eventHandlerOverride(this.setChannels, event); - return; - } - - if (defaultEventHandler && typeof defaultEventHandler === 'function') { - defaultEventHandler(event); - } - }; - - public registerSubscriptions = () => { - if (this.hasSubscriptions) { - // Already listening for events and changes - return; - } - - for (const eventType of Object.keys(channelManagerEventToHandlerMapping)) { - this.addUnsubscribeFunction( - this.client.on(eventType, this.subscriptionOrOverride).unsubscribe, - ); - } - }; -} diff --git a/src/client.ts b/src/client.ts index 82683c92cf..a132a216f0 100644 --- a/src/client.ts +++ b/src/client.ts @@ -71,12 +71,8 @@ import { Moderation } from './moderation'; import { ThreadManager } from './thread_manager'; import { DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE } from './constants'; import { PollManager } from './poll_manager'; -import type { - ChannelManagerEventHandlerOverrides, - ChannelManagerOptions, - QueryChannelsRequestType, -} from './channel_manager'; -import { ChannelManager } from './channel_manager'; +import type { ChannelManagerOptions } from './ChannelManager'; +import { ChannelManager } from './ChannelManager'; import { MessageDeliveryReporter } from './messageDelivery'; import { NotificationManager } from './notifications'; import { ReminderManager } from './reminders'; @@ -530,32 +526,22 @@ export class StreamChat extends ChatApi { }; /** - * Creates an instance of `ChannelManager`. + * Creates an instance of `ChannelManager` — one or more `ChannelPaginator` channel lists kept in sync + * with WS events, with ownership arbitration between them. * * @internal * - * @param config - The channel manager configuration. - * @param config.eventHandlerOverrides - The overrides for event handlers to be used (optional, - * defaults to `{}`). - * @param config.options - The options used for the channel manager (optional, defaults to `{}`). - * @param config.queryChannelsOverride - Override for the underlying `queryChannels` request (optional). + * @param config - The channel manager configuration, minus the client (optional). + * @param config.paginators - The channel lists to manage (optional, defaults to none; add them later + * with `insertPaginator`). + * @param config.eventHandlers - Event handler pipelines keyed by event type (optional, defaults to + * `ChannelManager.getDefaultHandlers()`). + * @param config.ownershipResolver - Decides which paginator(s) own a channel matched by several + * (optional). * @returns A new `ChannelManager` instance. */ - createChannelManager = ({ - eventHandlerOverrides = {}, - options = {}, - queryChannelsOverride, - }: { - eventHandlerOverrides?: ChannelManagerEventHandlerOverrides; - options?: ChannelManagerOptions; - queryChannelsOverride?: QueryChannelsRequestType; - }) => - new ChannelManager({ - client: this, - eventHandlerOverrides, - options, - queryChannelsOverride, - }); + createChannelManager = (config: Omit = {}) => + new ChannelManager({ ...config, client: this }); /** * Creates a new WebSocket connection with the current user. diff --git a/src/index.ts b/src/index.ts index 28953be440..3a11787349 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,7 +27,6 @@ export * from './thread_manager'; export * from './token_manager'; export * from './types'; export * from './uploadManager'; -export * from './channel_manager'; export * from './offline-support'; export * from './LiveLocationManager'; // Don't use * here, that can break module augmentation https://github.com/microsoft/TypeScript/issues/46617 @@ -55,5 +54,5 @@ export { promoteChannel, } from './utils'; export { FixedSizeQueueCache } from './utils/FixedSizeQueueCache'; -export * from './ChannelPaginatorsOrchestrator'; +export * from './ChannelManager'; export * from './EventHandlerPipeline'; diff --git a/src/pagination/paginators/ChannelPaginator.ts b/src/pagination/paginators/ChannelPaginator.ts index 2a871ddb14..141d8bbf9d 100644 --- a/src/pagination/paginators/ChannelPaginator.ts +++ b/src/pagination/paginators/ChannelPaginator.ts @@ -539,12 +539,12 @@ export class ChannelPaginator extends BasePaginator /** * Writes a cid order into the offline cache under the query that produced it. * - * `filters` and `sort` are passed separately even though `options` already contains them: the DB - * derives the cache row key from those two top-level arguments (see the TODO at - * `channel_manager.ts:276`), while `options` carries the full request — the only place + * `filters` and `sort` are passed separately even though `options` already contains them: the concrete + * (RN) DB implementation derives the cache row key from those two top-level arguments in + * `convertFilterSortToQuery`, while `options` carries the full request — the only place * `predefined_filter` / `filter_values` / `sort_values` appear, without which two predefined-filter * lists cannot be told apart. The duplication goes away once `convertFilterSortToQuery` derives the - * key from `options`, which is a change in the concrete (RN) DB implementation plus a schema bump. + * key from `options` (a change in that implementation plus a schema-version bump to flush stale rows). */ protected cacheCidsForQuery({ cids, diff --git a/test/unit/ChannelPaginatorsOrchestrator.test.ts b/test/unit/ChannelManager.test.ts similarity index 78% rename from test/unit/ChannelPaginatorsOrchestrator.test.ts rename to test/unit/ChannelManager.test.ts index ee3b222ccb..3042754ebf 100644 --- a/test/unit/ChannelPaginatorsOrchestrator.test.ts +++ b/test/unit/ChannelManager.test.ts @@ -8,9 +8,9 @@ import { type StreamChat, } from '../../src'; import { - ChannelPaginatorsOrchestrator, + ChannelManager, createPriorityOwnershipResolver, -} from '../../src/ChannelPaginatorsOrchestrator'; +} from '../../src/ChannelManager'; vi.mock('../../src/pagination/utility.queryChannel', async () => { return { getChannel: vi.fn(async ({ client, id, type }) => { @@ -20,7 +20,7 @@ vi.mock('../../src/pagination/utility.queryChannel', async () => { }); import { getChannel as mockGetChannel } from '../../src/pagination/utility.queryChannel'; -describe('ChannelPaginatorsOrchestrator', () => { +describe('ChannelManager', () => { let client: StreamChat; beforeEach(() => { @@ -36,16 +36,16 @@ describe('ChannelPaginatorsOrchestrator', () => { const p1 = new ChannelPaginator({ client, filters: { type: 'messaging' } }); const p2 = new ChannelPaginator({ client, filters: { type: 'messaging' } }); - const orchestrator = new ChannelPaginatorsOrchestrator({ + const channelManager = new ChannelManager({ client, paginators: [p1, p2], }); - orchestrator.registerSubscriptions(); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: 'message.new', cid: ch.cid }); await vi.waitFor(() => { - expect(orchestrator.getPaginatorById(p1.id)).toStrictEqual(p1); - expect(orchestrator.getPaginatorById(p2.id)).toStrictEqual(p2); + expect(channelManager.getPaginatorById(p1.id)).toStrictEqual(p1); + expect(channelManager.getPaginatorById(p2.id)).toStrictEqual(p2); expect(p1.items).toHaveLength(1); expect(p1.items![0]).toStrictEqual(ch); expect(p2.items).toHaveLength(1); @@ -56,7 +56,7 @@ describe('ChannelPaginatorsOrchestrator', () => { it('keeps channel only in highest-priority matching paginator when resolver provided', async () => { const pHigh = new ChannelPaginator({ client, filters: { type: 'messaging' } }); const pLow = new ChannelPaginator({ client, filters: { type: 'messaging' } }); - const orchestrator = new ChannelPaginatorsOrchestrator({ + const channelManager = new ChannelManager({ client, paginators: [pLow, pHigh], ownershipResolver: createPriorityOwnershipResolver([pHigh.id, pLow.id]), @@ -65,7 +65,7 @@ describe('ChannelPaginatorsOrchestrator', () => { const ch = makeChannel('messaging:101'); client.activeChannels[ch.cid] = ch; - orchestrator.registerSubscriptions(); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: 'message.new', cid: ch.cid }); await vi.waitFor(() => { @@ -78,7 +78,7 @@ describe('ChannelPaginatorsOrchestrator', () => { it('keeps item in all priority ownership paginators when resolver returns multiple ids', async () => { const pHigh = new ChannelPaginator({ client, filters: { type: 'messaging' } }); const pLow = new ChannelPaginator({ client, filters: { type: 'messaging' } }); - const orchestrator = new ChannelPaginatorsOrchestrator({ + const channelManager = new ChannelManager({ client, paginators: [pLow, pHigh], ownershipResolver: () => [pHigh.id, pLow.id], @@ -87,7 +87,7 @@ describe('ChannelPaginatorsOrchestrator', () => { const ch = makeChannel('messaging:101'); client.activeChannels[ch.cid] = ch; - orchestrator.registerSubscriptions(); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: 'message.new', cid: ch.cid }); await vi.waitFor(() => { @@ -101,7 +101,7 @@ describe('ChannelPaginatorsOrchestrator', () => { it('accepts ownershipResolver as array of ids and applies priority', async () => { const pLow = new ChannelPaginator({ client, filters: { type: 'messaging' } }); const pHigh = new ChannelPaginator({ client, filters: { type: 'messaging' } }); - const orchestrator = new ChannelPaginatorsOrchestrator({ + const channelManager = new ChannelManager({ client, paginators: [pLow, pHigh], ownershipResolver: [pHigh.id, pLow.id], @@ -110,7 +110,7 @@ describe('ChannelPaginatorsOrchestrator', () => { const ch = makeChannel('messaging:102'); client.activeChannels[ch.cid] = ch; - orchestrator.registerSubscriptions(); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: 'message.new', cid: ch.cid }); await vi.waitFor(() => { @@ -123,7 +123,7 @@ describe('ChannelPaginatorsOrchestrator', () => { it('keeps items only in owner paginators if some matching paginators are not listed in ownershipResolver array', async () => { const pLow = new ChannelPaginator({ client, filters: { type: 'messaging' } }); const pHigh = new ChannelPaginator({ client, filters: { type: 'messaging' } }); - const orchestrator = new ChannelPaginatorsOrchestrator({ + const channelManager = new ChannelManager({ client, paginators: [pLow, pHigh], ownershipResolver: [pHigh.id], @@ -132,7 +132,7 @@ describe('ChannelPaginatorsOrchestrator', () => { const ch = makeChannel('messaging:102'); client.activeChannels[ch.cid] = ch; - orchestrator.registerSubscriptions(); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: 'message.new', cid: ch.cid }); await vi.waitFor(() => { @@ -146,7 +146,7 @@ describe('ChannelPaginatorsOrchestrator', () => { const p1 = new ChannelPaginator({ client, filters: { type: 'messaging' } }); const p2 = new ChannelPaginator({ client, filters: { type: 'messaging' } }); const p3 = new ChannelPaginator({ client, filters: { type: 'messagingX' } }); - const orchestrator = new ChannelPaginatorsOrchestrator({ + const channelManager = new ChannelManager({ client, paginators: [p1, p2, p3], ownershipResolver: [p3.id], @@ -155,7 +155,7 @@ describe('ChannelPaginatorsOrchestrator', () => { const ch = makeChannel('messaging:102'); client.activeChannels[ch.cid] = ch; - orchestrator.registerSubscriptions(); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: 'message.new', cid: ch.cid }); await vi.waitFor(() => { @@ -187,7 +187,7 @@ describe('ChannelPaginatorsOrchestrator', () => { id: 'p2', paginatorOptions: { pageSize: 1 }, }); - new ChannelPaginatorsOrchestrator({ + new ChannelManager({ client, paginators: [p1, p2], ownershipResolver: [p2.id], @@ -217,14 +217,43 @@ describe('ChannelPaginatorsOrchestrator', () => { }); }); + describe('client.createChannelManager', () => { + it('builds a manager bound to the client, with or without options', () => { + const paginator = new ChannelPaginator({ client }); + + expect(client.createChannelManager()).toBeInstanceOf(ChannelManager); + + const channelManager = client.createChannelManager({ paginators: [paginator] }); + + expect(channelManager).toBeInstanceOf(ChannelManager); + expect(channelManager.client).toBe(client); + expect(channelManager.paginators).toStrictEqual([paginator]); + }); + + it('ref-counts its subscriptions', () => { + const channelManager = client.createChannelManager(); + + const unregisterFirst = channelManager.registerSubscriptions(); + channelManager.registerSubscriptions(); + expect(channelManager.hasSubscriptions).toBe(true); + + // two consumers registered, so the first unregister must not tear the subscriptions down + unregisterFirst(); + expect(channelManager.hasSubscriptions).toBe(true); + + channelManager.unregisterSubscriptions(); + expect(channelManager.hasSubscriptions).toBe(false); + }); + }); + describe('constructor', () => { it('initiates with default options', () => { // @ts-expect-error accessing protected property - const defaultHandlers = ChannelPaginatorsOrchestrator.defaultEventHandlers; - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); - expect(orchestrator.paginators).toHaveLength(0); + const defaultHandlers = ChannelManager.defaultEventHandlers; + const channelManager = new ChannelManager({ client }); + expect(channelManager.paginators).toHaveLength(0); - expect(orchestrator.pipelines.size).toBe(Object.keys(defaultHandlers).length); + expect(channelManager.pipelines.size).toBe(Object.keys(defaultHandlers).length); }); it('initiates with custom options', () => { @@ -234,8 +263,8 @@ describe('ChannelPaginatorsOrchestrator', () => { const customEventHandler = vi.fn(); // @ts-expect-error accessing protected property - const defaultHandlers = ChannelPaginatorsOrchestrator.defaultEventHandlers; - const eventHandlers = ChannelPaginatorsOrchestrator.getDefaultHandlers(); + const defaultHandlers = ChannelManager.defaultEventHandlers; + const eventHandlers = ChannelManager.getDefaultHandlers(); eventHandlers['channel.visible'] = [ ...(eventHandlers['channel.visible'] ?? []), @@ -259,36 +288,36 @@ describe('ChannelPaginatorsOrchestrator', () => { }, ]; - const orchestrator = new ChannelPaginatorsOrchestrator({ + const channelManager = new ChannelManager({ client, eventHandlers, paginators: [paginator], }); - expect(orchestrator.paginators).toHaveLength(1); - expect(orchestrator.getPaginatorById(paginator.id)).toStrictEqual(paginator); - expect(orchestrator.pipelines.size).toBe(Object.keys(defaultHandlers).length + 1); + expect(channelManager.paginators).toHaveLength(1); + expect(channelManager.getPaginatorById(paginator.id)).toStrictEqual(paginator); + expect(channelManager.pipelines.size).toBe(Object.keys(defaultHandlers).length + 1); - expect(orchestrator.pipelines.get('channel.visible')?.size).toBe(2); + expect(channelManager.pipelines.get('channel.visible')?.size).toBe(2); // @ts-expect-error accessing protected property - expect(orchestrator.pipelines.get('channel.visible')?.handlers[0].id).toBe( + expect(channelManager.pipelines.get('channel.visible')?.handlers[0].id).toBe( eventHandlers['channel.visible'][0].id, ); // @ts-expect-error accessing protected property - expect(orchestrator.pipelines.get('channel.visible')?.handlers[1].id).toBe( + expect(channelManager.pipelines.get('channel.visible')?.handlers[1].id).toBe( eventHandlers['channel.visible'][1].id, ); // @ts-expect-error accessing protected property - expect(orchestrator.pipelines.get('channel.deleted').size).toBe(1); + expect(channelManager.pipelines.get('channel.deleted').size).toBe(1); // @ts-expect-error accessing protected property - expect(orchestrator.pipelines.get('channel.deleted').handlers[0].id).toBe( + expect(channelManager.pipelines.get('channel.deleted').handlers[0].id).toBe( eventHandlers['channel.deleted'][0].id, ); // @ts-expect-error accessing protected property - expect(orchestrator.pipelines.get('custom.event').size).toBe(1); + expect(channelManager.pipelines.get('custom.event').size).toBe(1); // @ts-expect-error accessing protected property - expect(orchestrator.pipelines.get('custom.event').handlers[0].id).toBe( + expect(channelManager.pipelines.get('custom.event').handlers[0].id).toBe( eventHandlers['custom.event'][0].id, ); }); @@ -297,9 +326,9 @@ describe('ChannelPaginatorsOrchestrator', () => { describe('registerSubscriptions', () => { it('subscribes only once', async () => { const onSpy = vi.spyOn(client, 'on'); - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); - orchestrator.registerSubscriptions(); - orchestrator.registerSubscriptions(); + const channelManager = new ChannelManager({ client }); + channelManager.registerSubscriptions(); + channelManager.registerSubscriptions(); expect(onSpy).toHaveBeenCalledTimes(1); }); @@ -307,7 +336,7 @@ describe('ChannelPaginatorsOrchestrator', () => { const customChannelDeletedHandler = vi.fn(); const customEventHandler = vi.fn(); - const eventHandlers = ChannelPaginatorsOrchestrator.getDefaultHandlers(); + const eventHandlers = ChannelManager.getDefaultHandlers(); eventHandlers['channel.deleted'] = [ { @@ -323,8 +352,8 @@ describe('ChannelPaginatorsOrchestrator', () => { }, ]; - const orchestrator = new ChannelPaginatorsOrchestrator({ client, eventHandlers }); - orchestrator.registerSubscriptions(); + const channelManager = new ChannelManager({ client, eventHandlers }); + channelManager.registerSubscriptions(); const channelDeletedEvent = { type: 'channel.deleted', cid: 'x' } as const; @@ -334,7 +363,7 @@ describe('ChannelPaginatorsOrchestrator', () => { expect(customChannelDeletedHandler).toHaveBeenCalledTimes(1); expect(customChannelDeletedHandler).toHaveBeenCalledWith( expect.objectContaining({ - ctx: { orchestrator }, + ctx: { channelManager }, event: channelDeletedEvent, }), ); @@ -348,7 +377,7 @@ describe('ChannelPaginatorsOrchestrator', () => { expect(customEventHandler).toHaveBeenCalledTimes(1); expect(customEventHandler).toHaveBeenCalledWith( expect.objectContaining({ - ctx: { orchestrator }, + ctx: { channelManager }, event: customEvent, }), ); @@ -358,74 +387,74 @@ describe('ChannelPaginatorsOrchestrator', () => { describe('insertPaginator', () => { it('appends when no index is provided', () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const p1 = new ChannelPaginator({ client }); const p2 = new ChannelPaginator({ client }); - orchestrator.insertPaginator({ paginator: p1 }); - orchestrator.insertPaginator({ paginator: p2 }); + channelManager.insertPaginator({ paginator: p1 }); + channelManager.insertPaginator({ paginator: p2 }); - expect(orchestrator.paginators.map((p) => p.id)).toEqual([p1.id, p2.id]); + expect(channelManager.paginators.map((p) => p.id)).toEqual([p1.id, p2.id]); }); it('inserts at specific index', () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const p1 = new ChannelPaginator({ client }); const p2 = new ChannelPaginator({ client }); const p3 = new ChannelPaginator({ client }); - orchestrator.insertPaginator({ paginator: p1 }); - orchestrator.insertPaginator({ paginator: p3 }); - orchestrator.insertPaginator({ paginator: p2, index: 1 }); + channelManager.insertPaginator({ paginator: p1 }); + channelManager.insertPaginator({ paginator: p3 }); + channelManager.insertPaginator({ paginator: p2, index: 1 }); - expect(orchestrator.paginators.map((p) => p.id)).toEqual([p1.id, p2.id, p3.id]); + expect(channelManager.paginators.map((p) => p.id)).toEqual([p1.id, p2.id, p3.id]); }); it('moves existing paginator to new index', () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const p1 = new ChannelPaginator({ client }); const p2 = new ChannelPaginator({ client }); const p3 = new ChannelPaginator({ client }); - orchestrator.insertPaginator({ paginator: p1 }); - orchestrator.insertPaginator({ paginator: p2 }); - orchestrator.insertPaginator({ paginator: p3 }); + channelManager.insertPaginator({ paginator: p1 }); + channelManager.insertPaginator({ paginator: p2 }); + channelManager.insertPaginator({ paginator: p3 }); // move p1 from 0 to 2 - orchestrator.insertPaginator({ paginator: p1, index: 2 }); - expect(orchestrator.paginators.map((p) => p.id)).toEqual([p2.id, p3.id, p1.id]); + channelManager.insertPaginator({ paginator: p1, index: 2 }); + expect(channelManager.paginators.map((p) => p.id)).toEqual([p2.id, p3.id, p1.id]); }); it('clamps out-of-bounds index', () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const p1 = new ChannelPaginator({ client }); const p2 = new ChannelPaginator({ client }); - orchestrator.insertPaginator({ paginator: p1, index: -10 }); // -> 0 - orchestrator.insertPaginator({ paginator: p2, index: 999 }); // -> end + channelManager.insertPaginator({ paginator: p1, index: -10 }); // -> 0 + channelManager.insertPaginator({ paginator: p2, index: 999 }); // -> end - expect(orchestrator.paginators.map((p) => p.id)).toEqual([p1.id, p2.id]); + expect(channelManager.paginators.map((p) => p.id)).toEqual([p1.id, p2.id]); }); }); describe('addEventHandler', () => { it('registers a custom handler and can unsubscribe it', async () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const channelUpdatedHandler = vi.fn(); - const unsubscribe = orchestrator.addEventHandler({ + const unsubscribe = channelManager.addEventHandler({ eventType: 'channel.updated', id: 'custom', handle: channelUpdatedHandler, }); - orchestrator.registerSubscriptions(); + channelManager.registerSubscriptions(); const channelUpdatedEvent = { type: 'channel.updated', cid: 'x' } as const; client.dispatchEvent(channelUpdatedEvent); // event listeners are executed async await vi.waitFor(() => { expect(channelUpdatedHandler).toHaveBeenCalledWith({ - ctx: { orchestrator }, + ctx: { channelManager }, event: channelUpdatedEvent, }); }); @@ -441,31 +470,31 @@ describe('ChannelPaginatorsOrchestrator', () => { describe('setEventHandler', () => { it('replaces the existing handlers for a given event type', async () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const eventType = 'channel.updated'; const channelUpdatedEvent = { type: eventType, cid: 'x' } as const; const channelUpdatedHandler1 = vi.fn(); const channelUpdatedHandler2 = vi.fn(); - const unsubscribe = orchestrator.addEventHandler({ + const unsubscribe = channelManager.addEventHandler({ eventType, id: 'custom', handle: channelUpdatedHandler1, }); - orchestrator.registerSubscriptions(); + channelManager.registerSubscriptions(); client.dispatchEvent(channelUpdatedEvent); // event listeners are executed async await vi.waitFor(() => { expect(channelUpdatedHandler1).toHaveBeenCalledWith({ - ctx: { orchestrator }, + ctx: { channelManager }, event: channelUpdatedEvent, }); }); expect(channelUpdatedHandler1).toHaveBeenCalledTimes(1); expect(channelUpdatedHandler2).toHaveBeenCalledTimes(0); - orchestrator.setEventHandlers({ + channelManager.setEventHandlers({ eventType, handlers: [{ id: 'custom2', handle: channelUpdatedHandler2 }], }); @@ -473,7 +502,7 @@ describe('ChannelPaginatorsOrchestrator', () => { client.dispatchEvent(channelUpdatedEvent); await vi.waitFor(() => { expect(channelUpdatedHandler2).toHaveBeenCalledWith({ - ctx: { orchestrator }, + ctx: { channelManager }, event: channelUpdatedEvent, }); }); @@ -489,24 +518,24 @@ describe('ChannelPaginatorsOrchestrator', () => { describe('removeEventHandler', () => { it('does not create a pipeline for which the event type is removed', async () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const eventType = 'channel.updatedX'; - expect(orchestrator.pipelines.get(eventType)).toBeUndefined(); - orchestrator.removeEventHandlers({ + expect(channelManager.pipelines.get(eventType)).toBeUndefined(); + channelManager.removeEventHandlers({ eventType, handlers: [{ idMatch: { id: 'XXX' } }], }); - expect(orchestrator.pipelines.get(eventType)).toBeUndefined(); + expect(channelManager.pipelines.get(eventType)).toBeUndefined(); }); it('removes the existing handlers for a given event type', async () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const eventType = 'channel.updated'; const channelUpdatedEvent = { type: eventType, cid: 'x' } as const; const channelUpdatedHandler1 = vi.fn(); const channelUpdatedHandler2 = vi.fn(); - orchestrator.setEventHandlers({ + channelManager.setEventHandlers({ eventType, handlers: [ { @@ -520,9 +549,9 @@ describe('ChannelPaginatorsOrchestrator', () => { ], }); - orchestrator.registerSubscriptions(); + channelManager.registerSubscriptions(); // @ts-expect-error accessing protected property handlers - expect(orchestrator.pipelines.get(eventType).handlers).toHaveLength(2); + expect(channelManager.pipelines.get(eventType).handlers).toHaveLength(2); client.dispatchEvent(channelUpdatedEvent); // wait for async handler execution @@ -531,7 +560,7 @@ describe('ChannelPaginatorsOrchestrator', () => { expect(channelUpdatedHandler2).toHaveBeenCalledTimes(1); }); - orchestrator.removeEventHandlers({ + channelManager.removeEventHandlers({ eventType, handlers: [{ idMatch: { id: 'custom', regexMatch: true } }], }); @@ -542,15 +571,15 @@ describe('ChannelPaginatorsOrchestrator', () => { expect(channelUpdatedHandler2).toHaveBeenCalledTimes(1); }); // @ts-expect-error accessing protected property handlers - expect(orchestrator.pipelines.get(eventType).handlers).toHaveLength(0); + expect(channelManager.pipelines.get(eventType).handlers).toHaveLength(0); }); }); describe('ensurePipeline', () => { it('returns the same pipeline instance for the same event type', () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); - const p1 = orchestrator.ensurePipeline('channel.updated'); - const p2 = orchestrator.ensurePipeline('channel.updated'); + const channelManager = new ChannelManager({ client }); + const p1 = channelManager.ensurePipeline('channel.updated'); + const p2 = channelManager.ensurePipeline('channel.updated'); expect(p1).toBe(p2); }); }); @@ -561,11 +590,11 @@ describe('ChannelPaginatorsOrchestrator', () => { const paginator2 = new ChannelPaginator({ client }); vi.spyOn(paginator1, 'reload').mockResolvedValue(); vi.spyOn(paginator2, 'reload').mockResolvedValue(); - const orchestrator = new ChannelPaginatorsOrchestrator({ + const channelManager = new ChannelManager({ client, paginators: [paginator1, paginator2], }); - await orchestrator.reload(); + await channelManager.reload(); expect(paginator1.reload).toHaveBeenCalledTimes(1); expect(paginator2.reload).toHaveBeenCalledTimes(1); }); @@ -593,13 +622,13 @@ describe('ChannelPaginatorsOrchestrator', () => { const r1 = vi.spyOn(p1, 'removeItem'); const r2 = vi.spyOn(p2, 'removeItem'); - const orchestrator = new ChannelPaginatorsOrchestrator({ + const channelManager = new ChannelManager({ client, paginators: [p1, p2], }); client.activeChannels[cid] = ch; - orchestrator.registerSubscriptions(); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: eventType, cid } as const); await vi.waitFor(() => { @@ -610,12 +639,12 @@ describe('ChannelPaginatorsOrchestrator', () => { }); it('is a no-op when cid is missing', async () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const p = new ChannelPaginator({ client }); const r = vi.spyOn(p, 'removeItem'); - orchestrator.insertPaginator({ paginator: p }); - orchestrator.registerSubscriptions(); + channelManager.insertPaginator({ paginator: p }); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: eventType } as const); // no cid await vi.waitFor(() => { @@ -624,12 +653,12 @@ describe('ChannelPaginatorsOrchestrator', () => { }); it('tries to remove non-existent channel from all paginators', async () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const p = new ChannelPaginator({ client }); const r = vi.spyOn(p, 'removeItem'); - orchestrator.insertPaginator({ paginator: p }); - orchestrator.registerSubscriptions(); + channelManager.insertPaginator({ paginator: p }); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: eventType, cid: 'messaging:404' }); // no such channel await vi.waitFor(() => { @@ -656,11 +685,11 @@ describe('ChannelPaginatorsOrchestrator', () => { seed(regular, [channel]); seed(hiddenOnly, [channel]); - const orchestrator = new ChannelPaginatorsOrchestrator({ + const channelManager = new ChannelManager({ client, paginators: [regular, hiddenOnly], }); - orchestrator.registerSubscriptions(); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: 'channel.hidden', cid } as const); @@ -678,11 +707,11 @@ describe('ChannelPaginatorsOrchestrator', () => { client.activeChannels[cid] = channel; const regular = new ChannelPaginator({ client }); - const orchestrator = new ChannelPaginatorsOrchestrator({ + const channelManager = new ChannelManager({ client, paginators: [regular], }); - orchestrator.registerSubscriptions(); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: 'channel.hidden', cid } as const); await vi.waitFor(() => expect(regular.items ?? []).toEqual([])); @@ -700,11 +729,11 @@ describe('ChannelPaginatorsOrchestrator', () => { it('falls back to event.channel.cid when the event carries no top-level identifiers', async () => { const cid = 'messaging:added-1'; const paginator = new ChannelPaginator({ client }); - const orchestrator = new ChannelPaginatorsOrchestrator({ + const channelManager = new ChannelManager({ client, paginators: [paginator], }); - orchestrator.registerSubscriptions(); + channelManager.registerSubscriptions(); // notification.added_to_channel has optional cid / channel_type / channel_id — only // event.channel is guaranteed @@ -723,11 +752,11 @@ describe('ChannelPaginatorsOrchestrator', () => { it('does not query a channel it cannot identify', async () => { const paginator = new ChannelPaginator({ client }); - const orchestrator = new ChannelPaginatorsOrchestrator({ + const channelManager = new ChannelManager({ client, paginators: [paginator], }); - orchestrator.registerSubscriptions(); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: 'notification.added_to_channel' } as never); @@ -750,31 +779,31 @@ describe('ChannelPaginatorsOrchestrator', () => { const r1 = vi.spyOn(p1, 'removeItem'); const r2 = vi.spyOn(p2, 'removeItem'); - const orchestrator = new ChannelPaginatorsOrchestrator({ + const channelManager = new ChannelManager({ client, paginators: [p1, p2], }); client.activeChannels[cid] = ch; - orchestrator.registerSubscriptions(); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: eventType, cid } as const); await vi.waitFor(() => { // The client evicts the channel from activeChannels on // notification.removed_from_channel (stream-chat-js #1788), so the - // orchestrator no longer has the instance and removes purely by id. + // channelManager no longer has the instance and removes purely by id. expect(r1).toHaveBeenCalledWith({ id: ch.cid, item: undefined }); expect(r2).toHaveBeenCalledWith({ id: ch.cid, item: undefined }); }); }); it('is a no-op when cid is missing', async () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const p = new ChannelPaginator({ client }); const r = vi.spyOn(p, 'removeItem'); - orchestrator.insertPaginator({ paginator: p }); - orchestrator.registerSubscriptions(); + channelManager.insertPaginator({ paginator: p }); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: eventType } as const); // no cid await vi.waitFor(() => { @@ -783,12 +812,12 @@ describe('ChannelPaginatorsOrchestrator', () => { }); it('tries to remove non-existent channel from all paginators', async () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const p = new ChannelPaginator({ client }); const r = vi.spyOn(p, 'removeItem'); - orchestrator.insertPaginator({ paginator: p }); - orchestrator.registerSubscriptions(); + channelManager.insertPaginator({ paginator: p }); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: eventType, cid: 'messaging:404' }); // no such channel await vi.waitFor(() => { @@ -802,7 +831,7 @@ describe('ChannelPaginatorsOrchestrator', () => { 'event %s', (eventType) => { it('re-emits item lists for paginators that already contain the channel', async () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const ch = makeChannel('messaging:3'); client.activeChannels[ch.cid] = ch; @@ -818,8 +847,8 @@ describe('ChannelPaginatorsOrchestrator', () => { const partialNextSpy1 = vi.spyOn(p1.state, 'partialNext'); const partialNextSpy2 = vi.spyOn(p2.state, 'partialNext'); - orchestrator.insertPaginator({ paginator: p1 }); - orchestrator.registerSubscriptions(); + channelManager.insertPaginator({ paginator: p1 }); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: eventType, cid: ch.cid }); await vi.waitFor(() => { @@ -841,7 +870,7 @@ describe('ChannelPaginatorsOrchestrator', () => { 'notification.message_new', ] as EventTypes[])('event %s', (eventType) => { it('ingests when matchesFilter, removes when not', async () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const ch = makeChannel('messaging:5'); client.activeChannels[ch.cid] = ch; @@ -852,8 +881,8 @@ describe('ChannelPaginatorsOrchestrator', () => { .spyOn(p, 'removeItem') .mockReturnValue({ state: { currentIndex: 0, insertionIndex: 1 } }); - orchestrator.insertPaginator({ paginator: p }); - orchestrator.registerSubscriptions(); + channelManager.insertPaginator({ paginator: p }); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: eventType, cid: ch.cid }); await vi.waitFor(() => { @@ -872,7 +901,7 @@ describe('ChannelPaginatorsOrchestrator', () => { }); it('loads channel by (type,id) when not in activeChannels', async () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const p = new ChannelPaginator({ client }); const removeItemSpy = vi @@ -880,8 +909,8 @@ describe('ChannelPaginatorsOrchestrator', () => { .mockReturnValue({ state: { currentIndex: 0, insertionIndex: -1 } }); const ingestItemSpy = vi.spyOn(p, 'ingestItem').mockReturnValue(true); vi.spyOn(p, 'matchesFilter').mockReturnValue(true); - orchestrator.insertPaginator({ paginator: p }); - orchestrator.registerSubscriptions(); + channelManager.insertPaginator({ paginator: p }); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: eventType, @@ -902,7 +931,7 @@ describe('ChannelPaginatorsOrchestrator', () => { }); it('uses event.channel if provided', async () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const ch = makeChannel('messaging:7'); client.activeChannels[ch.cid] = ch; @@ -914,8 +943,8 @@ describe('ChannelPaginatorsOrchestrator', () => { const ingestItemSpy = vi.spyOn(p, 'ingestItem').mockReturnValue(true); vi.spyOn(p, 'matchesFilter').mockReturnValue(true); - orchestrator.insertPaginator({ paginator: p }); - orchestrator.registerSubscriptions(); + channelManager.insertPaginator({ paginator: p }); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: eventType, @@ -928,7 +957,7 @@ describe('ChannelPaginatorsOrchestrator', () => { }); it('removes channel if does not match the filter anymore', async () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const ch = makeChannel('messaging:7'); client.activeChannels[ch.cid] = ch; @@ -940,8 +969,8 @@ describe('ChannelPaginatorsOrchestrator', () => { const ingestItemSpy = vi.spyOn(p, 'ingestItem').mockReturnValue(true); vi.spyOn(p, 'matchesFilter').mockReturnValue(false); - orchestrator.insertPaginator({ paginator: p }); - orchestrator.registerSubscriptions(); + channelManager.insertPaginator({ paginator: p }); + channelManager.registerSubscriptions(); client.dispatchEvent({ type: eventType, @@ -967,15 +996,15 @@ describe('ChannelPaginatorsOrchestrator', () => { vi.setSystemTime(now); const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now.getTime()); - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const ch = makeChannel('messaging:5'); client.activeChannels[ch.cid] = ch; const paginator = new ChannelPaginator({ client }); const matchesFilterSpy = vi.spyOn(paginator, 'matchesFilter').mockReturnValue(true); - orchestrator.insertPaginator({ paginator }); - orchestrator.registerSubscriptions(); + channelManager.insertPaginator({ paginator }); + channelManager.registerSubscriptions(); // @ts-expect-error accessing protected property expect(paginator.boosts.size).toBe(0); @@ -1036,15 +1065,15 @@ describe('ChannelPaginatorsOrchestrator', () => { vi.setSystemTime(now); const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now.getTime()); - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const ch = makeChannel('messaging:5'); client.activeChannels[ch.cid] = ch; const paginator = new ChannelPaginator({ client }); const matchesFilterSpy = vi.spyOn(paginator, 'matchesFilter').mockReturnValue(true); - orchestrator.insertPaginator({ paginator }); - orchestrator.registerSubscriptions(); + channelManager.insertPaginator({ paginator }); + channelManager.registerSubscriptions(); // @ts-expect-error accessing protected property expect(paginator.boosts.size).toBe(0); @@ -1059,7 +1088,7 @@ describe('ChannelPaginatorsOrchestrator', () => { describe('user.presence.changed', () => { it('updates user on channels where the user is a member and re-emits lists', async () => { - const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const channelManager = new ChannelManager({ client }); const ch1 = makeChannel('messaging:13'); ch1.state.members = { @@ -1083,8 +1112,8 @@ describe('ChannelPaginatorsOrchestrator', () => { p.state.partialNext({ items: [ch1, ch2] }); const partialNextSpy = vi.spyOn(p.state, 'partialNext'); - orchestrator.insertPaginator({ paginator: p }); - orchestrator.registerSubscriptions(); + channelManager.insertPaginator({ paginator: p }); + channelManager.registerSubscriptions(); // user u1 presence changed client.dispatchEvent({ @@ -1118,12 +1147,12 @@ describe('ChannelPaginatorsOrchestrator', () => { const ch = makeChannel('messaging:200'); const p1 = new ChannelPaginator({ client, filters: { type: 'messaging' } }); const p2 = new ChannelPaginator({ client, filters: { type: 'messaging' } }); - const orchestrator = new ChannelPaginatorsOrchestrator({ + const channelManager = new ChannelManager({ client, paginators: [p1, p2], }); - orchestrator.ingestChannel(ch); + channelManager.ingestChannel(ch); expect(p1.items?.map((c) => c.cid)).toEqual(['messaging:200']); expect(p2.items?.map((c) => c.cid)).toEqual(['messaging:200']); @@ -1132,14 +1161,14 @@ describe('ChannelPaginatorsOrchestrator', () => { it('routes a non-matching channel to a catch-all fallback (lowest priority) and keeps matches in the primary', () => { const primary = new ChannelPaginator({ client, filters: { type: 'messaging' } }); const fallback = new ChannelPaginator({ client, filters: {} }); - const orchestrator = new ChannelPaginatorsOrchestrator({ + const channelManager = new ChannelManager({ client, paginators: [primary, fallback], ownershipResolver: createPriorityOwnershipResolver([primary.id, fallback.id]), }); - orchestrator.ingestChannel(makeChannel('messaging:201')); - orchestrator.ingestChannel(makeChannel('team:202')); + channelManager.ingestChannel(makeChannel('messaging:201')); + channelManager.ingestChannel(makeChannel('team:202')); // A channel matching the primary filter is owned by the primary only (higher priority), // even though the catch-all fallback also matches it. diff --git a/test/unit/channel_manager.test.ts b/test/unit/channel_manager.test.ts deleted file mode 100644 index 5f8e14ae02..0000000000 --- a/test/unit/channel_manager.test.ts +++ /dev/null @@ -1,2801 +0,0 @@ -import sinon from 'sinon'; -import { - Channel, - ChannelStateResponseFields, - ChannelManager, - ChannelResponse, - StreamChat, - ChannelManagerOptions, - DEFAULT_CHANNEL_MANAGER_OPTIONS, - channelManagerEventToHandlerMapping, - DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS, - QueryChannelsRequestType, - QueryChannelsResponse, - RequestMetadata, - EventPayload, -} from '../../src'; - -import { generateChannel } from './test-utils/generateChannel'; -import { getClientWithUser } from './test-utils/getClient'; -import * as utils from '../../src/utils'; - -import { describe, beforeEach, afterEach, expect, it, vi, MockInstance } from 'vitest'; -import { MockOfflineDB } from './offline-support/MockOfflineDB'; -import { DEFAULT_QUERY_CHANNELS_RETRY_COUNT } from '../../src/constants'; - -describe('ChannelManager', () => { - let client: StreamChat; - let channelManager: ChannelManager; - let channelsResponse: ChannelStateResponseFields[]; - - beforeEach(async () => { - client = await getClientWithUser(); - channelManager = client.createChannelManager({}); - channelManager.registerSubscriptions(); - channelsResponse = [ - generateChannel({ channel: { id: 'channel1' } }), - generateChannel({ channel: { id: 'channel2' } }), - generateChannel({ channel: { id: 'channel3' } }), - ]; - client.hydrateActiveChannels(channelsResponse); - const channels = channelsResponse.map((c) => - client.channel(c.channel!.type, c.channel!.id), - ); - channelManager.state.partialNext({ channels, initialized: true }); - }); - - afterEach(() => { - sinon.restore(); - sinon.reset(); - }); - - describe('initialization', () => { - let channelManager: ChannelManager; - - beforeEach(() => { - channelManager = client.createChannelManager({}); - }); - - afterEach(() => { - sinon.restore(); - sinon.reset(); - }); - - it('initializes properly', () => { - const state = channelManager.state.getLatestValue(); - expect(state.channels).to.be.empty; - expect(state.pagination).to.deep.equal({ - isLoading: false, - isLoadingNext: false, - hasNext: false, - options: DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS, - }); - expect(state.initialized).to.be.false; - }); - - it('should properly set eventHandlerOverrides, options and queryChannelsRequest if they are passed', async () => { - const eventHandlerOverrides = { - newMessageHandler: () => {}, - }; - const options = { - allowNotLoadedChannelPromotionForEvent: { - 'channel.visible': false, - 'message.new': false, - 'notification.added_to_channel': false, - 'notification.message_new': false, - }, - }; - const queryChannelsOverride = async () => { - console.log('Called from override.'); - return new Promise((resolve) => { - resolve([]); - }); - }; - const newChannelManager = client.createChannelManager({ - eventHandlerOverrides, - options, - queryChannelsOverride, - }); - - expect( - Object.fromEntries((newChannelManager as any).eventHandlerOverrides), - ).to.deep.equal(eventHandlerOverrides); - expect((newChannelManager as any).options).to.deep.equal({ - ...DEFAULT_CHANNEL_MANAGER_OPTIONS, - ...options, - }); - - const consoleLogSpy = vi.spyOn(console, 'log'); - await (newChannelManager as any).queryChannelsRequest({}); - expect(consoleLogSpy).toHaveBeenCalledWith('Called from override.'); - }); - - it('should properly set the default event handlers', async () => { - const { - eventHandlers, - channelDeletedHandler, - channelHiddenHandler, - channelVisibleHandler, - memberUpdatedHandler, - newMessageHandler, - notificationAddedToChannelHandler, - notificationNewMessageHandler, - notificationRemovedFromChannelHandler, - } = channelManager as any; - - expect(Object.fromEntries(eventHandlers)).to.deep.equal({ - channelDeletedHandler, - channelHiddenHandler, - channelVisibleHandler, - memberUpdatedHandler, - newMessageHandler, - notificationAddedToChannelHandler, - notificationNewMessageHandler, - notificationRemovedFromChannelHandler, - }); - - const clientQueryChannelsSpy = vi - .spyOn(client, 'queryChannelsAndHydrate') - .mockImplementation(async () => []); - await (channelManager as any).queryChannelsRequest({}); - expect(clientQueryChannelsSpy).toHaveBeenCalledOnce(); - }); - }); - - describe('setters', () => { - it('should properly set eventHandlerOverrides and filter out falsy values', () => { - const eventHandlerOverrides = { - newMessageHandler: () => {}, - channelDeletedHandler: () => {}, - }; - - channelManager.setEventHandlerOverrides(eventHandlerOverrides); - expect( - Object.fromEntries((channelManager as any).eventHandlerOverrides), - ).to.deep.equal(eventHandlerOverrides); - - channelManager.setEventHandlerOverrides({ - ...eventHandlerOverrides, - notificationRemovedFromChannelHandler: undefined, - channelHiddenHandler: undefined, - }); - expect( - Object.fromEntries((channelManager as any).eventHandlerOverrides), - ).to.deep.equal(eventHandlerOverrides); - }); - - it('should properly set queryChannelRequest', async () => { - const queryChannelsOverride = async () => { - console.log('Called from override.'); - return new Promise((resolve) => { - resolve([]); - }); - }; - - channelManager.setQueryChannelsRequest(queryChannelsOverride); - - const consoleLogSpy = vi.spyOn(console, 'log'); - await (channelManager as any).queryChannelsRequest({}); - expect(consoleLogSpy).toHaveBeenCalledWith('Called from override.'); - }); - - it('should properly set options', () => { - const options = { - lockChannelOrder: true, - allowNotLoadedChannelPromotionForEvent: { - 'channel.visible': false, - 'message.new': false, - 'notification.added_to_channel': true, - 'notification.message_new': true, - }, - abortInFlightQuery: false, - }; - channelManager.setOptions(options); - - expect((channelManager as any).options).to.deep.equal(options); - }); - - it('should respect option defaults if not explicitly provided', () => { - const partialOptions1: ChannelManagerOptions = { lockChannelOrder: true }; - const partialOptions2: ChannelManagerOptions = {}; - - channelManager.setOptions(partialOptions1); - let options = (channelManager as any).options; - Object.entries(DEFAULT_CHANNEL_MANAGER_OPTIONS).forEach(([k, val]) => { - const key = k as keyof ChannelManagerOptions; - const wantedValue = partialOptions1[key] ?? DEFAULT_CHANNEL_MANAGER_OPTIONS[key]; - expect(options[key]).to.deep.equal(wantedValue); - }); - - channelManager.setOptions(partialOptions2); - options = (channelManager as any).options; - Object.entries(DEFAULT_CHANNEL_MANAGER_OPTIONS).forEach(([k, val]) => { - const key = k as keyof ChannelManagerOptions; - const wantedValue = partialOptions2[key] ?? DEFAULT_CHANNEL_MANAGER_OPTIONS[key]; - expect(options[key]).to.deep.equal(wantedValue); - }); - }); - - describe('setChannels', () => { - it('should properly set channels if a direct value is provided', () => { - const { channels: prevChannels } = channelManager.state.getLatestValue(); - channelManager.setChannels(prevChannels.splice(1)); - - const { channels: newChannels } = channelManager.state.getLatestValue(); - - expect(newChannels.map((c) => c.id)).to.deep.equal(['channel2', 'channel3']); - }); - - it('should update the reference of state.channels if changed', () => { - const { channels: prevChannels } = channelManager.state.getLatestValue(); - channelManager.setChannels([...prevChannels]); - - const { channels: newChannels } = channelManager.state.getLatestValue(); - - expect(newChannels.map((c) => c.id)).to.deep.equal(prevChannels.map((c) => c.id)); - expect(newChannels).to.not.equal(prevChannels); - }); - - it('should use a factory function to calculate the new state if provided', () => { - const { channels: prevChannels } = channelManager.state.getLatestValue(); - channelManager.setChannels((prevChannelsRef) => { - expect(prevChannelsRef).to.equal(prevChannels); - return prevChannelsRef.reverse(); - }); - - const { channels: newChannels } = channelManager.state.getLatestValue(); - - expect(newChannels.map((c) => c.id)).to.deep.equal([ - 'channel3', - 'channel2', - 'channel1', - ]); - }); - - it('should maintain referential integrity if the same channels are passed', () => { - const { channels: prevChannels } = channelManager.state.getLatestValue(); - channelManager.setChannels(prevChannels); - - const { channels: newChannels } = channelManager.state.getLatestValue(); - - expect(newChannels.map((c) => c.id)).to.deep.equal(prevChannels.map((c) => c.id)); - expect(newChannels).to.equal(prevChannels); - }); - - it('should maintain referential integrity from the setter factory as well', () => { - const { channels: prevChannels } = channelManager.state.getLatestValue(); - channelManager.setChannels((prevChannelsRef) => { - return prevChannelsRef; - }); - - const { channels: newChannels } = channelManager.state.getLatestValue(); - - expect(newChannels.map((c) => c.id)).to.deep.equal(prevChannels.map((c) => c.id)); - expect(newChannels).to.equal(prevChannels); - }); - - it('passes full predefined-filter query options when upserting CIDs', async () => { - client.setOfflineDBApi(new MockOfflineDB({ client })); - await client.offlineDb!.init(client.userID as string); - client.offlineDb!.state.partialNext({ - initialized: true, - userId: client.userID, - }); - ( - client.offlineDb!.upsertCidsForQuery as unknown as MockInstance - ).mockResolvedValue([]); - - const { channels, pagination } = channelManager.state.getLatestValue(); - const options = { - predefined_filter: 'user_messaging', - filter_values: { user_id: 'user123' }, - sort_values: { sort_field: 'last_message_at' }, - limit: 10, - offset: 20, - presence: true, - state: true, - watch: true, - filter_conditions: { team: 'blue' }, - sort: [{ field: 'last_message_at', direction: -1 }], - }; - - channelManager.state.partialNext({ - pagination: { - ...pagination, - options, - }, - }); - - channelManager.setChannels(channels); - - expect(client.offlineDb!.upsertCidsForQuery).toHaveBeenCalledExactlyOnceWith({ - cids: channels.map((channel) => channel.cid), - filters: options.filter_conditions, - options, - sort: options.sort, - }); - }); - }); - }); - - describe('event subscriptions', () => { - it('should only invoke event handlers if registerSubscriptions has been called', () => { - const newChannelManager = client.createChannelManager({}); - - const originalNewMessageHandler = (newChannelManager as any).eventHandlers.get( - 'newMessageHandler', - ); - const originalNotificationAddedToChannelHandler = ( - newChannelManager as any - ).eventHandlers.get('notificationAddedToChannelHandler'); - - const newMessageHandlerSpy = sinon.spy(originalNewMessageHandler); - const notificationAddedToChannelHandlerSpy = sinon.spy( - originalNotificationAddedToChannelHandler, - ); - const clientOnSpy = sinon.spy(client, 'on'); - - (newChannelManager as any).eventHandlers.set( - 'newMessageHandler', - newMessageHandlerSpy, - ); - (newChannelManager as any).eventHandlers.set( - 'notificationAddedToChannelHandler', - notificationAddedToChannelHandlerSpy, - ); - - client.dispatchEvent({ type: 'message.new' }); - client.dispatchEvent({ type: 'notification.added_to_channel' }); - - expect(clientOnSpy.called).to.be.false; - expect(newMessageHandlerSpy.called).to.be.false; - expect(notificationAddedToChannelHandlerSpy.called).to.be.false; - - newChannelManager.registerSubscriptions(); - - expect(clientOnSpy.called).to.be.true; - - client.dispatchEvent({ type: 'message.new' }); - client.dispatchEvent({ type: 'notification.added_to_channel' }); - - expect(newMessageHandlerSpy.calledOnce).to.be.true; - expect(notificationAddedToChannelHandlerSpy.calledOnce).to.be.true; - }); - - it('should register listeners to all configured event handlers and do it exactly once', () => { - const clientOnSpy = sinon.spy(client, 'on'); - const newChannelManager = client.createChannelManager({}); - - newChannelManager.registerSubscriptions(); - newChannelManager.registerSubscriptions(); - - expect(clientOnSpy.callCount).to.equal( - Object.keys(channelManagerEventToHandlerMapping).length, - ); - Object.keys(channelManagerEventToHandlerMapping).forEach((eventType) => { - expect(clientOnSpy.calledWith(eventType)).to.be.true; - }); - }); - - it('should unregister subscriptions if unregisterSubscriptions is called', () => { - const newChannelManager = client.createChannelManager({}); - - const originalNewMessageHandler = (newChannelManager as any).eventHandlers.get( - 'newMessageHandler', - ); - const originalNotificationAddedToChannelHandler = ( - newChannelManager as any - ).eventHandlers.get('notificationAddedToChannelHandler'); - - const newMessageHandlerSpy = sinon.spy(originalNewMessageHandler); - const notificationAddedToChannelHandlerSpy = sinon.spy( - originalNotificationAddedToChannelHandler, - ); - - (newChannelManager as any).eventHandlers.set( - 'newMessageHandler', - newMessageHandlerSpy, - ); - (newChannelManager as any).eventHandlers.set( - 'notificationAddedToChannelHandler', - notificationAddedToChannelHandlerSpy, - ); - - newChannelManager.registerSubscriptions(); - newChannelManager.unregisterSubscriptions(); - - client.dispatchEvent({ type: 'message.new' }); - client.dispatchEvent({ type: 'notification.added_to_channel' }); - - expect(newMessageHandlerSpy.called).to.be.false; - expect(notificationAddedToChannelHandlerSpy.called).to.be.false; - }); - - it('should call overrides for event handlers if they exist', () => { - const newChannelManager = client.createChannelManager({}); - - const originalNewMessageHandler = (newChannelManager as any).eventHandlers.get( - 'newMessageHandler', - ); - const originalNotificationAddedToChannelHandler = ( - newChannelManager as any - ).eventHandlers.get('notificationAddedToChannelHandler'); - - const newMessageHandlerSpy = sinon.spy(originalNewMessageHandler); - const notificationAddedToChannelHandlerSpy = sinon.spy( - originalNotificationAddedToChannelHandler, - ); - const newMessageHandlerOverrideSpy = sinon.spy(() => {}); - - (newChannelManager as any).eventHandlers.set( - 'newMessageHandler', - newMessageHandlerSpy, - ); - (newChannelManager as any).eventHandlers.set( - 'notificationAddedToChannelHandler', - notificationAddedToChannelHandlerSpy, - ); - - newChannelManager.registerSubscriptions(); - newChannelManager.setEventHandlerOverrides({ - newMessageHandler: newMessageHandlerOverrideSpy, - }); - - client.dispatchEvent({ type: 'message.new' }); - client.dispatchEvent({ type: 'notification.added_to_channel' }); - - expect(newMessageHandlerSpy.called).to.be.false; - expect(newMessageHandlerOverrideSpy.called).to.be.true; - expect(notificationAddedToChannelHandlerSpy.called).to.be.true; - }); - }); - - it('should call channel.updated event handler override', () => { - const spy = sinon.spy(() => {}); - channelManager.setEventHandlerOverrides({ channelUpdatedHandler: spy }); - spy.resetHistory(); - - client.dispatchEvent({ type: 'channel.updated' }); - - expect(spy.callCount).to.be.equal(1); - }); - - it('should call channel.truncated event handler override', () => { - const spy = sinon.spy(() => {}); - channelManager.setEventHandlerOverrides({ channelTruncatedHandler: spy }); - spy.resetHistory(); - - client.dispatchEvent({ type: 'channel.truncated' }); - - expect(spy.callCount).to.be.equal(1); - }); - - (['channel.updated', 'channel.truncated'] as const).forEach((eventType) => { - it(`should do nothing on ${eventType} by default`, () => { - const spy = sinon.spy(() => {}); - channelManager.state.subscribe(spy); - spy.resetHistory(); - - const channel = channelsResponse[channelsResponse.length - 1].channel; - client.dispatchEvent({ - type: eventType, - channel_type: channel.type, - channel_id: channel.id, - }); - - expect(spy.called).to.be.false; - }); - }); - - describe('querying and pagination', () => { - let clientQueryChannelsStub: sinon.SinonStub; - let mockChannelPages: Array>; - let mockChannelCidMap: Record; - let channelManager: ChannelManager; - - beforeEach(() => { - channelManager = client.createChannelManager({}); - const channelQueryResponses = [ - Array.from({ length: 10 }, () => generateChannel()), - Array.from({ length: 10 }, () => generateChannel()), - Array.from({ length: 5 }, () => generateChannel()), - ]; - mockChannelPages = channelQueryResponses.map((channelQueryResponse) => { - client.hydrateActiveChannels(channelQueryResponse); - return channelQueryResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - }); - mockChannelCidMap = Object.fromEntries( - mockChannelPages.flat().map((obj) => [obj.cid, obj]), - ); - clientQueryChannelsStub = sinon - .stub(client, 'queryChannelsAndHydrate') - .callsFake((request) => { - const cidFilter = request?.filter_conditions?.cid; - if (typeof cidFilter === 'object' && cidFilter !== null && '$in' in cidFilter) { - const toReturn = (cidFilter['$in'] ?? []) as string[]; - return Promise.resolve(toReturn.map((cid) => mockChannelCidMap[cid])); - } - const offset = request?.offset ?? 0; - return Promise.resolve(mockChannelPages[Math.floor(offset / 10)]); - }); - }); - - afterEach(() => { - sinon.restore(); - sinon.reset(); - }); - - describe('queryChannels', () => { - describe('with OfflineDB', () => { - let hydrateActiveChannelsSpy: sinon.SinonSpy; - let executeChannelsQuerySpy: sinon.SinonSpy; - let scheduleSyncStatusCallbackSpy: sinon.SinonSpy; - - beforeEach(async () => { - client.setOfflineDBApi(new MockOfflineDB({ client })); - await client.offlineDb!.init(client.userID as string); - ( - client.offlineDb!.getChannelsForQuery as unknown as MockInstance - ).mockResolvedValue(mockChannelPages[0]); - - hydrateActiveChannelsSpy = sinon.stub(client, 'hydrateActiveChannels'); - executeChannelsQuerySpy = sinon.stub( - channelManager as any, - 'executeChannelsQuery', - ); - scheduleSyncStatusCallbackSpy = sinon.spy( - client.offlineDb!.syncManager, - 'scheduleSyncStatusChangeCallback', - ); - - channelManager.state.partialNext({ initialized: false }); - }); - - afterEach(() => { - sinon.restore(); - sinon.reset(); - }); - - it('hydrates channels from DB if not initialized and user ID is available', async () => { - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ channels: nextValue.channels }), - stateChangeSpy, - ); - stateChangeSpy.resetHistory(); - - const request = { - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - }; - await channelManager.queryChannels(request); - - const { channels } = channelManager.state.getLatestValue(); - - expect(client.offlineDb!.getChannelsForQuery).toHaveBeenCalledExactlyOnceWith({ - userId: client.userID, - options: request, - }); - - expect( - hydrateActiveChannelsSpy.calledOnceWithExactly(mockChannelPages[0], { - offlineMode: true, - skipInitialization: [], - }), - ).toBe(true); - - expect(stateChangeSpy.calledOnceWithExactly(channels)); - expect(executeChannelsQuerySpy.called).to.be.false; - expect(scheduleSyncStatusCallbackSpy.called).to.be.true; - }); - - it('passes full predefined-filter query options when hydrating channels from DB', async () => { - const request = { - filter_conditions: {}, - sort: [], - predefined_filter: 'user_messaging', - filter_values: { user_id: 'dan' }, - sort_values: { sort_field: 'last_message_at' }, - limit: 20, - }; - - await channelManager.queryChannels(request); - - expect(client.offlineDb!.getChannelsForQuery).toHaveBeenCalledExactlyOnceWith({ - userId: client.userID, - options: request, - }); - }); - - it('does NOT hydrate from DB if already initialized', async () => { - channelManager.state.partialNext({ initialized: true }); - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ channels: nextValue.channels }), - stateChangeSpy, - ); - stateChangeSpy.resetHistory(); - - await channelManager.queryChannels({ - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - }); - - expect(client.offlineDb!.getChannelsForQuery).not.toHaveBeenCalled(); - expect(hydrateActiveChannelsSpy.called).to.be.false; - expect(stateChangeSpy.called).to.be.false; - expect(executeChannelsQuerySpy.called).to.be.false; - expect(scheduleSyncStatusCallbackSpy.called).to.be.true; - }); - - it('schedules sync callback if syncStatus is false and invoke it when synced', async () => { - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ channels: nextValue.channels }), - stateChangeSpy, - ); - stateChangeSpy.resetHistory(); - - const request = { - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - }; - await channelManager.queryChannels(request); - - expect(executeChannelsQuerySpy.called).to.be.false; - expect(scheduleSyncStatusCallbackSpy.calledOnce).toBe(true); - - const [id, callback] = scheduleSyncStatusCallbackSpy.firstCall.args; - - expect(id).toBe((channelManager as any).id); - expect(typeof callback).toBe('function'); - - await callback(); - - expect( - executeChannelsQuerySpy.calledOnceWithExactly({ - filters: request.filter_conditions, - sort: request.sort, - options: request, - stateOptions: {}, - }), - ).to.be.true; - - const callbackSpy = sinon.spy(callback); - client.offlineDb!.syncManager['scheduledSyncStatusCallbacks'].set( - id, - callbackSpy, - ); - - await client.offlineDb!.syncManager['invokeSyncStatusListeners'](true); - - expect(callbackSpy.called).to.be.true; - }); - - it('does NOT schedule sync callback if syncStatus is true', async () => { - client.offlineDb!.syncManager.syncStatus = true; - - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ channels: nextValue.channels }), - stateChangeSpy, - ); - stateChangeSpy.resetHistory(); - - await channelManager.queryChannels({ - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - }); - - expect(client.offlineDb!.getChannelsForQuery).toHaveBeenCalled(); - expect(hydrateActiveChannelsSpy.called).to.be.true; - expect(stateChangeSpy.called).to.be.true; - expect(scheduleSyncStatusCallbackSpy.called).to.be.false; - expect(executeChannelsQuerySpy.calledOnce).to.be.true; - }); - - it('continues with normal queryChannels flow if client.user is missing', async () => { - client.user = undefined; - - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ channels: nextValue.channels }), - stateChangeSpy, - ); - stateChangeSpy.resetHistory(); - - await channelManager.queryChannels({ - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - }); - - expect(client.offlineDb!.getChannelsForQuery).not.toHaveBeenCalled(); - expect(hydrateActiveChannelsSpy.called).to.be.false; - expect(stateChangeSpy.called).to.be.false; - expect(scheduleSyncStatusCallbackSpy.called).to.be.false; - expect(executeChannelsQuerySpy.calledOnce).to.be.true; - }); - }); - - it('should not query if pagination.isLoading is true', async () => { - channelManager.state.next((prevState) => ({ - ...prevState, - pagination: { - ...prevState.pagination, - isLoading: true, - }, - })); - - await channelManager.queryChannels({}); - - expect(clientQueryChannelsStub.called).to.be.false; - }); - - it('should not query more than once from the same manager for 2 different queries', async () => { - await Promise.all([ - channelManager.queryChannels({}), - channelManager.queryChannels({}), - ]); - expect(clientQueryChannelsStub.calledOnce).to.be.true; - }); - - it('should query more than once if channelManager.options.abortInFlightQuery is true', async () => { - channelManager.setOptions({ abortInFlightQuery: true }); - await Promise.all([ - channelManager.queryChannels({}), - channelManager.queryChannels({}), - ]); - expect(clientQueryChannelsStub.callCount).to.equal(2); - }); - - it('should set the state to loading while an active query is happening', async () => { - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ isLoading: nextValue.pagination.isLoading }), - stateChangeSpy, - ); - // TODO: Check why the test doesn't work without this; - // something keeps invoking one extra state change - // and I can't figure out what. - stateChangeSpy.resetHistory(); - - await channelManager.queryChannels({}); - - expect(clientQueryChannelsStub.calledOnce).to.be.true; - expect(stateChangeSpy.callCount).to.equal(2); - expect(stateChangeSpy.args[0][0]).to.deep.equal({ isLoading: true }); - expect(stateChangeSpy.args[1][0]).to.deep.equal({ isLoading: false }); - }); - - it('should set state.initialized to true after the first queryChannels is done', async () => { - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ initialized: nextValue.initialized }), - stateChangeSpy, - ); - stateChangeSpy.resetHistory(); - - const { initialized } = channelManager.state.getLatestValue(); - - expect(initialized).to.be.false; - - await channelManager.queryChannels({}); - - expect(clientQueryChannelsStub.calledOnce).to.be.true; - expect(stateChangeSpy.calledOnce).to.be.true; - expect(stateChangeSpy.args[0][0]).to.deep.equal({ initialized: true }); - }); - - describe('executeChannelsQuery', () => { - it('should properly update the options after executeChannelsQuery', async () => { - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ pagination: nextValue.pagination }), - stateChangeSpy, - ); - stateChangeSpy.resetHistory(); - - await channelManager['executeChannelsQuery']({ - options: { - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 0, - }, - }); - - const { channels } = channelManager.state.getLatestValue(); - - expect(clientQueryChannelsStub.calledOnce).to.be.true; - expect(stateChangeSpy.callCount).to.equal(1); - expect(stateChangeSpy.args[0][0]).to.deep.equal({ - pagination: { - hasNext: true, - isLoading: false, - isLoadingNext: false, - options: { - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 10, - }, - }, - }); - expect(channels.length).to.equal(10); - }); - - it('passes full predefined-filter query options when upserting CIDs after query', async () => { - client.setOfflineDBApi(new MockOfflineDB({ client })); - await client.offlineDb!.init(client.userID as string); - client.offlineDb!.state.partialNext({ - initialized: true, - userId: client.userID, - }); - ( - client.offlineDb!.upsertCidsForQuery as unknown as MockInstance - ).mockResolvedValue([]); - - const queryOptions = { - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - predefined_filter: 'user_messaging', - filter_values: { user_id: 'user123' }, - sort_values: { sort_field: 'last_message_at' }, - limit: 10, - offset: 0, - presence: true, - state: true, - watch: true, - }; - const { pagination } = channelManager.state.getLatestValue(); - channelManager.state.partialNext({ - pagination: { ...pagination, options: queryOptions }, - }); - - await channelManager['executeChannelsQuery']({ - options: queryOptions, - stateOptions: {}, - }); - - expect(client.offlineDb!.upsertCidsForQuery).toHaveBeenCalledExactlyOnceWith({ - cids: mockChannelPages[0].map((channel) => channel.cid), - filters: { filterA: true }, - options: queryOptions, - sort: [{ field: 'asc', direction: 1 }], - }); - }); - - it('should properly update hasNext and offset after executeChannelsQuery if the first returned page is less than the limit', async () => { - clientQueryChannelsStub.callsFake(() => mockChannelPages[2]); - await channelManager['executeChannelsQuery']({ - filters: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - options: { limit: 10, offset: 0 }, - }); - - const { - channels, - pagination: { - hasNext, - options: { offset }, - }, - } = channelManager.state.getLatestValue(); - - expect(clientQueryChannelsStub.calledOnce).to.be.true; - expect(channels.length).to.equal(5); - expect(offset).to.equal(5); - expect(hasNext).to.be.false; - }); - - it('retries up to 3 times when queryChannels fails', async () => { - clientQueryChannelsStub.rejects(new Error('fail')); - const sleepSpy = vi.spyOn(utils, 'sleep'); - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ error: nextValue.error }), - stateChangeSpy, - ); - stateChangeSpy.resetHistory(); - - await channelManager['executeChannelsQuery']({ - filters: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - options: { limit: 10, offset: 0 }, - }); - - const { channels, initialized } = channelManager.state.getLatestValue(); - - expect(clientQueryChannelsStub.callCount).to.equal( - DEFAULT_QUERY_CHANNELS_RETRY_COUNT + 1, - ); // // initial + however many retried are configured - expect(sleepSpy).toHaveBeenCalledTimes(DEFAULT_QUERY_CHANNELS_RETRY_COUNT); - expect(stateChangeSpy.callCount).to.equal(1); - expect(stateChangeSpy.args[0][0]).to.deep.equal({ - error: new Error( - 'Maximum number of retries reached in queryChannels. Last error message is: Error: fail', - ), - }); - expect(channels.length).to.equal(0); - expect(initialized).to.be.false; - }); - - it('should not set error when offline support is enabled and there are channels in the DB', async () => { - clientQueryChannelsStub.callsFake(() => mockChannelPages[2]); - client.setOfflineDBApi(new MockOfflineDB({ client })); - await client.offlineDb!.init(client.userID as string); - - channelManager.state.partialNext({ - channels: mockChannelPages[2], - }); - - clientQueryChannelsStub.rejects(new Error('fail')); - const sleepSpy = vi.spyOn(utils, 'sleep'); - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ - error: nextValue.error, - }), - stateChangeSpy, - ); - stateChangeSpy.resetHistory(); - - await channelManager['executeChannelsQuery']({ - filters: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - options: { limit: 10, offset: 0 }, - }); - - const { channels, initialized, error, pagination } = - channelManager.state.getLatestValue(); - - expect(clientQueryChannelsStub.callCount).to.equal( - DEFAULT_QUERY_CHANNELS_RETRY_COUNT + 1, - ); // initial + however many retried are configured - expect(sleepSpy).toHaveBeenCalledTimes(DEFAULT_QUERY_CHANNELS_RETRY_COUNT); - expect(error).toEqual(undefined); - expect(channels.length).to.equal(5); - expect(initialized).to.be.false; - expect(pagination.isLoading).to.be.false; - }); - - it('does not retry more than 3 times', async () => { - clientQueryChannelsStub.rejects(new Error('fail')); - const sleepSpy = vi.spyOn(utils, 'sleep'); - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ error: nextValue.error }), - stateChangeSpy, - ); - stateChangeSpy.resetHistory(); - - await channelManager['executeChannelsQuery']( - { - filters: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - options: { limit: 10, offset: 0 }, - }, - 3, - ); - - const { channels, initialized } = channelManager.state.getLatestValue(); - - expect(clientQueryChannelsStub.callCount).to.equal(1); - expect(sleepSpy).toHaveBeenCalledTimes(0); - expect(stateChangeSpy.callCount).to.equal(1); - expect(stateChangeSpy.args[0][0]).to.deep.equal({ - error: new Error( - 'Maximum number of retries reached in queryChannels. Last error message is: Error: fail', - ), - }); - expect(channels.length).to.equal(0); - expect(initialized).to.be.false; - }); - - it('retries once and succeeds on second try', async () => { - clientQueryChannelsStub.onFirstCall().rejects(new Error('flaky')); - const sleepSpy = vi.spyOn(utils, 'sleep'); - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ error: nextValue.error, channels: nextValue.channels }), - stateChangeSpy, - ); - stateChangeSpy.resetHistory(); - - await channelManager['executeChannelsQuery']({ - filters: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - options: { limit: 10, offset: 0 }, - }); - - const { channels, initialized } = channelManager.state.getLatestValue(); - - expect(clientQueryChannelsStub.callCount).to.equal(2); - expect(sleepSpy).toHaveBeenCalledTimes(1); - expect(stateChangeSpy.callCount).to.equal(1); - expect(stateChangeSpy.args[0][0].channels.length).to.equal(10); - expect(channels.length).to.equal(10); - expect(initialized).to.be.true; - }); - }); - - it('should properly set the new pagination parameters and update the offset after the query', async () => { - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ pagination: nextValue.pagination }), - stateChangeSpy, - ); - stateChangeSpy.resetHistory(); - - const request = { - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 0, - }; - await channelManager.queryChannels(request); - - const { channels } = channelManager.state.getLatestValue(); - - expect(clientQueryChannelsStub.calledOnce).to.be.true; - expect(stateChangeSpy.callCount).to.equal(2); - expect(stateChangeSpy.args[0][0]).to.deep.equal({ - pagination: { - hasNext: false, - isLoading: true, - isLoadingNext: false, - options: request, - }, - }); - expect(stateChangeSpy.args[1][0]).to.deep.equal({ - pagination: { - hasNext: true, - isLoading: false, - isLoadingNext: false, - options: { ...request, offset: 10 }, - }, - }); - expect(channels.length).to.equal(10); - }); - - it('should properly update hasNext and offset if the first returned page is less than the limit', async () => { - clientQueryChannelsStub.callsFake(() => mockChannelPages[2]); - await channelManager.queryChannels({ - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 0, - }); - - const { - channels, - pagination: { - hasNext, - options: { offset }, - }, - } = channelManager.state.getLatestValue(); - - expect(clientQueryChannelsStub.calledOnce).to.be.true; - expect(channels.length).to.equal(5); - expect(offset).to.equal(5); - expect(hasNext).to.be.false; - }); - - it('should execute queryChannelsOverride if set', async () => { - const fetchedChannels = mockChannelPages[2].concat(mockChannelPages[1]); - const queryChannelsOverride = async ( - ...params: Parameters - ) => { - const [request, ...restParams] = params; - const updatedRequest = { - ...request, - filter_conditions: { - ...request?.filter_conditions, - cid: { $in: fetchedChannels.map((c) => c.cid) }, - }, - }; - - return await client.queryChannelsAndHydrate(updatedRequest, ...restParams); - }; - channelManager.setQueryChannelsRequest(queryChannelsOverride); - - await channelManager.queryChannels({ - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 15, - offset: 0, - }); - - const { - channels, - pagination: { - hasNext, - options: { offset }, - }, - } = channelManager.state.getLatestValue(); - - expect(clientQueryChannelsStub.calledOnce).to.be.true; - expect(channels.length).to.equal(15); - expect(channels).to.deep.equal(fetchedChannels); - expect(offset).to.equal(15); - expect(hasNext).to.be.true; - }); - }); - - describe('loadNext', () => { - it('should not run loadNext if queryChannels has not been run at least once', async () => { - channelManager.state.partialNext({ initialized: false }); - - await channelManager.loadNext(); - - expect(clientQueryChannelsStub.called).to.be.false; - }); - - it('should not run loadNext if a query is already in progress or if we are at the last page', async () => { - channelManager.state.next((prevState) => ({ - ...prevState, - initialized: true, - pagination: { ...prevState.pagination, isLoadingNext: true, hasNext: true }, - })); - await channelManager.loadNext(); - expect(clientQueryChannelsStub.called).to.be.false; - - channelManager.state.next((prevState) => ({ - ...prevState, - initialized: true, - pagination: { ...prevState.pagination, isLoadingNext: false, hasNext: false }, - })); - await channelManager.loadNext(); - expect(clientQueryChannelsStub.called).to.be.false; - }); - - it('should not queryChannels more than once regardless of number of consecutive loadNext invocations', async () => { - channelManager.state.next((prevState) => ({ - ...prevState, - initialized: true, - pagination: { ...prevState.pagination, isLoadingNext: false, hasNext: true }, - })); - await Promise.all([channelManager.loadNext(), channelManager.loadNext()]); - expect(clientQueryChannelsStub.calledOnce).to.be.true; - }); - - it('should set the state to loading next page while an active query is happening', async () => { - channelManager.state.next((prevState) => ({ - ...prevState, - initialized: true, - pagination: { ...prevState.pagination, isLoadingNext: false, hasNext: true }, - })); - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ isLoadingNext: nextValue.pagination.isLoadingNext }), - stateChangeSpy, - ); - stateChangeSpy.resetHistory(); - - await channelManager.loadNext(); - - expect(clientQueryChannelsStub.calledOnce).to.be.true; - expect(stateChangeSpy.callCount).to.equal(2); - expect(stateChangeSpy.args[0][0]).to.deep.equal({ isLoadingNext: true }); - expect(stateChangeSpy.args[1][0]).to.deep.equal({ isLoadingNext: false }); - }); - - it('should properly set the new pagination parameters and update the offset after loading next', async () => { - await channelManager.queryChannels({ - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 0, - }); - - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ pagination: nextValue.pagination }), - stateChangeSpy, - ); - stateChangeSpy.resetHistory(); - - await channelManager.loadNext(); - - const { channels } = channelManager.state.getLatestValue(); - - // one from queryChannels and one from loadNext - expect(clientQueryChannelsStub.callCount).to.equal(2); - expect(stateChangeSpy.callCount).to.equal(2); - expect(stateChangeSpy.args[0][0]).to.deep.equal({ - pagination: { - hasNext: true, - isLoading: false, - isLoadingNext: true, - options: { - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 10, - }, - }, - }); - expect(stateChangeSpy.args[1][0]).to.deep.equal({ - pagination: { - hasNext: true, - isLoading: false, - isLoadingNext: false, - options: { - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 20, - }, - }, - }); - expect(channels.length).to.equal(20); - }); - - it('should properly paginate even if state.channels gets modified in the meantime', async () => { - await channelManager.queryChannels({ - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 0, - }); - channelManager.state.next((prevState) => ({ - ...prevState, - channels: [...mockChannelPages[2].slice(0, 5), ...prevState.channels], - })); - - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ pagination: nextValue.pagination }), - stateChangeSpy, - ); - - stateChangeSpy.resetHistory(); - - await channelManager.loadNext(); - - const { channels } = channelManager.state.getLatestValue(); - - expect(clientQueryChannelsStub.callCount).to.equal(2); - expect(stateChangeSpy.callCount).to.equal(2); - expect(stateChangeSpy.args[0][0]).to.deep.equal({ - pagination: { - hasNext: true, - isLoading: false, - isLoadingNext: true, - options: { - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 10, - }, - }, - }); - expect(stateChangeSpy.args[1][0]).to.deep.equal({ - pagination: { - hasNext: true, - isLoading: false, - isLoadingNext: false, - options: { - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 20, - }, - }, - }); - expect(channels.length).to.equal(25); - }); - - it('should properly deduplicate when paginating if channels from the next page have been promoted', async () => { - await channelManager.queryChannels({ - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 0, - }); - channelManager.state.next((prevState) => ({ - ...prevState, - channels: [...mockChannelPages[1].slice(0, 5), ...prevState.channels], - })); - - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ pagination: nextValue.pagination }), - stateChangeSpy, - ); - - stateChangeSpy.resetHistory(); - - await channelManager.loadNext(); - - const { channels } = channelManager.state.getLatestValue(); - - expect(clientQueryChannelsStub.callCount).to.equal(2); - expect(stateChangeSpy.callCount).to.equal(2); - expect(stateChangeSpy.args[0][0]).to.deep.equal({ - pagination: { - hasNext: true, - isLoading: false, - isLoadingNext: true, - options: { - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 10, - }, - }, - }); - expect(stateChangeSpy.args[1][0]).to.deep.equal({ - pagination: { - hasNext: true, - isLoading: false, - isLoadingNext: false, - options: { - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 20, - }, - }, - }); - expect(channels.length).to.equal(20); - }); - - it('should properly deduplicate when paginating if channels latter pages have been promoted and reached', async () => { - await channelManager.queryChannels({ - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 0, - }); - channelManager.state.next((prevState) => ({ - ...prevState, - channels: [...mockChannelPages[2].slice(0, 3), ...prevState.channels], - })); - - const stateChangeSpy = sinon.spy(); - channelManager.state.subscribeWithSelector( - (nextValue) => ({ pagination: nextValue.pagination }), - stateChangeSpy, - ); - - stateChangeSpy.resetHistory(); - - await channelManager.loadNext(); - - const { channels: channelsAfterFirstPagination } = - channelManager.state.getLatestValue(); - expect(channelsAfterFirstPagination.length).to.equal(23); - - await channelManager.loadNext(); - - const { channels } = channelManager.state.getLatestValue(); - - expect(clientQueryChannelsStub.callCount).to.equal(3); - expect(stateChangeSpy.callCount).to.equal(4); - expect(stateChangeSpy.args[0][0]).to.deep.equal({ - pagination: { - hasNext: true, - isLoading: false, - isLoadingNext: true, - options: { - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 10, - }, - }, - }); - expect(stateChangeSpy.args[1][0]).to.deep.equal({ - pagination: { - hasNext: true, - isLoading: false, - isLoadingNext: false, - options: { - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 20, - }, - }, - }); - expect(stateChangeSpy.args[3][0]).to.deep.equal({ - pagination: { - hasNext: false, - isLoading: false, - isLoadingNext: false, - options: { - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 25, - }, - }, - }); - expect(channels.length).to.equal(25); - }); - - it('should correctly update hasNext and offset if the last page has been reached', async () => { - const { channels: initialChannels } = channelManager.state.getLatestValue(); - expect(initialChannels.length).to.equal(0); - - await channelManager.queryChannels({ - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 10, - offset: 0, - }); - await channelManager.loadNext(); - - const { - channels: secondToLastPage, - pagination: { hasNext: prevHasNext }, - } = channelManager.state.getLatestValue(); - expect(secondToLastPage.length).to.equal(20); - expect(prevHasNext).to.be.true; - - await channelManager.loadNext(); - - const { - channels: lastPage, - pagination: { - hasNext, - options: { offset }, - }, - } = channelManager.state.getLatestValue(); - - expect(lastPage.length).to.equal(25); - expect(hasNext).to.be.false; - expect(offset).to.equal(25); - }); - - it('should properly paginate with queryChannelsOverride if set', async () => { - const fetchedChannels = mockChannelPages[2].concat(mockChannelPages[1]); - const fetchedNextPageChannels = mockChannelPages[0]; - const queryChannelsOverride = async ( - ...params: Parameters - ) => { - const [request, ...restParams] = params; - const isInitialPage = request?.offset === 0; - const updatedRequest = { - ...request, - filter_conditions: { - ...request?.filter_conditions, - cid: { - $in: (isInitialPage ? fetchedChannels : fetchedNextPageChannels).map( - (c) => c.cid, - ), - }, - }, - }; - - return await client.queryChannelsAndHydrate(updatedRequest, ...restParams); - }; - - channelManager.setQueryChannelsRequest(queryChannelsOverride); - - await channelManager.queryChannels({ - filter_conditions: { filterA: true }, - sort: [{ field: 'asc', direction: 1 }], - limit: 15, - offset: 0, - }); - - const { - channels: prevChannels, - pagination: { - hasNext: prevHasNext, - options: { offset: prevOffset }, - }, - } = channelManager.state.getLatestValue(); - - expect(prevChannels.length).to.equal(15); - expect(prevChannels).to.deep.equal(fetchedChannels); - expect(prevOffset).to.equal(15); - expect(prevHasNext).to.be.true; - - await channelManager.loadNext(); - - const { - channels, - pagination: { - hasNext, - options: { offset }, - }, - } = channelManager.state.getLatestValue(); - - expect(channels.length).to.equal(25); - expect(channels).to.deep.equal(fetchedChannels.concat(fetchedNextPageChannels)); - expect(offset).to.equal(25); - expect(hasNext).to.be.false; - }); - }); - }); - - describe('websocket event handlers', () => { - let setChannelsStub: MockInstance; - let isChannelPinnedStub: MockInstance<(typeof utils)['isChannelPinned']>; - let isChannelArchivedStub: MockInstance<(typeof utils)['isChannelArchived']>; - let shouldConsiderArchivedChannelsStub: MockInstance< - (typeof utils)['shouldConsiderArchivedChannels'] - >; - let shouldConsiderPinnedChannelsStub: MockInstance< - (typeof utils)['shouldConsiderPinnedChannels'] - >; - let promoteChannelSpy: MockInstance<(typeof utils)['promoteChannel']>; - let getAndWatchChannelStub: MockInstance<(typeof utils)['getAndWatchChannel']>; - let findLastPinnedChannelIndexStub: MockInstance< - (typeof utils)['findLastPinnedChannelIndex'] - >; - let extractSortValueStub: MockInstance<(typeof utils)['extractSortValue']>; - const setChannelMembership = ( - channelId: string, - membership: Record, - ) => { - const channel = client.channel('messaging', channelId); - channel.state.membership = { - user: { id: client.userID }, - user_id: client.userID, - ...membership, - } as never; - - return channel; - }; - const queryChannelsWithPredefinedFilterResponse = async ({ - filter, - sort, - }: { - filter: Record; - sort?: NonNullable['sort']; - }) => { - vi.spyOn(client, 'queryChannels').mockResolvedValueOnce({ - duration: '0.01s', - channels: channelsResponse, - predefined_filter: { - name: 'messaging_channels', - filter, - sort, - }, - metadata: {} as RequestMetadata, - }); - - await channelManager.queryChannels({ - filter_conditions: {}, - sort: [], - predefined_filter: 'messaging_channels', - }); - setChannelsStub.mockClear(); - }; - - beforeEach(() => { - setChannelsStub = vi.spyOn(channelManager, 'setChannels'); - isChannelPinnedStub = vi.spyOn(utils, 'isChannelPinned'); - isChannelArchivedStub = vi.spyOn(utils, 'isChannelArchived'); - shouldConsiderArchivedChannelsStub = vi.spyOn( - utils, - 'shouldConsiderArchivedChannels', - ); - shouldConsiderPinnedChannelsStub = vi.spyOn(utils, 'shouldConsiderPinnedChannels'); - getAndWatchChannelStub = vi.spyOn(utils, 'getAndWatchChannel'); - findLastPinnedChannelIndexStub = vi.spyOn(utils, 'findLastPinnedChannelIndex'); - extractSortValueStub = vi.spyOn(utils, 'extractSortValue'); - promoteChannelSpy = vi.spyOn(utils, 'promoteChannel'); - }); - - afterEach(() => { - vi.resetAllMocks(); - sinon.restore(); - sinon.reset(); - }); - - describe('predefined filter response metadata', () => { - it('does not promote an archived channel into a resolved non-archived list on message.new', async () => { - await queryChannelsWithPredefinedFilterResponse({ - filter: { archived: false }, - }); - setChannelMembership('channel2', { - archived_at: '2024-01-15T10:30:00Z', - }); - - client.dispatchEvent({ - type: 'message.new', - channel_type: 'messaging', - channel_id: 'channel2', - } as EventPayload<'message.new'>); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('does not promote an unarchived channel into a resolved archived list on message.new', async () => { - await queryChannelsWithPredefinedFilterResponse({ - filter: { archived: true }, - }); - - client.dispatchEvent({ - type: 'message.new', - channel_type: 'messaging', - channel_id: 'channel2', - } as EventPayload<'message.new'>); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('does not move a pinned channel when resolved predefined sort considers pinned_at', async () => { - await queryChannelsWithPredefinedFilterResponse({ - filter: {}, - sort: [{ field: 'pinned_at', direction: -1 }], - }); - setChannelMembership('channel2', { - pinned_at: '2024-01-15T10:30:00Z', - }); - - client.dispatchEvent({ - type: 'message.new', - channel_type: 'messaging', - channel_id: 'channel2', - } as EventPayload<'message.new'>); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('does not promote an archived channel into a resolved non-archived list on notification.message_new', async () => { - const clock = sinon.useFakeTimers(); - await queryChannelsWithPredefinedFilterResponse({ - filter: { archived: false }, - }); - const channel = setChannelMembership('channel4', { - archived_at: '2024-01-15T10:30:00Z', - }); - getAndWatchChannelStub.mockResolvedValueOnce(channel); - - client.dispatchEvent({ - type: 'notification.message_new', - channel: { type: 'messaging', id: 'channel4' } as unknown as ChannelResponse, - } as EventPayload<'notification.message_new'>); - - await clock.runAllAsync(); - clock.restore(); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('does not add an archived channel into a resolved non-archived list on channel.visible', async () => { - const clock = sinon.useFakeTimers(); - await queryChannelsWithPredefinedFilterResponse({ - filter: { archived: false }, - }); - const channel = setChannelMembership('channel4', { - archived_at: '2024-01-15T10:30:00Z', - }); - getAndWatchChannelStub.mockResolvedValueOnce(channel); - - client.dispatchEvent({ - type: 'channel.visible', - channel_id: 'channel4', - channel_type: 'messaging', - } as EventPayload<'channel.visible'>); - - await clock.runAllAsync(); - clock.restore(); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('uses resolved predefined filter metadata when handling member.updated archive changes', async () => { - await queryChannelsWithPredefinedFilterResponse({ - filter: { archived: false }, - }); - setChannelMembership('channel2', { - archived_at: '2024-01-15T10:30:00Z', - }); - - client.dispatchEvent({ - type: 'member.updated', - channel_id: 'channel2', - channel_type: 'messaging', - member: { user: { id: client.userId! } }, - } as EventPayload<'member.updated'>); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect( - (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), - ).to.deep.equal(['channel1', 'channel3']); - }); - - it('uses resolved predefined sort metadata when handling member.updated pin changes', async () => { - await queryChannelsWithPredefinedFilterResponse({ - filter: {}, - sort: [{ field: 'pinned_at', direction: -1 }], - }); - setChannelMembership('channel1', { - pinned_at: '2024-01-15T10:30:00Z', - }); - setChannelMembership('channel3', { - pinned_at: '2024-01-15T10:30:00Z', - }); - - client.dispatchEvent({ - type: 'member.updated', - channel_id: 'channel3', - channel_type: 'messaging', - member: { user: { id: client.userID } }, - } as EventPayload<'member.updated'>); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect( - (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), - ).to.deep.equal(['channel3', 'channel1', 'channel2']); - }); - - it('keeps non-predefined query behavior based on caller filters and sort', async () => { - vi.spyOn(client, 'queryChannels').mockResolvedValueOnce({ - duration: '0.01s', - channels: channelsResponse, - metadata: {} as RequestMetadata, - }); - await channelManager.queryChannels({ - filter_conditions: { archived: false }, - sort: [], - limit: 10, - }); - setChannelsStub.mockClear(); - setChannelMembership('channel2', { - archived_at: '2024-01-15T10:30:00Z', - }); - - client.dispatchEvent({ - type: 'message.new', - channel_type: 'messaging', - channel_id: 'channel2', - } as EventPayload<'message.new'>); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('preserves resolved predefined response metadata after loading the next page', async () => { - vi.spyOn(client, 'queryChannels') - .mockResolvedValueOnce({ - duration: '0.01s', - channels: channelsResponse, - predefined_filter: { - name: 'messaging_channels', - filter: { archived: false }, - sort: [{ field: 'pinned_at', direction: -1 }], - }, - metadata: {} as RequestMetadata, - }) - .mockResolvedValueOnce({ - duration: '0.01s', - channels: [ - generateChannel({ channel: { id: 'channel4' } }), - generateChannel({ channel: { id: 'channel5' } }), - ], - predefined_filter: { - name: 'messaging_channels', - filter: { archived: false }, - sort: [{ field: 'pinned_at', direction: -1 }], - }, - metadata: {} as RequestMetadata, - }); - - await channelManager.queryChannels({ - filter_conditions: {}, - sort: [], - predefined_filter: 'messaging_channels', - limit: 2, - offset: 0, - }); - await channelManager.loadNext(); - setChannelsStub.mockClear(); - setChannelMembership('channel2', { - archived_at: '2024-01-15T10:30:00Z', - }); - - client.dispatchEvent({ - type: 'message.new', - channel_type: 'messaging', - channel_id: 'channel2', - } as EventPayload<'message.new'>); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('clears resolved predefined response metadata when switching to a non-predefined query', async () => { - vi.spyOn(client, 'queryChannels') - .mockResolvedValueOnce({ - duration: '0.01s', - channels: channelsResponse, - predefined_filter: { - name: 'messaging_channels', - filter: { archived: false }, - sort: [{ field: 'pinned_at', direction: -1 }], - }, - metadata: {} as RequestMetadata, - }) - .mockResolvedValueOnce({ - duration: '0.01s', - channels: channelsResponse, - metadata: {} as RequestMetadata, - }); - - await channelManager.queryChannels({ - filter_conditions: {}, - sort: [], - predefined_filter: 'messaging_channels', - }); - await channelManager.queryChannels({ - filter_conditions: {}, - sort: [], - limit: 10, - }); - setChannelsStub.mockClear(); - setChannelMembership('channel2', { - archived_at: '2024-01-15T10:30:00Z', - }); - - client.dispatchEvent({ - type: 'message.new', - channel_type: 'messaging', - channel_id: 'channel2', - } as EventPayload<'message.new'>); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect( - (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), - ).to.deep.equal(['channel2', 'channel1', 'channel3']); - }); - }); - - describe('channelDeletedHandler, channelHiddenHandler and notificationRemovedFromChannelHandler', () => { - let channelToRemove: ChannelResponse; - - beforeEach(() => { - channelToRemove = channelsResponse[1].channel!; - }); - - ( - [ - 'channel.deleted', - 'channel.hidden', - 'notification.removed_from_channel', - ] as const - ).forEach((eventType) => { - it('should return early if channels is undefined', () => { - channelManager.state.partialNext({ channels: undefined }); - - client.dispatchEvent({ - type: eventType, - cid: channelToRemove.cid, - } as EventPayload); - client.dispatchEvent({ - type: eventType, - channel: channelToRemove, - } as EventPayload); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('should remove the channel when event.cid matches', () => { - client.dispatchEvent({ - type: eventType, - cid: channelToRemove.cid, - } as EventPayload); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - const channels = setChannelsStub.mock.lastCall?.[0] as Channel[]; - - expect(channels.map((c) => c.id)).to.deep.equal(['channel1', 'channel3']); - }); - - it('should remove the channel when event.channel?.cid matches', () => { - client.dispatchEvent({ - type: eventType, - channel: channelToRemove, - } as EventPayload); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect( - (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), - ).to.deep.equal(['channel1', 'channel3']); - }); - - it('should not modify the list if no channels match', () => { - const { channels: prevChannels } = channelManager.state.getLatestValue(); - client.dispatchEvent({ type: eventType, cid: 'channel123' } as EventPayload< - typeof eventType - >); - const { channels: newChannels } = channelManager.state.getLatestValue(); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - expect(prevChannels).to.equal(newChannels); - expect(prevChannels).to.deep.equal(newChannels); - }); - }); - }); - - describe('newMessageHandler', () => { - it('should not update the state early if channels are not defined', () => { - channelManager.state.partialNext({ channels: undefined }); - - client.dispatchEvent({ - type: 'message.new', - channel_type: 'messaging', - channel_id: 'channel2', - } as EventPayload<'message.new'>); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('should not update the state if channel is pinned and sorting considers pinned channels', () => { - const { channels: prevChannels } = channelManager.state.getLatestValue(); - isChannelPinnedStub.mockReturnValueOnce(true); - shouldConsiderPinnedChannelsStub.mockReturnValueOnce(true); - - client.dispatchEvent({ - type: 'message.new', - channel_type: 'messaging', - channel_id: 'channel2', - } as EventPayload<'message.new'>); - - const { channels: newChannels } = channelManager.state.getLatestValue(); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - expect(prevChannels).to.equal(newChannels); - expect(prevChannels).to.deep.equal(newChannels); - }); - - it('should not update the state if channel is archived and sorting considers archived channels, but the filter is false', () => { - const { channels: prevChannels } = channelManager.state.getLatestValue(); - channelManager.state.next((prevState) => ({ - ...prevState, - pagination: { - ...prevState.pagination, - options: { - ...prevState.pagination.options, - filter_conditions: { archived: false }, - }, - }, - })); - isChannelArchivedStub.mockReturnValueOnce(true); - shouldConsiderArchivedChannelsStub.mockReturnValueOnce(true); - - client.dispatchEvent({ - type: 'message.new', - channel_type: 'messaging', - channel_id: 'channel2', - } as EventPayload<'message.new'>); - - const { channels: newChannels } = channelManager.state.getLatestValue(); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - expect(prevChannels).to.equal(newChannels); - expect(prevChannels).to.deep.equal(newChannels); - }); - - it('should not update the state if channel is not archived and sorting considers archived channels, but the filter is true', () => { - const { channels: prevChannels } = channelManager.state.getLatestValue(); - channelManager.state.next((prevState) => ({ - ...prevState, - pagination: { - ...prevState.pagination, - options: { - ...prevState.pagination.options, - filter_conditions: { archived: true }, - }, - }, - })); - isChannelArchivedStub.mockReturnValueOnce(false); - shouldConsiderArchivedChannelsStub.mockReturnValueOnce(true); - - client.dispatchEvent({ - type: 'message.new', - channel_type: 'messaging', - channel_id: 'channel2', - } as EventPayload<'message.new'>); - - const { channels: newChannels } = channelManager.state.getLatestValue(); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - expect(prevChannels).to.equal(newChannels); - expect(prevChannels).to.deep.equal(newChannels); - }); - - it('should not update the state if channelManager.options.lockChannelOrder is true', () => { - const { channels: prevChannels } = channelManager.state.getLatestValue(); - channelManager.setOptions({ lockChannelOrder: true }); - - client.dispatchEvent({ - type: 'message.new', - channel_type: 'messaging', - channel_id: 'channel2', - } as EventPayload<'message.new'>); - - const { channels: newChannels } = channelManager.state.getLatestValue(); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - expect(prevChannels).to.equal(newChannels); - expect(prevChannels).to.deep.equal(newChannels); - - channelManager.setOptions({}); - }); - - it('should not update the state if the channel is not part of the list and allowNotLoadedChannelPromotionForEvent["message.new"] if false', () => { - const { channels: prevChannels } = channelManager.state.getLatestValue(); - isChannelPinnedStub.mockReturnValueOnce(false); - isChannelArchivedStub.mockReturnValueOnce(false); - shouldConsiderArchivedChannelsStub.mockReturnValueOnce(false); - shouldConsiderPinnedChannelsStub.mockReturnValueOnce(false); - channelManager.setOptions({ - allowNotLoadedChannelPromotionForEvent: { - 'channel.visible': true, - 'message.new': false, - 'notification.added_to_channel': true, - 'notification.message_new': true, - }, - }); - - client.dispatchEvent({ - type: 'message.new', - channel_type: 'messaging', - channel_id: 'channel4', - } as EventPayload<'message.new'>); - - const { channels: newChannels } = channelManager.state.getLatestValue(); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - expect(prevChannels).to.equal(newChannels); - expect(prevChannels).to.deep.equal(newChannels); - - channelManager.setOptions({}); - }); - - it('should move the channel upwards if it is not part of the list and allowNotLoadedChannelPromotionForEvent["message.new"] is true', () => { - isChannelPinnedStub.mockReturnValue(false); - isChannelArchivedStub.mockReturnValue(false); - shouldConsiderArchivedChannelsStub.mockReturnValue(false); - shouldConsiderPinnedChannelsStub.mockReturnValue(false); - - const stateBefore = channelManager.state.getLatestValue(); - - client.dispatchEvent({ - type: 'message.new', - channel_type: 'messaging', - channel_id: 'channel4', - } as EventPayload<'message.new'>); - - const stateAfter = channelManager.state.getLatestValue(); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect(promoteChannelSpy).toHaveBeenCalledOnce(); - - expect(stateBefore.channels.map((v) => v.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - expect(stateAfter.channels.map((v) => v.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel4", - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - }); - - it('should move the channel upwards if all conditions allow it', () => { - isChannelPinnedStub.mockReturnValueOnce(false); - isChannelArchivedStub.mockReturnValueOnce(false); - shouldConsiderArchivedChannelsStub.mockReturnValueOnce(false); - shouldConsiderPinnedChannelsStub.mockReturnValueOnce(false); - - const stateBefore = channelManager.state.getLatestValue(); - - client.dispatchEvent({ - type: 'message.new', - channel_type: 'messaging', - channel_id: 'channel2', - } as EventPayload<'message.new'>); - - const stateAfter = channelManager.state.getLatestValue(); - - expect(promoteChannelSpy).toHaveBeenCalledOnce(); - expect(setChannelsStub).toHaveBeenCalledOnce(); - - expect(stateBefore.channels.map((v) => v.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - expect(stateAfter.channels.map((v) => v.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel2", - "messaging:channel1", - "messaging:channel3", - ] - `); - }); - }); - - describe('notificationNewMessageHandler', () => { - let clock: sinon.SinonFakeTimers; - - beforeEach(() => { - clock = sinon.useFakeTimers(); - }); - - afterEach(() => { - clock.restore(); - }); - - it('should not update the state if the event has no id and type', async () => { - client.dispatchEvent({ - type: 'notification.message_new', - channel: {} as unknown as ChannelResponse, - } as EventPayload<'notification.message_new'>); - - await clock.runAllAsync(); - - expect(getAndWatchChannelStub).toHaveBeenCalledTimes(0); - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('should execute getAndWatchChannel if id and type are provided', async () => { - const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); - const newChannel = client.channel( - newChannelResponse.channel!.type, - newChannelResponse.channel!.id, - ); - getAndWatchChannelStub.mockResolvedValue(newChannel); - client.dispatchEvent({ - type: 'notification.message_new', - channel: { type: 'messaging', id: 'channel4' } as unknown as ChannelResponse, - } as EventPayload<'notification.message_new'>); - - await clock.runAllAsync(); - - expect(getAndWatchChannelStub).toHaveBeenCalledOnce(); - expect(getAndWatchChannelStub).toHaveBeenCalledWith({ - client, - id: 'channel4', - type: 'messaging', - }); - }); - - it('should not update the state if channel is archived and filters do not allow it', async () => { - isChannelArchivedStub.mockReturnValue(true); - shouldConsiderArchivedChannelsStub.mockReturnValue(true); - const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); - getAndWatchChannelStub.mockImplementation(async () => - client.channel( - newChannelResponse.channel!.type, - newChannelResponse.channel!.id, - ), - ); - - channelManager.state.next((prevState) => ({ - ...prevState, - pagination: { - ...prevState.pagination, - options: { - ...prevState.pagination.options, - filter_conditions: { archived: false }, - }, - }, - })); - - client.dispatchEvent({ - type: 'notification.message_new', - channel: newChannelResponse.channel, - } as EventPayload<'notification.message_new'>); - - await clock.runAllAsync(); - - expect(getAndWatchChannelStub).toHaveBeenCalled(); - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('should not update the state if channel is not archived and and filters allow it', async () => { - isChannelArchivedStub.mockReturnValueOnce(false); - shouldConsiderArchivedChannelsStub.mockReturnValueOnce(true); - const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); - getAndWatchChannelStub.mockImplementation(async () => - client.channel( - newChannelResponse.channel!.type, - newChannelResponse.channel!.id, - ), - ); - - channelManager.state.next((prevState) => ({ - ...prevState, - pagination: { - ...prevState.pagination, - options: { - ...prevState.pagination.options, - filter_conditions: { archived: true }, - }, - }, - })); - - client.dispatchEvent({ - type: 'notification.message_new', - channel: newChannelResponse.channel as ChannelResponse, - } as EventPayload<'notification.message_new'>); - - await clock.runAllAsync(); - - expect(getAndWatchChannelStub).toHaveBeenCalled(); - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('should not update the state if allowNotLoadedChannelPromotionForEvent["notification.message_new"] is false', async () => { - const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); - const newChannel = client.channel( - newChannelResponse.channel!.type, - newChannelResponse.channel!.id, - ); - getAndWatchChannelStub.mockResolvedValueOnce(newChannel); - channelManager.setOptions({ - allowNotLoadedChannelPromotionForEvent: { - 'channel.visible': true, - 'message.new': true, - 'notification.added_to_channel': true, - 'notification.message_new': false, - }, - }); - client.dispatchEvent({ - type: 'notification.message_new', - channel: { type: 'messaging', id: 'channel4' } as unknown as ChannelResponse, - } as EventPayload<'notification.message_new'>); - - await clock.runAllAsync(); - - expect(getAndWatchChannelStub).toHaveBeenCalled(); - expect(setChannelsStub).toHaveBeenCalledTimes(0); - - channelManager.setOptions({}); - }); - - it('should move channel when all criteria are met', async () => { - const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); - const newChannel = client.channel( - newChannelResponse.channel!.type, - newChannelResponse.channel!.id, - ); - getAndWatchChannelStub.mockResolvedValueOnce(newChannel); - - const stateBefore = channelManager.state.getLatestValue(); - - client.dispatchEvent({ - type: 'notification.message_new', - channel: { type: 'messaging', id: 'channel4' }, - } as EventPayload<'notification.message_new'>); - - await clock.runAllAsync(); - - const stateAfter = channelManager.state.getLatestValue(); - - expect(getAndWatchChannelStub).toHaveBeenCalledOnce(); - expect(promoteChannelSpy).toHaveBeenCalledOnce(); - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect(stateBefore.channels.map((c) => c.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - expect(stateAfter.channels.map((c) => c.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel4", - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - }); - - it('should not add duplicate channels for multiple event invocations', async () => { - const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); - const newChannel = client.channel( - newChannelResponse.channel!.type, - newChannelResponse.channel!.id, - ); - getAndWatchChannelStub.mockResolvedValue(newChannel); - - const stateBefore = channelManager.state.getLatestValue(); - - const event = { - type: 'notification.message_new', - channel: newChannelResponse.channel as ChannelResponse, - } as EventPayload<'notification.message_new'>; - // call the event 3 times - client.dispatchEvent(event); - client.dispatchEvent(event); - client.dispatchEvent(event); - - await clock.runAllAsync(); - - const stateAfter = channelManager.state.getLatestValue(); - - expect(getAndWatchChannelStub.mock.calls.length).to.equal(3); - expect(promoteChannelSpy.mock.calls.length).to.equal(3); - expect(setChannelsStub.mock.calls.length).to.equal(3); - expect(stateBefore.channels.map((c) => c.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - expect(stateAfter.channels.map((c) => c.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel4", - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - }); - }); - - describe('channelVisibleHandler', () => { - let clock: sinon.SinonFakeTimers; - - beforeEach(() => { - clock = sinon.useFakeTimers(); - }); - - afterEach(() => { - clock.restore(); - }); - - it('should not update the state if the event has no id and type', async () => { - client.dispatchEvent({ - type: 'channel.visible', - channel: {}, - } as EventPayload<'channel.visible'>); - - await clock.runAllAsync(); - - expect(getAndWatchChannelStub).toHaveBeenCalledTimes(0); - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('should not update the state if channels is undefined', async () => { - channelManager.state.partialNext({ channels: undefined }); - const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); - getAndWatchChannelStub.mockImplementation(async () => - client.channel( - newChannelResponse.channel!.type, - newChannelResponse.channel!.id, - ), - ); - client.dispatchEvent({ - type: 'channel.visible', - channel_id: newChannelResponse.channel!.id, - channel_type: newChannelResponse.channel!.type, - } as EventPayload<'channel.visible'>); - - await clock.runAllAsync(); - - expect(getAndWatchChannelStub).toHaveBeenCalled(); - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('should not update the state if the channel is archived and filters do not allow it (archived:false)', async () => { - isChannelArchivedStub.mockReturnValueOnce(true); - shouldConsiderArchivedChannelsStub.mockReturnValueOnce(true); - const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); - - getAndWatchChannelStub.mockImplementation(async () => - client.channel( - newChannelResponse.channel!.type, - newChannelResponse.channel!.id, - ), - ); - - channelManager.state.next((prevState) => ({ - ...prevState, - pagination: { - ...prevState.pagination, - options: { - ...prevState.pagination.options, - filter_conditions: { archived: false }, - }, - }, - })); - - client.dispatchEvent({ - type: 'channel.visible', - channel_id: newChannelResponse.channel!.cid, - channel_type: newChannelResponse.channel!.type, - } as EventPayload<'channel.visible'>); - - await clock.runAllAsync(); - - expect(getAndWatchChannelStub).toHaveBeenCalled(); - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('should not update the state if the channel is archived and filters do not allow it (archived:true)', async () => { - isChannelArchivedStub.mockReturnValueOnce(false); - shouldConsiderArchivedChannelsStub.mockReturnValueOnce(true); - - const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); - - getAndWatchChannelStub.mockImplementation(async () => - client.channel( - newChannelResponse.channel!.type, - newChannelResponse.channel!.id, - ), - ); - - channelManager.state.next((prevState) => ({ - ...prevState, - pagination: { - ...prevState.pagination, - options: { - ...prevState.pagination.options, - filter_conditions: { archived: true }, - }, - }, - })); - - client.dispatchEvent({ - type: 'channel.visible', - channel_id: newChannelResponse.channel!.id, - channel_type: newChannelResponse.channel!.type, - } as EventPayload<'channel.visible'>); - - await clock.runAllAsync(); - - expect(getAndWatchChannelStub).toHaveBeenCalled(); - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('should add the channel to the list if all criteria are met', async () => { - const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); - const newChannel = client.channel( - newChannelResponse.channel!.type, - newChannelResponse.channel!.id, - ); - getAndWatchChannelStub.mockResolvedValue(newChannel); - - const stateBefore = channelManager.state.getLatestValue(); - - client.dispatchEvent({ - type: 'channel.visible', - channel_id: 'channel4', - channel_type: 'messaging', - } as EventPayload<'channel.visible'>); - - await clock.runAllAsync(); - - const stateAfter = channelManager.state.getLatestValue(); - - expect(getAndWatchChannelStub).toHaveBeenCalledOnce(); - expect(promoteChannelSpy).toHaveBeenCalledOnce(); - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect(stateBefore.channels.map((c) => c.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - expect(stateAfter.channels.map((c) => c.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel4", - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - }); - }); - - describe('memberUpdatedHandler', () => { - let clock: sinon.SinonFakeTimers; - let dispatchMemberUpdatedEvent: (id?: string) => void; - - beforeEach(() => { - clock = sinon.useFakeTimers(); - dispatchMemberUpdatedEvent = (id?: string) => - client.dispatchEvent({ - type: 'member.updated', - channel_id: id ?? 'channel2', - channel_type: 'messaging', - member: { user: { id: client?.userId ?? 'anonymous' } }, - } as EventPayload<'member.updated'>); - }); - - afterEach(() => { - clock.restore(); - }); - - it('should not update state if event member does not have user or user id does not match', () => { - client.dispatchEvent({ - type: 'member.updated', - channel_id: 'channel2', - channel_type: 'messaging', - member: { user: { id: 'wrongUserID' } }, - } as EventPayload<'member.updated'>); - expect(setChannelsStub).toHaveBeenCalledTimes(0); - - client.dispatchEvent({ - type: 'member.updated', - channel_id: 'channel2', - channel_type: 'messaging', - member: {}, - } as EventPayload<'member.updated'>); - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('should not update state if channel_type or channel_id is not present', () => { - client.dispatchEvent({ - type: 'member.updated', - member: { user: { id: 'user123' } }, - } as EventPayload<'member.updated'>); - expect(setChannelsStub).toHaveBeenCalledTimes(0); - client.dispatchEvent({ - type: 'member.updated', - member: { user: { id: 'user123' } }, - channel_type: 'messaging', - } as EventPayload<'member.updated'>); - expect(setChannelsStub).toHaveBeenCalledTimes(0); - client.dispatchEvent({ - type: 'member.updated', - member: { user: { id: 'user123' } }, - channel_id: 'channel2', - } as EventPayload<'member.updated'>); - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('should not update state early if channels are not available in state', () => { - channelManager.state.partialNext({ channels: undefined }); - dispatchMemberUpdatedEvent(); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('should not update state if options.lockChannelOrder is true', () => { - channelManager.setOptions({ lockChannelOrder: true }); - dispatchMemberUpdatedEvent(); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('should not update state if neither channel pinning nor archiving should not be considered', () => { - shouldConsiderPinnedChannelsStub.mockReturnValueOnce(false); - shouldConsiderArchivedChannelsStub.mockReturnValueOnce(false); - dispatchMemberUpdatedEvent(); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('should update the state if only pinned channels should be considered', () => { - shouldConsiderPinnedChannelsStub.mockReturnValueOnce(true); - shouldConsiderArchivedChannelsStub.mockReturnValueOnce(false); - dispatchMemberUpdatedEvent(); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - }); - - it('should update the state if only archived channels should be considered', () => { - shouldConsiderPinnedChannelsStub.mockReturnValueOnce(false); - shouldConsiderArchivedChannelsStub.mockReturnValueOnce(true); - dispatchMemberUpdatedEvent(); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - }); - - it('should handle archiving correctly', () => { - channelManager.state.next((prevState) => ({ - ...prevState, - pagination: { - ...prevState.pagination, - options: { - ...prevState.pagination.options, - filter_conditions: { archived: true }, - }, - }, - })); - isChannelArchivedStub.mockReturnValueOnce(true); - shouldConsiderArchivedChannelsStub.mockReturnValueOnce(true); - shouldConsiderPinnedChannelsStub.mockReturnValueOnce(true); - dispatchMemberUpdatedEvent(); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect( - (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), - ).to.deep.equal(['channel2', 'channel1', 'channel3']); - }); - - it('should pin channel at the correct position when pinnedAtSort is 1', () => { - isChannelPinnedStub.mockReturnValueOnce(false); - shouldConsiderPinnedChannelsStub.mockReturnValueOnce(true); - findLastPinnedChannelIndexStub.mockReturnValueOnce(0); - extractSortValueStub.mockReturnValueOnce(1); - dispatchMemberUpdatedEvent('channel3'); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect( - (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), - ).to.deep.equal(['channel1', 'channel3', 'channel2']); - }); - - it('should pin channel at the correct position when pinnedAtSort is -1 and the target is not pinned', () => { - isChannelPinnedStub.mockImplementationOnce((c) => c.id === 'channel1'); - shouldConsiderPinnedChannelsStub.mockReturnValueOnce(true); - findLastPinnedChannelIndexStub.mockReturnValueOnce(0); - extractSortValueStub.mockReturnValueOnce(-1); - dispatchMemberUpdatedEvent('channel3'); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect( - (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), - ).to.deep.equal(['channel1', 'channel3', 'channel2']); - }); - - it('should pin channel at the correct position when pinnedAtSort is -1 and the target is pinned', () => { - isChannelPinnedStub.mockImplementationOnce((c) => - ['channel1', 'channel3'].includes(c.id!), - ); - shouldConsiderPinnedChannelsStub.mockReturnValueOnce(true); - findLastPinnedChannelIndexStub.mockReturnValueOnce(0); - extractSortValueStub.mockReturnValueOnce(-1); - dispatchMemberUpdatedEvent('channel3'); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect( - (setChannelsStub.mock.calls[0][0] as Channel[]).map((c) => c.id), - ).to.deep.equal(['channel3', 'channel1', 'channel2']); - }); - - it('should not update state if position of target channel does not change', () => { - isChannelPinnedStub.mockReturnValueOnce(false); - shouldConsiderPinnedChannelsStub.mockReturnValueOnce(true); - findLastPinnedChannelIndexStub.mockReturnValueOnce(0); - extractSortValueStub.mockReturnValueOnce(1); - dispatchMemberUpdatedEvent(); - - const { channels } = channelManager.state.getLatestValue(); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - expect(channels[1].id).to.equal('channel2'); - }); - }); - - describe('notificationAddedToChannelHandler', () => { - let clock: sinon.SinonFakeTimers; - - beforeEach(() => { - clock = sinon.useFakeTimers(); - }); - - afterEach(() => { - clock.restore(); - }); - - it('should not update state if event.channel defaults are missing', async () => { - client.dispatchEvent({ - type: 'notification.added_to_channel', - } as EventPayload<'notification.added_to_channel'>); - await clock.runAllAsync(); - expect(setChannelsStub).toHaveBeenCalledTimes(0); - - client.dispatchEvent({ - type: 'notification.added_to_channel', - channel: { id: '123' } as unknown as ChannelResponse, - } as EventPayload<'notification.added_to_channel'>); - await clock.runAllAsync(); - expect(setChannelsStub).toHaveBeenCalledTimes(0); - }); - - it('should not update state if allowNotLoadedChannelPromotionForEvent["notification.added_to_channel"] is false', async () => { - const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); - const newChannel = client.channel( - newChannelResponse.channel!.type, - newChannelResponse.channel!.id, - ); - getAndWatchChannelStub.mockResolvedValueOnce(newChannel); - channelManager.setOptions({ - allowNotLoadedChannelPromotionForEvent: { - 'channel.visible': true, - 'message.new': true, - 'notification.added_to_channel': false, - 'notification.message_new': true, - }, - }); - client.dispatchEvent({ - type: 'notification.added_to_channel', - channel: { - id: 'channel4', - type: 'messaging', - members: [{ user_id: 'user1' }], - }, - } as EventPayload<'notification.added_to_channel'>); - - await clock.runAllAsync(); - - expect(setChannelsStub).toHaveBeenCalledTimes(0); - channelManager.setOptions({}); - }); - - it('should call getAndWatchChannel with correct parameters', async () => { - const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); - const newChannel = client.channel( - newChannelResponse.channel!.type, - newChannelResponse.channel!.id, - ); - getAndWatchChannelStub.mockResolvedValueOnce(newChannel); - client.dispatchEvent({ - type: 'notification.added_to_channel', - channel: { - id: 'channel4', - type: 'messaging', - members: [{ user_id: 'user1' }], - }, - } as EventPayload<'notification.added_to_channel'>); - - await clock.runAllAsync(); - - expect(getAndWatchChannelStub).toHaveBeenCalledOnce(); - expect(getAndWatchChannelStub.mock.calls[0][0]).to.deep.equal({ - client, - id: 'channel4', - type: 'messaging', - members: ['user1'], - }); - }); - - it('should move the channel upwards when criteria is met', async () => { - const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); - const newChannel = client.channel( - newChannelResponse.channel!.type, - newChannelResponse.channel!.id, - ); - getAndWatchChannelStub.mockResolvedValue(newChannel); - - const stateBefore = channelManager.state.getLatestValue(); - - client.dispatchEvent({ - type: 'notification.added_to_channel', - channel: { - id: 'channel4', - type: 'messaging', - members: [{ user_id: 'user1' }], - }, - } as EventPayload<'notification.added_to_channel'>); - - await clock.runAllAsync(); - - const stateAfter = channelManager.state.getLatestValue(); - - expect(setChannelsStub).toHaveBeenCalledOnce(); - expect(promoteChannelSpy).toHaveBeenCalledOnce(); - expect(stateBefore.channels.map((c) => c.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - expect(stateAfter.channels.map((c) => c.cid)).toMatchInlineSnapshot(` - [ - "messaging:channel4", - "messaging:channel1", - "messaging:channel2", - "messaging:channel3", - ] - `); - }); - }); - }); -}); From 0a1f8c2b5391383398b8406a80dab84f8f1af898 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 5 Aug 2026 14:25:50 +0200 Subject: [PATCH 06/22] test: consolidate test suite --- src/ChannelManager.ts | 9 ++- test/unit/ChannelManager.test.ts | 72 +++++++++++++++++++ .../paginators/ChannelPaginator.test.ts | 17 +++++ 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/ChannelManager.ts b/src/ChannelManager.ts index 278cd7a916..4007319783 100644 --- a/src/ChannelManager.ts +++ b/src/ChannelManager.ts @@ -112,10 +112,13 @@ const removeItem: EventHandlerPipelineHandler = ({ event, ctx: { channelManager }, }) => { - if (!event.cid) return; - const channel = channelManager.client.activeChannels[event.cid]; + // `getCidFromEvent`, not `event.cid`: on `channel.deleted` the cid can arrive nested in + // `event.channel` only, and the legacy ChannelManager removed by `event.cid || event.channel?.cid` + const cid = getCidFromEvent(event); + if (!cid) return; + const channel = channelManager.client.activeChannels[cid]; channelManager.paginators.forEach((paginator) => { - paginator.removeItem({ id: event.cid, item: channel }); + paginator.removeItem({ id: cid, item: channel }); }); }; diff --git a/test/unit/ChannelManager.test.ts b/test/unit/ChannelManager.test.ts index 3042754ebf..dc07838fe4 100644 --- a/test/unit/ChannelManager.test.ts +++ b/test/unit/ChannelManager.test.ts @@ -230,6 +230,26 @@ describe('ChannelManager', () => { expect(channelManager.paginators).toStrictEqual([paginator]); }); + // ported from the legacy suite ("should only invoke event handlers if registerSubscriptions has been + // called" / "should unregister subscriptions if unregisterSubscriptions is called") + it('handles events only while subscribed', async () => { + const handler = vi.fn(); + const channelManager = client.createChannelManager({ + eventHandlers: { 'message.new': [{ handle: handler, id: 'test' }] }, + }); + + client.dispatchEvent({ type: 'message.new', cid: 'messaging:1' }); + await vi.waitFor(() => expect(handler).not.toHaveBeenCalled()); + + channelManager.registerSubscriptions(); + client.dispatchEvent({ type: 'message.new', cid: 'messaging:1' }); + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + + channelManager.unregisterSubscriptions(); + client.dispatchEvent({ type: 'message.new', cid: 'messaging:1' }); + await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + }); + it('ref-counts its subscriptions', () => { const channelManager = client.createChannelManager(); @@ -652,6 +672,26 @@ describe('ChannelManager', () => { }); }); + // ported from the legacy ChannelManager suite, which removed by `event.cid || event.channel?.cid` + it('removes the channel when only event.channel carries the cid', async () => { + const cid = 'messaging:nested-cid'; + const channelManager = new ChannelManager({ client }); + const p = new ChannelPaginator({ client }); + const r = vi.spyOn(p, 'removeItem'); + + channelManager.insertPaginator({ paginator: p }); + channelManager.registerSubscriptions(); + + client.dispatchEvent({ + type: eventType, + channel: { cid, id: 'nested-cid', type: 'messaging' } as ChannelResponse, + }); + + await vi.waitFor(() => { + expect(r).toHaveBeenCalledWith({ id: cid, item: undefined }); + }); + }); + it('tries to remove non-existent channel from all paginators', async () => { const channelManager = new ChannelManager({ client }); const p = new ChannelPaginator({ client }); @@ -667,6 +707,38 @@ describe('ChannelManager', () => { }); }); + // ported from the legacy suite's "predefined filter response metadata" block, which asserted the same + // thing through its WS handlers: a channel the backend-resolved filter excludes must not be promoted + // into the list by an event. + describe('backend-resolved predefined filter', () => { + it('a message.new in an archived channel does not add it to a list the backend filtered to { archived: false }', async () => { + const archived = makeChannel('messaging:archived-1'); + archived.state.membership = { + user: { id: client.userID as string }, + archived_at: '2025-09-03T12:19:39.101089Z', + }; + client.activeChannels[archived.cid] = archived; + + const paginator = new ChannelPaginator({ client }); + // the query reports that the backend applied `{ archived: false }`, which the local filters do not say + vi.spyOn(client, 'queryChannelsAndHydrate').mockResolvedValue({ + channels: [], + duration: '0.1ms', + predefined_filter: { name: 'unarchived', filter: { archived: false } }, + }); + await paginator.toTail(); + + const channelManager = new ChannelManager({ client, paginators: [paginator] }); + channelManager.registerSubscriptions(); + + client.dispatchEvent({ type: 'message.new', cid: archived.cid }); + + await vi.waitFor(() => { + expect(paginator.items).toEqual([]); + }); + }); + }); + describe('event channel.hidden', () => { const seed = (paginator: ChannelPaginator, channels: Channel[]) => paginator.setItems({ diff --git a/test/unit/pagination/paginators/ChannelPaginator.test.ts b/test/unit/pagination/paginators/ChannelPaginator.test.ts index e3c25c53b8..222dc4cbeb 100644 --- a/test/unit/pagination/paginators/ChannelPaginator.test.ts +++ b/test/unit/pagination/paginators/ChannelPaginator.test.ts @@ -1046,6 +1046,23 @@ describe('ChannelPaginator', () => { expect(upsertCidsForQuery).not.toHaveBeenCalled(); }); + // ported from the legacy suite ("continues with normal queryChannels flow if client.user is missing") + it('queries normally without touching the cache when there is no user', async () => { + await setUpOfflineDb({ syncStatus: false }); + client.user = undefined; + const queryChannels = vi + .spyOn(client, 'queryChannelsAndHydrate') + .mockResolvedValue({ channels: [], duration: '0.1ms' }); + const paginator = makePaginator(); + + await paginator.toTail(); + + // no user id means no cache key, so neither the read nor the sync deferral applies + expect(getChannelsForQuery).not.toHaveBeenCalled(); + expect(scheduleSyncStatusChangeCallback).not.toHaveBeenCalled(); + expect(queryChannels).toHaveBeenCalledTimes(1); + }); + describe('while the offline sync is in progress', () => { it('surfaces the cached page and defers the query until the sync completes', async () => { await setUpOfflineDb({ syncStatus: false }); From 000982bbb935e0a8959a27e44e94177bb5420a26 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 5 Aug 2026 14:52:27 +0200 Subject: [PATCH 07/22] docs: remove dead-code and adjust docs --- CLAUDE.md | 3 +- src/index.ts | 1 - src/types.ts | 12 - src/utils.ts | 257 ------------- test/unit/utils.test.ts | 546 --------------------------- v9-to-v10-migration-guide-methods.md | 201 +++++++++- 6 files changed, 202 insertions(+), 818 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6823121978..0b3d64b297 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,8 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth ### Module map of `src/` - **`client.ts` — `StreamChat` facade.** ~5k-line class. Prefer `StreamChat.getInstance(key, secret?, options?)` — the constructor exists for advanced uses but `getInstance` is what `connectUser` warnings and most docs assume. Owns: the axios instance, WS connection lifecycle, `TokenManager`, and a registry of subsystem managers (`threads`, `polls`, `notifications`, `reminders`, `moderation`, `uploadManager`, `messageDeliveryReporter`, plus an optional `offlineDb` injected via `setOfflineDBApi`). New REST endpoints are added here as methods that call `axiosInstance` and return a type from `types.ts`. -- **`channel.ts` (~2.5k lines) + `channel_state.ts` (~1.1k) + `channel_manager.ts` + `channel_batch_updater.ts`** — per-channel object, its in-memory state, and the manager that orchestrates collections of channels (query/sort/filter, pagination, archived/pinned handling). **Messages are NOT stored on `channel.state`.** The message list, thread replies, and pinned messages each live in a paginator — `channel.messagePaginator`, `thread.messagePaginator`, and `channel.pinnedMessagesPaginator` — which are the single source of truth (interval storage + a canonical `ItemIndex`). Read them via `channel.messagePaginator.state.items` / `.getItem(id)` / `.headmostItem` (newest loaded item), and mutate via the paginator (`ingestItem` / `removeItem`), never a legacy `channel.state.addMessageSorted()` / `state.messages` (removed). `channel.state.last_message_at` was **removed**; the channel's latest-message timestamp lives on `channel.messagePaginator.lastMessageAt` (its `aggregateState` store — seeded from `ChannelResponse.last_message_at`, then advanced monotonically as messages are ingested). See `docs/breaking-changes-v14-v15.md`. +- **`channel.ts` (~2.5k lines) + `channel_state.ts` (~1.1k) + `channel_batch_updater.ts`** — per-channel object and its in-memory state. **Messages are NOT stored on `channel.state`.** The message list, thread replies, and pinned messages each live in a paginator — `channel.messagePaginator`, `thread.messagePaginator`, and `channel.pinnedMessagesPaginator` — which are the single source of truth (interval storage + a canonical `ItemIndex`). Read them via `channel.messagePaginator.state.items` / `.getItem(id)` / `.headmostItem` (newest loaded item), and mutate via the paginator (`ingestItem` / `removeItem`), never a legacy `channel.state.addMessageSorted()` / `state.messages` (removed). `channel.state.last_message_at` was **removed**; the channel's latest-message timestamp lives on `channel.messagePaginator.lastMessageAt` (its `aggregateState` store — seeded from `ChannelResponse.last_message_at`, then advanced monotonically as messages are ingested). See `docs/breaking-changes-v14-v15.md`. +- **`ChannelManager.ts`** — channel _lists_. Holds one or more `ChannelPaginator`s (`state.paginators`), keeps them in sync with WS events through an `EventHandlerPipeline` per event type, and arbitrates ownership when a channel matches several lists (`ownershipResolver` / `createPriorityOwnershipResolver`). Replaced the old `channel_manager.ts` (single hand-sorted `state.channels` list with named handler overrides) in v10 — see `v9-to-v10-migration-guide-methods.md`. Filtering and ordering are the paginator's job: `matchesFilter()` runs the filter compiler over `Channel` field resolvers, ordering comes from a comparator compiled from `sort`, and `client.createChannelManager(options?)` is the factory. - **`connection.ts` (`StableWSConnection`) + `connection_fallback.ts` (`WSConnectionFallback`)** — realtime transport. Primary WS implementation does its own 25s ping / 35s health-check loop and reconnects on close/error/offline events; the fallback long-polls over HTTP. The client picks between them based on first-connect outcome; both emit `connection.changed` / `transport.changed` events into the client's local event bus. - **`store.ts` — `StateStore`.** Reactive primitive (see "State and subscription patterns" below). - **`signing.ts` — webhook + token helpers.** Server-side primitives `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `verifySignature` (recent CHA-3071 added compressed-payload support). These are re-exported through `client.ts`. **The HMAC is always computed over the uncompressed JSON bytes** — gzip detection uses the `1f 8b` magic bytes, not headers, so the same handler works whether your platform middleware auto-decompressed or not. `CheckSignature` is deprecated in favor of `verifySignature` purely to fix parameter order; new code should use `verifySignature(body, signature, secret)`. diff --git a/src/index.ts b/src/index.ts index 3a11787349..3015c19258 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,7 +51,6 @@ export { logChatPromiseExecution, localMessageToNewMessagePayload, formatMessage, - promoteChannel, } from './utils'; export { FixedSizeQueueCache } from './utils/FixedSizeQueueCache'; export * from './ChannelManager'; diff --git a/src/types.ts b/src/types.ts index 15a0a017e7..e679cc767e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,3 @@ -import type { Channel } from './channel'; import type { AxiosRequestConfig, AxiosResponse } from 'axios'; import type { StableWSConnection } from './connection'; import type { @@ -976,17 +975,6 @@ export type AIState = | 'AI_STATE_GENERATING' | (string & {}); -export type PromoteChannelParams = { - channels: Array; - channelToMove: Channel; - sort: ChannelSort; - /** - * If the index of the channel within `channels` list which is being moved upwards - * (`channelToMove`) is known, you can supply it to skip extra calculation. - */ - channelToMoveIndexWithinChannels?: number; -}; - /** * An identifier containing information about the downstream SDK using stream-chat. It * is used to resolve the user agent. diff --git a/src/utils.ts b/src/utils.ts index ac3bd6d9fa..b5fe45adea 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,15 +1,10 @@ import FormData from 'form-data'; import type { - ChannelFilters, - ChannelGetOrCreateRequest, - ChannelSort, - ChannelStateResponse, LocalMessage, MessageRequest, MessageResponse, OwnUserBase, OwnUserResponse, - PromoteChannelParams, ReactionGroupResponse, UpdatedMessage, UserResponse, @@ -687,85 +682,6 @@ export const uniqBy = ( }); }; -/** - * A utility object used to prevent duplicate invocation of channel.watch() to be triggered when - * 'notification.message_new' and 'notification.added_to_channel' events arrive at the same time. - */ -const WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL: Record< - string, - Promise | undefined -> = {}; - -type GetChannelParams = { - client: StreamChat; - channel?: Channel; - id?: string; - members?: string[]; - options?: ChannelGetOrCreateRequest; - type?: string; -}; -/** - * Calls channel.watch() if it was not already recently called. Waits for watch promise to resolve even if it was invoked previously. - * If the channel is not passed as a property, it will get it either by its channel.cid or by its members list and do the same. - * - * @param params - The channel query parameters. - * @param params.client - The chat client instance. - * @param params.members - Member user ids used to construct or identify the channel. - * @param params.options - Options forwarded to the underlying channel watch request. - * @param params.type - The channel type. - * @param params.id - The channel id. - * @param params.channel - An existing channel to watch (skips construction from type/id/members). - */ -export const getAndWatchChannel = async ({ - channel, - client, - id, - members, - options, - type, -}: GetChannelParams) => { - if (!channel && !type) { - throw new Error('Channel or channel type have to be provided to query a channel.'); - } - - // unfortunately typescript is not able to infer that if (!channel && !type) === false, then channel or type has to be truthy - - const channelToWatch = - channel || - // `members` are member IDs; the OpenAPI `ChannelData.members` expects member objects. - client.channel(type as string, id, { - members: members?.map((user_id) => ({ user_id })), - }); - - // need to keep as with call to channel.watch the id can be changed from undefined to an actual ID generated server-side - const originalCid = channelToWatch.id - ? channelToWatch.cid - : members && members.length - ? generateChannelTempCid(channelToWatch.type, members) - : undefined; - - if (!originalCid) { - throw new Error( - 'Channel ID or channel members array have to be provided to query a channel.', - ); - } - - const queryPromise = WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid]; - - if (queryPromise) { - await queryPromise; - } else { - try { - WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid] = channelToWatch.watch(options); - await WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid]; - } finally { - delete WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid]; - } - } - - return channelToWatch; -}; - /** * Generates a temporary channel.cid for channels created without ID, as they need to be referenced * by an identifier until the back-end generates the final ID. The cid is generated by its member IDs @@ -781,179 +697,6 @@ export const generateChannelTempCid = (channelType: string, members: string[]) = return `${channelType}:!members-${membersStr}`; }; -/** - * Checks if a channel is pinned or not. Will return true only if channel.state.membership.pinned_at exists. - * - * @param channel - The channel to check. - */ -export const isChannelPinned = (channel: Channel) => { - if (!channel) return false; - - const member = channel.state.membership; - - return !!member?.pinned_at; -}; - -/** - * Checks if a channel is archived or not. Will return true only if channel.state.membership.archived_at exists. - * - * @param channel - The channel to check. - */ -export const isChannelArchived = (channel: Channel) => { - if (!channel) return false; - - const member = channel.state.membership; - - return !!member?.archived_at; -}; - -/** - * A utility that tells us whether we should consider archived channels or not based - * on filters. Will return true only if filters.archived exists and is a boolean value. - * - * @param filters - The channel filters to inspect. - */ -export const shouldConsiderArchivedChannels = (filters: ChannelFilters | undefined) => { - if (!filters) return false; - - return typeof filters.archived === 'boolean'; -}; - -/** - * Extracts the value of the sort parameter at a given index, for a targeted key. Can - * handle both array and object versions of sort. Will return null if the index/key - * combination does not exist. - * - * @param params - The extraction parameters. - * @param params.atIndex - the index at which we'll examine the sort value, if it's an array one - * @param params.sort - the sort value - both array and object notations are accepted - * @param params.targetKey - the target key which needs to exist for the sort at a certain index - */ -export const extractSortValue = ({ - atIndex, - sort, - targetKey, -}: { - atIndex: number; - targetKey: string; - sort?: ChannelSort; -}) => { - if (!sort) return null; - // `ChannelSort` is now `SortParamRequest[]` (`{ field, direction }[]`). Return the `direction` of - // the entry at `atIndex` when its `field` matches `targetKey`, otherwise null. - const option = sort[atIndex] ?? null; - if (!option || option.field !== targetKey) return null; - return option.direction ?? null; -}; - -/** - * Returns true only if `{ pinned_at: -1 }` or `{ pinned_at: 1 }` option is first within the `sort` array. - */ -export const shouldConsiderPinnedChannels = (sort: ChannelSort) => { - const value = findPinnedAtSortOrder({ sort }); - - if (typeof value !== 'number') return false; - - return Math.abs(value) === 1; -}; - -/** - * Checks whether the sort value of type object contains a pinned_at value or if - * an array sort value type has the first value be an object containing pinned_at. - * - * @param params - The sort container. - * @param params.sort - The sort value to inspect for a `pinned_at` order. - */ -export const findPinnedAtSortOrder = ({ sort }: { sort: ChannelSort }) => - extractSortValue({ - atIndex: 0, - sort, - targetKey: 'pinned_at', - }); - -/** - * Finds the index of the last consecutively pinned channel, starting from the start of the - * array. Will not consider any pinned channels after the contiguous subsequence at the - * start of the array. - * - * @param params - The channel list container. - * @param params.channels - The channels to scan from the start of the array. - */ -export const findLastPinnedChannelIndex = ({ channels }: { channels: Channel[] }) => { - let lastPinnedChannelIndex: number | null = null; - - for (const channel of channels) { - if (!isChannelPinned(channel)) break; - - if (typeof lastPinnedChannelIndex === 'number') { - lastPinnedChannelIndex++; - } else { - lastPinnedChannelIndex = 0; - } - } - - return lastPinnedChannelIndex; -}; - -/** - * A utility used to move a channel towards the beginning of a list of channels (promote it to a higher position). It - * considers pinned channels in the process if needed and makes sure to only update the list reference if the list - * should actually change. It will try to move the channel as high as it can within the list. - * - * @param params - The promotion parameters. - * @param params.channels - the list of channels we want to modify - * @param params.channelToMove - the channel we want to promote - * @param params.channelToMoveIndexWithinChannels - optionally, the index of the channel we want to move if we know it (will skip a manual check) - * @param params.sort - the sort value used to check for pinned channels - */ -export const promoteChannel = ({ - channels, - channelToMove, - channelToMoveIndexWithinChannels, - sort, -}: PromoteChannelParams) => { - // get index of channel to move up - const targetChannelIndex = - channelToMoveIndexWithinChannels ?? - channels.findIndex((channel) => channel.cid === channelToMove.cid); - - const targetChannelExistsWithinList = targetChannelIndex >= 0; - const targetChannelAlreadyAtTheTop = targetChannelIndex === 0; - - // pinned channels should not move within the list based on recent activity, channels which - // receive messages and are not pinned should move upwards but only under the last pinned channel - // in the list - const considerPinnedChannels = shouldConsiderPinnedChannels(sort); - const isTargetChannelPinned = isChannelPinned(channelToMove); - - if (targetChannelAlreadyAtTheTop || (considerPinnedChannels && isTargetChannelPinned)) { - return channels; - } - - const newChannels = [...channels]; - - // target channel index is known, remove it from the list - if (targetChannelExistsWithinList) { - newChannels.splice(targetChannelIndex, 1); - } - - // as position of pinned channels has to stay unchanged, we need to - // find last pinned channel in the list to move the target channel after - let lastPinnedChannelIndex: number | null = null; - if (considerPinnedChannels) { - lastPinnedChannelIndex = findLastPinnedChannelIndex({ channels: newChannels }); - } - - // re-insert it at the new place (to specific index if pinned channels are considered) - newChannels.splice( - typeof lastPinnedChannelIndex === 'number' ? lastPinnedChannelIndex + 1 : 0, - 0, - channelToMove, - ); - - return newChannels; -}; - export const isDate = (value: unknown): value is Date => !!(value as Date).getTime; export const isLocalMessage = (message: unknown): message is LocalMessage => diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index f0f5f84726..c0c9698fb1 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -7,7 +7,6 @@ import { generateUser } from './test-utils/generateUser'; import { getClientWithUser } from './test-utils/getClient'; import { - getAndWatchChannel, findIndexInSortedArray, channelHasReadEvents, channelTracksReadLocally, @@ -15,14 +14,6 @@ import { formatMessage, throttle, generateChannelTempCid, - shouldConsiderArchivedChannels, - shouldConsiderPinnedChannels, - isChannelArchived, - isChannelPinned, - findLastPinnedChannelIndex, - findPinnedAtSortOrder, - extractSortValue, - promoteChannel, uniqBy, runDetached, sleep, @@ -208,136 +199,6 @@ describe('findIndexInSortedArray', () => { }); }); -describe('getAndWatchChannel', () => { - let client: StreamChat; - let sandbox: sinon.SinonSandbox; - - beforeEach(async () => { - sandbox = sinon.createSandbox(); - - client = await getClientWithUser(); - - const mockedMembers = [ - generateMember({ user: generateUser() }), - generateMember({ user: generateUser() }), - ]; - const mockedChannelsQueryResponse = [ - ...Array.from({ length: 2 }, () => generateChannel()), - generateChannel({ channel: { type: 'messaging' }, members: mockedMembers }), - ]; - sandbox - .stub(client, 'queryChannels') - .resolves({ channels: mockedChannelsQueryResponse }); - }); - - afterEach(() => { - sandbox.restore(); - }); - - it('should throw an error if neither channel nor type is provided', async () => { - await client.queryChannelsAndHydrate({}); - await expect( - getAndWatchChannel({ client, id: 'test-id', members: [] }), - ).rejects.toThrow('Channel or channel type have to be provided to query a channel.'); - }); - - it('should throw an error if neither channel ID nor members array is provided', async () => { - await client.queryChannelsAndHydrate({}); - await expect( - getAndWatchChannel({ client, type: 'test-type', id: undefined, members: [] }), - ).rejects.toThrow( - 'Channel ID or channel members array have to be provided to query a channel.', - ); - }); - - it('should return an existing channel if provided', async () => { - const channels = await client.queryChannelsAndHydrate({}); - const channel = channels[0]; - const watchStub = sandbox.stub(channel, 'watch'); - const result = await getAndWatchChannel({ - channel, - client, - members: [], - options: {}, - }); - - expect(result).to.equal(channel); - expect(watchStub.calledOnce).to.be.true; - }); - - it('should return the channel if only type and id are provided', async () => { - const channels = await client.queryChannelsAndHydrate({}); - const channel = channels[0]; - const { id, type } = channel; - const watchStub = sandbox.stub(channel, 'watch'); - const channelSpy = sandbox.spy(client, 'channel'); - const result = await getAndWatchChannel({ - client, - type, - id, - options: {}, - }); - - expect(channelSpy.calledOnce).to.be.true; - // @ts-ignore - expect(channelSpy.calledWith(type, id)).to.be.true; - expect(watchStub.calledOnce).to.be.true; - expect(result).to.equal(channel); - }); - - it('should return the channel if only type and members are provided', async () => { - const channels = await client.queryChannelsAndHydrate({}); - const channel = channels[2]; - const { type } = channel; - const members = Object.keys(channel.state.members); - const watchStub = sandbox.stub(channel, 'watch'); - const channelSpy = sandbox.spy(client, 'channel'); - const result = await getAndWatchChannel({ - client, - type, - members, - options: {}, - }); - expect(channelSpy.calledOnce).to.be.true; - expect( - channelSpy.calledWith(type, undefined, { - members: members.map((userId) => ({ user_id: userId })), - }), - ).to.be.true; - expect(watchStub.calledOnce).to.be.true; - expect(result).to.equal(channel); - }); - - it('should not call watch again if a query is already in progress', async () => { - const channels = await client.queryChannelsAndHydrate({}); - const channel = channels[0]; - const { id, type, cid } = channel; - // @ts-ignore - const watchStub = sandbox.stub(channel, 'watch').resolves({}); - - const result = await Promise.all([ - getAndWatchChannel({ - client, - type, - id, - members: [], - options: {}, - }), - getAndWatchChannel({ - client, - type, - id, - members: [], - options: {}, - }), - ]); - - expect(watchStub.calledOnce).to.be.true; - expect(result[0]).to.equal(channel); - expect(result[1]).to.equal(channel); - }); -}); - describe('generateChannelTempCid', () => { it('should return a valid temp cid for valid input', () => { const result = generateChannelTempCid('messaging', ['alice', 'bob']); @@ -360,413 +221,6 @@ describe('generateChannelTempCid', () => { }); }); -describe('Channel pinning and archiving utils', () => { - let client: StreamChat; - let sandbox: sinon.SinonSandbox; - - beforeEach(async () => { - sandbox = sinon.createSandbox(); - client = await getClientWithUser(); - }); - - afterEach(() => { - sandbox.restore(); - }); - - describe('Channel pinning', () => { - it('should return false if channel is null', () => { - expect(isChannelPinned(null as unknown as Channel)).to.be.false; - }); - - it('should return false if pinned_at is undefined', () => { - const channelResponse = generateChannel({ membership: {} }); - client.hydrateActiveChannels([channelResponse]); - const channel = client.channel( - channelResponse.channel.type, - channelResponse.channel.id, - ); - expect(isChannelPinned(channel)).to.be.false; - }); - - it('should return true if pinned_at is set', () => { - const channelResponse = generateChannel({ - membership: { pinned_at: '2024-02-04T12:00:00Z' }, - }); - client.hydrateActiveChannels([channelResponse]); - const channel = client.channel( - channelResponse.channel.type, - channelResponse.channel.id, - ); - expect(isChannelPinned(channel)).to.be.true; - }); - - describe('extractSortValue', () => { - it('should return null if sort is undefined', () => { - expect(extractSortValue({ atIndex: 0, targetKey: 'pinned_at', sort: undefined })) - .to.be.null; - }); - - it('should extract correct sort value from an array', () => { - const sort: ChannelSort = [ - { field: 'pinned_at', direction: -1 }, - { field: 'created_at', direction: 1 }, - ]; - expect(extractSortValue({ atIndex: 0, targetKey: 'pinned_at', sort })).to.equal( - -1, - ); - }); - - it('should return null if key does not match targetKey', () => { - const sort: ChannelSort = [{ field: 'created_at', direction: 1 }]; - expect(extractSortValue({ atIndex: 0, targetKey: 'pinned_at', sort })).to.be.null; - }); - }); - - describe('shouldConsiderPinnedChannels', () => { - it('should return false if sort is undefined', () => { - expect(shouldConsiderPinnedChannels(undefined as any)).to.be.false; - }); - - it('should return false if pinned_at is not a number', () => { - const sort = [{ field: 'pinned_at', direction: 'invalid' }]; - expect(shouldConsiderPinnedChannels(sort as any)).to.be.false; - }); - - it('should return false if pinned_at is not first in sort', () => { - const sort: ChannelSort = [ - { field: 'created_at', direction: 1 }, - { field: 'pinned_at', direction: 1 }, - ]; - expect(shouldConsiderPinnedChannels(sort)).to.be.false; - }); - - it('should return true if pinned_at is 1 or -1 at index 0', () => { - const sort1: ChannelSort = [{ field: 'pinned_at', direction: 1 }]; - const sort2: ChannelSort = [{ field: 'pinned_at', direction: -1 }]; - expect(shouldConsiderPinnedChannels(sort1)).to.be.true; - expect(shouldConsiderPinnedChannels(sort2)).to.be.true; - }); - }); - - describe('findPinnedAtSortOrder', () => { - it('should return null if sort is undefined', () => { - expect(findPinnedAtSortOrder({ sort: null as unknown as ChannelSort })).to.be - .null; - }); - - it('should return null if pinned_at is not present', () => { - const sort: ChannelSort = [{ field: 'created_at', direction: 1 }]; - expect(findPinnedAtSortOrder({ sort })).to.be.null; - }); - - it('should return pinned_at if found in an array', () => { - const sort: ChannelSort = [{ field: 'pinned_at', direction: 1 }]; - expect(findPinnedAtSortOrder({ sort })).to.equal(1); - }); - }); - - describe('findLastPinnedChannelIndex', () => { - it('should return null if no channels are provided', () => { - expect(findLastPinnedChannelIndex({ channels: [] })).to.be.null; - }); - - it('should return null if no channels are pinned', () => { - const channelsResponse = [ - generateChannel({ membership: {} }), - generateChannel({ membership: {} }), - ]; - client.hydrateActiveChannels(channelsResponse); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - expect(findLastPinnedChannelIndex({ channels })).to.be.null; - }); - - it('should return last index of a pinned channel', () => { - const channelsResponse = [ - generateChannel({ membership: { pinned_at: '2024-02-04T12:00:00Z' } }), - generateChannel({ membership: { pinned_at: '2024-02-04T12:01:00Z' } }), - generateChannel({ membership: {} }), - ]; - client.hydrateActiveChannels(channelsResponse); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - - expect(findLastPinnedChannelIndex({ channels })).to.equal(1); - }); - }); - }); - - describe('Channel archiving', () => { - it('should return false if channel is null', () => { - expect(isChannelArchived(null as unknown as Channel)).to.be.false; - }); - - it('should return false if archived_at is undefined', () => { - const channelResponse = generateChannel({ membership: {} }); - client.hydrateActiveChannels([channelResponse]); - const channel = client.channel( - channelResponse.channel.type, - channelResponse.channel.id, - ); - expect(isChannelArchived(channel)).to.be.false; - }); - - it('should return true if archived_at is set', () => { - const channelResponse = generateChannel({ - membership: { archived_at: '2024-02-04T12:00:00Z' }, - }); - client.hydrateActiveChannels([channelResponse]); - const channel = client.channel( - channelResponse.channel.type, - channelResponse.channel.id, - ); - expect(isChannelArchived(channel)).to.be.true; - }); - - it('should return false if filters is null', () => { - expect(shouldConsiderArchivedChannels(null as unknown as ChannelFilters)).to.be - .false; - }); - - it('should return false if filters.archived is missing', () => { - const mockFilters = {}; - expect(shouldConsiderArchivedChannels(mockFilters)).to.be.false; - }); - - it('should return false if filters.archived is not a boolean', () => { - const mockFilters = { archived: 'yes' } as unknown as ChannelFilters; - expect(shouldConsiderArchivedChannels(mockFilters)).to.be.false; - }); - - it('should return true if filters.archived is true', () => { - const mockFilters = { archived: true }; - expect(shouldConsiderArchivedChannels(mockFilters)).to.be.true; - }); - - it('should return true if filters.archived is false', () => { - const mockFilters = { archived: false }; - expect(shouldConsiderArchivedChannels(mockFilters)).to.be.true; - }); - }); -}); - -describe('promoteChannel', () => { - let client: StreamChat; - - beforeEach(async () => { - client = await getClientWithUser(); - }); - - it('should return the original list if the channel is already at the top', () => { - const channelsResponse = [generateChannel(), generateChannel()]; - client.hydrateActiveChannels(channelsResponse); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - const result = promoteChannel({ - channels, - channelToMove: channels[0], - sort: [], - }); - - expect(result).to.deep.equal(channels); - expect(result).to.be.equal(channels); - }); - - it('should return the original list if the channel is pinned and pinned channels should be considered', () => { - const channelsResponse = [ - generateChannel({ membership: { pinned_at: '2024-02-04T12:00:00Z' } }), - generateChannel({ membership: { pinned_at: '2024-02-04T12:01:00Z' } }), - ]; - client.hydrateActiveChannels(channelsResponse); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - const channelToMove = channels[1]; - - const result = promoteChannel({ - channels, - channelToMove, - sort: [{ field: 'pinned_at', direction: 1 }], - }); - - expect(result).to.deep.equal(channels); - expect(result).to.be.equal(channels); - }); - - it('should move a non-pinned channel upwards if it exists in the list', () => { - const channelsResponse = [ - generateChannel({ channel: { id: 'channel1' } }), - generateChannel({ channel: { id: 'channel2' } }), - generateChannel({ channel: { id: 'channel3' } }), - ]; - client.hydrateActiveChannels(channelsResponse); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - const channelToMove = channels[2]; - - const result = promoteChannel({ - channels, - channelToMove, - sort: [], - }); - - expect(result.map((c) => c.id)).to.deep.equal(['channel3', 'channel1', 'channel2']); - expect(result).to.not.equal(channels); - }); - - it('should correctly move a non-pinned channel if its index is provided', () => { - const channelsResponse = [ - generateChannel({ channel: { id: 'channel1' } }), - generateChannel({ channel: { id: 'channel2' } }), - generateChannel({ channel: { id: 'channel3' } }), - ]; - client.hydrateActiveChannels(channelsResponse); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - const channelToMove = channels[2]; - - const result = promoteChannel({ - channels, - channelToMove, - sort: [], - channelToMoveIndexWithinChannels: 2, - }); - - expect(result.map((c) => c.id)).to.deep.equal(['channel3', 'channel1', 'channel2']); - expect(result).to.not.equal(channels); - }); - - it('should move a non-pinned channel upwards if it does not exist in the list', () => { - const channelsResponse = [ - generateChannel({ channel: { id: 'channel1' } }), - generateChannel({ channel: { id: 'channel2' } }), - generateChannel({ channel: { id: 'channel3' } }), - ]; - const newChannel = generateChannel({ channel: { id: 'channel4' } }); - client.hydrateActiveChannels([...channelsResponse, newChannel]); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - const channelToMove = client.channel(newChannel.channel.type, newChannel.channel.id); - - const result = promoteChannel({ - channels, - channelToMove, - sort: [], - }); - - expect(result.map((c) => c.id)).to.deep.equal([ - 'channel4', - 'channel1', - 'channel2', - 'channel3', - ]); - expect(result).to.not.equal(channels); - }); - - it('should correctly move a non-pinned channel upwards if it does not exist and the index is provided', () => { - const channelsResponse = [ - generateChannel({ channel: { id: 'channel1' } }), - generateChannel({ channel: { id: 'channel2' } }), - generateChannel({ channel: { id: 'channel3' } }), - ]; - const newChannel = generateChannel({ channel: { id: 'channel4' } }); - client.hydrateActiveChannels([...channelsResponse, newChannel]); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - const channelToMove = client.channel(newChannel.channel.type, newChannel.channel.id); - - const result = promoteChannel({ - channels, - channelToMove, - sort: [], - channelToMoveIndexWithinChannels: -1, - }); - - expect(result.map((c) => c.id)).to.deep.equal([ - 'channel4', - 'channel1', - 'channel2', - 'channel3', - ]); - expect(result).to.not.equal(channels); - }); - - it('should move the channel just below the last pinned channel if pinned channels are considered', () => { - const channelsResponse = [ - generateChannel({ - channel: { id: 'pinned1' }, - membership: { pinned_at: '2024-02-04T12:00:00Z' }, - }), - generateChannel({ - channel: { id: 'pinned2' }, - membership: { pinned_at: '2024-02-04T12:01:00Z' }, - }), - generateChannel({ channel: { id: 'channel1' } }), - generateChannel({ channel: { id: 'channel2' } }), - ]; - client.hydrateActiveChannels(channelsResponse); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - const channelToMove = channels[3]; - - const result = promoteChannel({ - channels, - channelToMove, - sort: [{ field: 'pinned_at', direction: -1 }], - }); - - expect(result.map((c) => c.id)).to.deep.equal([ - 'pinned1', - 'pinned2', - 'channel2', - 'channel1', - ]); - expect(result).to.not.equal(channels); - }); - - it('should move the channel to the top of the list if pinned channels exist but are not considered', () => { - const channelsResponse = [ - generateChannel({ - channel: { id: 'pinned1' }, - membership: { pinned_at: '2024-02-04T12:01:00Z' }, - }), - generateChannel({ - channel: { id: 'pinned2' }, - membership: { pinned_at: '2024-02-04T12:00:00Z' }, - }), - generateChannel({ channel: { id: 'channel1' } }), - generateChannel({ channel: { id: 'channel2' } }), - ]; - client.hydrateActiveChannels(channelsResponse); - const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), - ); - const channelToMove = channels[2]; - - const result = promoteChannel({ - channels, - channelToMove, - sort: [], - }); - - expect(result.map((c) => c.id)).to.deep.equal([ - 'channel1', - 'pinned1', - 'pinned2', - 'channel2', - ]); - expect(result).to.not.equal(channels); - }); -}); - describe('uniqBy', () => { it('should return an empty array if input is not an array', () => { expect(uniqBy(null, 'id')).to.deep.equal([]); diff --git a/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md index 7cfb1d934c..7270db7033 100644 --- a/v9-to-v10-migration-guide-methods.md +++ b/v9-to-v10-migration-guide-methods.md @@ -434,10 +434,35 @@ Unchanged. Unchanged. `setUserAgent` is still marked `@deprecated` — prefer setting `sdkIdentifier`. -#### `client.createChannelManager` / `client.setOfflineDBApi` / `client.setMessageComposerSetupFunction` +#### `client.setOfflineDBApi` / `client.setMessageComposerSetupFunction` Unchanged (composer setup function is new in v10 but not a rename). +#### `client.createChannelManager` + +Kept, but it builds the **new** `ChannelManager` (see [ChannelManager](#channelmanager) below) and takes the +manager options directly instead of the legacy trio: + +```diff +- const manager = client.createChannelManager({ +- eventHandlerOverrides: { newMessageHandler: (setChannels, event) => { /* … */ } }, +- options: { lockChannelOrder: true }, +- queryChannelsOverride: (options, stateOptions) => client.queryChannelsAndHydrate(options, stateOptions), +- }); +- await manager.queryChannels({ filter_conditions: { members: { $in: [userId] } }, sort, limit: 20 }); ++ const paginator = new ChannelPaginator({ ++ client, ++ filters: { members: { $in: [userId] } }, ++ sort, ++ paginatorOptions: { pageSize: 20, lockItemOrder: true }, ++ }); ++ const manager = client.createChannelManager({ paginators: [paginator] }); ++ manager.registerSubscriptions(); ++ await paginator.toTail(); +``` + +Calling it with no arguments is valid; every option is forwarded to the `ChannelManager` constructor. + #### `client._enrichAxiosOptions` / `client._logApiRequest` / `client._logApiError` / `client._normalizeDate` / `client._setupConnection` Removed. Callers should not rely on these internals; `_setupConnection` was an alias for `openConnection`. @@ -878,6 +903,180 @@ Mostly unchanged. The relevant tweaks: --- +## ChannelManager + +`ChannelManager` was **rewritten**, not renamed: v10's class (`src/ChannelManager.ts`) is the former +`ChannelPaginatorsOrchestrator`, and the v9 `src/channel_manager.ts` is deleted. One manager now holds N +channel lists — each a `ChannelPaginator` — instead of one hand-sorted array. + +### State + +| v9 (`manager.state`) | v10 | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `channels: Channel[]` | `paginator.state.items` | +| `pagination.hasNext` | `paginator.state.hasMoreTail` (`hasNext` getter still exists, deprecated) | +| `pagination.isLoading` | `paginator.state.isLoading` | +| `pagination.isLoadingNext` | removed — one `isLoading` flag; combine with `hasMoreTail` if needed | +| `pagination.options` | the paginator's `filters` / `sort` / `options` / `pageSize` | +| `initialized` | removed — `paginator.state.items === undefined` means "never queried"; `paginator.isInitialized` for an imperative check | +| `error` | `paginator.state.lastQueryError` | +| — | `manager.state.paginators` (the lists this manager drives) | + +### Methods + +| v9 | v10 | +| -------------------------------------------- | ---------------------------------------------------------------------- | +| `manager.queryChannels(request, stateOpts?)` | `paginator.toTail({ reset: 'yes' })` (or `paginator.reload()`) | +| `manager.loadNext()` | `paginator.toTail()` / `paginator.toTailDebounced()` | +| `manager.setChannels(valueOrFactory)` | `paginator.setItems({ valueOrFactory })` | +| `manager.setQueryChannelsRequest(fn)` | `paginatorOptions.doRequest` | +| `manager.setOptions(...)` | per-paginator options (see below) | +| `manager.setEventHandlerOverrides(...)` | `manager.setEventHandlers` / `addEventHandler` / `removeEventHandlers` | +| `manager.registerSubscriptions()` | unchanged, but now returns an unsubscribe and is ref-counted | + +### Options + +| v9 option | v10 | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `lockChannelOrder` | `paginatorOptions.lockItemOrder` | +| `abortInFlightQuery` | removed — a query is never started while one is in flight; `paginator.cancelScheduledQuery()` cancels a debounced one | +| `allowNotLoadedChannelPromotionForEvent` | removed — insert the exported `ignoreEventsForUnknownChannels` handler at the head of the pipeline (`index: 0`) for the event types you want to ignore:
`manager.addEventHandler({ eventType: 'message.new', handle: ignoreEventsForUnknownChannels, id: 'ignore-unknown', index: 0 })`
It stops the chain for any event whose channel is not in `client.activeChannels`. | + +### Event handlers + +The 10 named overrides (`newMessageHandler`, `channelDeletedHandler`, …), each receiving +`(setChannels, event)`, are replaced by an `EventHandlerPipeline` per event type. Handlers receive +`{ event, ctx: { channelManager } }` and can stop the chain by returning `{ action: 'stop' }`: + +```diff +- const manager = client.createChannelManager({ +- eventHandlerOverrides: { +- newMessageHandler: (setChannels, event) => setChannels((channels) => reorder(channels, event)), +- }, +- }); ++ manager.setEventHandlers({ ++ eventType: 'message.new', ++ handlers: [{ handle: ({ event, ctx: { channelManager } }) => { /* … */ }, id: 'my-handler' }], ++ }); +``` + +Default-handler ids are `ChannelManager:default-handler:` — pass them to `removeEventHandlers` +or to `position` when inserting. Unlike v9, `channel.updated` and `channel.truncated` are **not** no-ops by +default (they re-emit the affected lists), and `channel.hidden` re-evaluates the filters instead of removing +the channel outright, so a list filtering `{ hidden: true }` keeps it. + +### Types + +Three separate situations — the first is the dangerous one, because that code still compiles. + +**1. Same name, different shape.** These keep their v9 names but now describe the new class: + +| Type | v9 | v10 | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `ChannelManagerState` | `{ channels: Channel[]; initialized: boolean; pagination: ChannelManagerPagination; error: Error \| undefined }` | `{ paginators: ChannelPaginator[] }` — the list state moved to `paginator.state` (see the table above) | +| `ChannelManagerOptions` | `{ abortInFlightQuery?; allowNotLoadedChannelPromotionForEvent?; lockChannelOrder? }` | `{ client: StreamChat; paginators?: ChannelPaginator[]; eventHandlers?: ChannelManagerEventHandlers; ownershipResolver? }` | + +**2. Replaced by a differently-named type.** The mechanism changed, so this is a rewrite rather than a +find-and-replace: + +| v9 | v10 | +| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `ChannelManagerEventHandlerOverrides` (handler-name → override fn) | `ChannelManagerEventHandlers` (event type → ordered `LabeledEventHandler[]` pipeline) | +| `EventHandlerType` / `EventHandlerOverrideType` / `GenericEventHandlerType` | `EventHandlerPipelineHandler` — receives `{ event, ctx: { channelManager } }` | +| `ChannelSetterParameterType` / `ChannelSetterType` | `SetPaginatorItemsParams['valueOrFactory']` (passed to `paginator.setItems`) | +| `QueryChannelsRequestType` / `QueryChannelsRequestOutput` | `PaginatorOptions['doRequest']` | + +**3. Removed with no replacement:** `ChannelManagerPagination`, `ChannelManagerEventTypes`, +`ChannelManagerEventHandlerNames`, `ExecuteChannelsQueryPayload`, `channelManagerEventToHandlerMapping`, +`DEFAULT_CHANNEL_MANAGER_OPTIONS`, `DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS`. + +### Coming from a v10 release candidate? + +`10.0.0-rc.1` and earlier shipped this class as **`ChannelPaginatorsOrchestrator`**. If you are upgrading +from an RC rather than from v9, the change is a pure rename: + +| RC | v10 final | +| ----------------------------------------------------- | ----------------------------------- | +| `ChannelPaginatorsOrchestrator` | `ChannelManager` | +| `ChannelPaginatorsOrchestratorState` | `ChannelManagerState` | +| `ChannelPaginatorsOrchestratorOptions` | `ChannelManagerOptions` | +| `ChannelPaginatorsOrchestratorEventHandlers` | `ChannelManagerEventHandlers` | +| `ChannelPaginatorsOrchestratorEventHandlerContext` | `ChannelManagerEventHandlerContext` | +| handler ctx key `{ orchestrator }` | `{ channelManager }` | +| ids `ChannelPaginatorsOrchestrator:default-handler:*` | `ChannelManager:default-handler:*` | +| module `stream-chat` (unchanged) | `stream-chat` (unchanged) | + +Nothing else in the RC API changed, and no deprecated alias is exported — the old names are gone. + +### Removed helpers (were exported from `stream-chat`) + +`promoteChannel`, `findLastPinnedChannelIndex`, `findPinnedAtSortOrder`, `shouldConsiderPinnedChannels`, +`shouldConsiderArchivedChannels`, `extractSortValue`, `isChannelPinned`, `isChannelArchived`, +`getAndWatchChannel` and the `PromoteChannelParams` type existed only to serve the v9 manager's +hand-written ordering. Replacements: + +| Removed | Use instead | +| --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `promoteChannel`, `findLastPinnedChannelIndex`, `findPinnedAtSortOrder`, `shouldConsiderPinnedChannels`, `extractSortValue` | nothing — stop reordering the list yourself; see [If you called `promoteChannel`](#if-you-called-promotechannel) | +| `isChannelPinned`, `isChannelArchived`, `shouldConsiderArchivedChannels` | `paginator.matchesFilter(channel)` with `{ pinned: true }` / `{ archived: true }` filters | +| `getAndWatchChannel` | `client.channel(type, id).watch()`. The SDK's own helper (`getChannel`, which additionally coalesces concurrent watches of the same cid) stays internal. | + +#### If you called `promoteChannel` + +In v9 the list was a plain array you reordered by hand: `promoteChannel` moved a channel to the top, and +the pinned-channel helpers existed to stop it from jumping above the pinned block. In v10 **you do not +reorder the list at all** — position is derived from `sort` by a comparator the paginator compiles, and +every `ingestItem` re-inserts the channel where that comparator says it belongs. + +So the replacement is a sort, declared once: + +```ts +const paginator = new ChannelPaginator({ + client, + filters: { members: { $in: [userId] } }, + // pinned channels form a contiguous block at the top, most recently pinned first; + // everything else follows, most recent activity first + sort: [ + { field: 'pinned_at', direction: -1 }, + { field: 'last_message_at', direction: -1 }, + ], +}); +``` + +Why that puts pins on top: an unpinned channel has no `pinned_at`, and the comparator sorts +missing values to the tail **regardless of direction** — so a leading `pinned_at` splits the list into +"pinned, then the rest", and each group is ordered by the next sort term. Note this only holds while +`pinned_at` is the _first_ term; with anything ahead of it, that term dominates and the pins are no longer a +block. + +Three consequences for code that used to call `promoteChannel` on a WS event: + +- **New message in a loaded channel** — nothing to do. `channel.messagePaginator.lastMessageAt` advances + when the message is ingested, so the next `ingestItem` (which the manager's default handlers perform) + places the channel correctly. +- **Surfacing a channel that is not in the list** — a search result, a freshly created DM — use + `manager.ingestChannel(channel)`. It routes the channel into every list whose filter it matches (honoring + the ownership resolver) and removes it from those it no longer matches. +- **Surfacing a channel whose sort key did not change** — use `paginator.boost(channel.cid, { ttlMs })`. + A boost outranks the sort for that one item until it expires. It is deliberately not pin-aware: if a list + should keep its pins on top, do not boost, and let the sort decide. + +### Paginator method rename + +`buildFilters()` is gone from the whole paginator stack, because it meant "request filters" on +`ChannelPaginator` and "matching filters" on the base/message paginators: + +- **`ChannelPaginator.buildQueryFilters()`** — filters sent to the server (and used as the offline-db cache + key). +- **`buildMatchFilters()`** (on `BasePaginator`, `ChannelPaginator`, `MessageIntervalPaginator`, + `PinnedMessagePaginator`) — filters items are matched against locally, consumed by `matchesFilter()`. + +`ChannelQueryShape` is now the `queryChannels` request itself (`filter_conditions`, `sort`, `limit`, …) +plus `stateOptions`, rather than a `{ filters, sort, options }` wrapper — relevant if you implement +`paginatorOptions.doRequest`. + +--- + ## Moderation `Moderation` now `extends ModerationApi`. All complex admin methods were removed; the kept methods have positional-param renames only. From 22d28b41d244ebb4089fe6ea68283d6103ad9329 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 5 Aug 2026 15:45:26 +0200 Subject: [PATCH 08/22] test: fix failing test --- test/unit/pagination/paginators/ChannelPaginator.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/unit/pagination/paginators/ChannelPaginator.test.ts b/test/unit/pagination/paginators/ChannelPaginator.test.ts index 222dc4cbeb..d1aea7874a 100644 --- a/test/unit/pagination/paginators/ChannelPaginator.test.ts +++ b/test/unit/pagination/paginators/ChannelPaginator.test.ts @@ -1015,8 +1015,12 @@ describe('ChannelPaginator', () => { await paginator.toTail(); upsertCidsForQuery.mockClear(); - // channelB receives a newer message and moves to the top - a reorder no query performed + // channelB receives a newer message and moves to the top - a reorder with no query performed. + // Mirrors what the manager's `updateLists` does: boost, then ingest. The boost is what moves the + // channel — `ingestItem` alone cannot, because the mutated `Channel` is the same object the + // paginator already holds, so it has no previous sort key to relocate from. setLastMessageAt(channelB, new Date('1972-01-01T00:00:00.000Z')); + paginator.boost(channelB.cid, { seq: paginator.maxBoostSeq + 1 }); paginator.ingestItem(channelB); expect(upsertCidsForQuery).toHaveBeenCalledWith( From 7b1929c1e8534b680793b0dd8b33f6c848e38bdb Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 17 Aug 2026 10:35:21 +0200 Subject: [PATCH 09/22] feat: add instance configuration service --- CLAUDE.md | 2 +- README.md | 2 +- docs/instance-configuration.md | 925 ++++++++++++++++++ src/CooldownTimer.ts | 23 + src/LiveLocationManager.ts | 37 +- src/channel.ts | 127 ++- src/client.ts | 200 +++- .../InstanceConfigurationService.ts | 441 ++++++++- .../applyInstanceConfiguration.ts | 189 ++++ src/configuration/index.ts | 8 + src/configuration/serverAuthority.ts | 146 +++ src/configuration/shape.ts | 524 ++++++++++ src/configuration/types.ts | 337 +++++-- src/index.ts | 56 +- src/logger.ts | 1 + src/messageComposer/LocationComposer.ts | 5 +- .../configuration/configuration.ts | 13 +- src/messageComposer/configuration/types.ts | 6 + src/messageComposer/messageComposer.ts | 281 ++++-- .../MessageDeliveryReporter.ts | 112 ++- src/messageOperations/MessageOperations.ts | 39 +- src/notifications/NotificationManager.ts | 24 +- src/pagination/paginators/BasePaginator.ts | 198 +++- .../paginators/MessageIntervalPaginator.ts | 38 +- src/pagination/paginators/MessagePaginator.ts | 27 + .../paginators/PinnedMessagePaginator.ts | 63 +- src/pagination/utility.normalization.ts | 19 +- src/reminders/ReminderManager.ts | 8 + src/search/SearchController.ts | 26 +- src/thread.ts | 75 ++ src/thread_manager.ts | 35 +- src/types.ts | 15 + src/utils/copyConfigPatch.ts | 45 + src/utils/deepFreezeConfig.ts | 33 + src/utils/objectPath.ts | 67 ++ test/unit/CooldownTimer.test.ts | 66 ++ .../MessageComposer/LocationComposer.test.ts | 1 + .../MessageComposer/messageComposer.test.ts | 6 +- test/unit/channel.test.js | 12 +- test/unit/client.construction.test.ts | 4 +- test/unit/client.test.js | 31 +- .../InstanceConfigurationService.test.ts | 333 +++++++ .../applyInstanceConfiguration.test.ts | 386 ++++++++ .../unit/configuration/channel.config.test.ts | 245 +++++ test/unit/configuration/client.config.test.ts | 149 +++ .../configuration/configBoundaries.test.ts | 156 +++ .../configuration/configPublishing.test.ts | 214 ++++ test/unit/configuration/configShape.test.ts | 139 +++ .../configState.unification.test.ts | 225 +++++ .../configuration/configurableInTree.test.ts | 204 ++++ .../instanceConfiguration.integration.test.ts | 543 ++++++++++ .../messagePaginator.config.test.ts | 266 +++++ .../configuration/resolutionOrder.test.ts | 172 ++++ .../configuration/serverAuthority.test.ts | 511 ++++++++++ test/unit/configuration/thread.config.test.ts | 194 ++++ .../MessageDeliveryReporter.test.ts | 14 +- .../BasePaginator.stateThrottle.test.ts | 103 ++ .../paginator.initializeConfig.test.ts | 201 ++++ .../utility.normalization.dotPath.test.ts | 84 ++ test/unit/utils/objectPath.test.ts | 70 ++ 60 files changed, 8131 insertions(+), 345 deletions(-) create mode 100644 docs/instance-configuration.md create mode 100644 src/configuration/applyInstanceConfiguration.ts create mode 100644 src/configuration/serverAuthority.ts create mode 100644 src/configuration/shape.ts create mode 100644 src/utils/copyConfigPatch.ts create mode 100644 src/utils/deepFreezeConfig.ts create mode 100644 src/utils/objectPath.ts create mode 100644 test/unit/configuration/InstanceConfigurationService.test.ts create mode 100644 test/unit/configuration/applyInstanceConfiguration.test.ts create mode 100644 test/unit/configuration/channel.config.test.ts create mode 100644 test/unit/configuration/client.config.test.ts create mode 100644 test/unit/configuration/configBoundaries.test.ts create mode 100644 test/unit/configuration/configPublishing.test.ts create mode 100644 test/unit/configuration/configShape.test.ts create mode 100644 test/unit/configuration/configState.unification.test.ts create mode 100644 test/unit/configuration/configurableInTree.test.ts create mode 100644 test/unit/configuration/instanceConfiguration.integration.test.ts create mode 100644 test/unit/configuration/messagePaginator.config.test.ts create mode 100644 test/unit/configuration/resolutionOrder.test.ts create mode 100644 test/unit/configuration/serverAuthority.test.ts create mode 100644 test/unit/configuration/thread.config.test.ts create mode 100644 test/unit/pagination/BasePaginator.stateThrottle.test.ts create mode 100644 test/unit/pagination/paginator.initializeConfig.test.ts create mode 100644 test/unit/pagination/utility.normalization.dotPath.test.ts create mode 100644 test/unit/utils/objectPath.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 40e9fc2d0b..225e05580b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,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..73755859de --- /dev/null +++ b/docs/instance-configuration.md @@ -0,0 +1,925 @@ +# 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.channelConfigsByType`.** The latter is an internal cache of the +> **server-provided channel-type configuration**, keyed by channel type. It is not part of the supported +> surface — read server config through `channel.getConfig()`. `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 setters, one open key space + +`setConfig(key, subtree)` accepts **any** key, so a class of your own participates without changing +this package. `set(tree)` is a typed contract and rejects top-level keys it does not know — which is +what makes a typo in the whole-tree form a compile error rather than a silent no-op. To use a custom key +with `set`, augment `InstanceConfigTree` (see [Custom keys](#6-custom-keys)). + +--- + +## 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 it comes from | +| --- | ----------------------------- | ------------------------------------------ | ---------------------------------------------------- | +| 1 | **Package defaults** | every instance | `DEFAULT_*_CONFIG` constants | +| 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 3 is worth one caveat.** Whether a construction argument beats the declarative tree depends on who +supplies it, and the two cases genuinely differ: + +- `MessageComposer` puts its constructor `config` **after** the declarative tree — that argument comes from + an integrator building a composer deliberately, so it is the more specific intent. +- `BasePaginator` puts its constructor options **before** the declarative tree — for `channel.messagePaginator` + and friends those options are the _SDK's own_ construction values (`channel.ts` supplies them), so your + declarative configuration should override them. + +Both orders are right for their case, but they are not the same order. If you construct a paginator +yourself, expect the declarative tree to win. + +### 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()`. + +Built-in keys only: a key you registered through module augmentation has no entry, so merge in +`client.config.getTree()` if you need those too. + +### 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 — `MessageComposer`, every paginator, `MessageOperations`, +`client.notifications`, `client.reminders`, `client.threads`, `client.messageDeliveryReporter`, +`SearchController`, `LiveLocationManager` — with two exceptions: + +| entity | `configState` | `config` | `updateConfig` | +| ------------------- | ------------- | -------- | -------------- | +| everything else | yes | yes | yes | +| `Channel`, `Thread` | yes | — | — | + +`Channel` and `Thread` are deliberately left with the store alone, because **`channel.getConfig()` already +means something else** — it returns the channel _type_'s server-side configuration (`shared_locations`, +`max_message_length`, the command list). A `channel.config` beside it would read as the same thing in getter +form while returning `{ requestHandlers }`, and nothing would catch the confusion: both names resolve, both +return a plausible object. Their instance configuration is one field wide and its only writer wants the store +anyway, so the getter would exist purely to make this table square. Read it as +`channel.configState.getLatestValue()`. + +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`** deep-freezes 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 `location` and `text` are copied on every single resolution + because the server's restrictions and upper bounds name them, which left the two most-configured subtrees + writable. +- **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 } } } } +``` + +Custom keys are included. 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.getConfig()?.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. + +**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`. + +### Requested vs effective + +Because the mechanisms differ, reading `config` means different things per feature: + +```ts +composer.config.location.enabled; // effective — the server value was merged in +composer.config.linkPreviews.enabled; // requested — the server check lives in the getter +composer.linkPreviewsManager.enabled; // effective (server && requested) +``` + +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.** +For the guarded features, a getter or an explicit check is what tells you the effective answer. 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. + +--- + +## 6. Custom keys + +The key space is open, so a class of your own — or a downstream SDK's — can use the same mechanism. +Augment both interfaces, then wire the class with the exported helper: + +```ts +import { applyInstanceConfiguration, type StreamChat } from 'stream-chat'; + +class MyWidget { + config = { pollIntervalMs: 5_000, theme: 'light' as 'light' | 'dark' }; + private unsubscribe: () => void; + + constructor(private client: StreamChat) { + this.unsubscribe = applyInstanceConfiguration({ + args: { widget: this }, + config: client.config, + key: 'myWidget', + applyConfig: (next) => Object.assign(this.config, next), + reinitializeConfig: () => this.initializeConfig(), + }); + } + + /** Re-derives from current inputs — what `client.config.reset()` calls. */ + initializeConfig() { + this.config = { pollIntervalMs: 5_000, theme: 'light' }; + Object.assign(this.config, this.client.config.getConfig('myWidget') ?? {}); + } + + destroy() { + this.unsubscribe(); + } +} + +declare module 'stream-chat' { + interface InstanceSetupFunctionArgs { + myWidget: { widget: MyWidget }; + } + interface InstanceConfigTree { + myWidget: { pollIntervalMs?: number; theme?: 'light' | 'dark' }; + } +} +``` + +Then configure it exactly like a built-in key: + +```ts +client.config.set({ myWidget: { pollIntervalMs: 1_000 } }); +client.config.setSetupFunction('myWidget', ({ widget }) => widget.onUpdate(handler)); +``` + +`applyInstanceConfiguration` gives you the same guarantees the built-ins have — immediate application, +teardown before re-apply, error containment — so do not hand-roll the subscription. + +**The cost of an open key space:** a typo is a valid custom key. `setSetupFunction('cahnnel', fn)` cannot +be rejected without breaking extensibility, so it silently does nothing. The SDK logs at debug level when +a function is registered for a key that is neither built-in nor has a subscriber. Using `set(tree)` +instead of `setConfig` gives you a compile error for the same mistake. + +--- + +## 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.getConfig()` | + +`client.configs` is also gone — it _did_ ship, but keyed by cid, and it is now keyed by channel type. An +alias would let `client.configs[cid]` return `undefined` instead of failing, so the name was removed to +keep the break loud. Read server channel configuration through `channel.getConfig()`. + +`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 service 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..058d6e096c 100644 --- a/src/CooldownTimer.ts +++ b/src/CooldownTimer.ts @@ -64,6 +64,20 @@ export class CooldownTimer extends WithSubscriptions { return this.state.getLatestValue().ownLatestMessageDate; } + /** + * Opt-in event handling for a timer driven on its own. + * + * **Nothing in this package calls this**, and that is deliberate rather than an oversight: the owning + * `Channel` refreshes the timer imperatively from `query()`, from its `message.new` and `channel.updated` + * handlers, and from `updatePartial()`. Each subscription below therefore duplicates a refresh the + * channel already performs — `channel.updated` unconditionally, which is broader than the guard here. + * + * So do not read a subscription here as the thing that makes a case work. It shipped in v9.50.3 + * unregistered too, and a `capabilities.changed` handler was added here on the assumption it closed a + * gap; the gap was real but the fix was inert, and it is `Channel.updatePartial` that closes it. + * + * Kept because it is released surface an integrator can still use to drive a timer the channel does not. + */ public registerSubscriptions = () => { this.incrementRefCount(); if (this.hasSubscriptions) return; @@ -84,6 +98,15 @@ export class CooldownTimer extends WithSubscriptions { this.refresh(); }).unsubscribe, ); + + // `canSkipCooldown` derives from `own_capabilities`, which can change without `cooldown` changing — and + // the guard above filters exactly that case out. Unguarded on purpose: `refresh()` already no-ops + // unless one of its inputs actually moved. + this.addUnsubscribeFunction( + this.channel.on('capabilities.changed', () => { + this.refresh(); + }).unsubscribe, + ); }; public setCooldownRemaining = (cooldownRemaining: number) => { diff --git a/src/LiveLocationManager.ts b/src/LiveLocationManager.ts index ec56cdb5c6..c58e9d3cff 100644 --- a/src/LiveLocationManager.ts +++ b/src/LiveLocationManager.ts @@ -59,6 +59,21 @@ 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 = { + minUpdateThrottleMs: UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT, +}; + export class LiveLocationManager extends WithSubscriptions { public state: StateStore; private client: StreamChat; @@ -66,6 +81,12 @@ export class LiveLocationManager extends WithSubscriptions { private _deviceId: string; private watchLocation: WatchLocation; + /** + * Resolved configuration, as a store — the shape every configurable class exposes + * (`configState` / `config` / `updateConfig`). + */ + readonly configState: StateStore; + static symbol = Symbol(LiveLocationManager.name); constructor({ @@ -87,6 +108,19 @@ export class LiveLocationManager extends WithSubscriptions { this._deviceId = getDeviceId(); this.getDeviceId = getDeviceId; this.watchLocation = watchLocation; + this.configState = new StateStore({ + ...DEFAULT_LIVE_LOCATION_MANAGER_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.configState.partialNext(config); } public async init() { @@ -174,8 +208,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 0a023e8ecb..4da6660446 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -8,6 +8,7 @@ import { MessageReceiptsTracker } from './messageDelivery'; import type { ReadStoreReconcileMeta } from './messageDelivery'; import { MessagePaginator, PinnedMessagePaginator } from './pagination/paginators'; import { MessageOperations } from './messageOperations'; +import { DEFAULT_MESSAGE_OPERATIONS_CONFIG } from './messageOperations/MessageOperations'; import { channelHasReadEvents, formatMessage, @@ -17,6 +18,12 @@ import { } from './utils'; import type { StreamChat } from './client'; import { chatLoggerSystem } from './logger'; +import { applyInstanceConfiguration } from './configuration/applyInstanceConfiguration'; +import type { ChannelDeclarativeConfig } from './configuration/types'; +import { + mergeDeclarativeMessageOperationsConfig, + mergeDeclarativePaginatorConfig, +} from './configuration/types'; import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from './constants'; import type { AIState, @@ -61,6 +68,7 @@ import type { } from './types'; import type { RoleName } from './permissions'; import { StateStore } from './store'; +import type { Unsubscribe } from './store'; import type { ChannelMemberRequest as Gen_ChannelMemberRequest, ChannelPushPreferencesResponse as Gen_ChannelPushPreferencesResponse, @@ -183,6 +191,12 @@ export class Channel extends ChannelApi { 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; /** * Creates a `Channel` instance bound to the given chat client. @@ -226,6 +240,19 @@ export class Channel extends ChannelApi { this.isTyping = false; this.disconnected = 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, @@ -234,8 +261,15 @@ 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, + unreadReferencePolicy: messagePaginatorConfig?.unreadReferencePolicy, + paginatorOptions: { declarativeConfig: messagePaginatorConfig }, + }); + this.pinnedMessagesPaginator = new PinnedMessagePaginator({ + channel: this, + paginatorOptions: { declarativeConfig: declarativeConfig?.pinnedMessagesPaginator }, + }); this.messageReceiptsTracker = new MessageReceiptsTracker({ channel: this }); this.messageReceiptsTracker.registerSubscriptions(); @@ -306,6 +340,67 @@ export class Channel extends ChannelApi { }, }, }); + + // 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'], + }); + } + + /** + * 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 { + // Replaces rather than merges: this is a derivation, so a handler dropped from the declarative + // tree must disappear. Anything else writing directly into `configState.requestHandlers` — the + // React SDK's per-component props do — has to re-apply after a re-derivation; see the note in + // `useChannelRequestHandlers`. + this.configState.next({ requestHandlers: declarativeConfig?.requestHandlers }); + + // 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( + 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.updateConfig({ + ...DEFAULT_MESSAGE_OPERATIONS_CONFIG, + ...mergeDeclarativeMessageOperationsConfig( + this.getClient().config.getConfig('messageOperations') ?? undefined, + declarativeConfig?.messageOperations, + ), + }); } /** @@ -327,7 +422,8 @@ export class Channel extends ChannelApi { */ getConfig() { const client = this.getClient(); - return client.configs[this.cid]; + // Keyed by channel type — the config is a property of the type, not of this channel. + return client.channelConfigsByType[this.type]; } _sendMessage(request: Gen_SendMessageRequest) { @@ -726,6 +822,12 @@ export class Channel extends ChannelApi { this._syncStateFromChannelData(this.data, previousData); // If the capabiltities are changed, we trigger the `capabilities.changed` event. if (capabilitiesChanged) { + // `canSkipCooldown` is derived from `own_capabilities` and stored, so it has to be recomputed here. + // This channel drives its cooldown timer — `query()` and the `channel.updated` handler refresh it the + // same way — and this was the one route that announced a capability change without doing so, leaving + // a granted or revoked `skip-slow-mode` unobserved. The timer's own `capabilities.changed` + // subscription does not cover it: nothing registers the timer's subscriptions. + this.cooldownTimer.refresh(); this.getClient().dispatchEvent({ type: 'capabilities.changed', cid: this.cid, @@ -1528,12 +1630,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 @@ -2591,6 +2696,10 @@ export class Channel extends ChannelApi { logger.withExtraTags('_disconnect', this.cid).info('Disconnecting the channel.'); this.disconnected = true; + // 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.messageReceiptsTracker.unregisterSubscriptions(); this.cooldownTimer.clearTimeout(); // Release the store-backed paginators so the message store no longer pins this removed channel diff --git a/src/client.ts b/src/client.ts index be680e1fd8..96316eb9b2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -68,24 +68,30 @@ import { InsightMetrics, postInsights } from './insights'; import { chatLoggerSystem } from './logger'; import { Thread } from './thread'; import { Moderation } from './moderation'; -import { ThreadManager } from './thread_manager'; +import { DEFAULT_THREAD_MANAGER_CONFIG, ThreadManager } from './thread_manager'; import { DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE } from './constants'; import { PollManager } from './poll_manager'; import { EntityStore } from './entityStore/EntityStore'; import { ChannelManager } from './ChannelManager'; -import { MessageDeliveryReporter } from './messageDelivery'; +import { + DEFAULT_MESSAGE_DELIVERY_REPORTER_CONFIG, + MessageDeliveryReporter, +} from './messageDelivery'; import { NotificationManager } from './notifications'; -import { ReminderManager } from './reminders'; +import { DEFAULT_NOTIFICATION_MANAGER_CONFIG } from './notifications/configuration'; +import type { NotificationManagerConfig } from './notifications'; +import { DEFAULT_REMINDER_MANAGER_CONFIG, ReminderManager } from './reminders'; import type { AbstractOfflineDB } from './offline-support'; import { getPendingTaskChannelData } from './offline-support/util'; import { FixedSizeQueueCache } from './utils/FixedSizeQueueCache'; +import { mergeWith } from './utils/mergeWith'; +import { isEqual } from './utils/mergeWith/mergeWithCore'; import type { MessageComposer } from './messageComposer'; -import type { - MessageComposerSetupState, - SetInstanceConfigurationFunctions, -} from './configuration'; +import type { MessageComposerSetupState } from './configuration'; import { InstanceConfigurationService } from './configuration/InstanceConfigurationService'; +import { applyInstanceConfiguration } from './configuration/applyInstanceConfiguration'; import { StateStore } from './store'; +import type { Unsubscribe } from './store'; import type { GetApplicationResponse as Gen_GetApplicationResponse, MarkDeliveredRequest as Gen_MarkDeliveredRequest, @@ -181,7 +187,17 @@ export class StreamChat extends ChatApi { moderation: Moderation; mutedChannels: ChannelMute[]; readonly mutedUsersStore: StateStore<{ mutedUsers: UserMuteResponse[] }>; - readonly configsStore: StateStore; + /** + * Reactive store behind {@link channelConfigsByType}. 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 channelConfigsByTypeStore: StateStore; blockedUsers: StateStore; node: boolean; options: StreamChatOptions; @@ -227,7 +243,16 @@ export class StreamChat extends ChatApi { private cachedUserAgent?: string; readonly messageComposerCache: FixedSizeQueueCache; private nextRequestAbortController: AbortController | null = null; - instanceConfigurationService = new InstanceConfigurationService(); + /** + * Configuration you register for instances the SDK creates on your behalf — channels, threads, + * composers, and the client's own managers. See `InstanceConfigurationService`. + * + * Not to be confused with {@link channelConfigsByType}, which holds the **server-provided channel-type + * configs** keyed by channel type. This one is yours; that one is the backend's. + */ + readonly config = new InstanceConfigurationService(); + /** Teardown for the `'client'` setup function, released by {@link disconnectUser}. */ + private unsubscribeClientConfiguration?: Unsubscribe; /** * Initializes a client. @@ -265,7 +290,7 @@ export class StreamChat extends ChatApi { this.mutedUsersStore = new StateStore<{ mutedUsers: UserMuteResponse[] }>({ mutedUsers: [], }); - this.configsStore = new StateStore<{ configs: Configs }>({ + this.channelConfigsByTypeStore = new StateStore<{ configs: Configs }>({ configs: {}, }); this.blockedUsers = new StateStore({ userIds: [] }); @@ -316,7 +341,7 @@ export class StreamChat extends ChatApi { this.activeChannels = {}; // mapping between channel groups and configs - this.configs = {}; + this.channelConfigsByType = {}; this.persistUserOnConnectionFailure = this.options?.persistUserOnConnectionFailure; // If its a server-side client, then lets initialize the tokenManager, since token will be @@ -337,6 +362,75 @@ 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 service 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.updateConfig({ + ...DEFAULT_REMINDER_MANAGER_CONFIG, + ...config?.reminders, + }); + this.threads.updateConfig({ + ...DEFAULT_THREAD_MANAGER_CONFIG, + ...config?.threads, + }); + this.messageDeliveryReporter.updateConfig({ + ...DEFAULT_MESSAGE_DELIVERY_REPORTER_CONFIG, + ...config?.messageDelivery, + }); + // Deep-merged, not spread: `notifications.durations` is nested and the slice is a `DeepPartial`, so + // `{ durations: { error } }` must keep the three sibling durations rather than replace the object. + this.notifications.updateConfig( + mergeWith( + { ...DEFAULT_NOTIFICATION_MANAGER_CONFIG }, + (config?.notifications ?? {}) as object, + ) as Partial, + ); } get mutedUsers() { @@ -347,12 +441,29 @@ export class StreamChat extends ChatApi { this.mutedUsersStore.next({ mutedUsers }); } - get configs() { - return this.configsStore.getLatestValue().configs; + /** + * Cache of server-provided channel configuration, keyed by **channel type** — the settings are + * defined per type, so one entry serves every channel of that type. + * + * Read it through {@link Channel.getConfig} rather than here. Not to be confused with + * {@link config}, which is the configuration *you* register for SDK-created instances. + * + * This was `client.configs` through v9, keyed by **cid**. There is deliberately no `configs` alias: + * the name survived but its key space did not, so an alias would make `client.configs[cid]` return + * `undefined` instead of failing. Removing the name turns a silent wrong lookup into an obvious one. + * + * Assigning through this setter notifies subscribers; mutating the returned record in place does not. + * Prefer {@link _addChannelConfig}. + * + * @internal + */ + get channelConfigsByType() { + return this.channelConfigsByTypeStore.getLatestValue().configs; } - set configs(configs: Configs) { - this.configsStore.next({ configs }); + /** @internal */ + set channelConfigsByType(configs: Configs) { + this.channelConfigsByTypeStore.next({ configs }); } /** @@ -409,18 +520,13 @@ export class StreamChat extends ChatApi { _hasConnectionID = () => Boolean(this._getConnectionID()); + /** + * @deprecated Use `client.config.setSetupFunction('messageComposer', fn)`. + */ public setMessageComposerSetupFunction = ( setupFunction: MessageComposerSetupState['setupFunction'], ) => { - this.instanceConfigurationService.setSetupFunctions({ - MessageComposer: setupFunction, - }); - }; - - public setInstanceConfigurationFunction = ( - setupFunctions: SetInstanceConfigurationFunctions, - ) => { - this.instanceConfigurationService.setSetupFunctions(setupFunctions); + this.config.setSetupFunction('messageComposer', setupFunction); }; /** @@ -506,6 +612,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(); } /** @@ -649,6 +758,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 @@ -1504,13 +1616,39 @@ export class StreamChat extends ChatApi { this.options.device = device; } - _addChannelConfig({ cid, config }: ChannelResponse) { - if (this._cacheEnabled()) { - this.configs = { - ...this.configs, - [cid]: config, - }; - } + /** + * Caches a channel type's server configuration. + * + * Keyed by **type**, not cid: every field in `ChannelConfigWithInfo` is a channel-*type* setting + * (`automod`, `commands`, `max_message_length`, the feature flags), and `config.name` is the type + * name. Keying by cid stored one identical copy per channel and left channels of an + * already-seen type reporting no config at all until they were themselves queried. + * + * An absent `config` is ignored rather than stored. `ChannelResponse.config` is optional — the + * `notification.message_new` payload is one route that may omit it — and writing `undefined` would + * un-learn* a config already known for the type. Keyed by cid that voided one channel; keyed by type it + * voids every channel of the type, and since the composer reads `getConfig()` for `shared_locations` and + * `max_message_length`, the result is a server restriction silently lifted (**DV-16**). + * + * A config deep-equal to the one already stored is ignored too, which is what keeps a channel query from + * waking every live composer. The API returns a **fresh object** for the same channel type on every + * response, so the store's `===` no-op never applied and the by-type selector in + * `MessageComposer.subscribeChannelConfigChanged` fired on each one. Measured on a 10-channel + * `queryChannels` page with three open composers: 30 configuration re-resolutions and 30 subscriber runs, + * every one of them producing a value identical to the last. Comparing here rather than in the composer + * skips the work as well as the notification, and covers every other reader of this store too. + * + * @internal + */ + _addChannelConfig({ config, type }: Pick) { + if (!config) return; + if (!this._cacheEnabled()) return; + if (isEqual(this.channelConfigsByType[type], config)) return; + + this.channelConfigsByType = { + ...this.channelConfigsByType, + [type]: config, + }; } /** diff --git a/src/configuration/InstanceConfigurationService.ts b/src/configuration/InstanceConfigurationService.ts index c602ea4bb3..7d4a6e11b2 100644 --- a/src/configuration/InstanceConfigurationService.ts +++ b/src/configuration/InstanceConfigurationService.ts @@ -1,73 +1,412 @@ /** - * 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 + * 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`. * - * 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) + * Not to be confused with `client.channelConfigsByType`, which holds the **server-provided channel-type configs**. * - * The setupFunction is a function that is used to set up the instance configuration. + * 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. + * + * Keys are **open**: the four built-ins are typed for autocomplete, but any string works, so a + * downstream SDK or an integrator can register a key for a class of its own. Stores are therefore + * created lazily — a key must work whether the setter or the subscriber arrives first. + * + * 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 type { - ChannelSetupState, - MessageComposerSetupState, - SetInstanceConfigurationFunctions, - SetInstanceConfigurationServiceStates, - StreamChatSetupState, - ThreadSetupState, +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 { + BUILT_IN_INSTANCE_KEYS, + CONSTRUCTION_ONLY_CONFIG_PATHS, + type InstanceConfigOf, + type InstanceConfigState, + type InstanceConfigTree, + type InstanceSetupFunction, + type InstanceSetupKey, + type InstanceSetupState, } from './types'; +import type { DeepPartial } from '../types.utility'; -type InstanceKey = keyof SetInstanceConfigurationServiceStates; +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 service 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 service 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 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], + /** + * **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 InstanceConfigurationService.set} or + * {@link InstanceConfigurationService.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.debugIfKeyLooksUnused(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, subtree as DeepPartial>); + } + } + + /** Registers declarative configuration for one key, deep-merged into what is already there. */ + setConfig( + key: K, + config: DeepPartial>, + ): void { + this.debugIfKeyLooksUnused(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 & Record { + const tree: Record = {}; + + for (const key of this.configStates.keys()) { + const config = this.getConfigState(key).getLatestValue().config; + if (config && Object.keys(config).length > 0) tree[key] = config; } + + return tree as DeepPartial & Record; } - get Channel() { - return this.setupStates.Channel; + // ------------------------------------------------------------------------- + // 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?: InstanceSetupKey): 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).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. + this.getSetupState(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: InstanceSetupKey, 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); + }; } - get MessageComposer() { - return this.setupStates.MessageComposer; + /** @internal */ + hasLiveInstances(key: InstanceSetupKey): boolean { + return (this.liveInstances.get(key)?.size ?? 0) > 0; } - get StreamChat() { - return this.setupStates.StreamChat; + // ------------------------------------------------------------------------- + // Diagnostics + // ------------------------------------------------------------------------- + + /** + * An open key space means a typo — `'cahnnel'` — is a valid custom key that silently does nothing. + * It cannot be rejected without breaking extensibility, and a warning would fire on the legitimate + * "register before the instance subscribes" ordering, so the message stays at debug level. + */ + private debugIfKeyLooksUnused(key: InstanceSetupKey): void { + if ((BUILT_IN_INSTANCE_KEYS as readonly string[]).includes(key)) return; + if (this.hasLiveInstances(key)) return; + logger + .withExtraTags(key) + .debug( + 'Configuration registered for a key that is not built in and has no subscriber yet. This is ' + + 'expected if the owning class subscribes later; otherwise check the key spelling.', + ); } - get Thread() { - return this.setupStates.Thread; + /** + * 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: InstanceSetupKey, + 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/applyInstanceConfiguration.ts b/src/configuration/applyInstanceConfiguration.ts new file mode 100644 index 0000000000..b5543edb68 --- /dev/null +++ b/src/configuration/applyInstanceConfiguration.ts @@ -0,0 +1,189 @@ +import { chatLoggerSystem } from '../logger'; +import type { + ConfiguredInstance, + InstanceConfigurationService, +} from './InstanceConfigurationService'; +import type { + InstanceConfigOf, + InstanceSetupFunctionArgsOf, + InstanceSetupKey, + InstanceSetupTearDownFunction, +} from './types'; +import type { DeepPartial } from '../types.utility'; +import type { Unsubscribe } from '../store'; + +const logger = chatLoggerSystem.getLogger('instance-configuration'); + +export type ApplyInstanceConfigurationParams = { + /** The instance's argument for its setup function — `{ channel }`, `{ composer }`, and so on. */ + args: InstanceSetupFunctionArgsOf; + /** The client's configuration service, i.e. `client.config`. */ + config: InstanceConfigurationService; + key: K; + /** + * 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; + /** + * 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 InstanceSetupKey[]; +}; + +/** + * Subscribes one instance to the configuration registered for its key, and returns the unsubscribe. + * + * This is the single place the semantics live, so every configured instance behaves identically — including one + * written outside this package for a custom key: + * + * - 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. + * + * @example + * ```ts + * class MyWidget { + * private unsubscribe = applyInstanceConfiguration({ + * config: client.config, + * key: 'myWidget', + * args: { widget: this }, + * applyConfig: (next) => Object.assign(this.config, next), + * reinitializeConfig: () => this.initializeConfig(), + * }); + * } + * ``` + */ +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/index.ts b/src/configuration/index.ts index fcb073fefc..e2696177e5 100644 --- a/src/configuration/index.ts +++ b/src/configuration/index.ts @@ -1 +1,9 @@ +export * from './applyInstanceConfiguration'; +export * from './serverAuthority'; +export * from './shape'; export * from './types'; +// The service is reached as `client.config`, never constructed by integrators — export the type only. +export type { + ConfiguredInstance, + InstanceConfigurationService, +} from './InstanceConfigurationService'; diff --git a/src/configuration/serverAuthority.ts b/src/configuration/serverAuthority.ts new file mode 100644 index 0000000000..ff4b84d460 --- /dev/null +++ b/src/configuration/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 + * `getConfig()`, something else might read capabilities — and the answer depends on an instance that + * exists. That is also why this does not live in `InstanceConfigurationService`: 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.getConfig()?.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/configuration/shape.ts b/src/configuration/shape.ts new file mode 100644 index 0000000000..b9278dc289 --- /dev/null +++ b/src/configuration/shape.ts @@ -0,0 +1,524 @@ +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 { NotificationManagerConfig } from '../notifications/types'; +import type { ReminderManagerConfig } from '../reminders/ReminderManager'; +import type { + AttachmentManagerConfig, + CommandsConfig, + DraftsConfiguration, + LinkPreviewsManagerConfig, + LocationComposerConfig, + MessageComposerConfig, + 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 = { + acceptedFiles: { + description: + 'File types offered in the file picker, as extensions or MIME patterns. Empty means no restriction.', + kind: 'value', + type: 'string[]', + }, + 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 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', + }, + 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 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, +}; + +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 InstanceConfigurationService.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 InstanceConfigurationService.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', + }, + 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', + }, + 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..021f6c57ba 100644 --- a/src/configuration/types.ts +++ b/src/configuration/types.ts @@ -1,81 +1,300 @@ import type { StreamChat } from '../client'; 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, ChannelInstanceConfig } from '../channel'; +import type { Thread, ThreadInstanceConfig } 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; +/** + * Maps a configuration key to the argument its setup function receives. + * + * Augment this interface to register a key for a class this package does not know about — the same + * module-augmentation pattern used by the `Custom*Data` interfaces in `custom_types.ts`: + * + * ```ts + * declare module 'stream-chat' { + * interface InstanceSetupFunctionArgs { + * myWidget: { widget: MyWidget }; + * } + * } + * ``` + */ +export interface InstanceSetupFunctionArgs { + channel: { channel: Channel }; + client: { client: StreamChat }; + messageComposer: { composer: MessageComposer }; + thread: { thread: Thread }; +} -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; +/** The four built-in keys, plus any key an integrator or a downstream SDK registers. */ +export type InstanceSetupKey = keyof InstanceSetupFunctionArgs | (string & {}); + +/** + * The keys this package wires itself. Used to scope diagnostics — never to reject a caller's key, + * which would defeat the point of an open key space. + */ +export const BUILT_IN_INSTANCE_KEYS: readonly (keyof InstanceSetupFunctionArgs)[] = [ + 'channel', + 'client', + 'messageComposer', + '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. + */ +const INSTANCE_CONFIG_TREE_KEY_PRESENCE: Record = { + channel: true, + client: true, + messageComposer: true, + messageOperations: true, + messagePaginator: true, + thread: true, }; -export type StreamChatTearDownFunction = () => void; +export const INSTANCE_CONFIG_TREE_KEYS = Object.keys( + INSTANCE_CONFIG_TREE_KEY_PRESENCE, +).sort() as readonly (keyof InstanceConfigTree)[]; + +// --------------------------------------------------------------------------- +// Tier 2 — setup functions +// --------------------------------------------------------------------------- + +export type InstanceSetupFunctionArgsOf = + K extends keyof InstanceSetupFunctionArgs + ? InstanceSetupFunctionArgs[K] + : Record; + +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?: ChannelInstanceConfig['requestHandlers']; +}; -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?: ThreadInstanceConfig['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 interface InstanceConfigTree { + channel: ChannelDeclarativeConfig; + client: ClientDeclarativeConfig; + 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; + thread: ThreadDeclarativeConfig; +} + +export type InstanceConfigOf = K extends keyof InstanceConfigTree + ? InstanceConfigTree[K] + : Record; + +export type InstanceConfigState = { + config: DeepPartial> | null; }; -export type SetupFnOf = - T extends StateStore - ? S extends { setupFunction?: infer F } - ? F - : never - : never; - -export type SetInstanceConfigurationFunctions = { - [K in keyof SetInstanceConfigurationServiceStates]?: SetupFnOf< - SetInstanceConfigurationServiceStates[K] - >; +/** + * 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); + +/** + * 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 the paginators + * expose rebuild methods (`setStateThrottleOptions`, `setDebounceOptions`) that make a late change + * take effect. + */ +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', + ], + }; + +// --------------------------------------------------------------------------- +// Compatibility surface +// --------------------------------------------------------------------------- + +// Only the `MessageComposer` key ever functioned: the `StreamChat`, `Channel` and `Thread` setup +// functions were stored and never invoked, so no working code can have depended on their types. Aliases +// for those were removed rather than deprecated — a type error is the signal that tells someone their +// setup function was dead. v10 is a major, so this is the moment for that. + +/** @deprecated Use {@link InstanceSetupTearDownFunction}. */ +export type MessageComposerTearDownFunction = InstanceSetupTearDownFunction; +/** @deprecated Use `InstanceSetupFunction<'messageComposer'>`. */ +export type MessageComposerSetupFunction = InstanceSetupFunction<'messageComposer'>; +/** @deprecated Use `InstanceSetupState<'messageComposer'>`. */ +export type MessageComposerSetupState = InstanceSetupState<'messageComposer'>; diff --git a/src/index.ts b/src/index.ts index 38ae430cb0..b299f0524c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,53 @@ export * from './client'; export * from './client_state'; export * from './channel'; export * from './channel_state'; -export * from './configuration'; +// Don't use * here: `export *` can break module augmentation of `InstanceSetupFunctionArgs` and +// `InstanceConfigTree`, the same reason the `Custom*Data` interfaces below are listed explicitly. +// https://github.com/microsoft/TypeScript/issues/46617 +export { applyInstanceConfiguration } from './configuration/applyInstanceConfiguration'; +export type { ApplyInstanceConfigurationParams } from './configuration/applyInstanceConfiguration'; +export { + BUILT_IN_INSTANCE_KEYS, + INSTANCE_CONFIG_TREE_KEYS, + CONSTRUCTION_ONLY_CONFIG_PATHS, +} from './configuration/types'; +export type { + ChannelDeclarativeConfig, + ClientDeclarativeConfig, + DeclarativeMessagePaginatorConfig, + DeclarativePaginatorConfig, + InstanceConfigOf, + InstanceConfigState, + InstanceConfigTree, + InstanceSetupFunction, + InstanceSetupFunctionArgs, + InstanceSetupFunctionArgsOf, + InstanceSetupKey, + InstanceSetupState, + InstanceSetupTearDownFunction, + MessageComposerSetupFunction, + MessageComposerSetupState, + MessageComposerTearDownFunction, + ThreadDeclarativeConfig, + UnreadReferencePolicy, +} from './configuration/types'; +export type { + ConfiguredInstance, + InstanceConfigurationService, +} from './configuration/InstanceConfigurationService'; +export { mergeServerRestrictions } from './configuration/serverAuthority'; +export type { + ServerRestrictions, + ServerUpperBounds, +} from './configuration/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'; @@ -28,7 +74,13 @@ export * from './search'; export * from './signing'; export * from './store'; export { Thread } from './thread'; -export type { ThreadState, ThreadReadState, ThreadUserReadState } from './thread'; +export type { + CustomThreadMarkReadRequestFn, + ThreadInstanceConfig, + 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 c30d312948..0ffd315bb5 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -7,6 +7,7 @@ export type ChatLoggerScope = | 'client' | 'connection' | 'connection-fallback' + | 'instance-configuration' | 'message-composer' | 'offline-db' | 'state-store' diff --git a/src/messageComposer/LocationComposer.ts b/src/messageComposer/LocationComposer.ts index 7738113c9a..2bbd7ffa18 100644 --- a/src/messageComposer/LocationComposer.ts +++ b/src/messageComposer/LocationComposer.ts @@ -29,8 +29,6 @@ export type LocationComposerState = { export type LocationComposerSnapshot = LocationComposerState; -const MIN_LIVE_LOCATION_SHARE_DURATION = 60 * 1000; // 1 minute; - const initState = ({ message, }: { @@ -69,8 +67,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/configuration/configuration.ts b/src/messageComposer/configuration/configuration.ts index 9a8ff18c0f..a8b9d327d5 100644 --- a/src/messageComposer/configuration/configuration.ts +++ b/src/messageComposer/configuration/configuration.ts @@ -8,6 +8,7 @@ import type { TextComposerConfig, } from './types'; import { generateUUIDv4 } from '../../utils'; +import { deepFreezeConfig } from '../../utils/deepFreezeConfig'; import { DEFAULT_COMMANDS_CONFIG } from './commands.configuration'; export const DEFAULT_LINK_PREVIEW_MANAGER_CONFIG: LinkPreviewsManagerConfig = { @@ -43,13 +44,21 @@ export const DEFAULT_TEXT_COMPOSER_CONFIG: TextComposerConfig = { 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, text: DEFAULT_TEXT_COMPOSER_CONFIG, -}; +}); diff --git a/src/messageComposer/configuration/types.ts b/src/messageComposer/configuration/types.ts index 193fbe4697..6b7a2c5e70 100644 --- a/src/messageComposer/configuration/types.ts +++ b/src/messageComposer/configuration/types.ts @@ -106,6 +106,12 @@ 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 MessageComposerConfig = { diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index dc16bd16a1..22fa909694 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -15,6 +15,14 @@ import type { Unsubscribe } from '../store'; import { StateStore } from '../store'; import { formatMessage, generateUUIDv4, isLocalMessage } from '../utils'; import { mergeWith } from '../utils/mergeWith'; +import { isEqual } from '../utils/mergeWith/mergeWithCore'; +import { copyConfigPatch } from '../utils/copyConfigPatch'; +import { deepFreezeConfig } from '../utils/deepFreezeConfig'; +import { mergeServerRestrictions } from '../configuration/serverAuthority'; +import type { + ServerRestrictions, + ServerUpperBounds, +} from '../configuration/serverAuthority'; import { Channel } from '../channel'; import { Thread } from '../thread'; import type { @@ -29,6 +37,7 @@ import type { UserResponse, } from '../types'; import { chatLoggerSystem } from '../logger'; +import { applyInstanceConfiguration } from '../configuration/applyInstanceConfiguration'; import { WithSubscriptions } from '../utils/WithSubscriptions'; import type { StreamChat } from '../client'; import type { CommandSendability, MessageComposerConfig } from './configuration/types'; @@ -44,7 +53,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, @@ -189,6 +197,23 @@ export class MessageComposer extends WithSubscriptions { customDataManager: CustomDataManager; private snapshots: MessageComposerSnapshot[] = []; private effectHandlers: MessageComposerEffectHandlers; + /** + * Configuration passed to this composer's constructor, kept so {@link initializeConfig} can + * reproduce the constructor's derivation rather than restoring a snapshot of its result. + * + * Copied on the way in — it is read on *every* resolution, for the composer's whole life, so holding + * the caller's object would let a later mutation of it change resolved configuration silently. Same + * boundary rule as {@link updateConfig} and `InstanceConfigurationService.setConfig`. + */ + private readonly explicitConfig?: DeepPartial; + /** + * Every {@link updateConfig} patch so far, merged in the order they arrived. + * + * Stages 4 and 5 of the resolution order both arrive through `updateConfig`, so this one layer holds a + * setup function's work and a caller's own changes alike — they are equally "asked for", and both have + * to outlive a re-resolution. Cleared by {@link initializeConfig}, which is what a reset means. + */ + private imperativeConfig: DeepPartial = {}; // todo: mediaRecorder: MediaRecorderController; constructor({ @@ -215,43 +240,8 @@ 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< - 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, - ), - ); + this.explicitConfig = config && copyConfigPatch(config); + this.configState = new StateStore(this.resolvedConfig); let message: LocalMessage | DraftMessage | undefined = undefined; if (compositionIsDraftResponse(composition)) { @@ -305,7 +295,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(); } @@ -476,12 +474,171 @@ 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), + // Copied at the boundary, for the same reason `InstanceConfigurationService.setConfig` does it: + // `mergeWith` reuses a source subtree verbatim where the target has nothing, and `imperativeConfig` + // starts empty — so without this a caller's `patch.text` was stored by reference, and a later + // `patch.text.maxLengthOnSend = 5` changed every subsequent resolution with no notification. + this.imperativeConfig = mergeWith(this.imperativeConfig, copyConfigPatch(config)); + this.publishConfig(); + } + + /** + * 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. `getConfig()` is re-read on every call, so a restriction that changes mid-session is picked up + * rather than captured once. + */ + private get serverRestrictions(): ServerRestrictions { + return { location: { enabled: this.channel.getConfig()?.shared_locations } }; + } + + /** + * What this composer has been **asked** for, before the server has any say — stages 1 to 5 of + * `docs/instance-configuration.md` §3, later layers winning: + * + * 1. package defaults; + * 2. the declarative tree for the `messageComposer` key, re-read live so a change is picked up; + * 3. this composer's constructor argument; + * 4. and 5. every patch handed to {@link updateConfig} — which is where a setup function's work and a + * caller's own imperative change both land, in the order they happened. + * + * Keeping this separate from the published configuration is what lets a restriction be *re-applied* + * rather than *accumulated*. Applying restrictions to the previous published result made them + * one-directional: a server `false` written into the config was indistinguishable from a client's own + * `false`, so it either became permanent or overwrote the client's intent, depending on which way the + * call was written (**DV-18**). Resolving from the request every time makes the operation idempotent, + * so neither can happen. + */ + private get requestedConfig(): MessageComposerConfig { + const declarative = (this.client.config.getConfig('messageComposer') ?? + {}) as DeepPartial; + const layers: DeepPartial[] = [ + declarative, + this.explicitConfig ?? {}, + this.imperativeConfig, + ]; + + const requested = layers.reduce( + (resolved, layer) => mergeWith(resolved, layer), + { ...DEFAULT_COMPOSER_CONFIG }, ); + + // `sendValidator` is a function, and the deep merge is not the right tool for choosing between two of + // them — hence the explicit override, given the most specific layer that actually names one. Searched + // from the most specific end, so a later layer that stayed silent does not erase an earlier choice. + const validatorSource = [...layers] + .reverse() + .find((layer) => typeof layer.commands?.sendValidator === 'function'); + + return applyCommandValidatorOverride(requested, validatorSource); + } + + /** + * 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.getConfig()?.max_message_length; + + return { + text: { maxLengthOnEdit: maxMessageLength, maxLengthOnSend: maxMessageLength }, + }; } + /** + * The requested configuration with the server's restrictions applied — the value callers read. + * + * Frozen on the way out, so the "nested writes are caught at runtime" guarantee holds for the whole + * tree rather than for whichever subtrees the merge happened to leave pointing at the frozen defaults. + * It did not: `serverRestrictions` names `location` and `serverUpperBounds` names `text` on *every* + * resolution, so those two were always copied into fresh, writable objects — and they are the two + * subtrees callers actually configure. `composer.config.text.maxLengthOnSend = 5` therefore mutated + * published state while notifying nobody, while the identical write to `drafts` threw. + * + * Freezing here rather than in {@link publishConfig} because the constructor seeds `configState` from + * this getter directly, and a composer that is never re-published would otherwise keep an unfrozen + * value for its whole life. + */ + private get resolvedConfig(): MessageComposerConfig { + return deepFreezeConfig( + mergeServerRestrictions( + this.requestedConfig, + this.serverRestrictions, + this.serverUpperBounds, + ), + ) as MessageComposerConfig; + } + + /** + * 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. In the React SDK that is a re-render for any consumer whose selector returns part of the config + * rather than a scalar. + * + * 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 composers 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 = () => { + const nextConfig = this.resolvedConfig; + if (isEqual(this.configState.getLatestValue(), nextConfig)) return; + this.configState.next(nextConfig); + }; + + /** + * 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.imperativeConfig = {}; + this.publishConfig(); + }; + + /** + * 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() }); }; @@ -597,6 +754,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()); @@ -640,24 +798,31 @@ 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.channelConfigsByType[type]`) 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 `getConfig()` 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. + */ + private subscribeChannelConfigChanged = () => + this.client.channelConfigsByTypeStore.subscribeWithSelector( + ({ configs }) => ({ channelConfig: configs[this.channel.type] }), + () => this.applyServerRestrictions(), + ); private subscribeMessageDeleted = () => this.client.on('message.deleted', (event) => { diff --git a/src/messageDelivery/MessageDeliveryReporter.ts b/src/messageDelivery/MessageDeliveryReporter.ts index ce8a759f0d..a28315370c 100644 --- a/src/messageDelivery/MessageDeliveryReporter.ts +++ b/src/messageDelivery/MessageDeliveryReporter.ts @@ -1,4 +1,5 @@ import type { StreamChat } from '../client'; +import { StateStore } from '../store'; import { Channel } from '../channel'; import type { ThreadUserReadState } from '../thread'; import { Thread } from '../thread'; @@ -14,10 +15,29 @@ import { throttle, userHasReadReceipts } from '../utils'; import { isAPIError, isErrorRetryable } from '../errors'; import type { MarkReadResponse as Gen_MarkReadResponse } from '../gen/models'; -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 = { + 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; @@ -45,14 +65,56 @@ 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; + /** + * Resolved configuration, as a store — the shape every configurable class exposes + * (`configState` / `config` / `updateConfig`). + */ + readonly configState: StateStore; + constructor({ client }: MessageDeliveryReporterOptions) { this.client = client; + this.configState = new StateStore({ + ...DEFAULT_MESSAGE_DELIVERY_REPORTER_CONFIG, + }); + } + + /** 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) { + const { markAsReadThrottleTimeoutMs, ...rest } = config; + if (Object.keys(rest).length) this.configState.partialNext(rest); + if (typeof markAsReadThrottleTimeoutMs === 'number') { + this.setMarkAsReadThrottleOptions({ markAsReadThrottleTimeoutMs }); + } + } + + /** + * 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) => { + if (this.config.markAsReadThrottleTimeoutMs === markAsReadThrottleTimeoutMs) return; + this.configState.partialNext({ markAsReadThrottleTimeoutMs }); + this.throttledMarkRead = this.buildThrottledMarkRead(markAsReadThrottleTimeoutMs); + }; + private get markDeliveredRequestInFlight() { return this.markDeliveredRequestPromise !== null; } @@ -75,13 +137,13 @@ export class MessageDeliveryReporter { } 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; } @@ -103,9 +165,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 }; @@ -340,24 +404,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..897b58fa71 100644 --- a/src/messageOperations/MessageOperations.ts +++ b/src/messageOperations/MessageOperations.ts @@ -1,5 +1,6 @@ // todo: add tests import type { MessageRequest, UpdateMessageOptions } from '../types'; +import { StateStore } from '../store'; import { formatMessage, localMessageToNewMessagePayload } from '../utils'; import { MessageOperationStatePolicy } from './MessageOperationStatePolicy'; import type { @@ -9,8 +10,17 @@ 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 = { + failedSendCacheMaxSize: 100, + failedSendCacheTtlMs: 5 * 60 * 1000, +}; type FailedSendCacheEntry = { message: MessageRequest; @@ -23,9 +33,28 @@ export class MessageOperations { private policy: MessageOperationStatePolicy; private failedSendCache = new Map(); + /** + * Resolved configuration, as a store — the shape every configurable class exposes + * (`configState` / `config` / `updateConfig`). + */ + readonly configState: StateStore; + constructor(ctx: MessageOperationsContext) { this.ctx = ctx; this.policy = new MessageOperationStatePolicy({ ingest: ctx.ingest, get: ctx.get }); + this.configState = new StateStore({ + ...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.configState.partialNext(config); } private normalizeMessage(message: MessageRequest): MessageRequest { @@ -38,7 +67,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 +82,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 +101,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..fd43c7ee96 100644 --- a/src/notifications/NotificationManager.ts +++ b/src/notifications/NotificationManager.ts @@ -12,11 +12,31 @@ import { DEFAULT_NOTIFICATION_MANAGER_CONFIG } from './configuration'; export class NotificationManager { store: StateStore; private timeouts: Map = new Map(); - config: NotificationManagerConfig; + + /** + * 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). + */ + readonly configState: StateStore; constructor(config: Partial = {}) { this.store = new StateStore({ notifications: [] }); - this.config = mergeWith(DEFAULT_NOTIFICATION_MANAGER_CONFIG, config); + this.configState = new StateStore( + mergeWith(DEFAULT_NOTIFICATION_MANAGER_CONFIG, config), + ); + } + + /** + * 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) { + this.configState.next((current) => mergeWith({ ...current }, config as object)); } get notifications() { diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 67118d96c6..5ebbd09a3b 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -319,7 +319,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-installed behaviour + * (`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,16 +416,27 @@ 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 @@ -423,7 +462,30 @@ export abstract class BasePaginator { * active window + pagination status. */ intervalViews: StateStore>; - config: BasePaginatorConfig; + + /** + * 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. + */ + readonly configState: StateStore>; + + /** + * 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 @@ -507,40 +569,26 @@ export abstract class BasePaginator { } protected constructor({ + declarativeConfig, initialCursor, initialOffset, itemIndex, createItemIndex, ...options }: PaginatorOptions = {}) { - this.config = { + this.explicitOptions = { initialCursor, initialOffset, ...options }; + this.configState = new StateStore>({ ...DEFAULT_PAGINATION_OPTIONS, - initialCursor, - initialOffset, - ...options, - }; + ...this.explicitOptions, + ...(declarativeConfig ?? {}), + }); const { debounceMs } = this.config; this.state = new StateStore>({ ...this.initialState, cursor: initialCursor, offset: 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 }, - ); - } + this.setStateThrottleOptions({ stateThrottleMs: this.config.stateThrottleMs }); this.intervalViews = new StateStore>({ logicalHead: [], logicalTail: [], @@ -657,20 +705,37 @@ 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. */ + updateConfig(config: Partial>) { + this.configState.partialNext(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 */ @@ -2400,6 +2465,81 @@ 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 }) => { + this.flushPendingPublishes(); + + // Guarded: `initializeConfig` has already written this value as part of the whole-config `next()`, + // and an unguarded `partialNext` always allocates a new object, so it would emit a second, + // identical notification on every re-derive. + if (this.config.stateThrottleMs !== stateThrottleMs) { + this.updateConfig({ stateThrottleMs } as Partial>); + } + 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. + * Subclasses override this to re-install what *their* constructors install — see + * `PinnedMessagePaginator`, whose `doRequest` and comparators live nowhere else. + * + * Note that a subclass re-installing its wiring publishes a *second* `configState` notification for + * what is logically one re-derivation. Both carry a complete config, so no subscriber sees a + * half-applied state; collapsing them would mean routing every subclass's structural overlay through + * the base derivation, which is a larger change than the duplicate notification justifies. + */ + initializeConfig(declarativeConfig?: DeclarativePaginatorConfig): void { + this.configState.next({ + ...DEFAULT_PAGINATION_OPTIONS, + ...this.explicitOptions, + ...(declarativeConfig ?? {}), + } as BasePaginatorConfig); + + this.setDebounceOptions({ debounceMs: this.config.debounceMs }); + this.setStateThrottleOptions({ stateThrottleMs: this.config.stateThrottleMs }); + } + protected shouldResetStateBeforeQuery( prevQueryShape: unknown | undefined, nextQueryShape: unknown | undefined, diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 75348e423f..1a0f45d18e 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -2,6 +2,7 @@ import type { AnyInterval, CursorDerivator, CursorDeriveResult, + DeclarativePaginatorConfig, Interval, PaginationDirection, PaginationQueryParams, @@ -234,7 +235,6 @@ export class MessageIntervalPaginator extends BasePaginator< })), pageSize: paginatorOptions?.pageSize ?? DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE, }); - this.config.deriveCursor = makeDeriveCursor(this); this.channel = channel; this.parentMessageId = parentMessageId; this._id = id ?? `message-paginator-${generateUUIDv4()}`; @@ -252,18 +252,36 @@ 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]); } + /** + * Cursor derivation and in-memory item ordering, both of which this class installs directly on + * `config` rather than passing as constructor options. Kept in a method so the constructor and + * {@link initializeConfig} install them from one place — a re-derivation that rebuilt `config` from + * options alone would otherwise silently drop both. + */ + protected installIntervalBehaviour(): void { + this.updateConfig({ + 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; + }, + }), + }); + } + + override initializeConfig(declarativeConfig?: DeclarativePaginatorConfig): void { + super.initializeConfig(declarativeConfig); + this.installIntervalBehaviour(); + } + get id() { return this._id; } diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 51519b4d1b..24a2205f35 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -1,4 +1,5 @@ import type { + DeclarativePaginatorConfig, ExecuteQueryReturnValue, Interval, PostQueryReconcileParams, @@ -123,6 +124,13 @@ export class MessagePaginator extends MessageIntervalPaginator { */ readonly aggregateState: StateStore; + /** + * The message list raises `stateThrottleMs` from the base's `undefined` to 500ms. Remembered here so + * `initializeConfig` re-applies it: a bare re-derivation would otherwise inherit the base default and + * silently drop the list's render coalescing. + */ + private readonly subclassDefaults: { stateThrottleMs?: number }; + constructor({ unreadReferencePolicy = 'snapshot', ...options @@ -141,6 +149,9 @@ export class MessagePaginator extends MessageIntervalPaginator { // ancestor), so both the main list and the pinned list share the client-global message store. }); this.unreadReferencePolicy = unreadReferencePolicy; + this.subclassDefaults = { + stateThrottleMs: options.paginatorOptions?.stateThrottleMs ?? 500, + }; this.unreadStateSnapshot = new StateStore({ lastReadAt: null, firstUnreadMessageId: null, @@ -156,6 +167,22 @@ export class MessagePaginator extends MessageIntervalPaginator { }); } + /** + * Re-derives configuration with this subclass's own default folded in. A declarative slice that names + * `stateThrottleMs` still wins — the fallback only fills the gap the base default would leave. + * + * Passed *into* the base derivation rather than re-applied afterwards: `configState` is a store now, so + * a second write would emit a second notification for what is logically one re-derivation. Precedence + * is unchanged, because `subclassDefaults` already resolves to the constructor's explicit value when + * one was given. + */ + override initializeConfig(declarativeConfig?: DeclarativePaginatorConfig): void { + super.initializeConfig({ + stateThrottleMs: this.subclassDefaults.stateThrottleMs, + ...declarativeConfig, + }); + } + /** * Channel-list sort key: the later of the newest loaded message's `created_at` and the server seed. * **Derived** (never stored) so it cannot drift from {@link lastMessage}. `null` until seeded or a diff --git a/src/pagination/paginators/PinnedMessagePaginator.ts b/src/pagination/paginators/PinnedMessagePaginator.ts index a2ed97fe36..869473e114 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 { + DeclarativePaginatorConfig, + PaginatorCursor, + PaginatorOptions, +} from './BasePaginator'; import { MessageIntervalPaginator, type MessageQueryShape, @@ -62,6 +66,16 @@ export class PinnedMessagePaginator extends MessageIntervalPaginator { paginatorOptions, }); + this.installPinnedMessageBehaviour(); + } + + /** + * The ordering and request behaviour that makes this a *pinned*-message paginator rather than a plain + * one. Kept in a method so both the constructor and {@link initializeConfig} install it from the same + * place — a re-derivation that reset `config` would otherwise leave the base's `created_at` ordering + * and no `doRequest` at all. + */ + 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). @@ -76,25 +90,38 @@ export class PinnedMessagePaginator extends MessageIntervalPaginator { resolvePathValue: resolveDotPathValue, tiebreaker, }); - this.config.itemOrderComparator = makeComparator({ - sort: pinnedAtSort, - resolvePathValue: resolveDotPathValue, - tiebreaker, + this.updateConfig({ + 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. + doRequest: async ( + options: MessageQueryShape, + ): Promise<{ cursor?: PaginatorCursor; items: LocalMessage[] }> => { + const { messages } = await this.channel.getPinnedMessages( + options as PinnedMessagePaginationOptions, + [{ direction: 1, field: 'pinned_at' }], + ); + const items = messages.map(formatMessage); + return { cursor: this.getCursorFromQueryResults({ items }), items }; + }, }); + } - // 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, - [{ direction: 1, field: 'pinned_at' }], - ); - const items = messages.map(formatMessage); - return { cursor: this.getCursorFromQueryResults({ items }), items }; - }; + /** + * Re-derives configuration, then **re-installs** the ordering and request behaviour this class sets up + * in its constructor. Those live nowhere but here — they are closures over `this`, so no snapshot of + * configuration values could restore them. Without this override a reset would leave the paginator + * ordering by `created_at` and querying the wrong endpoint. + */ + override initializeConfig(declarativeConfig?: DeclarativePaginatorConfig): void { + super.initializeConfig(declarativeConfig); + this.installPinnedMessageBehaviour(); } 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..e6b5791e06 100644 --- a/src/reminders/ReminderManager.ts +++ b/src/reminders/ReminderManager.ts @@ -76,6 +76,14 @@ export class ReminderManager extends WithSubscriptions { } // 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' && diff --git a/src/search/SearchController.ts b/src/search/SearchController.ts index e2362b76b5..f6cb176fb8 100644 --- a/src/search/SearchController.ts +++ b/src/search/SearchController.ts @@ -31,7 +31,12 @@ export class SearchController { */ _internalState: StateStore; state: StateStore; - config: SearchControllerConfig; + + /** + * 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). + */ + readonly configState: StateStore; constructor({ config, sources }: SearchControllerOptions = {}) { this.state = new StateStore({ @@ -40,8 +45,25 @@ export class SearchController { sources: sources ?? [], }); this._internalState = new StateStore({}); - this.config = { keepSingleActiveSource: true, ...config }; + this.configState = new StateStore({ + keepSingleActiveSource: true, + ...config, + }); } + + /** + * 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.configState.partialNext(config); + } + get hasNext() { return this.sources.some((source) => source.hasNext); } diff --git a/src/thread.ts b/src/thread.ts index 6039a5bfb5..8e95769aca 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -30,8 +30,15 @@ import type { StreamChat } from './client'; import type { CustomThreadData } from './custom_types'; import { MessageComposer } from './messageComposer'; import { MessageOperations } from './messageOperations'; +import { DEFAULT_MESSAGE_OPERATIONS_CONFIG } from './messageOperations/MessageOperations'; import { WithSubscriptions } from './utils/WithSubscriptions'; import { MessagePaginator } from './pagination'; +import { applyInstanceConfiguration } from './configuration/applyInstanceConfiguration'; +import type { ThreadDeclarativeConfig } from './configuration/types'; +import { + mergeDeclarativeMessageOperationsConfig, + mergeDeclarativePaginatorConfig, +} from './configuration/types'; import type { PipelineEvent } from './EventHandlerPipeline'; export type ThreadState = { @@ -191,13 +198,25 @@ export class Thread extends WithSubscriptions { this.client = client; + // 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, + unreadReferencePolicy: messagePaginatorConfig?.unreadReferencePolicy, paginatorOptions: { pageSize: DEFAULT_PAGE_LIMIT, + declarativeConfig: messagePaginatorConfig, }, }); @@ -294,6 +313,38 @@ 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 { + // Replaces rather than merges: a handler dropped from the declarative tree must disappear. + this.configState.next({ requestHandlers: declarativeConfig?.requestHandlers }); + + this.messagePaginator.initializeConfig( + 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.updateConfig({ + ...DEFAULT_MESSAGE_OPERATIONS_CONFIG, + ...mergeDeclarativeMessageOperationsConfig( + this.client.config.getConfig('messageOperations') ?? undefined, + declarativeConfig?.messageOperations, + ), + }); } get channel() { @@ -396,6 +447,7 @@ export class Thread extends WithSubscriptions { return; } + this.addUnsubscribeFunction(this.subscribeThreadSetupStateChange()); this.addUnsubscribeFunction(this.subscribeParentMessageFromStore()); this.addUnsubscribeFunction(this.subscribeThreadUpdated()); this.addUnsubscribeFunction(this.subscribeMarkActiveThreadRead()); @@ -409,6 +461,29 @@ 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. + * + * Note the consequence: a thread that never calls `registerSubscriptions()` gets no *setup function* + * — matching how `MessageComposer` already behaves. Declarative configuration is unaffected, because + * the constructor applies it directly. Applying the setup function at construction instead would + * break the teardown symmetry that `WithSubscriptions` provides. + */ + 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 2a8d22c218..8bd7ee825e 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -16,7 +16,19 @@ import { WithSubscriptions } from './utils/WithSubscriptions'; const eventIsHealthCheck = (event: Event): event is EventPayload<'health.check'> => 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 = { + connectionRecoveryThrottleMs: 1000, +}; const MAX_QUERY_THREADS_LIMIT = 25; export const THREAD_MANAGER_INITIAL_STATE = { active: false, @@ -74,15 +86,34 @@ export class ThreadManager extends WithSubscriptions { // used for threads which are not stored in the list // private threadCache: Record = {}; + /** + * Resolved configuration, as a store — the shape every configurable class exposes + * (`configState` / `config` / `updateConfig`). + */ + public readonly configState: StateStore; + constructor({ client }: { client: StreamChat }) { super(); + this.configState = new StateStore({ + ...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.configState.partialNext(config); + } + public get threadsById() { const { threads } = this.state.getLatestValue(); @@ -226,7 +257,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 e679cc767e..3dee66d88e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,8 @@ 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 { RESERVED_UPDATED_MESSAGE_FIELDS } from './constants'; import type { APIError, @@ -390,6 +392,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 service 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. @@ -765,6 +776,10 @@ export type CommandVariants = | 'unmute' | keyof CustomCommandData; +/** + * Server-provided channel configuration, keyed by **channel type** (`messaging`, `livestream`, …) — + * every field in `ChannelConfigWithInfo` is a type-level setting. Read it via `channel.getConfig()`. + */ export type Configs = Record; export type ConnectionOpen = EventPayload<'health.check'>; diff --git a/src/utils/copyConfigPatch.ts b/src/utils/copyConfigPatch.ts new file mode 100644 index 0000000000..2b0566ef94 --- /dev/null +++ b/src/utils/copyConfigPatch.ts @@ -0,0 +1,45 @@ +import { isWalkableRecord } from './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 service 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. + * + * @internal + */ +export const copyConfigPatch = (value: T): T => { + if (Array.isArray(value)) { + return value.map((entry) => copyConfigPatch(entry)) 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; + + const copy: Record = {}; + for (const key of Reflect.ownKeys(value)) { + if (!Object.prototype.propertyIsEnumerable.call(value, key)) continue; + copy[key] = copyConfigPatch((value as Record)[key]); + } + return copy as T; + } + + return value; +}; diff --git a/src/utils/deepFreezeConfig.ts b/src/utils/deepFreezeConfig.ts new file mode 100644 index 0000000000..d3fb2dbf76 --- /dev/null +++ b/src/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/utils/objectPath.ts b/src/utils/objectPath.ts new file mode 100644 index 0000000000..6647d90581 --- /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 + * `InstanceConfigurationService` 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/unit/CooldownTimer.test.ts b/test/unit/CooldownTimer.test.ts index 303af4c197..1f6531fcce 100644 --- a/test/unit/CooldownTimer.test.ts +++ b/test/unit/CooldownTimer.test.ts @@ -23,6 +23,72 @@ 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. + */ + 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/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/messageComposer.test.ts b/test/unit/MessageComposer/messageComposer.test.ts index 75b9642f01..d57a764e9e 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -104,10 +104,11 @@ const setup = ({ } = {}) => { const mockClient = new StreamChat('test-api-key'); mockClient.user = user; - const cid = 'messaging:test-channel-id'; + const channelType = 'messaging'; if (channelConfig) { + // Keyed by channel type, not cid — see `Configs`. // @ts-expect-error incomplete channel config object - mockClient.configs[cid] = channelConfig; + mockClient.channelConfigsByType[channelType] = channelConfig; } // Create a proper Channel instance with only the necessary attributes mocked const mockChannel = mockClient.channel('messaging', 'test-channel-id'); @@ -208,6 +209,7 @@ describe('MessageComposer', () => { location: { enabled: customConfig.location!.enabled, getDeviceId: DEFAULT_COMPOSER_CONFIG.location!.getDeviceId, + minShareDurationMs: DEFAULT_COMPOSER_CONFIG.location!.minShareDurationMs, }, sendMessageRequestFn: customConfig.sendMessageRequestFn, text: { diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 999a3662f5..a3dfafacf1 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -1761,7 +1761,7 @@ describe('Channel _handleChannelEvent', function () { it('prevents reporting delivery just reported', () => { // enable delivery events client._addChannelConfig({ - cid: channel.cid, + type: channel.type, config: { ...channel.getConfig(), delivery_events: true }, }); channel.state.read[user.id] = initialReadState; @@ -1787,7 +1787,7 @@ describe('Channel _handleChannelEvent', function () { it('keeps reporting delivery if having newer deliveries', () => { // enable delivery events client._addChannelConfig({ - cid: channel.cid, + type: channel.type, config: { ...channel.getConfig(), delivery_events: true }, }); channel.state.read[user.id] = initialReadState; @@ -1818,7 +1818,7 @@ 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, + type: channel.type, config: { ...channel.getConfig(), delivery_events: true }, }); channel.state.read[user.id] = initialReadState; @@ -2660,7 +2660,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', () => { @@ -2717,7 +2717,7 @@ describe('Channel lastMessage', async () => { it('should return last message - system message is ignored when skip_last_msg_update_for_system_msgs: true', () => { client._addChannelConfig({ - cid: channel.cid, + type: channel.type, config: { skip_last_msg_update_for_system_msgs: true }, }); channel.state = new ChannelState(channel); @@ -2741,7 +2741,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); }); diff --git a/test/unit/client.construction.test.ts b/test/unit/client.construction.test.ts index 5ef9d55da6..f26e59a797 100644 --- a/test/unit/client.construction.test.ts +++ b/test/unit/client.construction.test.ts @@ -78,7 +78,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.channelConfigsByType).to.deep.equal({}); expect(client.wsConnection).to.be.null; expect(client.wsPromise).to.be.null; @@ -103,7 +103,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.channelConfigsByType).to.not.equal(b.channelConfigsByType); 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 fa851f4e0c..caa23b7d1b 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 channelConfigsByTypeStore 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.channelConfigsByType).to.eql({}); + expect(client.channelConfigsByTypeStore.getLatestValue()).to.eql({ configs: {} }); const nextConfigs = { 'messaging:next': { typing_events: true } }; - client.configs = nextConfigs; + client.channelConfigsByType = nextConfigs; - expect(client.configs).to.equal(nextConfigs); - expect(client.configsStore.getLatestValue()).to.eql({ configs: nextConfigs }); + expect(client.channelConfigsByType).to.equal(nextConfigs); + expect(client.channelConfigsByTypeStore.getLatestValue()).to.eql({ + configs: nextConfigs, + }); }); - it('updates configsStore through _addChannelConfig when cache is enabled', () => { + it('updates channelConfigsByTypeStore through _addChannelConfig when cache is enabled', () => { const client = new StreamChat('key', 'secret'); client._addChannelConfig({ - cid: 'messaging:channel-1', + type: 'messaging', config: { replies: true }, }); - expect(client.configsStore.getLatestValue()).to.eql({ + expect(client.channelConfigsByTypeStore.getLatestValue()).to.eql({ configs: { - 'messaging:channel-1': { replies: true }, + // Keyed by channel type, so one entry serves every channel of that type. + messaging: { replies: true }, }, }); }); - it('does not update configsStore through _addChannelConfig when cache is disabled', () => { + it('does not update channelConfigsByTypeStore through _addChannelConfig when cache is disabled', () => { const client = new StreamChat('key', 'secret'); client._cacheEnabled = () => false; client._addChannelConfig({ - cid: 'messaging:channel-1', + type: 'messaging', config: { replies: true }, }); - expect(client.configsStore.getLatestValue()).to.eql({ configs: {} }); + expect(client.channelConfigsByTypeStore.getLatestValue()).to.eql({ configs: {} }); }); }); @@ -836,7 +839,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.channelConfigsByType).length).to.be.equal(0); sinon.restore(); }); diff --git a/test/unit/configuration/InstanceConfigurationService.test.ts b/test/unit/configuration/InstanceConfigurationService.test.ts new file mode 100644 index 0000000000..33897ce9fa --- /dev/null +++ b/test/unit/configuration/InstanceConfigurationService.test.ts @@ -0,0 +1,333 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { InstanceConfigurationService } from '../../../src/configuration/InstanceConfigurationService'; +import { chatLoggerSystem } from '../../../src/logger'; + +const noop = () => undefined; + +describe('InstanceConfigurationService', () => { + let service: InstanceConfigurationService; + + beforeEach(() => { + service = new InstanceConfigurationService(); + }); + + 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 InstanceConfigurationService(); + 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('logs at debug for a custom key with no subscriber', () => { + service.setSetupFunction('cahnnel', noop); + + expect(records).toHaveLength(1); + expect(records[0].level).toBe('debug'); + expect(records[0].message).toContain('not built in and has no subscriber'); + }); + + 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 a custom key that already has a subscriber', () => { + service.registerInstance('myWidget', { reinitializeConfig: noop }); + + service.setSetupFunction('myWidget', 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..88258b89ad --- /dev/null +++ b/test/unit/configuration/applyInstanceConfiguration.test.ts @@ -0,0 +1,386 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { InstanceConfigurationService } from '../../../src/configuration/InstanceConfigurationService'; +import { applyInstanceConfiguration } from '../../../src/configuration/applyInstanceConfiguration'; + +/** Stands in for a keyed instance. `applyInstanceConfiguration` never inspects its argument. */ +const instance = () => ({ widget: {} }) as never; + +describe('applyInstanceConfiguration', () => { + let service: InstanceConfigurationService; + + beforeEach(() => { + service = new InstanceConfigurationService(); + }); + + 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..e7c0789e01 --- /dev/null +++ b/test/unit/configuration/channel.config.test.ts @@ -0,0 +1,245 @@ +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 service 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. + expect(channel.messagePaginator.config.itemOrderComparator).not.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); + }); + }); +}); diff --git a/test/unit/configuration/client.config.test.ts b/test/unit/configuration/client.config.test.ts new file mode 100644 index 0000000000..22ab10d370 --- /dev/null +++ b/test/unit/configuration/client.config.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest'; +import { StreamChat } from '../../../src/client'; +import { applyInstanceConfiguration } from '../../../src/configuration/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. + 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..0356de343e --- /dev/null +++ b/test/unit/configuration/configBoundaries.test.ts @@ -0,0 +1,156 @@ +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 + * `InstanceConfigurationService.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(); + }); + }); +}); diff --git a/test/unit/configuration/configPublishing.test.ts b/test/unit/configuration/configPublishing.test.ts new file mode 100644 index 0000000000..34523b8627 --- /dev/null +++ b/test/unit/configuration/configPublishing.test.ts @@ -0,0 +1,214 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateChannel } from '../test-utils/generateChannel'; +import { getClientWithUser } from '../test-utils/getClient'; +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 for the same + * channel type on each response, so `_addChannelConfig` replaced the stored one, the by-type selector in + * `MessageComposer.subscribeChannelConfigChanged` fired, and every live composer re-resolved. Measured on a + * 10-channel page with three open composers: **30 publishes and 30 subscriber runs, down to 3** — one per + * composer, for the config genuinely arriving the first time. + * + * Two guards, at the source and at the sink, because they cover different routes: the source stops the work + * happening at all, the sink catches everything else that resolves to an unchanged value. + */ +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({ + type: 'messaging', + config: { ...serverConfig } as never, + }); + const first = client.channelConfigsByType['messaging']; + + client._addChannelConfig({ + type: 'messaging', + config: { ...serverConfig } as never, + }); + + // Same object kept, so the store never published and nothing downstream woke up. + expect(client.channelConfigsByType['messaging']).toBe(first); + }); + + it('does not notify the store for a repeated identical config', () => { + client._addChannelConfig({ + type: 'messaging', + config: { ...serverConfig } as never, + }); + const listener = vi.fn(); + client.channelConfigsByTypeStore.subscribe(listener); + listener.mockClear(); + + for (let i = 0; i < 10; i++) { + client._addChannelConfig({ + type: 'messaging', + config: { ...serverConfig } as never, + }); + } + + expect(listener).not.toHaveBeenCalled(); + }); + + it('still stores a config that genuinely changed', () => { + client._addChannelConfig({ + type: 'messaging', + config: { ...serverConfig } as never, + }); + + client._addChannelConfig({ + type: 'messaging', + config: { ...serverConfig, max_message_length: 120 } as never, + }); + + expect(client.channelConfigsByType['messaging']).toMatchObject({ + max_message_length: 120, + }); + }); + + it('keeps a repeated channel query from waking live composers', () => { + const composers = ['a', 'b', 'c'].map((id) => { + const composer = client.channel('messaging', id).messageComposer; + composer.registerSubscriptions(); + return composer; + }); + // The config arrives for the first time: every composer should hear about this one. + client._addChannelConfig({ + type: 'messaging', + config: { ...serverConfig } as never, + }); + + let publishes = 0; + composers.forEach((composer) => composer.configState.subscribe(() => publishes++)); + publishes = 0; + + for (let i = 0; i < 10; i++) { + client._addChannelConfig({ + type: 'messaging', + 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 composer = client.channel('messaging', channelResponse.id).messageComposer; + composer.registerSubscriptions(); + client._addChannelConfig({ + type: 'messaging', + config: { ...serverConfig } as never, + }); + + const reResolve = vi.spyOn(composer, 'applyServerRestrictions'); + + for (let i = 0; i < 10; i++) { + client._addChannelConfig({ + type: 'messaging', + config: { ...serverConfig } as never, + }); + } + + expect(reResolve).not.toHaveBeenCalled(); + }); + + it('does wake them when the server config actually changes', () => { + const composer = client.channel('messaging', channelResponse.id).messageComposer; + composer.registerSubscriptions(); + client._addChannelConfig({ + type: 'messaging', + config: { ...serverConfig } as never, + }); + expect(composer.config.text.maxLengthOnSend).toBe(5000); + + client._addChannelConfig({ + type: 'messaging', + 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. + client._addChannelConfig({ + type: 'messaging', + config: { shared_locations: false } as never, + }); + const composer = client.channel('messaging', channelResponse.id).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({ + type: 'messaging', + config: { shared_locations: true } as never, + }); + + expect(listener).toHaveBeenCalled(); + expect(composer.config.location.enabled).toBe(true); + }); + }); +}); diff --git a/test/unit/configuration/configShape.test.ts b/test/unit/configuration/configShape.test.ts new file mode 100644 index 0000000000..cb8dd25cea --- /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/types'; +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..ee7c84b940 --- /dev/null +++ b/test/unit/configuration/configState.unification.test.ts @@ -0,0 +1,225 @@ +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*. + */ +describe('resolved configuration is reactive on every configurable class', () => { + 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` and `Thread` deliberately stop at `configState`: `channel.getConfig()` already returns the + * channel *type*'s server configuration, so a `channel.config` beside it would read as the same thing in + * getter form while meaning something unrelated, with nothing to catch the confusion. + * + * Pinned as an absence because the docs state it as a deliberate exception. If someone adds the getter, + * this fails and points at the table that has to change with it. + */ + it('leaves Channel and Thread with the store alone, and no colliding getter', () => { + const channel = client.channel('messaging', channelResponse.id); + + expect(channel.configState).toBeDefined(); + expect('config' in channel).toBe(false); + expect('updateConfig' in channel).toBe(false); + // the member the name would have collided with, which does exist + expect(typeof channel.getConfig).toBe('function'); + }); + + /** + * `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, + 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..a85229728d --- /dev/null +++ b/test/unit/configuration/configurableInTree.test.ts @@ -0,0 +1,204 @@ +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 { INSTANCE_CONFIG_TREE_KEYS } from '../../../src/configuration/types'; +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, + }, + { + 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/instanceConfiguration.integration.test.ts b/test/unit/configuration/instanceConfiguration.integration.test.ts new file mode 100644 index 0000000000..f39962a7f1 --- /dev/null +++ b/test/unit/configuration/instanceConfiguration.integration.test.ts @@ -0,0 +1,543 @@ +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 { 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({ type: channel.type, 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 setDebounce = vi.spyOn(channel.messagePaginator, 'setDebounceOptions'); + const setThrottle = vi.spyOn(channel.messagePaginator, 'setStateThrottleOptions'); + + client.config.setConfig('channel', { messagePaginator: { debounceMs: 900 } }); + + // Both go through their rebuild setters; a plain assignment would be discarded. + expect(setDebounce).toHaveBeenCalledWith({ debounceMs: 900 }); + expect(setThrottle).toHaveBeenCalledWith({ stateThrottleMs: 250 }); + }); + }); + + 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); + }); + }); + + describe('server channel configuration is cached by type', () => { + it('serves every channel of a type from one entry', () => { + const a = client.channel('messaging', 'a'); + const b = client.channel('messaging', 'b'); + + setServerConfig(a, { shared_locations: false }); + + // `b` was never queried, but the config belongs to the *type* — keying by cid used to leave it + // reporting nothing until it was queried itself. + expect(b.getConfig()?.shared_locations).toBe(false); + expect(Object.keys(client.channelConfigsByType)).toEqual(['messaging']); + }); + + 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.getConfig()).toBeUndefined(); + }); + + it('reaches a composer built before the config arrived, for any channel of the type', () => { + const a = client.channel('messaging', 'a'); + const b = client.channel('messaging', 'b'); + b.messageComposer.registerSubscriptions(); + + // Config arrives via `a`'s query; `b`'s composer is watching the same type entry. + setServerConfig(a, { shared_locations: false }); + + expect(b.messageComposer.config.location.enabled).toBe(false); + }); + }); + + 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 service + // 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..c2524de444 --- /dev/null +++ b/test/unit/configuration/messagePaginator.config.test.ts @@ -0,0 +1,266 @@ +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 { + mergeDeclarativeMessageOperationsConfig, + mergeDeclarativePaginatorConfig, +} from '../../../src/configuration/types'; +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); + }); + + 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('routes read-once fields through their rebuild setters when set late', () => { + const channel = openChannel(); + const setThrottle = vi.spyOn(channel.messagePaginator, 'setStateThrottleOptions'); + + client.config.set({ messagePaginator: { stateThrottleMs: 350 } }); + + expect(setThrottle).toHaveBeenCalledWith({ stateThrottleMs: 350 }); + expect(channel.messagePaginator.config.stateThrottleMs).toBe(350); + }); + + 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/serverAuthority.test.ts b/test/unit/configuration/serverAuthority.test.ts new file mode 100644 index 0000000000..af31eb8638 --- /dev/null +++ b/test/unit/configuration/serverAuthority.test.ts @@ -0,0 +1,511 @@ +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/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.getConfig()?.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, + id: 'recovery-channel', + config: { + ...mockChannelQueryResponse.channel.config, + shared_locations: false, + }, + }, + }, + metadata: {}, + } as never) + .mockResolvedValueOnce({ + body: { + ...mockChannelQueryResponse, + channel: { + ...mockChannelQueryResponse.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. Keyed by cid that voided one channel's + * config; keyed by **type** (DEC-26) it voids every channel of the type, and since the composer reads + * `getConfig()` 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({ type: response.type, config: undefined }); + + expect(client.channelConfigsByType[response.type]).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..3436c70c28 --- /dev/null +++ b/test/unit/configuration/thread.config.test.ts @@ -0,0 +1,194 @@ +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), + }); + + 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 781d976aa0..6d86d71975 100644 --- a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts +++ b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts @@ -54,7 +54,7 @@ describe('MessageDeliveryReporter', () => { channel = client.channel(channelType, channelId); channel.initialized = true; - client.configs[channel.cid] = { + client.channelConfigsByType[channel.type] = { created_at: '', delivery_events: true, read_events: false, @@ -109,7 +109,7 @@ describe('MessageDeliveryReporter', () => { return channel; }); channels.forEach((ch) => { - client.configs[ch.cid] = { + client.channelConfigsByType[ch.type] = { created_at: '', delivery_events: true, read_events: false, @@ -154,7 +154,7 @@ describe('MessageDeliveryReporter', () => { }); it('does nothing when delievry events are disabled in channel config', async () => { - client.configs[channel.cid] = { + client.channelConfigsByType[channel.type] = { created_at: '', delivery_events: false, read_events: false, @@ -207,7 +207,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.channelConfigsByType[thread.channel.type] = { created_at: '', delivery_events: true, read_events: false, @@ -300,7 +300,7 @@ describe('MessageDeliveryReporter', () => { const ch2 = client.channel('messaging', 'ch2'); ch2.initialized = true; - client.configs[ch1.cid] = { + client.channelConfigsByType[ch1.type] = { created_at: '', delivery_events: true, read_events: false, @@ -308,7 +308,7 @@ describe('MessageDeliveryReporter', () => { updated_at: '', }; - client.configs[ch2.cid] = { + client.channelConfigsByType[ch2.type] = { created_at: '', delivery_events: true, read_events: false, @@ -395,7 +395,7 @@ describe('MessageDeliveryReporter', () => { return channel; }); channels.forEach((ch) => { - client.configs[ch.cid] = { + client.channelConfigsByType[ch.type] = { 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..e3179c1f61 --- /dev/null +++ b/test/unit/pagination/paginator.initializeConfig.test.ts @@ -0,0 +1,201 @@ +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 are re-installed as fresh closures over `this`, so their identities differ + // by design — what matters is that they are present rather than lost. + for (const field of ['deriveCursor', 'itemOrderComparator'] as const) { + expect(typeof fromConstructor[field]).toBe('function'); + expect(typeof paginator.config[field]).toBe('function'); + } + }); + + 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(); + }); + }); +}); 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/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(); + }); + }); +}); From 0d9c7eed7655856ea4ea7b057505468179d5bda6 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 17 Aug 2026 17:35:38 +0200 Subject: [PATCH 10/22] feat: introduce ConfigController configuration for a specific entity instance --- docs/instance-configuration.md | 148 +++++++++- src/LiveLocationManager.ts | 54 +++- src/channel.ts | 40 ++- src/client.ts | 40 +-- src/configuration/ConfigController.ts | 278 ++++++++++++++++++ .../InstanceConfigurationService.ts | 19 +- .../applyInstanceConfiguration.ts | 30 +- .../copyConfigPatch.ts | 2 +- src/configuration/declarativeSlices.ts | 81 +++++ .../deepFreezeConfig.ts | 0 src/configuration/keys.ts | 86 ++++++ src/configuration/shape.ts | 33 +++ src/configuration/types.ts | 145 ++------- src/index.ts | 11 +- .../configuration/commands.configuration.ts | 27 +- .../configuration/configuration.ts | 2 +- src/messageComposer/configuration/index.ts | 1 - src/messageComposer/messageComposer.ts | 163 ++++------ .../MessageDeliveryReporter.ts | 67 +++-- src/messageOperations/MessageOperations.ts | 43 ++- src/notifications/NotificationManager.ts | 45 ++- src/notifications/configuration.ts | 18 +- src/pagination/paginators/BasePaginator.ts | 152 +++++++--- .../paginators/MessageIntervalPaginator.ts | 144 +++++---- src/pagination/paginators/MessagePaginator.ts | 54 +--- .../paginators/PinnedMessagePaginator.ts | 105 ++++--- src/reminders/ReminderManager.ts | 65 ++-- src/search/SearchController.ts | 58 +++- src/thread.ts | 77 +++-- src/thread_manager.ts | 36 ++- .../configuration/ConfigController.test.ts | 181 ++++++++++++ .../unit/configuration/channel.config.test.ts | 8 +- test/unit/configuration/client.config.test.ts | 167 +++++++++++ .../configuration/configBoundaries.test.ts | 121 ++++++++ .../configuration/configPublishing.test.ts | 115 +++++++- test/unit/configuration/configShape.test.ts | 2 +- .../configState.unification.test.ts | 9 +- .../configuration/configurableInTree.test.ts | 25 +- .../defaultConfigImmutability.test.ts | 101 +++++++ .../instanceConfiguration.integration.test.ts | 19 +- .../messagePaginator.config.test.ts | 118 +++++++- .../selfRegisteringEntities.test.ts | 134 +++++++++ .../paginator.initializeConfig.test.ts | 122 +++++++- v9-to-v10-migration-guide-type-renames.md | 91 +++--- 44 files changed, 2555 insertions(+), 682 deletions(-) create mode 100644 src/configuration/ConfigController.ts rename src/{utils => configuration}/copyConfigPatch.ts (97%) create mode 100644 src/configuration/declarativeSlices.ts rename src/{utils => configuration}/deepFreezeConfig.ts (100%) create mode 100644 src/configuration/keys.ts create mode 100644 test/unit/configuration/ConfigController.test.ts create mode 100644 test/unit/configuration/defaultConfigImmutability.test.ts create mode 100644 test/unit/configuration/selfRegisteringEntities.test.ts diff --git a/docs/instance-configuration.md b/docs/instance-configuration.md index 73755859de..b707ff876e 100644 --- a/docs/instance-configuration.md +++ b/docs/instance-configuration.md @@ -151,6 +151,36 @@ client.config.set({ `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 service 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.unregisterSubscriptions()` (which it already needs) or `searchController.dispose()`. + ### Two setters, one open key space `setConfig(key, subtree)` accepts **any** key, so a class of your own participates without changing @@ -277,9 +307,10 @@ that order re-runs. For any one instance, its resolved configuration is built from these layers, later ones winning: -| # | Stage | Scope | Where it comes from | +| # | Stage | Scope | Where the stage comes from | | --- | ----------------------------- | ------------------------------------------ | ---------------------------------------------------- | -| 1 | **Package defaults** | every instance | `DEFAULT_*_CONFIG` constants | +| 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)` | @@ -327,17 +358,92 @@ client.config.setSetupFunction('messageComposer', ({ composer }) => { 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 3 is worth one caveat.** Whether a construction argument beats the declarative tree depends on who -supplies it, and the two cases genuinely differ: +**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 -- `MessageComposer` puts its constructor `config` **after** the declarative tree — that argument comes from - an integrator building a composer deliberately, so it is the more specific intent. -- `BasePaginator` puts its constructor options **before** the declarative tree — for `channel.messagePaginator` - and friends those options are the _SDK's own_ construction values (`channel.ts` supplies them), so your - declarative configuration should override them. +Two objects carry out the stages above, and neither object holds what the other holds. -Both orders are right for their case, but they are not the same order. If you construct a paginator -yourself, expect the declarative tree to win. +| | `InstanceConfigurationService` — 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 | the open key space (`'channel'`, `'messageComposer'`, a custom key) | 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: + +``` +client.config.set({ messagePaginator: { pageSize: 30 } }) + │ registered intent, stored under a key + ▼ +InstanceConfigurationService ← 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 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 @@ -904,6 +1010,26 @@ deprecation exists to keep _released_ code compiling and no stable release ever alias would let `client.configs[cid]` return `undefined` instead of failing, so the name was removed to keep the break loud. Read server channel configuration through `channel.getConfig()`. +### 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']`. + `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 diff --git a/src/LiveLocationManager.ts b/src/LiveLocationManager.ts index c58e9d3cff..7e1c991c85 100644 --- a/src/LiveLocationManager.ts +++ b/src/LiveLocationManager.ts @@ -9,7 +9,10 @@ */ import { withCancellation } from './utils/concurrency'; +import { deepFreezeConfig } from './configuration/deepFreezeConfig'; import { StateStore } from './store'; +import { ConfigController } from './configuration/ConfigController'; +import { applyInstanceConfiguration } from './configuration/applyInstanceConfiguration'; import { WithSubscriptions } from './utils/WithSubscriptions'; import type { StreamChat } from './client'; import type { Unsubscribe } from './store'; @@ -70,9 +73,10 @@ export type LiveLocationManagerConfig = { minUpdateThrottleMs: number; }; -export const DEFAULT_LIVE_LOCATION_MANAGER_CONFIG: LiveLocationManagerConfig = { - minUpdateThrottleMs: UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT, -}; +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; @@ -81,11 +85,18 @@ 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 unregisterSubscriptions}. */ + private unsubscribeConfiguration?: Unsubscribe; + /** * Resolved configuration, as a store — the shape every configurable class exposes * (`configState` / `config` / `updateConfig`). */ - readonly configState: StateStore; + get configState(): StateStore { + return this.configController.state; + } static symbol = Symbol(LiveLocationManager.name); @@ -108,8 +119,23 @@ export class LiveLocationManager extends WithSubscriptions { this._deviceId = getDeviceId(); this.getDeviceId = getDeviceId; this.watchLocation = watchLocation; - this.configState = new StateStore({ - ...DEFAULT_LIVE_LOCATION_MANAGER_CONFIG, + 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 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.unsubscribeConfiguration = applyInstanceConfiguration({ + args: { liveLocationManager: this }, + config: client.config, + key: 'liveLocationManager', + applyConfig: (config) => this.initializeConfig(config), + reinitializeConfig: () => + this.initializeConfig( + client.config.getConfig('liveLocationManager') ?? undefined, + ), }); } @@ -120,7 +146,12 @@ export class LiveLocationManager extends WithSubscriptions { /** Merges a partial configuration into the resolved config and notifies subscribers. */ updateConfig(config: Partial) { - this.configState.partialNext(config); + 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() { @@ -136,7 +167,14 @@ export class LiveLocationManager extends WithSubscriptions { this.addUnsubscribeFunction(this.subscribeTargetMessagesChange()); }; - public unregisterSubscriptions = () => super.unregisterSubscriptions(); + public unregisterSubscriptions = () => { + const released = super.unregisterSubscriptions(); + // Ref-counted: only the last caller actually tears down, and the configuration subscription is not + // one of the ref-counted ones — it was registered by the constructor, so it is released here. + this.unsubscribeConfiguration?.(); + this.unsubscribeConfiguration = undefined; + return released; + }; get messages() { return this.state.getLatestValue().messages; diff --git a/src/channel.ts b/src/channel.ts index 4da6660446..91aea26426 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -8,7 +8,6 @@ import { MessageReceiptsTracker } from './messageDelivery'; import type { ReadStoreReconcileMeta } from './messageDelivery'; import { MessagePaginator, PinnedMessagePaginator } from './pagination/paginators'; import { MessageOperations } from './messageOperations'; -import { DEFAULT_MESSAGE_OPERATIONS_CONFIG } from './messageOperations/MessageOperations'; import { channelHasReadEvents, formatMessage, @@ -23,7 +22,8 @@ import type { ChannelDeclarativeConfig } from './configuration/types'; import { mergeDeclarativeMessageOperationsConfig, mergeDeclarativePaginatorConfig, -} from './configuration/types'; + toDeclarativePaginatorConfig, +} from './configuration/declarativeSlices'; import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from './constants'; import type { AIState, @@ -68,6 +68,7 @@ import type { } from './types'; import type { RoleName } from './permissions'; import { StateStore } from './store'; +import { isEqual } from './utils/mergeWith/mergeWithCore'; import type { Unsubscribe } from './store'; import type { ChannelMemberRequest as Gen_ChannelMemberRequest, @@ -263,8 +264,12 @@ export class Channel extends ChannelApi { // latest window at construction). 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: messagePaginatorConfig }, + paginatorOptions: { + declarativeConfig: toDeclarativePaginatorConfig(messagePaginatorConfig), + }, }); this.pinnedMessagesPaginator = new PinnedMessagePaginator({ channel: this, @@ -376,14 +381,28 @@ export class Channel extends ChannelApi { // tree must disappear. Anything else writing directly into `configState.requestHandlers` — the // React SDK's per-component props do — has to re-apply after a re-derivation; see the note in // `useChannelRequestHandlers`. - this.configState.next({ requestHandlers: declarativeConfig?.requestHandlers }); + // + // Guarded for the same reason `MessageComposer.publishConfig` is: the object is freshly allocated + // every time, so `StateStore.next`'s `===` no-op can never apply and every re-derivation woke every + // subscriber with an identical value. This runs on each `alsoWatch` key change too — a + // `messagePaginator` or `messageOperations` registration re-runs the whole `channel` cycle — so the + // no-op publishes outnumber the real ones. Deep rather than `===` because `requestHandlers` is a + // record; `isEqual` compares its function values by identity, which is the right test for a handler. + const nextRequestHandlers = declarativeConfig?.requestHandlers; + if ( + !isEqual(this.configState.getLatestValue().requestHandlers, nextRequestHandlers) + ) { + this.configState.next({ requestHandlers: nextRequestHandlers }); + } // 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( - mergeDeclarativePaginatorConfig( - this.getClient().config.getConfig('messagePaginator') ?? undefined, - declarativeConfig?.messagePaginator, + toDeclarativePaginatorConfig( + mergeDeclarativePaginatorConfig( + this.getClient().config.getConfig('messagePaginator') ?? undefined, + declarativeConfig?.messagePaginator, + ), ), ); // Single parent, so it stays nested and takes no share of the shared key. @@ -394,13 +413,12 @@ export class Channel extends ChannelApi { // `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.updateConfig({ - ...DEFAULT_MESSAGE_OPERATIONS_CONFIG, - ...mergeDeclarativeMessageOperationsConfig( + this.messageOperations.initializeConfig( + mergeDeclarativeMessageOperationsConfig( this.getClient().config.getConfig('messageOperations') ?? undefined, declarativeConfig?.messageOperations, ), - }); + ); } /** diff --git a/src/client.ts b/src/client.ts index 96316eb9b2..080bb1c3ec 100644 --- a/src/client.ts +++ b/src/client.ts @@ -68,26 +68,20 @@ import { InsightMetrics, postInsights } from './insights'; import { chatLoggerSystem } from './logger'; import { Thread } from './thread'; import { Moderation } from './moderation'; -import { DEFAULT_THREAD_MANAGER_CONFIG, ThreadManager } from './thread_manager'; +import { ThreadManager } from './thread_manager'; import { DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE } from './constants'; import { PollManager } from './poll_manager'; import { EntityStore } from './entityStore/EntityStore'; import { ChannelManager } from './ChannelManager'; -import { - DEFAULT_MESSAGE_DELIVERY_REPORTER_CONFIG, - MessageDeliveryReporter, -} from './messageDelivery'; +import { MessageDeliveryReporter } from './messageDelivery'; import { NotificationManager } from './notifications'; -import { DEFAULT_NOTIFICATION_MANAGER_CONFIG } from './notifications/configuration'; -import type { NotificationManagerConfig } from './notifications'; -import { DEFAULT_REMINDER_MANAGER_CONFIG, ReminderManager } from './reminders'; +import { ReminderManager } from './reminders'; import type { AbstractOfflineDB } from './offline-support'; import { getPendingTaskChannelData } from './offline-support/util'; import { FixedSizeQueueCache } from './utils/FixedSizeQueueCache'; -import { mergeWith } from './utils/mergeWith'; import { isEqual } from './utils/mergeWith/mergeWithCore'; import type { MessageComposer } from './messageComposer'; -import type { MessageComposerSetupState } from './configuration'; +import type { InstanceSetupState } from './configuration'; import { InstanceConfigurationService } from './configuration/InstanceConfigurationService'; import { applyInstanceConfiguration } from './configuration/applyInstanceConfiguration'; import { StateStore } from './store'; @@ -411,26 +405,10 @@ export class StreamChat extends ChatApi { private initializeManagerConfig() { const config = this.config.getConfig('client'); - this.reminders.updateConfig({ - ...DEFAULT_REMINDER_MANAGER_CONFIG, - ...config?.reminders, - }); - this.threads.updateConfig({ - ...DEFAULT_THREAD_MANAGER_CONFIG, - ...config?.threads, - }); - this.messageDeliveryReporter.updateConfig({ - ...DEFAULT_MESSAGE_DELIVERY_REPORTER_CONFIG, - ...config?.messageDelivery, - }); - // Deep-merged, not spread: `notifications.durations` is nested and the slice is a `DeepPartial`, so - // `{ durations: { error } }` must keep the three sibling durations rather than replace the object. - this.notifications.updateConfig( - mergeWith( - { ...DEFAULT_NOTIFICATION_MANAGER_CONFIG }, - (config?.notifications ?? {}) as object, - ) as Partial, - ); + this.reminders.initializeConfig(config?.reminders); + this.threads.initializeConfig(config?.threads); + this.messageDeliveryReporter.initializeConfig(config?.messageDelivery); + this.notifications.initializeConfig(config?.notifications); } get mutedUsers() { @@ -524,7 +502,7 @@ export class StreamChat extends ChatApi { * @deprecated Use `client.config.setSetupFunction('messageComposer', fn)`. */ public setMessageComposerSetupFunction = ( - setupFunction: MessageComposerSetupState['setupFunction'], + setupFunction: InstanceSetupState<'messageComposer'>['setupFunction'], ) => { this.config.setSetupFunction('messageComposer', setupFunction); }; diff --git a/src/configuration/ConfigController.ts b/src/configuration/ConfigController.ts new file mode 100644 index 0000000000..2da6603afb --- /dev/null +++ b/src/configuration/ConfigController.ts @@ -0,0 +1,278 @@ +import { StateStore } from '../store'; +import { mergeWith } from '../utils/mergeWith'; +import { isEqual } from '../utils/mergeWith/mergeWithCore'; +import { copyConfigPatch } from './copyConfigPatch'; +import { deepFreezeConfig } from './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. + */ + 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, 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. + * + * **Public, so a class registered under a custom key gets the same behaviour as a built-in one.** + * `applyInstanceConfiguration` subscribes such a class to its key; this is what resolves the value once + * the slice arrives. Without it, an outside class would hand-roll the derivation and be free to + * reintroduce every bug this consolidated — leaking a shared default, publishing when nothing moved, + * storing a read-once field without applying it. + * + * The entity keeps the shape every configurable class exposes by forwarding: + * + * ```ts + * class MyWidget { + * private readonly configController = new ConfigController({ + * defaults: DEFAULT_MY_WIDGET_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); } + * } + * ``` + */ +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 `mergeWith` reuses a source subtree verbatim where the target has + * nothing — so without this the entity would hold the caller's object, and a later mutation of it would + * change resolved configuration with no notification. + */ + patch(patch: Partial): void { + const owned = copyConfigPatch(patch); + if (!this.options.retainPatches) { + // 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. + this.write({ ...this.value, ...owned } as TConfig); + 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/InstanceConfigurationService.ts b/src/configuration/InstanceConfigurationService.ts index 7d4a6e11b2..381d22fb32 100644 --- a/src/configuration/InstanceConfigurationService.ts +++ b/src/configuration/InstanceConfigurationService.ts @@ -26,17 +26,16 @@ 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 { copyConfigPatch } from './copyConfigPatch'; import { getPath, hasPath, isWalkableRecord } from '../utils/objectPath'; -import { - BUILT_IN_INSTANCE_KEYS, - CONSTRUCTION_ONLY_CONFIG_PATHS, - type InstanceConfigOf, - type InstanceConfigState, - type InstanceConfigTree, - type InstanceSetupFunction, - type InstanceSetupKey, - type InstanceSetupState, +import { BUILT_IN_INSTANCE_KEYS, CONSTRUCTION_ONLY_CONFIG_PATHS } from './keys'; +import type { + InstanceConfigOf, + InstanceConfigState, + InstanceConfigTree, + InstanceSetupFunction, + InstanceSetupKey, + InstanceSetupState, } from './types'; import type { DeepPartial } from '../types.utility'; diff --git a/src/configuration/applyInstanceConfiguration.ts b/src/configuration/applyInstanceConfiguration.ts index b5543edb68..d18f44bbf6 100644 --- a/src/configuration/applyInstanceConfiguration.ts +++ b/src/configuration/applyInstanceConfiguration.ts @@ -20,6 +20,18 @@ export type ApplyInstanceConfigurationParams = { /** The client's configuration service, i.e. `client.config`. */ config: InstanceConfigurationService; 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 InstanceSetupKey[]; /** * Applies a declarative configuration slice to the instance. Omit it if the instance has no * declarative surface and only wants the setup function. @@ -35,25 +47,15 @@ export type ApplyInstanceConfigurationParams = { * clear-registrations-only reset semantics. */ reinitializeConfig?: () => void; - /** - * 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 InstanceSetupKey[]; }; /** * Subscribes one instance to the configuration registered for its key, and returns the unsubscribe. * - * This is the single place the semantics live, so every configured instance behaves identically — including one - * written outside this package for a custom key: + * This is the single place the *subscription* semantics live, so every configured instance behaves + * identically — including one written outside this package for a custom key. Pair it with a + * `ConfigController`, which is the single place the *resolution* semantics live; between them an outside + * class gets exactly what a built-in one gets: * * - applies whatever is already registered, immediately; * - re-applies on every change to either slot, declarative configuration first and the setup function diff --git a/src/utils/copyConfigPatch.ts b/src/configuration/copyConfigPatch.ts similarity index 97% rename from src/utils/copyConfigPatch.ts rename to src/configuration/copyConfigPatch.ts index 2b0566ef94..63a87907a1 100644 --- a/src/utils/copyConfigPatch.ts +++ b/src/configuration/copyConfigPatch.ts @@ -1,4 +1,4 @@ -import { isWalkableRecord } from './objectPath'; +import { isWalkableRecord } from '../utils/objectPath'; /** * Copies a caller-supplied configuration patch, so the value the SDK stores shares no mutable object with diff --git a/src/configuration/declarativeSlices.ts b/src/configuration/declarativeSlices.ts new file mode 100644 index 0000000000..4d65bd1e09 --- /dev/null +++ b/src/configuration/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/utils/deepFreezeConfig.ts b/src/configuration/deepFreezeConfig.ts similarity index 100% rename from src/utils/deepFreezeConfig.ts rename to src/configuration/deepFreezeConfig.ts diff --git a/src/configuration/keys.ts b/src/configuration/keys.ts new file mode 100644 index 0000000000..79a21026dc --- /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. Used to scope diagnostics — never to reject a caller's key, + * which would defeat the point of an open key space. + * + * 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 index b9278dc289..cb26874d1a 100644 --- a/src/configuration/shape.ts +++ b/src/configuration/shape.ts @@ -9,6 +9,8 @@ import type { DeclarativePaginatorConfig } from '../pagination/paginators/BasePa 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 { @@ -360,6 +362,25 @@ const THREAD_MANAGER_FIELDS: Record = { }, }; +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: @@ -480,6 +501,12 @@ export const INSTANCE_CONFIG_TREE_SHAPE: Record< 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.", @@ -498,6 +525,12 @@ export const INSTANCE_CONFIG_TREE_SHAPE: Record< 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.', diff --git a/src/configuration/types.ts b/src/configuration/types.ts index 021f6c57ba..83dee56e82 100644 --- a/src/configuration/types.ts +++ b/src/configuration/types.ts @@ -1,4 +1,12 @@ 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 { MessageComposerConfig } from '../messageComposer/configuration/types'; import type { Channel, ChannelInstanceConfig } from '../channel'; @@ -32,45 +40,15 @@ import type { DeepPartial } from '../types.utility'; export interface InstanceSetupFunctionArgs { channel: { channel: Channel }; client: { client: StreamChat }; + liveLocationManager: { liveLocationManager: LiveLocationManager }; messageComposer: { composer: MessageComposer }; + searchController: { searchController: SearchController }; thread: { thread: Thread }; } /** The four built-in keys, plus any key an integrator or a downstream SDK registers. */ export type InstanceSetupKey = keyof InstanceSetupFunctionArgs | (string & {}); -/** - * The keys this package wires itself. Used to scope diagnostics — never to reject a caller's key, - * which would defeat the point of an open key space. - */ -export const BUILT_IN_INSTANCE_KEYS: readonly (keyof InstanceSetupFunctionArgs)[] = [ - 'channel', - 'client', - 'messageComposer', - '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. - */ -const INSTANCE_CONFIG_TREE_KEY_PRESENCE: Record = { - channel: true, - client: true, - messageComposer: true, - messageOperations: true, - messagePaginator: true, - thread: true, -}; - -export const INSTANCE_CONFIG_TREE_KEYS = Object.keys( - INSTANCE_CONFIG_TREE_KEY_PRESENCE, -).sort() as readonly (keyof InstanceConfigTree)[]; - // --------------------------------------------------------------------------- // Tier 2 — setup functions // --------------------------------------------------------------------------- @@ -182,6 +160,12 @@ export type ClientDeclarativeConfig = { export interface 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 @@ -197,6 +181,12 @@ export interface InstanceConfigTree { * 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; } @@ -207,94 +197,3 @@ export type InstanceConfigOf = K extends keyof InstanceConfigT export type InstanceConfigState = { config: DeepPartial> | null; }; - -/** - * 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); - -/** - * 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 the paginators - * expose rebuild methods (`setStateThrottleOptions`, `setDebounceOptions`) that make a late change - * take effect. - */ -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', - ], - }; - -// --------------------------------------------------------------------------- -// Compatibility surface -// --------------------------------------------------------------------------- - -// Only the `MessageComposer` key ever functioned: the `StreamChat`, `Channel` and `Thread` setup -// functions were stored and never invoked, so no working code can have depended on their types. Aliases -// for those were removed rather than deprecated — a type error is the signal that tells someone their -// setup function was dead. v10 is a major, so this is the moment for that. - -/** @deprecated Use {@link InstanceSetupTearDownFunction}. */ -export type MessageComposerTearDownFunction = InstanceSetupTearDownFunction; -/** @deprecated Use `InstanceSetupFunction<'messageComposer'>`. */ -export type MessageComposerSetupFunction = InstanceSetupFunction<'messageComposer'>; -/** @deprecated Use `InstanceSetupState<'messageComposer'>`. */ -export type MessageComposerSetupState = InstanceSetupState<'messageComposer'>; diff --git a/src/index.ts b/src/index.ts index b299f0524c..7f0ae3eef8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,11 +8,15 @@ export * from './channel_state'; // https://github.com/microsoft/TypeScript/issues/46617 export { applyInstanceConfiguration } from './configuration/applyInstanceConfiguration'; export type { ApplyInstanceConfigurationParams } from './configuration/applyInstanceConfiguration'; +export { ConfigController } from './configuration/ConfigController'; +export type { ConfigControllerOptions } from './configuration/ConfigController'; +// 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, - INSTANCE_CONFIG_TREE_KEYS, CONSTRUCTION_ONLY_CONFIG_PATHS, -} from './configuration/types'; + INSTANCE_CONFIG_TREE_KEYS, +} from './configuration/keys'; export type { ChannelDeclarativeConfig, ClientDeclarativeConfig, @@ -27,9 +31,6 @@ export type { InstanceSetupKey, InstanceSetupState, InstanceSetupTearDownFunction, - MessageComposerSetupFunction, - MessageComposerSetupState, - MessageComposerTearDownFunction, ThreadDeclarativeConfig, UnreadReferencePolicy, } from './configuration/types'; 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 a8b9d327d5..b7d619b62a 100644 --- a/src/messageComposer/configuration/configuration.ts +++ b/src/messageComposer/configuration/configuration.ts @@ -8,7 +8,7 @@ import type { TextComposerConfig, } from './types'; import { generateUUIDv4 } from '../../utils'; -import { deepFreezeConfig } from '../../utils/deepFreezeConfig'; +import { deepFreezeConfig } from '../../configuration/deepFreezeConfig'; import { DEFAULT_COMMANDS_CONFIG } from './commands.configuration'; export const DEFAULT_LINK_PREVIEW_MANAGER_CONFIG: LinkPreviewsManagerConfig = { 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/messageComposer.ts b/src/messageComposer/messageComposer.ts index 22fa909694..92ea6a433f 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -5,7 +5,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, @@ -14,10 +14,8 @@ import { import type { Unsubscribe } from '../store'; import { StateStore } from '../store'; import { formatMessage, generateUUIDv4, isLocalMessage } from '../utils'; -import { mergeWith } from '../utils/mergeWith'; -import { isEqual } from '../utils/mergeWith/mergeWithCore'; -import { copyConfigPatch } from '../utils/copyConfigPatch'; -import { deepFreezeConfig } from '../utils/deepFreezeConfig'; +import { ConfigController } from '../configuration/ConfigController'; +import { deepFreezeConfig } from '../configuration/deepFreezeConfig'; import { mergeServerRestrictions } from '../configuration/serverAuthority'; import type { ServerRestrictions, @@ -184,7 +182,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; @@ -198,22 +204,19 @@ export class MessageComposer extends WithSubscriptions { private snapshots: MessageComposerSnapshot[] = []; private effectHandlers: MessageComposerEffectHandlers; /** - * Configuration passed to this composer's constructor, kept so {@link initializeConfig} can - * reproduce the constructor's derivation rather than restoring a snapshot of its result. + * 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. * - * Copied on the way in — it is read on *every* resolution, for the composer's whole life, so holding - * the caller's object would let a later mutation of it change resolved configuration silently. Same - * boundary rule as {@link updateConfig} and `InstanceConfigurationService.setConfig`. + * 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 explicitConfig?: DeepPartial; - /** - * Every {@link updateConfig} patch so far, merged in the order they arrived. - * - * Stages 4 and 5 of the resolution order both arrive through `updateConfig`, so this one layer holds a - * setup function's work and a caller's own changes alike — they are equally "asked for", and both have - * to outlive a re-resolution. Cleared by {@link initializeConfig}, which is what a reset means. - */ - private imperativeConfig: DeepPartial = {}; + private readonly configController: ConfigController< + MessageComposerConfig, + DeepPartial + >; // todo: mediaRecorder: MediaRecorderController; constructor({ @@ -240,8 +243,25 @@ export class MessageComposer extends WithSubscriptions { ); } - this.explicitConfig = config && copyConfigPatch(config); - this.configState = new StateStore(this.resolvedConfig); + this.configController = new ConfigController< + MessageComposerConfig, + DeepPartial + >({ + 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.serverRestrictions, + this.serverUpperBounds, + ), + ) as MessageComposerConfig, + }); let message: LocalMessage | DraftMessage | undefined = undefined; if (compositionIsDraftResponse(composition)) { @@ -482,12 +502,24 @@ export class MessageComposer extends WithSubscriptions { * the server changing its mind. */ updateConfig(config: DeepPartial) { - // Copied at the boundary, for the same reason `InstanceConfigurationService.setConfig` does it: - // `mergeWith` reuses a source subtree verbatim where the target has nothing, and `imperativeConfig` - // starts empty — so without this a caller's `patch.text` was stored by reference, and a later - // `patch.text.maxLengthOnSend = 5` changed every subsequent resolution with no notification. - this.imperativeConfig = mergeWith(this.imperativeConfig, copyConfigPatch(config)); - this.publishConfig(); + 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; } /** @@ -502,47 +534,6 @@ export class MessageComposer extends WithSubscriptions { return { location: { enabled: this.channel.getConfig()?.shared_locations } }; } - /** - * What this composer has been **asked** for, before the server has any say — stages 1 to 5 of - * `docs/instance-configuration.md` §3, later layers winning: - * - * 1. package defaults; - * 2. the declarative tree for the `messageComposer` key, re-read live so a change is picked up; - * 3. this composer's constructor argument; - * 4. and 5. every patch handed to {@link updateConfig} — which is where a setup function's work and a - * caller's own imperative change both land, in the order they happened. - * - * Keeping this separate from the published configuration is what lets a restriction be *re-applied* - * rather than *accumulated*. Applying restrictions to the previous published result made them - * one-directional: a server `false` written into the config was indistinguishable from a client's own - * `false`, so it either became permanent or overwrote the client's intent, depending on which way the - * call was written (**DV-18**). Resolving from the request every time makes the operation idempotent, - * so neither can happen. - */ - private get requestedConfig(): MessageComposerConfig { - const declarative = (this.client.config.getConfig('messageComposer') ?? - {}) as DeepPartial; - const layers: DeepPartial[] = [ - declarative, - this.explicitConfig ?? {}, - this.imperativeConfig, - ]; - - const requested = layers.reduce( - (resolved, layer) => mergeWith(resolved, layer), - { ...DEFAULT_COMPOSER_CONFIG }, - ); - - // `sendValidator` is a function, and the deep merge is not the right tool for choosing between two of - // them — hence the explicit override, given the most specific layer that actually names one. Searched - // from the most specific end, so a later layer that stayed silent does not erase an earlier choice. - const validatorSource = [...layers] - .reverse() - .find((layer) => typeof layer.commands?.sendValidator === 'function'); - - return applyCommandValidatorOverride(requested, validatorSource); - } - /** * Ceilings this composer's channel imposes server-side. * @@ -559,37 +550,12 @@ export class MessageComposer extends WithSubscriptions { }; } - /** - * The requested configuration with the server's restrictions applied — the value callers read. - * - * Frozen on the way out, so the "nested writes are caught at runtime" guarantee holds for the whole - * tree rather than for whichever subtrees the merge happened to leave pointing at the frozen defaults. - * It did not: `serverRestrictions` names `location` and `serverUpperBounds` names `text` on *every* - * resolution, so those two were always copied into fresh, writable objects — and they are the two - * subtrees callers actually configure. `composer.config.text.maxLengthOnSend = 5` therefore mutated - * published state while notifying nobody, while the identical write to `drafts` threw. - * - * Freezing here rather than in {@link publishConfig} because the constructor seeds `configState` from - * this getter directly, and a composer that is never re-published would otherwise keep an unfrozen - * value for its whole life. - */ - private get resolvedConfig(): MessageComposerConfig { - return deepFreezeConfig( - mergeServerRestrictions( - this.requestedConfig, - this.serverRestrictions, - this.serverUpperBounds, - ), - ) as MessageComposerConfig; - } - /** * 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. In the React SDK that is a re-render for any consumer whose selector returns part of the config - * rather than a scalar. + * 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 @@ -598,9 +564,7 @@ export class MessageComposer extends WithSubscriptions { * registered, an empty `updateConfig({})`. */ private publishConfig = () => { - const nextConfig = this.resolvedConfig; - if (isEqual(this.configState.getLatestValue(), nextConfig)) return; - this.configState.next(nextConfig); + this.configController.rederive(this.declarativeConfig); }; /** @@ -614,8 +578,7 @@ export class MessageComposer extends WithSubscriptions { * {@link applyServerRestrictions}, which keep them. */ initializeConfig = () => { - this.imperativeConfig = {}; - this.publishConfig(); + this.configController.initialize(this.declarativeConfig); }; /** diff --git a/src/messageDelivery/MessageDeliveryReporter.ts b/src/messageDelivery/MessageDeliveryReporter.ts index a28315370c..ef2a145884 100644 --- a/src/messageDelivery/MessageDeliveryReporter.ts +++ b/src/messageDelivery/MessageDeliveryReporter.ts @@ -1,5 +1,7 @@ import type { StreamChat } from '../client'; -import { StateStore } from '../store'; +import { deepFreezeConfig } from '../configuration/deepFreezeConfig'; +import type { StateStore } from '../store'; +import { ConfigController } from '../configuration/ConfigController'; import { Channel } from '../channel'; import type { ThreadUserReadState } from '../thread'; import { Thread } from '../thread'; @@ -32,12 +34,13 @@ export type MessageDeliveryReporterConfig = { retryCountLimitForTimeoutIncrease: number; }; -export const DEFAULT_MESSAGE_DELIVERY_REPORTER_CONFIG: MessageDeliveryReporterConfig = { - markAsDeliveredBufferTimeoutMs: 1000, - markAsReadThrottleTimeoutMs: 1000, - maxDeliveredMessageCountInPayload: 100, - retryCountLimitForTimeoutIncrease: 3, -}; +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; @@ -70,16 +73,30 @@ export class MessageDeliveryReporter { // 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`). */ - readonly configState: StateStore; + get configState(): StateStore { + return this.configController.state; + } constructor({ client }: MessageDeliveryReporterOptions) { this.client = client; - this.configState = new StateStore({ - ...DEFAULT_MESSAGE_DELIVERY_REPORTER_CONFIG, + 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, + ); + }, }); } @@ -93,11 +110,25 @@ export class MessageDeliveryReporter { * setter, because the throttle captured the old interval in a closure and would otherwise ignore it. */ updateConfig(config: Partial) { - const { markAsReadThrottleTimeoutMs, ...rest } = config; - if (Object.keys(rest).length) this.configState.partialNext(rest); - if (typeof markAsReadThrottleTimeoutMs === 'number') { - this.setMarkAsReadThrottleOptions({ markAsReadThrottleTimeoutMs }); - } + 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); } /** @@ -110,9 +141,9 @@ export class MessageDeliveryReporter { setMarkAsReadThrottleOptions = ({ markAsReadThrottleTimeoutMs, }: Pick) => { - if (this.config.markAsReadThrottleTimeoutMs === markAsReadThrottleTimeoutMs) return; - this.configState.partialNext({ markAsReadThrottleTimeoutMs }); - this.throttledMarkRead = this.buildThrottledMarkRead(markAsReadThrottleTimeoutMs); + // 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() { diff --git a/src/messageOperations/MessageOperations.ts b/src/messageOperations/MessageOperations.ts index 897b58fa71..c469ccd575 100644 --- a/src/messageOperations/MessageOperations.ts +++ b/src/messageOperations/MessageOperations.ts @@ -1,6 +1,8 @@ // todo: add tests import type { MessageRequest, UpdateMessageOptions } from '../types'; -import { StateStore } from '../store'; +import { deepFreezeConfig } from '../configuration/deepFreezeConfig'; +import type { StateStore } from '../store'; +import { ConfigController } from '../configuration/ConfigController'; import { formatMessage, localMessageToNewMessagePayload } from '../utils'; import { MessageOperationStatePolicy } from './MessageOperationStatePolicy'; import type { @@ -17,10 +19,11 @@ export type MessageOperationsConfig = { failedSendCacheTtlMs: number; }; -export const DEFAULT_MESSAGE_OPERATIONS_CONFIG: MessageOperationsConfig = { - failedSendCacheMaxSize: 100, - failedSendCacheTtlMs: 5 * 60 * 1000, -}; +export const DEFAULT_MESSAGE_OPERATIONS_CONFIG: MessageOperationsConfig = + deepFreezeConfig({ + failedSendCacheMaxSize: 100, + failedSendCacheTtlMs: 5 * 60 * 1000, + }); type FailedSendCacheEntry = { message: MessageRequest; @@ -33,17 +36,21 @@ 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`). */ - readonly configState: StateStore; + get configState(): StateStore { + return this.configController.state; + } constructor(ctx: MessageOperationsContext) { this.ctx = ctx; this.policy = new MessageOperationStatePolicy({ ingest: ctx.ingest, get: ctx.get }); - this.configState = new StateStore({ - ...DEFAULT_MESSAGE_OPERATIONS_CONFIG, + this.configController = new ConfigController({ + defaults: DEFAULT_MESSAGE_OPERATIONS_CONFIG, }); } @@ -54,7 +61,25 @@ export class MessageOperations { /** Merges a partial configuration into the resolved config and notifies subscribers. */ updateConfig(config: Partial) { - 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 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 { diff --git a/src/notifications/NotificationManager.ts b/src/notifications/NotificationManager.ts index fd43c7ee96..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,23 +8,32 @@ 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(); + /** 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). */ - readonly configState: StateStore; + get configState(): StateStore { + return this.configController.state; + } constructor(config: Partial = {}) { this.store = new StateStore({ notifications: [] }); - this.configState = new StateStore( - 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', + }); } /** @@ -36,7 +46,32 @@ export class NotificationManager { /** Deep-merges a partial configuration into the resolved config and notifies subscribers. */ updateConfig(config: Partial) { - this.configState.next((current) => mergeWith({ ...current }, config as object)); + // 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..f4e6b11841 100644 --- a/src/notifications/configuration.ts +++ b/src/notifications/configuration.ts @@ -1,12 +1,14 @@ import type { NotificationManagerConfig } from './types'; +import { deepFreezeConfig } from '../configuration/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 5ebbd09a3b..30f442819b 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/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; @@ -321,7 +323,7 @@ export interface PaginatorPlugin { /** * The value-level subset of paginator configuration that can be supplied declaratively through - * `client.config`. Structural inputs (`itemIndex`, `createItemIndex`) and subclass-installed behaviour + * `client.config`. Structural inputs (`itemIndex`, `createItemIndex`) and subclass behaviour overrides * (`doRequest`, `itemOrderComparator`, `deriveCursor`) are deliberately absent — see * {@link BasePaginator.initializeConfig}. * @@ -442,14 +444,16 @@ 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>; @@ -463,6 +467,8 @@ export abstract class BasePaginator { */ intervalViews: StateStore>; + /** 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. * @@ -474,7 +480,9 @@ export abstract class BasePaginator { * 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. */ - readonly configState: StateStore>; + get configState(): StateStore> { + return this.configController.state; + } /** * Options this paginator was constructed with, kept so {@link initializeConfig} can rebuild the @@ -568,27 +576,56 @@ export abstract class BasePaginator { return 'asc'; } - protected constructor({ - declarativeConfig, - initialCursor, - initialOffset, - itemIndex, - createItemIndex, - ...options - }: PaginatorOptions = {}) { + /** + * @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, + itemIndex, + createItemIndex, + ...options + }: PaginatorOptions = {}, + builtInDefaults: Partial> = {}, + ) { this.explicitOptions = { initialCursor, initialOffset, ...options }; - this.configState = new StateStore>({ - ...DEFAULT_PAGINATION_OPTIONS, - ...this.explicitOptions, - ...(declarativeConfig ?? {}), + 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, }); - this.setStateThrottleOptions({ stateThrottleMs: this.config.stateThrottleMs }); + // 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: [], @@ -717,9 +754,12 @@ export abstract class BasePaginator { return this.configState.getLatestValue(); } - /** Merges a partial configuration into the resolved config and notifies subscribers. */ + /** + * 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.configState.partialNext(config); + this.configController.patch(config); } get pageSize() { @@ -2478,14 +2518,15 @@ export abstract class BasePaginator { * 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(); - // Guarded: `initializeConfig` has already written this value as part of the whole-config `next()`, - // and an unguarded `partialNext` always allocates a new object, so it would emit a second, - // identical notification on every re-derive. - if (this.config.stateThrottleMs !== stateThrottleMs) { - this.updateConfig({ stateThrottleMs } as Partial>); - } this._windowPublishThrottle = undefined; this._viewPublishThrottle = undefined; @@ -2508,7 +2549,7 @@ export abstract class BasePaginator { stateThrottleMs, { leading: true, trailing: true }, ); - }; + } /** * Re-derives this paginator's configuration from its real inputs: package defaults, the options it @@ -2521,23 +2562,38 @@ export abstract class BasePaginator { * 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. - * Subclasses override this to re-install what *their* constructors install — see - * `PinnedMessagePaginator`, whose `doRequest` and comparators live nowhere else. * - * Note that a subclass re-installing its wiring publishes a *second* `configState` notification for - * what is logically one re-derivation. Both carry a complete config, so no subscriber sees a - * half-applied state; collapsing them would mean routing every subclass's structural overlay through - * the base derivation, which is a larger change than the duplicate notification justifies. + * 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.configState.next({ - ...DEFAULT_PAGINATION_OPTIONS, - ...this.explicitOptions, - ...(declarativeConfig ?? {}), - } as BasePaginatorConfig); - - this.setDebounceOptions({ debounceMs: this.config.debounceMs }); - this.setStateThrottleOptions({ stateThrottleMs: this.config.stateThrottleMs }); + 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( diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 1a0f45d18e..48eadf1ea2 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -1,8 +1,8 @@ import type { AnyInterval, + BasePaginatorConfig, CursorDerivator, CursorDeriveResult, - DeclarativePaginatorConfig, Interval, PaginationDirection, PaginationQueryParams, @@ -202,39 +202,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, - }); + 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()}`; @@ -257,29 +268,58 @@ export class MessageIntervalPaginator extends BasePaginator< } /** - * Cursor derivation and in-memory item ordering, both of which this class installs directly on - * `config` rather than passing as constructor options. Kept in a method so the constructor and - * {@link initializeConfig} install them from one place — a re-derivation that rebuilt `config` from - * options alone would otherwise silently drop both. + * 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. */ - protected installIntervalBehaviour(): void { - this.updateConfig({ - 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; - }, - }), - }); + 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; } - override initializeConfig(declarativeConfig?: DeclarativePaginatorConfig): void { - super.initializeConfig(declarativeConfig); - this.installIntervalBehaviour(); + /** + * 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() { diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 24a2205f35..e52012f037 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -1,5 +1,5 @@ import type { - DeclarativePaginatorConfig, + BasePaginatorConfig, ExecuteQueryReturnValue, Interval, PostQueryReconcileParams, @@ -124,34 +124,26 @@ export class MessagePaginator extends MessageIntervalPaginator { */ readonly aggregateState: StateStore; - /** - * The message list raises `stateThrottleMs` from the base's `undefined` to 500ms. Remembered here so - * `initializeConfig` re-applies it: a bare re-derivation would otherwise inherit the base default and - * silently drop the list's render coalescing. - */ - private readonly subclassDefaults: { stateThrottleMs?: number }; - - 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.subclassDefaults = { - stateThrottleMs: options.paginatorOptions?.stateThrottleMs ?? 500, - }; this.unreadStateSnapshot = new StateStore({ lastReadAt: null, firstUnreadMessageId: null, @@ -167,22 +159,6 @@ export class MessagePaginator extends MessageIntervalPaginator { }); } - /** - * Re-derives configuration with this subclass's own default folded in. A declarative slice that names - * `stateThrottleMs` still wins — the fallback only fills the gap the base default would leave. - * - * Passed *into* the base derivation rather than re-applied afterwards: `configState` is a store now, so - * a second write would emit a second notification for what is logically one re-derivation. Precedence - * is unchanged, because `subclassDefaults` already resolves to the constructor's explicit value when - * one was given. - */ - override initializeConfig(declarativeConfig?: DeclarativePaginatorConfig): void { - super.initializeConfig({ - stateThrottleMs: this.subclassDefaults.stateThrottleMs, - ...declarativeConfig, - }); - } - /** * Channel-list sort key: the later of the newest loaded message's `created_at` and the server seed. * **Derived** (never stored) so it cannot drift from {@link lastMessage}. `null` until seeded or a diff --git a/src/pagination/paginators/PinnedMessagePaginator.ts b/src/pagination/paginators/PinnedMessagePaginator.ts index 869473e114..de54180a62 100644 --- a/src/pagination/paginators/PinnedMessagePaginator.ts +++ b/src/pagination/paginators/PinnedMessagePaginator.ts @@ -1,5 +1,5 @@ import type { - DeclarativePaginatorConfig, + BasePaginatorConfig, PaginatorCursor, PaginatorOptions, } from './BasePaginator'; @@ -30,6 +30,8 @@ export type PinnedMessagePaginatorOptions = { paginatorOptions?: PaginatorOptions; }; +const PINNED_AT_SORT: SortParamRequest[] = [{ field: 'pinned_at', direction: 1 }]; + /** * Pinned-message list paginator. * @@ -70,58 +72,71 @@ export class PinnedMessagePaginator extends MessageIntervalPaginator { } /** - * The ordering and request behaviour that makes this a *pinned*-message paginator rather than a plain - * one. Kept in a method so both the constructor and {@link initializeConfig} install it from the same - * place — a re-derivation that reset `config` would otherwise leave the base's `created_at` ordering - * and no `doRequest` at all. + * 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, + [{ direction: 1, field: 'pinned_at' }], + ); + 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, - }); - this.updateConfig({ - 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. - doRequest: async ( - options: MessageQueryShape, - ): Promise<{ cursor?: PaginatorCursor; items: LocalMessage[] }> => { - const { messages } = await this.channel.getPinnedMessages( - options as PinnedMessagePaginationOptions, - [{ direction: 1, field: 'pinned_at' }], - ); - const items = messages.map(formatMessage); - return { cursor: this.getCursorFromQueryResults({ items }), items }; - }, + tiebreaker: this.pinnedTiebreaker, }); - } - - /** - * Re-derives configuration, then **re-installs** the ordering and request behaviour this class sets up - * in its constructor. Those live nowhere but here — they are closures over `this`, so no snapshot of - * configuration values could restore them. Without this override a reset would leave the paginator - * ordering by `created_at` and querying the wrong endpoint. - */ - override initializeConfig(declarativeConfig?: DeclarativePaginatorConfig): void { - super.initializeConfig(declarativeConfig); - this.installPinnedMessageBehaviour(); + this.updateConfig(this.buildPinnedBehaviour()); } buildMatchFilters = (): PinnedMessagePaginatorFilter => ({ diff --git a/src/reminders/ReminderManager.ts b/src/reminders/ReminderManager.ts index e6b5791e06..b9105490a8 100644 --- a/src/reminders/ReminderManager.ts +++ b/src/reminders/ReminderManager.ts @@ -1,6 +1,8 @@ import { Reminder } from './Reminder'; +import { deepFreezeConfig } from '../configuration/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,19 +59,35 @@ 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); @@ -85,16 +103,25 @@ export class ReminderManager extends WithSubscriptions { } 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 f6cb176fb8..a63f819b99 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/applyInstanceConfiguration'; +import { deepFreezeConfig } from '../configuration/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 service. 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 @@ -32,23 +50,46 @@ export class SearchController { _internalState: StateStore; state: StateStore; + /** 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). */ - readonly configState: StateStore; + get configState(): StateStore { + return this.configController.state; + } - constructor({ config, sources }: SearchControllerOptions = {}) { + constructor({ client, config, sources }: SearchControllerOptions = {}) { this.state = new StateStore({ isActive: false, searchQuery: '', sources: sources ?? [], }); this._internalState = new StateStore({}); - this.configState = new StateStore({ - 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; } /** @@ -61,7 +102,12 @@ export class SearchController { /** Merges a partial configuration into the resolved config and notifies subscribers. */ updateConfig(config: Partial) { - this.configState.partialNext(config); + this.configController.patch(config); + } + + /** Rebuilds the resolved configuration from package defaults plus the declarative slice. */ + initializeConfig(config?: Partial) { + this.configController.initialize(config); } get hasNext() { diff --git a/src/thread.ts b/src/thread.ts index 8e95769aca..a7d122c591 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -30,15 +30,16 @@ import type { StreamChat } from './client'; import type { CustomThreadData } from './custom_types'; import { MessageComposer } from './messageComposer'; import { MessageOperations } from './messageOperations'; -import { DEFAULT_MESSAGE_OPERATIONS_CONFIG } from './messageOperations/MessageOperations'; import { WithSubscriptions } from './utils/WithSubscriptions'; +import { isEqual } from './utils/mergeWith/mergeWithCore'; import { MessagePaginator } from './pagination'; import { applyInstanceConfiguration } from './configuration/applyInstanceConfiguration'; import type { ThreadDeclarativeConfig } from './configuration/types'; import { mergeDeclarativeMessageOperationsConfig, mergeDeclarativePaginatorConfig, -} from './configuration/types'; + toDeclarativePaginatorConfig, +} from './configuration/declarativeSlices'; import type { PipelineEvent } from './EventHandlerPipeline'; export type ThreadState = { @@ -208,17 +209,23 @@ export class Thread extends WithSubscriptions { declarativeConfig?.messagePaginator, ); - this.messagePaginator = new MessagePaginator({ - channel: this.channel, - parentMessageId: this.id, - requestSort: DEFAULT_SORT, - itemOrder: DEFAULT_ITEM_ORDER, - unreadReferencePolicy: messagePaginatorConfig?.unreadReferencePolicy, - paginatorOptions: { - pageSize: DEFAULT_PAGE_LIMIT, - declarativeConfig: messagePaginatorConfig, + 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 @@ -327,24 +334,32 @@ export class Thread extends WithSubscriptions { */ initializeConfig(declarativeConfig?: ThreadDeclarativeConfig): void { // Replaces rather than merges: a handler dropped from the declarative tree must disappear. - this.configState.next({ requestHandlers: declarativeConfig?.requestHandlers }); + // Guarded against a no-op publish exactly as `Channel.initializeConfig` is — same freshly allocated + // object, same `alsoWatch` re-run, and `useThreadRequestHandlers` subscribes to this store too. + const nextRequestHandlers = declarativeConfig?.requestHandlers; + if ( + !isEqual(this.configState.getLatestValue().requestHandlers, nextRequestHandlers) + ) { + this.configState.next({ requestHandlers: nextRequestHandlers }); + } this.messagePaginator.initializeConfig( - mergeDeclarativePaginatorConfig( - this.client.config.getConfig('messagePaginator') ?? undefined, - declarativeConfig?.messagePaginator, + 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.updateConfig({ - ...DEFAULT_MESSAGE_OPERATIONS_CONFIG, - ...mergeDeclarativeMessageOperationsConfig( + this.messageOperations.initializeConfig( + mergeDeclarativeMessageOperationsConfig( this.client.config.getConfig('messageOperations') ?? undefined, declarativeConfig?.messageOperations, ), - }); + ); } get channel() { @@ -465,10 +480,24 @@ export class Thread extends WithSubscriptions { * Subscribes this thread to the `'thread'` configuration key. Registered through * `WithSubscriptions`, so `unregisterSubscriptions()` runs the setup function's teardown. * - * Note the consequence: a thread that never calls `registerSubscriptions()` gets no *setup function* - * — matching how `MessageComposer` already behaves. Declarative configuration is unaffected, because - * the constructor applies it directly. Applying the setup function at construction instead would - * break the teardown symmetry that `WithSubscriptions` provides. + * **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 service'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({ diff --git a/src/thread_manager.ts b/src/thread_manager.ts index 8bd7ee825e..d5d4b01d2b 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -1,5 +1,7 @@ import { chatLoggerSystem } from './logger'; +import { deepFreezeConfig } from './configuration/deepFreezeConfig'; import { StateStore } from './store'; +import { ConfigController } from './configuration/ConfigController'; import { throttle } from './utils'; import type { StreamChat } from './client'; @@ -26,9 +28,9 @@ export type ThreadManagerConfig = { connectionRecoveryThrottleMs: number; }; -export const DEFAULT_THREAD_MANAGER_CONFIG: ThreadManagerConfig = { +export const DEFAULT_THREAD_MANAGER_CONFIG: ThreadManagerConfig = deepFreezeConfig({ connectionRecoveryThrottleMs: 1000, -}; +}); const MAX_QUERY_THREADS_LIMIT = 25; export const THREAD_MANAGER_INITIAL_STATE = { active: false, @@ -86,17 +88,21 @@ 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`). */ - public readonly configState: StateStore; + get configState(): StateStore { + return this.configController.state; + } constructor({ client }: { client: StreamChat }) { super(); - this.configState = new StateStore({ - ...DEFAULT_THREAD_MANAGER_CONFIG, + this.configController = new ConfigController({ + defaults: DEFAULT_THREAD_MANAGER_CONFIG, }); this.client = client; this.state = new StateStore(THREAD_MANAGER_INITIAL_STATE); @@ -111,7 +117,25 @@ export class ThreadManager extends WithSubscriptions { /** Merges a partial configuration into the resolved config and notifies subscribers. */ public updateConfig(config: Partial) { - 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 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() { diff --git a/test/unit/configuration/ConfigController.test.ts b/test/unit/configuration/ConfigController.test.ts new file mode 100644 index 0000000000..88afafc83c --- /dev/null +++ b/test/unit/configuration/ConfigController.test.ts @@ -0,0 +1,181 @@ +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); + }); + }); + + 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/channel.config.test.ts b/test/unit/configuration/channel.config.test.ts index e7c0789e01..6f3fed6859 100644 --- a/test/unit/configuration/channel.config.test.ts +++ b/test/unit/configuration/channel.config.test.ts @@ -202,7 +202,13 @@ describe("the 'channel' configuration key", () => { client.config.reset('channel'); // Re-derivation re-installs it; a snapshot of config values never could have. - expect(channel.messagePaginator.config.itemOrderComparator).not.toBe(original); + // + // 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; diff --git a/test/unit/configuration/client.config.test.ts b/test/unit/configuration/client.config.test.ts index 22ab10d370..1ac9ad39b1 100644 --- a/test/unit/configuration/client.config.test.ts +++ b/test/unit/configuration/client.config.test.ts @@ -1,5 +1,7 @@ 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/applyInstanceConfiguration'; describe('client.config', () => { @@ -86,6 +88,171 @@ describe('client.config', () => { // `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'); diff --git a/test/unit/configuration/configBoundaries.test.ts b/test/unit/configuration/configBoundaries.test.ts index 0356de343e..8267a021b5 100644 --- a/test/unit/configuration/configBoundaries.test.ts +++ b/test/unit/configuration/configBoundaries.test.ts @@ -154,3 +154,124 @@ describe('configuration boundaries', () => { }); }); }); + +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({ + type: 'messaging', + 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({ + type: 'messaging', + 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({ + type: 'messaging', + 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 index 34523b8627..02d7316c23 100644 --- a/test/unit/configuration/configPublishing.test.ts +++ b/test/unit/configuration/configPublishing.test.ts @@ -1,6 +1,9 @@ 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'; /** @@ -15,8 +18,17 @@ import type { StreamChat } from '../../../src/client'; * 10-channel page with three open composers: **30 publishes and 30 subscriber runs, down to 3** — one per * composer, for the config genuinely arriving the first time. * - * Two guards, at the source and at the sink, because they cover different routes: the source stops the work - * happening at all, the sink catches everything else that resolves to an unchanged value. + * 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; @@ -211,4 +223,103 @@ describe('configuration publishes skip no-ops', () => { 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 index cb8dd25cea..6ab77fd9a1 100644 --- a/test/unit/configuration/configShape.test.ts +++ b/test/unit/configuration/configShape.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { CONSTRUCTION_ONLY_CONFIG_PATHS, INSTANCE_CONFIG_TREE_KEYS, -} from '../../../src/configuration/types'; +} from '../../../src/configuration/keys'; import { flattenConfigShape, INSTANCE_CONFIG_TREE_SHAPE, diff --git a/test/unit/configuration/configState.unification.test.ts b/test/unit/configuration/configState.unification.test.ts index ee7c84b940..be6ea732cc 100644 --- a/test/unit/configuration/configState.unification.test.ts +++ b/test/unit/configuration/configState.unification.test.ts @@ -14,8 +14,15 @@ import type { StreamChat } from '../../../src/client'; * 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 every configurable class', () => { +describe('resolved configuration is reactive on the classes that were converted to a store', () => { let client: StreamChat; let channelResponse: ReturnType['channel']; diff --git a/test/unit/configuration/configurableInTree.test.ts b/test/unit/configuration/configurableInTree.test.ts index a85229728d..92ccbb9627 100644 --- a/test/unit/configuration/configurableInTree.test.ts +++ b/test/unit/configuration/configurableInTree.test.ts @@ -4,7 +4,9 @@ import { generateMsg } from '../test-utils/generateMessage'; import { generateThreadResponse } from '../test-utils/generateThreadResponse'; import { getClientWithUser } from '../test-utils/getClient'; import { Thread } from '../../../src/thread'; -import { INSTANCE_CONFIG_TREE_KEYS } from '../../../src/configuration/types'; +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'; /** @@ -99,6 +101,27 @@ describe('every configurable object has a path in the configuration tree', () => 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 } } }), diff --git a/test/unit/configuration/defaultConfigImmutability.test.ts b/test/unit/configuration/defaultConfigImmutability.test.ts new file mode 100644 index 0000000000..7c3b8ebf90 --- /dev/null +++ b/test/unit/configuration/defaultConfigImmutability.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { StreamChat } from '../../../src/client'; +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_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_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_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 index f39962a7f1..90fdaf865b 100644 --- a/test/unit/configuration/instanceConfiguration.integration.test.ts +++ b/test/unit/configuration/instanceConfiguration.integration.test.ts @@ -173,14 +173,23 @@ describe('instance configuration — cross-instance', () => { it('changes observable behaviour for the read-once paginator fields', () => { client.config.set({ channel: { messagePaginator: { stateThrottleMs: 250 } } }); const channel = openChannel(); - const setDebounce = vi.spyOn(channel.messagePaginator, 'setDebounceOptions'); - const setThrottle = vi.spyOn(channel.messagePaginator, 'setStateThrottleOptions'); + 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 } }); - // Both go through their rebuild setters; a plain assignment would be discarded. - expect(setDebounce).toHaveBeenCalledWith({ debounceMs: 900 }); - expect(setThrottle).toHaveBeenCalledWith({ stateThrottleMs: 250 }); + // 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); }); }); diff --git a/test/unit/configuration/messagePaginator.config.test.ts b/test/unit/configuration/messagePaginator.config.test.ts index c2524de444..c6432e2732 100644 --- a/test/unit/configuration/messagePaginator.config.test.ts +++ b/test/unit/configuration/messagePaginator.config.test.ts @@ -3,11 +3,12 @@ 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/types'; +} from '../../../src/configuration/declarativeSlices'; import type { StreamChat } from '../../../src/client'; /** @@ -58,6 +59,94 @@ describe("the shared 'messagePaginator' configuration key", () => { 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' } }); @@ -198,14 +287,35 @@ describe("the shared 'messagePaginator' configuration key", () => { } }); - it('routes read-once fields through their rebuild setters when set late', () => { + 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 setThrottle = vi.spyOn(channel.messagePaginator, 'setStateThrottleOptions'); + const internals = channel.messagePaginator as unknown as { + _windowPublishThrottle: unknown; + }; + const before = internals._windowPublishThrottle; client.config.set({ messagePaginator: { stateThrottleMs: 350 } }); - expect(setThrottle).toHaveBeenCalledWith({ 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', () => { diff --git a/test/unit/configuration/selfRegisteringEntities.test.ts b/test/unit/configuration/selfRegisteringEntities.test.ts new file mode 100644 index 0000000000..e5b22c7b88 --- /dev/null +++ b/test/unit/configuration/selfRegisteringEntities.test.ts @@ -0,0 +1,134 @@ +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 unregister', () => { + 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(); + + expect(teardown).toHaveBeenCalledTimes(1); + }); + + it('stops hearing changes once unregistered', () => { + const manager = makeLiveLocation(); + manager.registerSubscriptions(); + manager.unregisterSubscriptions(); + + client.config.set({ liveLocationManager: { minUpdateThrottleMs: 7_000 } }); + + expect(manager.config.minUpdateThrottleMs).toBe( + DEFAULT_LIVE_LOCATION_MANAGER_CONFIG.minUpdateThrottleMs, + ); + }); + }); + + 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/pagination/paginator.initializeConfig.test.ts b/test/unit/pagination/paginator.initializeConfig.test.ts index e3179c1f61..f113c8caef 100644 --- a/test/unit/pagination/paginator.initializeConfig.test.ts +++ b/test/unit/pagination/paginator.initializeConfig.test.ts @@ -45,11 +45,12 @@ describe('paginator initializeConfig', () => { expect(paginator.config[field]).toEqual(fromConstructor[field]); } - // Behavioural fields are re-installed as fresh closures over `this`, so their identities differ - // by design — what matters is that they are present rather than lost. + // 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(typeof paginator.config[field]).toBe('function'); + expect(paginator.config[field]).toBe(fromConstructor[field]); } }); @@ -198,4 +199,119 @@ describe('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/v9-to-v10-migration-guide-type-renames.md b/v9-to-v10-migration-guide-type-renames.md index 127b522993..caf9d4bf76 100644 --- a/v9-to-v10-migration-guide-type-renames.md +++ b/v9-to-v10-migration-guide-type-renames.md @@ -5,6 +5,8 @@ > 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. 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. ## How to apply @@ -25,49 +27,52 @@ v10 exposes two generated types whose names collide with v9 aliases that pointed ## Rename table -| v9 (removed) | v10 (use this) | Notes | -| ------------------------------ | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `APIErrorResponse` | `APIError` | The v9 alias pointed at the generated `APIError`. Do not confuse with the local `APIError` in `src/errors.ts` — that's a different `Error & { code }` shape and is unaffected by this rename. If a file uses both, import the generated one with an `as Gen_APIError` alias. | -| `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` | The v10 `Automod` is a hand-authored string union (`'disabled' \| 'simple' \| 'AI' \| (string & {})`) in `src/types.ts`, not a generated re-export. Values are identical to v9. | -| `ChannelConfigAutomodBehavior` | `AutomodBehavior` | Same story — hand-authored union (`'flag' \| 'block' \| 'shadow_block' \| (string & {})`) in `src/types.ts`. Values are identical to v9. | -| `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` | Trivial 1:1 alias. | -| `ErrorFromResponse` | `StreamAPIError` | **Runtime value**, not just a type — was `export const ErrorFromResponse = StreamAPIError;`. Rewrite `instanceof ErrorFromResponse` and `new ErrorFromResponse(...)` call sites too. | -| `EventAPIResponse` | `APIResponse` + WS `Event` | The endpoint that used to return an event over HTTP is gone. Consume the response as `APIResponse` and pick up the event from the WS stream (`Event`). | -| `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. | -| `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` | The v9 alias pointed at the generated `APIError`. Do not confuse with the local `APIError` in `src/errors.ts` — that's a different `Error & { code }` shape and is unaffected by this rename. If a file uses both, import the generated one with an `as Gen_APIError` alias. | +| `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` | The v10 `Automod` is a hand-authored string union (`'disabled' \| 'simple' \| 'AI' \| (string & {})`) in `src/types.ts`, not a generated re-export. Values are identical to v9. | +| `ChannelConfigAutomodBehavior` | `AutomodBehavior` | Same story — hand-authored union (`'flag' \| 'block' \| 'shadow_block' \| (string & {})`) in `src/types.ts`. Values are identical to v9. | +| `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` | Trivial 1:1 alias. | +| `ErrorFromResponse` | `StreamAPIError` | **Runtime value**, not just a type — was `export const ErrorFromResponse = StreamAPIError;`. Rewrite `instanceof ErrorFromResponse` and `new ErrorFromResponse(...)` call sites too. | +| `EventAPIResponse` | `APIResponse` + WS `Event` | The endpoint that used to return an event over HTTP is gone. Consume the response as `APIResponse` and pick up the event from the WS stream (`Event`). | +| `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. | +| `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. | +| `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. | ## Types that are **not** renamed (kept as-is) From 0489989cfd9ca7e7f9e7d488b6ff92bba212572c Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 17 Aug 2026 21:20:46 +0200 Subject: [PATCH 11/22] refactor: move config utility functions into a dedicated folder --- src/LiveLocationManager.ts | 4 ++-- src/channel.ts | 4 ++-- src/client.ts | 2 +- src/configuration/ConfigController.ts | 4 ++-- src/configuration/InstanceConfigurationService.ts | 2 +- src/configuration/index.ts | 3 +-- .../{ => utils}/applyInstanceConfiguration.ts | 10 +++++----- src/configuration/{ => utils}/copyConfigPatch.ts | 2 +- src/configuration/{ => utils}/declarativeSlices.ts | 6 +++--- src/configuration/{ => utils}/deepFreezeConfig.ts | 0 src/configuration/utils/index.ts | 5 +++++ src/configuration/{ => utils}/serverAuthority.ts | 6 +++--- src/index.ts | 8 ++++---- src/messageComposer/configuration/configuration.ts | 2 +- src/messageComposer/messageComposer.ts | 8 ++++---- src/messageDelivery/MessageDeliveryReporter.ts | 2 +- src/messageOperations/MessageOperations.ts | 2 +- src/notifications/configuration.ts | 2 +- src/pagination/paginators/BasePaginator.ts | 2 +- src/reminders/ReminderManager.ts | 2 +- src/search/SearchController.ts | 4 ++-- src/thread.ts | 4 ++-- src/thread_manager.ts | 2 +- .../configuration/applyInstanceConfiguration.test.ts | 2 +- test/unit/configuration/client.config.test.ts | 2 +- .../unit/configuration/messagePaginator.config.test.ts | 2 +- test/unit/configuration/serverAuthority.test.ts | 2 +- 27 files changed, 49 insertions(+), 45 deletions(-) rename src/configuration/{ => utils}/applyInstanceConfiguration.ts (97%) rename src/configuration/{ => utils}/copyConfigPatch.ts (97%) rename src/configuration/{ => utils}/declarativeSlices.ts (93%) rename src/configuration/{ => utils}/deepFreezeConfig.ts (100%) create mode 100644 src/configuration/utils/index.ts rename src/configuration/{ => utils}/serverAuthority.ts (97%) diff --git a/src/LiveLocationManager.ts b/src/LiveLocationManager.ts index 7e1c991c85..1af3dc1fa2 100644 --- a/src/LiveLocationManager.ts +++ b/src/LiveLocationManager.ts @@ -9,10 +9,10 @@ */ import { withCancellation } from './utils/concurrency'; -import { deepFreezeConfig } from './configuration/deepFreezeConfig'; +import { deepFreezeConfig } from './configuration/utils/deepFreezeConfig'; import { StateStore } from './store'; import { ConfigController } from './configuration/ConfigController'; -import { applyInstanceConfiguration } from './configuration/applyInstanceConfiguration'; +import { applyInstanceConfiguration } from './configuration/utils/applyInstanceConfiguration'; import { WithSubscriptions } from './utils/WithSubscriptions'; import type { StreamChat } from './client'; import type { Unsubscribe } from './store'; diff --git a/src/channel.ts b/src/channel.ts index 91aea26426..efbe627053 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -17,13 +17,13 @@ import { } from './utils'; import type { StreamChat } from './client'; import { chatLoggerSystem } from './logger'; -import { applyInstanceConfiguration } from './configuration/applyInstanceConfiguration'; +import { applyInstanceConfiguration } from './configuration/utils/applyInstanceConfiguration'; import type { ChannelDeclarativeConfig } from './configuration/types'; import { mergeDeclarativeMessageOperationsConfig, mergeDeclarativePaginatorConfig, toDeclarativePaginatorConfig, -} from './configuration/declarativeSlices'; +} from './configuration/utils/declarativeSlices'; import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from './constants'; import type { AIState, diff --git a/src/client.ts b/src/client.ts index 080bb1c3ec..62a850bd0b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -83,7 +83,7 @@ import { isEqual } from './utils/mergeWith/mergeWithCore'; import type { MessageComposer } from './messageComposer'; import type { InstanceSetupState } from './configuration'; import { InstanceConfigurationService } from './configuration/InstanceConfigurationService'; -import { applyInstanceConfiguration } from './configuration/applyInstanceConfiguration'; +import { applyInstanceConfiguration } from './configuration/utils/applyInstanceConfiguration'; import { StateStore } from './store'; import type { Unsubscribe } from './store'; import type { diff --git a/src/configuration/ConfigController.ts b/src/configuration/ConfigController.ts index 2da6603afb..3d89a6058f 100644 --- a/src/configuration/ConfigController.ts +++ b/src/configuration/ConfigController.ts @@ -1,8 +1,8 @@ import { StateStore } from '../store'; import { mergeWith } from '../utils/mergeWith'; import { isEqual } from '../utils/mergeWith/mergeWithCore'; -import { copyConfigPatch } from './copyConfigPatch'; -import { deepFreezeConfig } from './deepFreezeConfig'; +import { copyConfigPatch } from './utils/copyConfigPatch'; +import { deepFreezeConfig } from './utils/deepFreezeConfig'; export type ConfigControllerOptions> = { /** diff --git a/src/configuration/InstanceConfigurationService.ts b/src/configuration/InstanceConfigurationService.ts index 381d22fb32..94d838f6cd 100644 --- a/src/configuration/InstanceConfigurationService.ts +++ b/src/configuration/InstanceConfigurationService.ts @@ -26,7 +26,7 @@ import { StateStore } from '../store'; import { chatLoggerSystem } from '../logger'; import { mergeWith } from '../utils/mergeWith'; import { isEqual } from '../utils/mergeWith/mergeWithCore'; -import { copyConfigPatch } from './copyConfigPatch'; +import { copyConfigPatch } from './utils/copyConfigPatch'; import { getPath, hasPath, isWalkableRecord } from '../utils/objectPath'; import { BUILT_IN_INSTANCE_KEYS, CONSTRUCTION_ONLY_CONFIG_PATHS } from './keys'; import type { diff --git a/src/configuration/index.ts b/src/configuration/index.ts index e2696177e5..ec4fabb78b 100644 --- a/src/configuration/index.ts +++ b/src/configuration/index.ts @@ -1,7 +1,6 @@ -export * from './applyInstanceConfiguration'; -export * from './serverAuthority'; 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, diff --git a/src/configuration/applyInstanceConfiguration.ts b/src/configuration/utils/applyInstanceConfiguration.ts similarity index 97% rename from src/configuration/applyInstanceConfiguration.ts rename to src/configuration/utils/applyInstanceConfiguration.ts index d18f44bbf6..e9471e6188 100644 --- a/src/configuration/applyInstanceConfiguration.ts +++ b/src/configuration/utils/applyInstanceConfiguration.ts @@ -1,16 +1,16 @@ -import { chatLoggerSystem } from '../logger'; +import { chatLoggerSystem } from '../../logger'; import type { ConfiguredInstance, InstanceConfigurationService, -} from './InstanceConfigurationService'; +} from '../InstanceConfigurationService'; import type { InstanceConfigOf, InstanceSetupFunctionArgsOf, InstanceSetupKey, InstanceSetupTearDownFunction, -} from './types'; -import type { DeepPartial } from '../types.utility'; -import type { Unsubscribe } from '../store'; +} from '../types'; +import type { DeepPartial } from '../../types.utility'; +import type { Unsubscribe } from '../../store'; const logger = chatLoggerSystem.getLogger('instance-configuration'); diff --git a/src/configuration/copyConfigPatch.ts b/src/configuration/utils/copyConfigPatch.ts similarity index 97% rename from src/configuration/copyConfigPatch.ts rename to src/configuration/utils/copyConfigPatch.ts index 63a87907a1..a12e90054f 100644 --- a/src/configuration/copyConfigPatch.ts +++ b/src/configuration/utils/copyConfigPatch.ts @@ -1,4 +1,4 @@ -import { isWalkableRecord } from '../utils/objectPath'; +import { isWalkableRecord } from '../../utils/objectPath'; /** * Copies a caller-supplied configuration patch, so the value the SDK stores shares no mutable object with diff --git a/src/configuration/declarativeSlices.ts b/src/configuration/utils/declarativeSlices.ts similarity index 93% rename from src/configuration/declarativeSlices.ts rename to src/configuration/utils/declarativeSlices.ts index 4d65bd1e09..c1c65c7b4a 100644 --- a/src/configuration/declarativeSlices.ts +++ b/src/configuration/utils/declarativeSlices.ts @@ -1,6 +1,6 @@ -import type { DeclarativePaginatorConfig } from '../pagination/paginators/BasePaginator'; -import type { MessageOperationsConfig } from '../messageOperations/MessageOperations'; -import type { DeclarativeMessagePaginatorConfig } from './types'; +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. diff --git a/src/configuration/deepFreezeConfig.ts b/src/configuration/utils/deepFreezeConfig.ts similarity index 100% rename from src/configuration/deepFreezeConfig.ts rename to src/configuration/utils/deepFreezeConfig.ts diff --git a/src/configuration/utils/index.ts b/src/configuration/utils/index.ts new file mode 100644 index 0000000000..246d414922 --- /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. `copyConfigPatch`, `deepFreezeConfig` and +// `declarativeSlices` are `@internal` and are imported by path instead. +export * from './applyInstanceConfiguration'; +export * from './serverAuthority'; diff --git a/src/configuration/serverAuthority.ts b/src/configuration/utils/serverAuthority.ts similarity index 97% rename from src/configuration/serverAuthority.ts rename to src/configuration/utils/serverAuthority.ts index ff4b84d460..26daaa78ab 100644 --- a/src/configuration/serverAuthority.ts +++ b/src/configuration/utils/serverAuthority.ts @@ -1,6 +1,6 @@ -import { mergeWith } from '../utils/mergeWith'; -import type { MergeWithCustomizer } from '../utils/mergeWith/mergeWithCore'; -import type { DeepPartial } from '../types.utility'; +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 diff --git a/src/index.ts b/src/index.ts index 7f0ae3eef8..c85c554b4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,8 +6,8 @@ export * from './channel_state'; // Don't use * here: `export *` can break module augmentation of `InstanceSetupFunctionArgs` and // `InstanceConfigTree`, the same reason the `Custom*Data` interfaces below are listed explicitly. // https://github.com/microsoft/TypeScript/issues/46617 -export { applyInstanceConfiguration } from './configuration/applyInstanceConfiguration'; -export type { ApplyInstanceConfigurationParams } from './configuration/applyInstanceConfiguration'; +export { applyInstanceConfiguration } from './configuration/utils/applyInstanceConfiguration'; +export type { ApplyInstanceConfigurationParams } from './configuration/utils/applyInstanceConfiguration'; export { ConfigController } from './configuration/ConfigController'; export type { ConfigControllerOptions } from './configuration/ConfigController'; // Named in the signatures of `client.config.set` / `setConfig`, so a caller has to be able to write it. @@ -38,11 +38,11 @@ export type { ConfiguredInstance, InstanceConfigurationService, } from './configuration/InstanceConfigurationService'; -export { mergeServerRestrictions } from './configuration/serverAuthority'; +export { mergeServerRestrictions } from './configuration/utils/serverAuthority'; export type { ServerRestrictions, ServerUpperBounds, -} from './configuration/serverAuthority'; +} from './configuration/utils/serverAuthority'; export { flattenConfigShape, INSTANCE_CONFIG_TREE_SHAPE } from './configuration/shape'; export type { ConfigGroupNode, diff --git a/src/messageComposer/configuration/configuration.ts b/src/messageComposer/configuration/configuration.ts index b7d619b62a..c67c9c10e2 100644 --- a/src/messageComposer/configuration/configuration.ts +++ b/src/messageComposer/configuration/configuration.ts @@ -8,7 +8,7 @@ import type { TextComposerConfig, } from './types'; import { generateUUIDv4 } from '../../utils'; -import { deepFreezeConfig } from '../../configuration/deepFreezeConfig'; +import { deepFreezeConfig } from '../../configuration/utils/deepFreezeConfig'; import { DEFAULT_COMMANDS_CONFIG } from './commands.configuration'; export const DEFAULT_LINK_PREVIEW_MANAGER_CONFIG: LinkPreviewsManagerConfig = { diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index 92ea6a433f..dcff8a8a04 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -15,12 +15,12 @@ import type { Unsubscribe } from '../store'; import { StateStore } from '../store'; import { formatMessage, generateUUIDv4, isLocalMessage } from '../utils'; import { ConfigController } from '../configuration/ConfigController'; -import { deepFreezeConfig } from '../configuration/deepFreezeConfig'; -import { mergeServerRestrictions } from '../configuration/serverAuthority'; +import { deepFreezeConfig } from '../configuration/utils/deepFreezeConfig'; +import { mergeServerRestrictions } from '../configuration/utils/serverAuthority'; import type { ServerRestrictions, ServerUpperBounds, -} from '../configuration/serverAuthority'; +} from '../configuration/utils/serverAuthority'; import { Channel } from '../channel'; import { Thread } from '../thread'; import type { @@ -35,7 +35,7 @@ import type { UserResponse, } from '../types'; import { chatLoggerSystem } from '../logger'; -import { applyInstanceConfiguration } from '../configuration/applyInstanceConfiguration'; +import { applyInstanceConfiguration } from '../configuration/utils/applyInstanceConfiguration'; import { WithSubscriptions } from '../utils/WithSubscriptions'; import type { StreamChat } from '../client'; import type { CommandSendability, MessageComposerConfig } from './configuration/types'; diff --git a/src/messageDelivery/MessageDeliveryReporter.ts b/src/messageDelivery/MessageDeliveryReporter.ts index ef2a145884..a40c1e9699 100644 --- a/src/messageDelivery/MessageDeliveryReporter.ts +++ b/src/messageDelivery/MessageDeliveryReporter.ts @@ -1,5 +1,5 @@ import type { StreamChat } from '../client'; -import { deepFreezeConfig } from '../configuration/deepFreezeConfig'; +import { deepFreezeConfig } from '../configuration/utils/deepFreezeConfig'; import type { StateStore } from '../store'; import { ConfigController } from '../configuration/ConfigController'; import { Channel } from '../channel'; diff --git a/src/messageOperations/MessageOperations.ts b/src/messageOperations/MessageOperations.ts index c469ccd575..b5f0d86291 100644 --- a/src/messageOperations/MessageOperations.ts +++ b/src/messageOperations/MessageOperations.ts @@ -1,6 +1,6 @@ // todo: add tests import type { MessageRequest, UpdateMessageOptions } from '../types'; -import { deepFreezeConfig } from '../configuration/deepFreezeConfig'; +import { deepFreezeConfig } from '../configuration/utils/deepFreezeConfig'; import type { StateStore } from '../store'; import { ConfigController } from '../configuration/ConfigController'; import { formatMessage, localMessageToNewMessagePayload } from '../utils'; diff --git a/src/notifications/configuration.ts b/src/notifications/configuration.ts index f4e6b11841..fdf4c748e9 100644 --- a/src/notifications/configuration.ts +++ b/src/notifications/configuration.ts @@ -1,5 +1,5 @@ import type { NotificationManagerConfig } from './types'; -import { deepFreezeConfig } from '../configuration/deepFreezeConfig'; +import { deepFreezeConfig } from '../configuration/utils/deepFreezeConfig'; const DURATION_MS = 3000 as const; diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 30f442819b..f86235cafc 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -1,5 +1,5 @@ import type { ItemLocation } from '../sortCompiler'; -import { deepFreezeConfig } from '../../configuration/deepFreezeConfig'; +import { deepFreezeConfig } from '../../configuration/utils/deepFreezeConfig'; import { binarySearch } from '../sortCompiler'; import { itemMatchesFilter } from '../filterCompiler'; import { isPatch, StateStore, type ValueOrPatch } from '../../store'; diff --git a/src/reminders/ReminderManager.ts b/src/reminders/ReminderManager.ts index b9105490a8..2bce1d38ad 100644 --- a/src/reminders/ReminderManager.ts +++ b/src/reminders/ReminderManager.ts @@ -1,5 +1,5 @@ import { Reminder } from './Reminder'; -import { deepFreezeConfig } from '../configuration/deepFreezeConfig'; +import { deepFreezeConfig } from '../configuration/utils/deepFreezeConfig'; import { DEFAULT_STOP_REFRESH_BOUNDARY_MS } from './ReminderTimer'; import { StateStore } from '../store'; import { ConfigController } from '../configuration/ConfigController'; diff --git a/src/search/SearchController.ts b/src/search/SearchController.ts index a63f819b99..411008fa1f 100644 --- a/src/search/SearchController.ts +++ b/src/search/SearchController.ts @@ -4,8 +4,8 @@ import type { MessageResponse } from '../types'; import type { StreamChat } from '../client'; import type { SearchSource } from './BaseSearchSource'; import { ConfigController } from '../configuration/ConfigController'; -import { applyInstanceConfiguration } from '../configuration/applyInstanceConfiguration'; -import { deepFreezeConfig } from '../configuration/deepFreezeConfig'; +import { applyInstanceConfiguration } from '../configuration/utils/applyInstanceConfiguration'; +import { deepFreezeConfig } from '../configuration/utils/deepFreezeConfig'; export type SearchControllerState = { isActive: boolean; diff --git a/src/thread.ts b/src/thread.ts index a7d122c591..3cb0ae9013 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -33,13 +33,13 @@ import { MessageOperations } from './messageOperations'; import { WithSubscriptions } from './utils/WithSubscriptions'; import { isEqual } from './utils/mergeWith/mergeWithCore'; import { MessagePaginator } from './pagination'; -import { applyInstanceConfiguration } from './configuration/applyInstanceConfiguration'; +import { applyInstanceConfiguration } from './configuration/utils/applyInstanceConfiguration'; import type { ThreadDeclarativeConfig } from './configuration/types'; import { mergeDeclarativeMessageOperationsConfig, mergeDeclarativePaginatorConfig, toDeclarativePaginatorConfig, -} from './configuration/declarativeSlices'; +} from './configuration/utils/declarativeSlices'; import type { PipelineEvent } from './EventHandlerPipeline'; export type ThreadState = { diff --git a/src/thread_manager.ts b/src/thread_manager.ts index d5d4b01d2b..48a6e0dc7e 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -1,5 +1,5 @@ import { chatLoggerSystem } from './logger'; -import { deepFreezeConfig } from './configuration/deepFreezeConfig'; +import { deepFreezeConfig } from './configuration/utils/deepFreezeConfig'; import { StateStore } from './store'; import { ConfigController } from './configuration/ConfigController'; import { throttle } from './utils'; diff --git a/test/unit/configuration/applyInstanceConfiguration.test.ts b/test/unit/configuration/applyInstanceConfiguration.test.ts index 88258b89ad..e5f978a3a7 100644 --- a/test/unit/configuration/applyInstanceConfiguration.test.ts +++ b/test/unit/configuration/applyInstanceConfiguration.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { InstanceConfigurationService } from '../../../src/configuration/InstanceConfigurationService'; -import { applyInstanceConfiguration } from '../../../src/configuration/applyInstanceConfiguration'; +import { applyInstanceConfiguration } from '../../../src/configuration/utils/applyInstanceConfiguration'; /** Stands in for a keyed instance. `applyInstanceConfiguration` never inspects its argument. */ const instance = () => ({ widget: {} }) as never; diff --git a/test/unit/configuration/client.config.test.ts b/test/unit/configuration/client.config.test.ts index 1ac9ad39b1..3478e58cda 100644 --- a/test/unit/configuration/client.config.test.ts +++ b/test/unit/configuration/client.config.test.ts @@ -2,7 +2,7 @@ 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/applyInstanceConfiguration'; +import { applyInstanceConfiguration } from '../../../src/configuration/utils/applyInstanceConfiguration'; describe('client.config', () => { it('exposes config', () => { diff --git a/test/unit/configuration/messagePaginator.config.test.ts b/test/unit/configuration/messagePaginator.config.test.ts index c6432e2732..fb7b78c88d 100644 --- a/test/unit/configuration/messagePaginator.config.test.ts +++ b/test/unit/configuration/messagePaginator.config.test.ts @@ -8,7 +8,7 @@ import { Thread } from '../../../src/thread'; import { mergeDeclarativeMessageOperationsConfig, mergeDeclarativePaginatorConfig, -} from '../../../src/configuration/declarativeSlices'; +} from '../../../src/configuration/utils/declarativeSlices'; import type { StreamChat } from '../../../src/client'; /** diff --git a/test/unit/configuration/serverAuthority.test.ts b/test/unit/configuration/serverAuthority.test.ts index af31eb8638..e42024baa7 100644 --- a/test/unit/configuration/serverAuthority.test.ts +++ b/test/unit/configuration/serverAuthority.test.ts @@ -2,7 +2,7 @@ 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/serverAuthority'; +import { mergeServerRestrictions } from '../../../src/configuration/utils/serverAuthority'; import type { StreamChat } from '../../../src/client'; /** From 92940376c71c4b07389b1fddfb42359c92af19a0 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 18 Aug 2026 10:54:00 +0200 Subject: [PATCH 12/22] feat: reflect server-side configuration in file upload and poll creation --- docs/instance-configuration.md | 74 +++++++++++ src/configuration/shape.ts | 51 ++++++++ src/messageComposer/attachmentManager.ts | 36 +++++- .../configuration/configuration.ts | 8 ++ src/messageComposer/configuration/types.ts | 32 +++++ src/messageComposer/messageComposer.ts | 42 ++++++- .../MessageComposer/attachmentManager.test.ts | 97 +++++++++++++++ .../MessageComposer/messageComposer.test.ts | 93 ++++++++++++++ .../configState.unification.test.ts | 1 + v9-to-v10-migration-guide-other.md | 115 +++++++++++++++++- 10 files changed, 536 insertions(+), 13 deletions(-) diff --git a/docs/instance-configuration.md b/docs/instance-configuration.md index b707ff876e..c86c8750af 100644 --- a/docs/instance-configuration.md +++ b/docs/instance-configuration.md @@ -842,6 +842,12 @@ config and your configuration both enable something, the user may lack the capab 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 Because the mechanisms differ, reading `config` means different things per feature: @@ -1030,6 +1036,74 @@ source, so there was no way to import them. Deprecating a name nobody could reac 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.getConfig()?.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.getConfig()`. + +### `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 diff --git a/src/configuration/shape.ts b/src/configuration/shape.ts index cb26874d1a..bf9c181a2e 100644 --- a/src/configuration/shape.ts +++ b/src/configuration/shape.ts @@ -19,6 +19,7 @@ import type { DraftsConfiguration, LinkPreviewsManagerConfig, LocationComposerConfig, + PollComposerConfig, MessageComposerConfig, TextComposerConfig, } from '../messageComposer/configuration/types'; @@ -179,12 +180,24 @@ const messageOperationsGroup = (description: string): ConfigGroupNode => ({ // --------------------------------------------------------------------------- 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', @@ -299,6 +312,15 @@ const TEXT_FIELDS: Record = { }, }; +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.', @@ -321,6 +343,11 @@ const MESSAGE_COMPOSER_FIELDS: Record = 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' }, }; @@ -448,6 +475,30 @@ const CHANNEL_FIELDS: Record = { "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', + }, + 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 = { diff --git a/src/messageComposer/attachmentManager.ts b/src/messageComposer/attachmentManager.ts index 42c07dc835..46c498fffc 100644 --- a/src/messageComposer/attachmentManager.ts +++ b/src/messageComposer/attachmentManager.ts @@ -176,8 +176,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() { @@ -739,11 +767,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/configuration.ts b/src/messageComposer/configuration/configuration.ts index c67c9c10e2..595ea2c64c 100644 --- a/src/messageComposer/configuration/configuration.ts +++ b/src/messageComposer/configuration/configuration.ts @@ -5,6 +5,7 @@ import type { LinkPreviewsManagerConfig, LocationComposerConfig, MessageComposerConfig, + PollComposerConfig, TextComposerConfig, } from './types'; import { generateUUIDv4 } from '../../utils'; @@ -31,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, @@ -41,6 +44,10 @@ 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(), @@ -60,5 +67,6 @@ export const DEFAULT_COMPOSER_CONFIG: MessageComposerConfig = deepFreezeConfig({ 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/types.ts b/src/messageComposer/configuration/types.ts index 6b7a2c5e70..17f5a0b152 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 @@ -114,6 +136,14 @@ export type LocationComposerConfig = { 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 = { /** If true, enables creating drafts on the server */ drafts: DraftsConfiguration; @@ -125,6 +155,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/messageComposer.ts b/src/messageComposer/messageComposer.ts index dcff8a8a04..f1dbd14a4d 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -257,7 +257,7 @@ export class MessageComposer extends WithSubscriptions { deepFreezeConfig( mergeServerRestrictions( requested, - this.serverRestrictions, + this.serverRestrictionsFor(requested), this.serverUpperBounds, ), ) as MessageComposerConfig, @@ -529,9 +529,45 @@ export class MessageComposer extends WithSubscriptions { * `location.enabled` is gated on `shared_locations`, and only an existing composer has a channel to * ask. `getConfig()` 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 `getConfig()` 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 get serverRestrictions(): ServerRestrictions { - return { location: { enabled: this.channel.getConfig()?.shared_locations } }; + private serverRestrictionsFor( + requested: MessageComposerConfig, + ): ServerRestrictions { + const channelConfig = this.channel.getConfig(); + + 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, + }, + location: { enabled: channelConfig?.shared_locations }, + polls: { enabled: channelConfig?.polls }, + }; } /** diff --git a/test/unit/MessageComposer/attachmentManager.test.ts b/test/unit/MessageComposer/attachmentManager.test.ts index 60ee8d9ee1..8a630b19a8 100644 --- a/test/unit/MessageComposer/attachmentManager.test.ts +++ b/test/unit/MessageComposer/attachmentManager.test.ts @@ -228,6 +228,8 @@ describe('AttachmentManager', () => { expect(attachmentManager.config).toEqual({ ...config, acceptedFiles: [], + customCdn: false, + enabled: true, trackUploadProgress: true, }); }); @@ -270,6 +272,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 = { @@ -1943,6 +1982,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/messageComposer.test.ts b/test/unit/MessageComposer/messageComposer.test.ts index d57a764e9e..7ca4a21446 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -194,6 +194,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, @@ -211,6 +213,7 @@ describe('MessageComposer', () => { 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, @@ -275,6 +278,96 @@ 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('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', diff --git a/test/unit/configuration/configState.unification.test.ts b/test/unit/configuration/configState.unification.test.ts index be6ea732cc..13b33631a4 100644 --- a/test/unit/configuration/configState.unification.test.ts +++ b/test/unit/configuration/configState.unification.test.ts @@ -215,6 +215,7 @@ describe('resolved configuration is reactive on the classes that were converted drafts: true, linkPreviews: true, location: true, + polls: true, text: true, }); }); diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md index 0695515e4f..be474b18d8 100644 --- a/v9-to-v10-migration-guide-other.md +++ b/v9-to-v10-migration-guide-other.md @@ -18,6 +18,7 @@ - Filter payloads now carry **per-endpoint operator constraints** (`Query*FilterConditions` types) — previously-permissive filter objects may stop type-checking. - `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.getConfig()`. **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`. @@ -296,6 +297,108 @@ 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.getConfig()?.uploads` sees only the server's half and will offer features the composer has already disabled: + +```ts +// v9 — the only available answer +if (channel.getConfig()?.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. Keep reading it from `channel.getConfig()`. + +### `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. + +### 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.getConfig()?.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.config` deliberately does not exist**, unlike other configurable classes. `channel.getConfig()` already returns the channel _type's server_ configuration, and a sibling `channel.config` holding the resolved _instance_ configuration would be two near-identical names for two different things. Read it through `channel.configState`. + +`DEFAULT_CHANNEL_CONFIG` is exported and deep-frozen, like every other default config constant. + --- ## Reminders — `messageId` → `message_id` @@ -421,7 +524,11 @@ 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))`. +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. **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. +9. **Replace raw `channel.getConfig()?.uploads` / `?.polls` / `?.shared_locations` reads** used to gate UI with the resolved composer values (`attachmentManager.isUploadEnabled`, `composer.config.polls.enabled`, `composer.config.location.enabled`). `commands` still comes from `getConfig()`. +10. **Replace raw `channel.getConfig()?.typing_events` / `?.read_events` reads** used to gate UI with `channel.configState`'s `typingEvents.enabled` / `readEvents.enabled`, which are the reconciled values and are reactive. +11. **Format `LocationComposer` preview `end_at` at read sites** — it's a `Date` now. +12. **Rename `ReminderManager` call-site keys** `messageId` → `message_id`. Same for any place you were shaping a reminder-event body. +13. **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`. +14. **Rewrite `client.revokeTokens(isoString)`** to `client.revokeTokens(new Date(isoString))`. From b139c3f9b5732e69db227ebe0208f82401e5d33e Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 18 Aug 2026 15:12:05 +0200 Subject: [PATCH 13/22] feat(configuration)!: resolve server flags into instance configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel-type flags were only readable from the raw server config, so any consumer combining them with registered configuration had to do it itself — and mostly didn't, offering features the client had already disabled. They now resolve into the instance's own configuration, which becomes the whole answer: uploads, polls and url_enrichment on MessageComposer; typing_events, read_events, replies, user_message_reminders, delivery_events and the command list on Channel. Channel gains a ConfigController to do it, re-deriving when the server's answer arrives after construction. Also fixes setters skipping a write when the server was masking the field, which let a stale earlier request win once the server relented. BREAKING CHANGE: `channel.getConfig()` is removed; use the `channel.serverConfig` getter, or `channel.config` for the six flags that now have a resolved counterpart. `ChannelInstanceConfig` -> `ChannelConfig`, `ThreadInstanceConfig` -> `ThreadConfig`, `InstanceConfigurationService` -> `InstanceConfigurationRegistry`. `MessageComposerConfig` gains required `polls`; `AttachmentManagerConfig` gains required `enabled` and `customCdn`. `linkPreviews.enabled` now defaults to `true`. See v9-to-v10-migration-guide-other.md. --- docs/instance-configuration.md | 26 ++- src/channel.ts | 220 +++++++++++++++--- src/client.ts | 12 +- ...ce.ts => InstanceConfigurationRegistry.ts} | 10 +- src/configuration/index.ts | 4 +- src/configuration/shape.ts | 42 +++- src/configuration/types.ts | 21 +- .../utils/applyInstanceConfiguration.ts | 8 +- src/configuration/utils/copyConfigPatch.ts | 2 +- src/configuration/utils/serverAuthority.ts | 6 +- src/index.ts | 6 +- src/messageComposer/attachmentManager.ts | 6 +- .../configuration/configuration.ts | 2 +- src/messageComposer/configuration/types.ts | 9 +- src/messageComposer/linkPreviewsManager.ts | 15 +- src/messageComposer/messageComposer.ts | 11 +- .../middleware/textComposer/commands.ts | 3 +- src/messageComposer/textComposer.ts | 18 +- .../MessageDeliveryReporter.ts | 4 +- src/pagination/paginators/MessagePaginator.ts | 2 +- src/search/SearchController.ts | 2 +- src/thread.ts | 11 +- src/types.ts | 4 +- src/utils/objectPath.ts | 2 +- test/typescript/index.js | 2 +- .../typescript/response-generators/channel.js | 2 +- .../linkPreviewsManager.test.ts | 80 ++++++- .../MessageComposer/messageComposer.test.ts | 39 +++- .../compositionValidation.test.ts | 17 +- .../messageComposer/linkPreviews.test.ts | 5 +- .../textComposer/CommandSearchSource.test.ts | 25 +- .../TextComposerMiddlewareExecutor.test.ts | 5 +- .../middleware/textComposer/command.test.ts | 3 +- .../unit/MessageComposer/textComposer.test.ts | 7 +- test/unit/channel.test.js | 6 +- ... => InstanceConfigurationRegistry.test.ts} | 10 +- .../applyInstanceConfiguration.test.ts | 6 +- .../unit/configuration/channel.config.test.ts | 151 +++++++++++- .../configuration/configBoundaries.test.ts | 2 +- .../configState.unification.test.ts | 23 +- .../defaultConfigImmutability.test.ts | 2 + .../instanceConfiguration.integration.test.ts | 6 +- .../configuration/serverAuthority.test.ts | 2 +- .../MessageDeliveryReporter.test.ts | 8 +- .../paginators/MessagePaginator.test.ts | 2 +- test/unit/test-utils/stubServerConfig.ts | 40 ++++ v9-to-v10-migration-guide-methods.md | 4 +- v9-to-v10-migration-guide-other.md | 77 ++++-- v9-to-v10-migration-guide-type-renames.md | 95 ++++---- 49 files changed, 827 insertions(+), 238 deletions(-) rename src/configuration/{InstanceConfigurationService.ts => InstanceConfigurationRegistry.ts} (98%) rename test/unit/configuration/{InstanceConfigurationService.test.ts => InstanceConfigurationRegistry.test.ts} (97%) create mode 100644 test/unit/test-utils/stubServerConfig.ts diff --git a/docs/instance-configuration.md b/docs/instance-configuration.md index c86c8750af..69f0aac352 100644 --- a/docs/instance-configuration.md +++ b/docs/instance-configuration.md @@ -168,7 +168,7 @@ client.config.set({ 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 service through a `client`, and +**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 @@ -381,7 +381,7 @@ object read it. Two objects carry out the stages above, and neither object holds what the other holds. -| | `InstanceConfigurationService` — the registry | `ConfigController` — the resolver | +| | `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** | @@ -403,7 +403,7 @@ any other instance. client.config.set({ messagePaginator: { pageSize: 30 } }) │ registered intent, stored under a key ▼ -InstanceConfigurationService ← the registry: keys, setup functions, reset +InstanceConfigurationRegistry ← the registry: keys, setup functions, reset │ applyInstanceConfiguration subscribes one instance to one key ▼ paginator.initializeConfig(slice) ← the instance is handed its own subtree @@ -850,18 +850,22 @@ authorization; it is stating that the authorization is about a different endpoin ### Requested vs effective -Because the mechanisms differ, reading `config` means different things per feature: +**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 — the server value was merged in -composer.config.linkPreviews.enabled; // requested — the server check lives in the getter -composer.linkPreviewsManager.enabled; // effective (server && requested) +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.** -For the guarded features, a getter or an explicit check is what tells you the effective answer. 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. +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. --- @@ -1112,7 +1116,7 @@ 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 service is +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: diff --git a/src/channel.ts b/src/channel.ts index efbe627053..6258c3d3de 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -18,6 +18,10 @@ import { import type { StreamChat } from './client'; import { chatLoggerSystem } from './logger'; import { applyInstanceConfiguration } from './configuration/utils/applyInstanceConfiguration'; +import { ConfigController } from './configuration/ConfigController'; +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, @@ -35,6 +39,7 @@ import type { ChannelResponse, ChannelStateResponseFields, ChannelUpdateOptions, + Command, CreateDraftResponse, DeleteMessageOptions, Event, @@ -67,8 +72,7 @@ import type { UserResponse, } from './types'; import type { RoleName } from './permissions'; -import { StateStore } from './store'; -import { isEqual } from './utils/mergeWith/mergeWithCore'; +import type { StateStore } from './store'; import type { Unsubscribe } from './store'; import type { ChannelMemberRequest as Gen_ChannelMemberRequest, @@ -144,7 +148,14 @@ export type CustomMarkReadRequestFn = (params: { options?: MarkReadRequest; }) => Promise; -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; @@ -152,8 +163,68 @@ 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`. + */ +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. */ @@ -185,7 +256,15 @@ export class Channel extends ChannelApi { isTyping: boolean; disconnected: boolean; 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; @@ -198,6 +277,10 @@ export class Channel extends ChannelApi { * 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. @@ -346,6 +429,29 @@ export class Channel extends ChannelApi { }, }); + this.configController = new ConfigController({ + defaults: DEFAULT_CHANNEL_CONFIG, + initialSlice: declarativeConfig as Partial | undefined, + // Nested groups: naming `typingEvents.enabled` must not drop `readEvents`. + mergeSlice: 'deep', + applyAuthority: (requested) => ({ + ...(mergeServerRestrictions(requested, this.serverRestrictions) as ChannelConfig), + // Assigned rather than merged: `mergeServerRestrictions` treats an array as an interior node + // and hands it to the deep merge, which would combine the two lists. The server owns this one + // outright, so it replaces. + availableCommands: this.serverConfig?.commands ?? [], + }), + }); + + // 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 the config for this channel type lands, or an app that disables + // `read_events` server-side would keep a channel that believes read receipts are on. + this.unsubscribeServerConfig = client.channelConfigsByTypeStore.subscribeWithSelector( + ({ configs }) => ({ channelConfig: configs[this.type] }), + () => 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. @@ -369,6 +475,25 @@ export class Channel extends ChannelApi { }); } + /** + * 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. * @@ -377,23 +502,19 @@ export class Channel extends ChannelApi { * `messagePaginator.pageSize` means stays inside the paginator. */ initializeConfig(declarativeConfig?: ChannelDeclarativeConfig): void { - // Replaces rather than merges: this is a derivation, so a handler dropped from the declarative - // tree must disappear. Anything else writing directly into `configState.requestHandlers` — the - // React SDK's per-component props do — has to re-apply after a re-derivation; see the note in - // `useChannelRequestHandlers`. + // 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`. // - // Guarded for the same reason `MessageComposer.publishConfig` is: the object is freshly allocated - // every time, so `StateStore.next`'s `===` no-op can never apply and every re-derivation woke every - // subscriber with an identical value. This runs on each `alsoWatch` key change too — a - // `messagePaginator` or `messageOperations` registration re-runs the whole `channel` cycle — so the - // no-op publishes outnumber the real ones. Deep rather than `===` because `requestHandlers` is a - // record; `isEqual` compares its function values by identity, which is the right test for a handler. - const nextRequestHandlers = declarativeConfig?.requestHandlers; - if ( - !isEqual(this.configState.getLatestValue().requestHandlers, nextRequestHandlers) - ) { - this.configState.next({ requestHandlers: nextRequestHandlers }); - } + // 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(declarativeConfig as Partial); // The shared `messagePaginator` key applies to every MessagePaginator — this channel's list and // every thread's replies — and the per-parent slice overrides it. @@ -434,14 +555,43 @@ 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(); + 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 the channel *type'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; + } + + /** + * The channel **type's** configuration, as the server reports it — feature flags such as `uploads`, + * `typing_events`, `read_events` and `commands`. + * + * 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() { // Keyed by channel type — the config is a property of the type, not of this channel. - return client.channelConfigsByType[this.type]; + return this.getClient().channelConfigsByType[this.type]; } _sendMessage(request: Gen_SendMessageRequest) { @@ -1285,7 +1435,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; @@ -1314,8 +1468,10 @@ export class Channel extends ChannelApi { override async markRead(data?: MarkReadRequest) { 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(data); @@ -1330,8 +1486,10 @@ export class Channel extends ChannelApi { override async markUnread(data?: MarkUnreadRequest) { 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(data); @@ -2718,6 +2876,8 @@ export class Channel extends ChannelApi { // 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(); this.cooldownTimer.clearTimeout(); // Release the store-backed paginators so the message store no longer pins this removed channel diff --git a/src/client.ts b/src/client.ts index 62a850bd0b..f5618c200a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -82,7 +82,7 @@ import { FixedSizeQueueCache } from './utils/FixedSizeQueueCache'; import { isEqual } from './utils/mergeWith/mergeWithCore'; import type { MessageComposer } from './messageComposer'; import type { InstanceSetupState } from './configuration'; -import { InstanceConfigurationService } from './configuration/InstanceConfigurationService'; +import { InstanceConfigurationRegistry } from './configuration/InstanceConfigurationRegistry'; import { applyInstanceConfiguration } from './configuration/utils/applyInstanceConfiguration'; import { StateStore } from './store'; import type { Unsubscribe } from './store'; @@ -239,12 +239,12 @@ export class StreamChat extends ChatApi { private nextRequestAbortController: AbortController | null = null; /** * Configuration you register for instances the SDK creates on your behalf — channels, threads, - * composers, and the client's own managers. See `InstanceConfigurationService`. + * composers, and the client's own managers. See `InstanceConfigurationRegistry`. * * Not to be confused with {@link channelConfigsByType}, which holds the **server-provided channel-type * configs** keyed by channel type. This one is yours; that one is the backend's. */ - readonly config = new InstanceConfigurationService(); + readonly config = new InstanceConfigurationRegistry(); /** Teardown for the `'client'` setup function, released by {@link disconnectUser}. */ private unsubscribeClientConfiguration?: Unsubscribe; @@ -359,7 +359,7 @@ export class StreamChat extends ChatApi { // 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 service is born here, so there is no earlier moment for a caller to register anything. + // 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(); @@ -423,7 +423,7 @@ export class StreamChat extends ChatApi { * Cache of server-provided channel configuration, keyed by **channel type** — the settings are * defined per type, so one entry serves every channel of that type. * - * Read it through {@link Channel.getConfig} rather than here. Not to be confused with + * 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 was `client.configs` through v9, keyed by **cid**. There is deliberately no `configs` alias: @@ -1605,7 +1605,7 @@ export class StreamChat extends ChatApi { * An absent `config` is ignored rather than stored. `ChannelResponse.config` is optional — the * `notification.message_new` payload is one route that may omit it — and writing `undefined` would * un-learn* a config already known for the type. Keyed by cid that voided one channel; keyed by type it - * voids every channel of the type, and since the composer reads `getConfig()` for `shared_locations` and + * voids every channel of the type, and since the composer reads `serverConfig` for `shared_locations` and * `max_message_length`, the result is a server restriction silently lifted (**DV-16**). * * A config deep-equal to the one already stored is ignored too, which is what keeps a channel query from diff --git a/src/configuration/InstanceConfigurationService.ts b/src/configuration/InstanceConfigurationRegistry.ts similarity index 98% rename from src/configuration/InstanceConfigurationService.ts rename to src/configuration/InstanceConfigurationRegistry.ts index 94d838f6cd..4726df4c1e 100644 --- a/src/configuration/InstanceConfigurationService.ts +++ b/src/configuration/InstanceConfigurationRegistry.ts @@ -43,7 +43,7 @@ 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 service can tell whether a key has any + * 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 @@ -51,7 +51,7 @@ const logger = chatLoggerSystem.getLogger('instance-configuration'); /** * A handle to one live instance that derives configuration from a key. * - * Deliberately just the re-derivation hook rather than the instance itself: the service never reads an + * 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 = { @@ -64,7 +64,7 @@ export type ConfiguredInstance = { type AnySetupStore = StateStore>; type AnyConfigStore = StateStore>; -export class InstanceConfigurationService { +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 @@ -88,8 +88,8 @@ export class InstanceConfigurationService { /** * **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 InstanceConfigurationService.set} or - * {@link InstanceConfigurationService.setConfig}, `null` until a caller registers something. + * 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}). diff --git a/src/configuration/index.ts b/src/configuration/index.ts index ec4fabb78b..f6dba0dab2 100644 --- a/src/configuration/index.ts +++ b/src/configuration/index.ts @@ -4,5 +4,5 @@ export * from './utils'; // The service is reached as `client.config`, never constructed by integrators — export the type only. export type { ConfiguredInstance, - InstanceConfigurationService, -} from './InstanceConfigurationService'; + InstanceConfigurationRegistry, +} from './InstanceConfigurationRegistry'; diff --git a/src/configuration/shape.ts b/src/configuration/shape.ts index bf9c181a2e..107a998429 100644 --- a/src/configuration/shape.ts +++ b/src/configuration/shape.ts @@ -19,8 +19,8 @@ import type { DraftsConfiguration, LinkPreviewsManagerConfig, LocationComposerConfig, - PollComposerConfig, MessageComposerConfig, + PollComposerConfig, TextComposerConfig, } from '../messageComposer/configuration/types'; @@ -487,6 +487,42 @@ const CHANNEL_FIELDS: Record = { }, 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: { @@ -530,11 +566,11 @@ const THREAD_FIELDS: Record = { * 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 InstanceConfigurationService.getTree}. Construction-only paths are absent for the same reason: + * {@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 InstanceConfigurationService.getTree} in to see those. + * entry here. Merge {@link InstanceConfigurationRegistry.getTree} in to see those. */ export const INSTANCE_CONFIG_TREE_SHAPE: Record< keyof InstanceConfigTree, diff --git a/src/configuration/types.ts b/src/configuration/types.ts index 83dee56e82..46e3b365d4 100644 --- a/src/configuration/types.ts +++ b/src/configuration/types.ts @@ -9,8 +9,8 @@ import type { } from '../search/SearchController'; import type { MessageComposer } from '../messageComposer'; import type { MessageComposerConfig } from '../messageComposer/configuration/types'; -import type { Channel, ChannelInstanceConfig } from '../channel'; -import type { Thread, ThreadInstanceConfig } from '../thread'; +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'; @@ -46,7 +46,7 @@ export interface InstanceSetupFunctionArgs { thread: { thread: Thread }; } -/** The four built-in keys, plus any key an integrator or a downstream SDK registers. */ +/** The six built-in keys, plus any key an integrator or a downstream SDK registers. */ export type InstanceSetupKey = keyof InstanceSetupFunctionArgs | (string & {}); // --------------------------------------------------------------------------- @@ -107,14 +107,25 @@ export type ChannelDeclarativeConfig = { messageOperations?: Partial; messagePaginator?: DeclarativeMessagePaginatorConfig; pinnedMessagesPaginator?: ImportedDeclarativePaginatorConfig; - requestHandlers?: ChannelInstanceConfig['requestHandlers']; + 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 ThreadDeclarativeConfig = { /** Overrides the shared top-level `messageOperations` key for thread replies only. */ messageOperations?: Partial; messagePaginator?: DeclarativeMessagePaginatorConfig; - requestHandlers?: ThreadInstanceConfig['requestHandlers']; + requestHandlers?: ThreadConfig['requestHandlers']; }; export type ClientDeclarativeConfig = { diff --git a/src/configuration/utils/applyInstanceConfiguration.ts b/src/configuration/utils/applyInstanceConfiguration.ts index e9471e6188..c44a23b986 100644 --- a/src/configuration/utils/applyInstanceConfiguration.ts +++ b/src/configuration/utils/applyInstanceConfiguration.ts @@ -1,8 +1,8 @@ import { chatLoggerSystem } from '../../logger'; import type { ConfiguredInstance, - InstanceConfigurationService, -} from '../InstanceConfigurationService'; + InstanceConfigurationRegistry, +} from '../InstanceConfigurationRegistry'; import type { InstanceConfigOf, InstanceSetupFunctionArgsOf, @@ -17,8 +17,8 @@ const logger = chatLoggerSystem.getLogger('instance-configuration'); export type ApplyInstanceConfigurationParams = { /** The instance's argument for its setup function — `{ channel }`, `{ composer }`, and so on. */ args: InstanceSetupFunctionArgsOf; - /** The client's configuration service, i.e. `client.config`. */ - config: InstanceConfigurationService; + /** 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` diff --git a/src/configuration/utils/copyConfigPatch.ts b/src/configuration/utils/copyConfigPatch.ts index a12e90054f..3addb4ea3c 100644 --- a/src/configuration/utils/copyConfigPatch.ts +++ b/src/configuration/utils/copyConfigPatch.ts @@ -8,7 +8,7 @@ import { isWalkableRecord } from '../../utils/objectPath'; * (`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 service held the caller's objects for the + * 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`, diff --git a/src/configuration/utils/serverAuthority.ts b/src/configuration/utils/serverAuthority.ts index 26daaa78ab..904d069b81 100644 --- a/src/configuration/utils/serverAuthority.ts +++ b/src/configuration/utils/serverAuthority.ts @@ -110,8 +110,8 @@ const upperBoundCustomizer: MergeWithCustomizer = ( * * **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 - * `getConfig()`, something else might read capabilities — and the answer depends on an instance that - * exists. That is also why this does not live in `InstanceConfigurationService`: that service merges + * `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. * @@ -120,7 +120,7 @@ const upperBoundCustomizer: MergeWithCustomizer = ( * // Inside a configurable class, on every path that resolves configuration: * this.configState.partialNext( * mergeServerRestrictions(requestedConfig, { - * location: { enabled: this.channel.getConfig()?.shared_locations }, + * location: { enabled: this.channel.serverConfig?.shared_locations }, * }), * ); * ``` diff --git a/src/index.ts b/src/index.ts index c85c554b4c..6e1efa213b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,8 +36,8 @@ export type { } from './configuration/types'; export type { ConfiguredInstance, - InstanceConfigurationService, -} from './configuration/InstanceConfigurationService'; + InstanceConfigurationRegistry, +} from './configuration/InstanceConfigurationRegistry'; export { mergeServerRestrictions } from './configuration/utils/serverAuthority'; export type { ServerRestrictions, @@ -77,7 +77,7 @@ export * from './store'; export { Thread } from './thread'; export type { CustomThreadMarkReadRequestFn, - ThreadInstanceConfig, + ThreadConfig, ThreadReadState, ThreadState, ThreadUserReadState, diff --git a/src/messageComposer/attachmentManager.ts b/src/messageComposer/attachmentManager.ts index 46c498fffc..266ed68914 100644 --- a/src/messageComposer/attachmentManager.ts +++ b/src/messageComposer/attachmentManager.ts @@ -150,7 +150,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 } }); } diff --git a/src/messageComposer/configuration/configuration.ts b/src/messageComposer/configuration/configuration.ts index 595ea2c64c..30227b0c4c 100644 --- a/src/messageComposer/configuration/configuration.ts +++ b/src/messageComposer/configuration/configuration.ts @@ -14,7 +14,7 @@ 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 { diff --git a/src/messageComposer/configuration/types.ts b/src/messageComposer/configuration/types.ts index 17f5a0b152..e427c03bae 100644 --- a/src/messageComposer/configuration/types.ts +++ b/src/messageComposer/configuration/types.ts @@ -112,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[]; 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 f1dbd14a4d..32df70fbfd 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -527,13 +527,13 @@ export class MessageComposer extends WithSubscriptions { * * 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. `getConfig()` is re-read on every call, so a restriction that changes mid-session is picked up + * 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 `getConfig()` themselves: a raw server flag answers only the server's half, so a UI + * 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 @@ -544,7 +544,7 @@ export class MessageComposer extends WithSubscriptions { private serverRestrictionsFor( requested: MessageComposerConfig, ): ServerRestrictions { - const channelConfig = this.channel.getConfig(); + const channelConfig = this.channel.serverConfig; return { /** @@ -565,6 +565,7 @@ export class MessageComposer extends WithSubscriptions { attachments: { enabled: requested.attachments.customCdn ? undefined : channelConfig?.uploads, }, + linkPreviews: { enabled: channelConfig?.url_enrichment }, location: { enabled: channelConfig?.shared_locations }, polls: { enabled: channelConfig?.polls }, }; @@ -579,7 +580,7 @@ export class MessageComposer extends WithSubscriptions { * 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.getConfig()?.max_message_length; + const maxMessageLength = this.channel.serverConfig?.max_message_length; return { text: { maxLengthOnEdit: maxMessageLength, maxLengthOnSend: maxMessageLength }, @@ -813,7 +814,7 @@ export class MessageComposer extends WithSubscriptions { /** * The channel's server-side config (`client.channelConfigsByType[type]`) 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 `getConfig()` was still undefined — + * 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. */ 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 a40c1e9699..01e906f46e 100644 --- a/src/messageDelivery/MessageDeliveryReporter.ts +++ b/src/messageDelivery/MessageDeliveryReporter.ts @@ -163,8 +163,8 @@ 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() { diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index e52012f037..21bc66ef3f 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -200,7 +200,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/search/SearchController.ts b/src/search/SearchController.ts index 411008fa1f..af3b4a5f13 100644 --- a/src/search/SearchController.ts +++ b/src/search/SearchController.ts @@ -30,7 +30,7 @@ export type SearchControllerOptions = { * * 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 service. Left out, the controller still works and `updateConfig` still applies; + * configuration registry. Left out, the controller still works and `updateConfig` still applies; * only the declarative key and its setup function go unheard. */ client?: StreamChat; diff --git a/src/thread.ts b/src/thread.ts index 3cb0ae9013..e532b084dc 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -85,14 +85,14 @@ export type CustomThreadMarkReadRequestFn = (params: { options?: MarkReadRequest; }) => Promise | void; -export type ThreadInstanceConfig = { +export type ThreadConfig = { requestHandlers?: { markReadRequest?: CustomThreadMarkReadRequestFn; }; }; export class Thread extends WithSubscriptions { - public readonly configState = new StateStore({}); + public readonly configState = new StateStore({}); public readonly state: StateStore; public readonly id: string; public readonly messageComposer: MessageComposer; @@ -362,6 +362,11 @@ export class Thread extends WithSubscriptions { ); } + /** This thread's resolved configuration — the shape every configurable class exposes. */ + get config(): Readonly { + return this.configState.getLatestValue(); + } + get channel() { return this.state.getLatestValue().channel; } @@ -487,7 +492,7 @@ export class Thread extends WithSubscriptions { * - 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 service's `liveInstances`, so `client.config.reset()` skips it, and + * - 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. * diff --git a/src/types.ts b/src/types.ts index 3dee66d88e..210166a05d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -397,7 +397,7 @@ export type StreamChatOptions = { * own managers are constructed. * * Equivalent to calling `client.config.set(tree)` immediately after construction, except for the - * `client` subtree — the configuration service is created inside the constructor, so this is the only + * `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; @@ -778,7 +778,7 @@ export type CommandVariants = /** * Server-provided channel configuration, keyed by **channel type** (`messaging`, `livestream`, …) — - * every field in `ChannelConfigWithInfo` is a type-level setting. Read it via `channel.getConfig()`. + * every field in `ChannelConfigWithInfo` is a type-level setting. Read it via `channel.serverConfig`. */ export type Configs = Record; diff --git a/src/utils/objectPath.ts b/src/utils/objectPath.ts index 6647d90581..fafb8a3565 100644 --- a/src/utils/objectPath.ts +++ b/src/utils/objectPath.ts @@ -5,7 +5,7 @@ * 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 - * `InstanceConfigurationService` can report a late registration at all. + * `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 diff --git a/test/typescript/index.js b/test/typescript/index.js index 0c5ca9fbbf..73a93155c6 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 588636fcde..7e9095a559 100644 --- a/test/typescript/response-generators/channel.js +++ b/test/typescript/response-generators/channel.js @@ -120,7 +120,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/MessageComposer/linkPreviewsManager.test.ts b/test/unit/MessageComposer/linkPreviewsManager.test.ts index 4d7860778a..e7afe3e122 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.channelConfigsByTypeStore.partialNext({ + configs: { channelType: { 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.channelConfigsByTypeStore.partialNext({ + configs: { channelType: { 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.channelConfigsByTypeStore.partialNext({ + configs: { channelType: { 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 7ca4a21446..b3a5d195b4 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 @@ -304,6 +305,30 @@ describe('MessageComposer', () => { }, ); + 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 @@ -711,7 +736,7 @@ describe('MessageComposer', () => { }, }); - vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + stubServerConfig(messageComposer.channel, { commands: [{ name: 'ban', description: 'Ban a user' }], }); @@ -743,7 +768,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' }], }); @@ -767,7 +792,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' }, @@ -1030,7 +1055,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' }; @@ -1059,7 +1084,7 @@ describe('MessageComposer', () => { }, }); - vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + stubServerConfig(messageComposer.channel, { commands: [{ name: 'ban', description: 'Ban a user' }], }); messageComposer.textComposer.state.partialNext({ @@ -2729,9 +2754,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 a3dfafacf1..d726eb9f79 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -1762,7 +1762,7 @@ describe('Channel _handleChannelEvent', function () { // enable delivery events client._addChannelConfig({ type: channel.type, - config: { ...channel.getConfig(), delivery_events: true }, + config: { ...channel.serverConfig, delivery_events: true }, }); channel.state.read[user.id] = initialReadState; @@ -1788,7 +1788,7 @@ describe('Channel _handleChannelEvent', function () { // enable delivery events client._addChannelConfig({ type: channel.type, - config: { ...channel.getConfig(), delivery_events: true }, + config: { ...channel.serverConfig, delivery_events: true }, }); channel.state.read[user.id] = initialReadState; const newerMessage = generateMsg({ @@ -1819,7 +1819,7 @@ describe('Channel _handleChannelEvent', function () { // enable delivery events client._addChannelConfig({ type: channel.type, - config: { ...channel.getConfig(), delivery_events: true }, + config: { ...channel.serverConfig, delivery_events: true }, }); channel.state.read[user.id] = initialReadState; diff --git a/test/unit/configuration/InstanceConfigurationService.test.ts b/test/unit/configuration/InstanceConfigurationRegistry.test.ts similarity index 97% rename from test/unit/configuration/InstanceConfigurationService.test.ts rename to test/unit/configuration/InstanceConfigurationRegistry.test.ts index 33897ce9fa..2a5bc36b23 100644 --- a/test/unit/configuration/InstanceConfigurationService.test.ts +++ b/test/unit/configuration/InstanceConfigurationRegistry.test.ts @@ -1,14 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { InstanceConfigurationService } from '../../../src/configuration/InstanceConfigurationService'; +import { InstanceConfigurationRegistry } from '../../../src/configuration/InstanceConfigurationRegistry'; import { chatLoggerSystem } from '../../../src/logger'; const noop = () => undefined; -describe('InstanceConfigurationService', () => { - let service: InstanceConfigurationService; +describe('InstanceConfigurationRegistry', () => { + let service: InstanceConfigurationRegistry; beforeEach(() => { - service = new InstanceConfigurationService(); + service = new InstanceConfigurationRegistry(); }); describe('stores', () => { @@ -160,7 +160,7 @@ describe('InstanceConfigurationService', () => { }); it('keeps two services independent, so configuration cannot leak between clients', () => { - const other = new InstanceConfigurationService(); + const other = new InstanceConfigurationRegistry(); service.setConfig('messageComposer', { drafts: { enabled: true } }); expect(other.getConfig('messageComposer')).toBeNull(); diff --git a/test/unit/configuration/applyInstanceConfiguration.test.ts b/test/unit/configuration/applyInstanceConfiguration.test.ts index e5f978a3a7..9dec81e2b2 100644 --- a/test/unit/configuration/applyInstanceConfiguration.test.ts +++ b/test/unit/configuration/applyInstanceConfiguration.test.ts @@ -1,15 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { InstanceConfigurationService } from '../../../src/configuration/InstanceConfigurationService'; +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: InstanceConfigurationService; + let service: InstanceConfigurationRegistry; beforeEach(() => { - service = new InstanceConfigurationService(); + service = new InstanceConfigurationRegistry(); }); describe('setup functions', () => { diff --git a/test/unit/configuration/channel.config.test.ts b/test/unit/configuration/channel.config.test.ts index 6f3fed6859..173aa04569 100644 --- a/test/unit/configuration/channel.config.test.ts +++ b/test/unit/configuration/channel.config.test.ts @@ -91,7 +91,7 @@ describe("the 'channel' configuration key", () => { channel: { messagePaginator: { unreadReferencePolicy: 'read-state-only' } }, }); - // Order-dependent by design; the service warns in this case rather than failing silently. + // Order-dependent by design; the registry warns in this case rather than failing silently. expect( (channel.messagePaginator as unknown as { unreadReferencePolicy: string }) .unreadReferencePolicy, @@ -248,4 +248,153 @@ describe("the 'channel' configuration key", () => { 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. + */ + describe('typing and read events', () => { + const withServerConfig = (config: Record, id = 'channel-id') => { + client.channelConfigsByTypeStore.partialNext({ + configs: { messaging: 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 + // `getConfig()` 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.channelConfigsByTypeStore.partialNext({ + configs: { messaging: { 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/configBoundaries.test.ts b/test/unit/configuration/configBoundaries.test.ts index 8267a021b5..c63a959e4e 100644 --- a/test/unit/configuration/configBoundaries.test.ts +++ b/test/unit/configuration/configBoundaries.test.ts @@ -6,7 +6,7 @@ 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 - * `InstanceConfigurationService.setConfig` on the reasoning that it was "the single boundary at which + * `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. diff --git a/test/unit/configuration/configState.unification.test.ts b/test/unit/configuration/configState.unification.test.ts index 13b33631a4..6b2b93d7d7 100644 --- a/test/unit/configuration/configState.unification.test.ts +++ b/test/unit/configuration/configState.unification.test.ts @@ -150,21 +150,24 @@ describe('resolved configuration is reactive on the classes that were converted }); /** - * `Channel` and `Thread` deliberately stop at `configState`: `channel.getConfig()` already returns the - * channel *type*'s server configuration, so a `channel.config` beside it would read as the same thing in - * getter form while meaning something unrelated, with nothing to catch the confusion. + * `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. * - * Pinned as an absence because the docs state it as a deliberate exception. If someone adds the getter, - * this fails and points at the table that has to change with it. + * 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('leaves Channel and Thread with the store alone, and no colliding getter', () => { + 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('config' in channel).toBe(false); - expect('updateConfig' in channel).toBe(false); - // the member the name would have collided with, which does exist - expect(typeof channel.getConfig).toBe('function'); + expect(channel.config).toBe(channel.configState.getLatestValue()); + expect(channel.serverConfig).toBe(client.channelConfigsByType.messaging); }); /** diff --git a/test/unit/configuration/defaultConfigImmutability.test.ts b/test/unit/configuration/defaultConfigImmutability.test.ts index 7c3b8ebf90..4832b7b626 100644 --- a/test/unit/configuration/defaultConfigImmutability.test.ts +++ b/test/unit/configuration/defaultConfigImmutability.test.ts @@ -1,5 +1,6 @@ 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'; @@ -26,6 +27,7 @@ import { DEFAULT_THREAD_MANAGER_CONFIG } from '../../../src/thread_manager'; */ describe('package default configurations are immutable', () => { const DEFAULTS = { + DEFAULT_CHANNEL_CONFIG, DEFAULT_COMPOSER_CONFIG, DEFAULT_LIVE_LOCATION_MANAGER_CONFIG, DEFAULT_MESSAGE_DELIVERY_REPORTER_CONFIG, diff --git a/test/unit/configuration/instanceConfiguration.integration.test.ts b/test/unit/configuration/instanceConfiguration.integration.test.ts index 90fdaf865b..bfd8d36d68 100644 --- a/test/unit/configuration/instanceConfiguration.integration.test.ts +++ b/test/unit/configuration/instanceConfiguration.integration.test.ts @@ -356,7 +356,7 @@ describe('instance configuration — cross-instance', () => { // `b` was never queried, but the config belongs to the *type* — keying by cid used to leave it // reporting nothing until it was queried itself. - expect(b.getConfig()?.shared_locations).toBe(false); + expect(b.serverConfig?.shared_locations).toBe(false); expect(Object.keys(client.channelConfigsByType)).toEqual(['messaging']); }); @@ -366,7 +366,7 @@ describe('instance configuration — cross-instance', () => { setServerConfig(messaging, { shared_locations: false }); - expect(livestream.getConfig()).toBeUndefined(); + expect(livestream.serverConfig).toBeUndefined(); }); it('reaches a composer built before the config arrived, for any channel of the type', () => { @@ -541,7 +541,7 @@ describe('instance configuration — cross-instance', () => { 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 service + // 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] } } }, diff --git a/test/unit/configuration/serverAuthority.test.ts b/test/unit/configuration/serverAuthority.test.ts index e42024baa7..1ff9c00fb2 100644 --- a/test/unit/configuration/serverAuthority.test.ts +++ b/test/unit/configuration/serverAuthority.test.ts @@ -176,7 +176,7 @@ describe('mergeServerRestrictions', () => { }); it('treats an undefined restriction as "the server did not say"', () => { - // What `channel.getConfig()?.shared_locations` returns before the channel config is known. Reading it + // 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 diff --git a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts index 6d86d71975..283012ee2f 100644 --- a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts +++ b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts @@ -13,6 +13,7 @@ import { Thread, } from '../../../src'; import type { AxiosResponse } from 'axios'; +import { stubServerConfig } from '../test-utils/stubServerConfig'; const channelType = 'messaging'; const channelId = 'channelId'; @@ -154,13 +155,16 @@ describe('MessageDeliveryReporter', () => { }); it('does nothing when delievry events are disabled in channel config', async () => { - client.channelConfigsByType[channel.type] = { + // Through the store, not by mutating `channelConfigsByType`: 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); diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 865df238a4..f328b0ebb6 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -1920,7 +1920,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/test-utils/stubServerConfig.ts b/test/unit/test-utils/stubServerConfig.ts new file mode 100644 index 0000000000..04187313ff --- /dev/null +++ b/test/unit/test-utils/stubServerConfig.ts @@ -0,0 +1,40 @@ +import type { Channel } from '../../../src/channel'; + +/** + * Sets the channel type's server configuration in tests. + * + * Writes through `client.channelConfigsByTypeStore` — 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 type = (channel as Channel).type; + + if (client && type) { + const write = (next: Record | undefined) => { + client.channelConfigsByTypeStore.partialNext({ + configs: { ...client.channelConfigsByType, [type]: 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/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md index fc2e74850e..7f7d6191cb 100644 --- a/v9-to-v10-migration-guide-methods.md +++ b/v9-to-v10-migration-guide-methods.md @@ -556,7 +556,7 @@ The v9 `verifyWebhook` / `verifyAndParseWebhook` reused `client.secret` implicit ### Constructor and lifecycle -`getClient()`, `getConfig()`, `clean()`, `_channelURL()`, `_checkInitialized()`, `_initializeState(...)`, `_disconnect()`, and `create(options?)` are unchanged. +`getClient()`, `clean()`, `_channelURL()`, `_checkInitialized()`, `_initializeState(...)`, `_disconnect()`, and `create(options?)` are unchanged. ### Removed with a rename → note @@ -564,6 +564,8 @@ The v9 `verifyWebhook` / `verifyAndParseWebhook` reused `client.secret` implicit - `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: the channel _type's_ server configuration (`ChannelConfigWithInfo`). 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 be474b18d8..6f8377dc79 100644 --- a/v9-to-v10-migration-guide-other.md +++ b/v9-to-v10-migration-guide-other.md @@ -18,7 +18,7 @@ - Filter payloads now carry **per-endpoint operator constraints** (`Query*FilterConditions` types) — previously-permissive filter objects may stop type-checking. - `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.getConfig()`. **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. +- 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`. @@ -333,11 +333,11 @@ const config: AttachmentManagerConfig = { 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.getConfig()?.uploads` sees only the server's half and will offer features the composer has already disabled: +**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.getConfig()?.uploads) showAttachmentButton(); +if (channel.serverConfig?.uploads) showAttachmentButton(); // v10 — the whole answer if (composer.attachmentManager.isUploadEnabled) showAttachmentButton(); @@ -345,7 +345,12 @@ if (composer.attachmentManager.isUploadEnabled) showAttachmentButton(); if (composer.config.attachments.enabled) … ``` -`commands` is deliberately **not** mirrored — the server sends a list, not a gate. Keep reading it from `channel.getConfig()`. +`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` @@ -370,6 +375,41 @@ Related: `AttachmentManager.isUploadEnabled` and `uploadFiles` now enforce the * 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: @@ -387,7 +427,7 @@ client.config.set({ ```ts // v9 — the server's half only -if (channel.getConfig()?.read_events) showReadReceipts(); +if (channel.serverConfig?.read_events) showReadReceipts(); // v10 — the whole answer, and reactive useStateStore(channel.configState, ({ readEvents }) => ({ enabled: readEvents.enabled })); @@ -395,7 +435,16 @@ useStateStore(channel.configState, ({ readEvents }) => ({ enabled: readEvents.en `markRead` / `markUnread` still throw when read events are off; the message now names both possible causes. -**`channel.config` deliberately does not exist**, unlike other configurable classes. `channel.getConfig()` already returns the channel _type's server_ configuration, and a sibling `channel.config` holding the resolved _instance_ configuration would be two near-identical names for two different things. Read it through `channel.configState`. +`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` | The channel **type'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. + +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. @@ -525,10 +574,12 @@ For each source file that touches the SDK: 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. **Add `enabled` / `customCdn` / `polls`** to any variable annotated as a complete `AttachmentManagerConfig` or `MessageComposerConfig` and built as an object literal. Partials are unaffected. -8. **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. -9. **Replace raw `channel.getConfig()?.uploads` / `?.polls` / `?.shared_locations` reads** used to gate UI with the resolved composer values (`attachmentManager.isUploadEnabled`, `composer.config.polls.enabled`, `composer.config.location.enabled`). `commands` still comes from `getConfig()`. -10. **Replace raw `channel.getConfig()?.typing_events` / `?.read_events` reads** used to gate UI with `channel.configState`'s `typingEvents.enabled` / `readEvents.enabled`, which are the reconciled values and are reactive. -11. **Format `LocationComposer` preview `end_at` at read sites** — it's a `Date` now. -12. **Rename `ReminderManager` call-site keys** `messageId` → `message_id`. Same for any place you were shaping a reminder-event body. -13. **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`. -14. **Rewrite `client.revokeTokens(isoString)`** to `client.revokeTokens(new Date(isoString))`. +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))`. diff --git a/v9-to-v10-migration-guide-type-renames.md b/v9-to-v10-migration-guide-type-renames.md index caf9d4bf76..c0f1afecb3 100644 --- a/v9-to-v10-migration-guide-type-renames.md +++ b/v9-to-v10-migration-guide-type-renames.md @@ -27,52 +27,55 @@ v10 exposes two generated types whose names collide with v9 aliases that pointed ## Rename table -| v9 (removed) | v10 (use this) | Notes | -| --------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `APIErrorResponse` | `APIError` | The v9 alias pointed at the generated `APIError`. Do not confuse with the local `APIError` in `src/errors.ts` — that's a different `Error & { code }` shape and is unaffected by this rename. If a file uses both, import the generated one with an `as Gen_APIError` alias. | -| `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` | The v10 `Automod` is a hand-authored string union (`'disabled' \| 'simple' \| 'AI' \| (string & {})`) in `src/types.ts`, not a generated re-export. Values are identical to v9. | -| `ChannelConfigAutomodBehavior` | `AutomodBehavior` | Same story — hand-authored union (`'flag' \| 'block' \| 'shadow_block' \| (string & {})`) in `src/types.ts`. Values are identical to v9. | -| `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` | Trivial 1:1 alias. | -| `ErrorFromResponse` | `StreamAPIError` | **Runtime value**, not just a type — was `export const ErrorFromResponse = StreamAPIError;`. Rewrite `instanceof ErrorFromResponse` and `new ErrorFromResponse(...)` call sites too. | -| `EventAPIResponse` | `APIResponse` + WS `Event` | The endpoint that used to return an event over HTTP is gone. Consume the response as `APIResponse` and pick up the event from the WS stream (`Event`). | -| `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. | -| `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. | -| `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` | The v9 alias pointed at the generated `APIError`. Do not confuse with the local `APIError` in `src/errors.ts` — that's a different `Error & { code }` shape and is unaffected by this rename. If a file uses both, import the generated one with an `as Gen_APIError` alias. | +| `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` | The v10 `Automod` is a hand-authored string union (`'disabled' \| 'simple' \| 'AI' \| (string & {})`) in `src/types.ts`, not a generated re-export. Values are identical to v9. | +| `ChannelConfigAutomodBehavior` | `AutomodBehavior` | Same story — hand-authored union (`'flag' \| 'block' \| 'shadow_block' \| (string & {})`) in `src/types.ts`. Values are identical to v9. | +| `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` | Trivial 1:1 alias. | +| `ErrorFromResponse` | `StreamAPIError` | **Runtime value**, not just a type — was `export const ErrorFromResponse = StreamAPIError;`. Rewrite `instanceof ErrorFromResponse` and `new ErrorFromResponse(...)` call sites too. | +| `EventAPIResponse` | `APIResponse` + WS `Event` | The endpoint that used to return an event over HTTP is gone. Consume the response as `APIResponse` and pick up the event from the WS stream (`Event`). | +| `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. | +| `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. | ## Types that are **not** renamed (kept as-is) From 363adefa95abd5fe335e115740821df38c991f24 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 21 Aug 2026 10:20:31 +0200 Subject: [PATCH 14/22] =?UTF-8?q?fix(configuration)!:=20key=20channel=20se?= =?UTF-8?q?rver=20configs=20by=20cid,=20not=20channel=20type=20A=20channel?= =?UTF-8?q?'s=20own=20`config=5Foverrides`=20narrow=20its=20type's=20setti?= =?UTF-8?q?ngs=20for=20that=20channel=20alone,=20so=20a=20type-keyed=20cac?= =?UTF-8?q?he=20could=20not=20hold=20two=20channels=20of=20one=20type=20th?= =?UTF-8?q?at=20disagree=20=E2=80=94=20they=20overwrote=20each=20other=20a?= =?UTF-8?q?nd=20every=20channel=20and=20composer=20of=20the=20type=20re-de?= =?UTF-8?q?rived=20to=20a=20value=20correct=20for=20at=20most=20one.=20`cl?= =?UTF-8?q?ient.configs`=20=E2=86=92=20`client.channelServerConfigs`;=20th?= =?UTF-8?q?e=20cid=20key=20space=20is=20v9's,=20unchanged.=20BREAKING=20CH?= =?UTF-8?q?ANGE:=20`client.configs`=20is=20renamed=20to=20`client.channelS?= =?UTF-8?q?erverConfigs`.=20Keys=20are=20still=20cids,=20so=20lookups=20tr?= =?UTF-8?q?anslate=20directly.=20Prefer=20`channel.serverConfig`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/instance-configuration.md | 48 +++--- src/channel.ts | 27 +++- src/client.ts | 68 ++++----- .../InstanceConfigurationRegistry.ts | 2 +- src/messageComposer/messageComposer.ts | 20 ++- src/types.ts | 5 +- .../linkPreviewsManager.test.ts | 12 +- .../MessageComposer/messageComposer.test.ts | 8 +- test/unit/channel.test.js | 8 +- test/unit/client.construction.test.ts | 4 +- test/unit/client.test.js | 30 ++-- .../unit/configuration/channel.config.test.ts | 10 +- .../configuration/configBoundaries.test.ts | 6 +- .../configuration/configPublishing.test.ts | 83 ++++++----- .../configState.unification.test.ts | 2 +- .../instanceConfiguration.integration.test.ts | 138 ++++++++++++++++-- .../configuration/serverAuthority.test.ts | 13 +- .../MessageDeliveryReporter.test.ts | 14 +- test/unit/test-utils/stubServerConfig.ts | 12 +- v9-to-v10-migration-guide-methods.md | 2 +- v9-to-v10-migration-guide-other.md | 4 +- 21 files changed, 331 insertions(+), 185 deletions(-) diff --git a/docs/instance-configuration.md b/docs/instance-configuration.md index 69f0aac352..a28f933e59 100644 --- a/docs/instance-configuration.md +++ b/docs/instance-configuration.md @@ -11,10 +11,11 @@ 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.channelConfigsByType`.** The latter is an internal cache of the -> **server-provided channel-type configuration**, keyed by channel type. It is not part of the supported -> surface — read server config through `channel.getConfig()`. `client.config` is yours: what you register -> for the instances the SDK creates. +> **`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 @@ -671,20 +672,21 @@ const unsubscribe = channel.messagePaginator.configState.subscribe(({ pageSize } Every configurable object has all three — `MessageComposer`, every paginator, `MessageOperations`, `client.notifications`, `client.reminders`, `client.threads`, `client.messageDeliveryReporter`, -`SearchController`, `LiveLocationManager` — with two exceptions: +`SearchController`, `LiveLocationManager` — with one exception: -| entity | `configState` | `config` | `updateConfig` | -| ------------------- | ------------- | -------- | -------------- | -| everything else | yes | yes | yes | -| `Channel`, `Thread` | yes | — | — | +| entity | `configState` | `config` | `updateConfig` | +| --------------- | ------------- | -------- | -------------- | +| everything else | yes | yes | yes | +| `Thread` | yes | — | — | -`Channel` and `Thread` are deliberately left with the store alone, because **`channel.getConfig()` already -means something else** — it returns the channel _type_'s server-side configuration (`shared_locations`, -`max_message_length`, the command list). A `channel.config` beside it would read as the same thing in getter -form while returning `{ requestHandlers }`, and nothing would catch the confusion: both names resolve, both -return a plausible object. Their instance configuration is one field wide and its only writer wants the store -anyway, so the getter would exist purely to make this table square. Read it as -`channel.configState.getLatestValue()`. +`Channel` was an exception too, 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, so `Channel` now has `config` like everything else. + +`Thread` still has the store alone. Its instance configuration is one field wide (`requestHandlers`) and its +only writer wants the store anyway, so the getter would exist purely to make this table square. Read it as +`thread.configState.getLatestValue()`. 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. @@ -798,7 +800,7 @@ implementation, so a configurable object with its own server-gated field applies ```ts this.configState.partialNext( mergeServerRestrictions(requestedConfig, { - location: { enabled: this.channel.getConfig()?.shared_locations }, + location: { enabled: this.channel.serverConfig?.shared_locations }, }), ); ``` @@ -1014,11 +1016,11 @@ deprecation exists to keep _released_ code compiling and no stable release ever | ----------------------------------------- | ----------------------------------------- | | `client.setInstanceConfigurationFunction` | `client.config.setSetupFunction(key, fn)` | | `client.instanceConfigurationService` | `client.config` | -| `client.configsStore` | `channel.getConfig()` | +| `client.configsStore` | `channel.serverConfig` | -`client.configs` is also gone — it _did_ ship, but keyed by cid, and it is now keyed by channel type. An -alias would let `client.configs[cid]` return `undefined` instead of failing, so the name was removed to -keep the break loud. Read server channel configuration through `channel.getConfig()`. +`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 @@ -1067,13 +1069,13 @@ reversible. [§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.getConfig()?.uploads` answers only the server's +**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.getConfig()`. +and no integrator intent to express, so consumers keep reading it from `channel.serverConfig`. ### `doUploadRequest` no longer implies a custom upload destination diff --git a/src/channel.ts b/src/channel.ts index dcee3dc611..4a1e78d3e3 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -439,10 +439,15 @@ export class Channel extends ChannelApi { // 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 the config for this channel type lands, or an app that disables - // `read_events` server-side would keep a channel that believes read receipts are on. - this.unsubscribeServerConfig = client.channelConfigsByTypeStore.subscribeWithSelector( - ({ configs }) => ({ channelConfig: configs[this.type] }), + // 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), ); @@ -564,7 +569,7 @@ export class Channel extends ChannelApi { /** * This channel's **resolved** configuration — the shape every configurable class exposes. * - * Not to be confused with {@link serverConfig}, which is the channel *type's* configuration as the + * 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 @@ -575,17 +580,23 @@ export class Channel extends ChannelApi { } /** - * The channel **type's** configuration, as the server reports it — feature flags such as `uploads`, + * 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() { - // Keyed by channel type — the config is a property of the type, not of this channel. - return this.getClient().channelConfigsByType[this.type]; + return this.getClient().channelServerConfigs[this.cid]; } _sendMessage(...args: Parameters) { diff --git a/src/client.ts b/src/client.ts index 75db163785..4308877d5e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -179,7 +179,7 @@ export class StreamChat extends ChatApi { mutedChannels: ChannelMute[]; readonly mutedUsersStore: StateStore<{ mutedUsers: UserMuteResponse[] }>; /** - * Reactive store behind {@link channelConfigsByType}. The only reactive way to observe server channel + * 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. * @@ -188,7 +188,7 @@ export class StreamChat extends ChatApi { * * @internal */ - readonly channelConfigsByTypeStore: StateStore; + readonly channelServerConfigsStore: StateStore; blockedUsers: StateStore; node: boolean; options: StreamChatOptions; @@ -237,8 +237,8 @@ export class StreamChat extends ChatApi { * 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 channelConfigsByType}, which holds the **server-provided channel-type - * configs** keyed by channel type. This one is yours; that one is the backend's. + * 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}. */ @@ -280,7 +280,7 @@ export class StreamChat extends ChatApi { this.mutedUsersStore = new StateStore<{ mutedUsers: UserMuteResponse[] }>({ mutedUsers: [], }); - this.channelConfigsByTypeStore = new StateStore<{ configs: Configs }>({ + this.channelServerConfigsStore = new StateStore({ configs: {}, }); this.blockedUsers = new StateStore({ userIds: [] }); @@ -327,8 +327,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.channelConfigsByType = {}; this.persistUserOnConnectionFailure = this.options?.persistUserOnConnectionFailure; // If its a server-side client, then lets initialize the tokenManager, since token will be @@ -413,28 +411,31 @@ export class StreamChat extends ChatApi { } /** - * Cache of server-provided channel configuration, keyed by **channel type** — the settings are - * defined per type, so one entry serves every channel of that type. + * 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 was `client.configs` through v9, keyed by **cid**. There is deliberately no `configs` alias: - * the name survived but its key space did not, so an alias would make `client.configs[cid]` return - * `undefined` instead of failing. Removing the name turns a silent wrong lookup into an obvious one. + * 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 channelConfigsByType() { - return this.channelConfigsByTypeStore.getLatestValue().configs; + get channelServerConfigs() { + return this.channelServerConfigsStore.getLatestValue().configs; } /** @internal */ - set channelConfigsByType(configs: Configs) { - this.channelConfigsByTypeStore.next({ configs }); + set channelServerConfigs(configs: Configs) { + this.channelServerConfigsStore.next({ configs }); } /** @@ -1628,37 +1629,26 @@ export class StreamChat extends ChatApi { } /** - * Caches a channel type's server configuration. + * Caches one channel's server configuration, read through {@link Channel.serverConfig}. * - * Keyed by **type**, not cid: every field in `ChannelConfigWithInfo` is a channel-*type* setting - * (`automod`, `commands`, `max_message_length`, the feature flags), and `config.name` is the type - * name. Keying by cid stored one identical copy per channel and left channels of an - * already-seen type reporting no config at all until they were themselves queried. + * 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. * - * An absent `config` is ignored rather than stored. `ChannelResponse.config` is optional — the - * `notification.message_new` payload is one route that may omit it — and writing `undefined` would - * un-learn* a config already known for the type. Keyed by cid that voided one channel; keyed by type it - * voids every channel of the type, and since the composer reads `serverConfig` for `shared_locations` and - * `max_message_length`, the result is a server restriction silently lifted (**DV-16**). - * - * A config deep-equal to the one already stored is ignored too, which is what keeps a channel query from - * waking every live composer. The API returns a **fresh object** for the same channel type on every - * response, so the store's `===` no-op never applied and the by-type selector in - * `MessageComposer.subscribeChannelConfigChanged` fired on each one. Measured on a 10-channel - * `queryChannels` page with three open composers: 30 configuration re-resolutions and 30 subscriber runs, - * every one of them producing a value identical to the last. Comparing here rather than in the composer - * skips the work as well as the notification, and covers every other reader of this store too. + * 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({ config, type }: Pick) { + _addChannelConfig({ cid, config }: Pick) { if (!config) return; if (!this._cacheEnabled()) return; - if (isEqual(this.channelConfigsByType[type], config)) return; + if (isEqual(this.channelServerConfigs[cid], config)) return; - this.channelConfigsByType = { - ...this.channelConfigsByType, - [type]: config, + this.channelServerConfigs = { + ...this.channelServerConfigs, + [cid]: config, }; } diff --git a/src/configuration/InstanceConfigurationRegistry.ts b/src/configuration/InstanceConfigurationRegistry.ts index 4726df4c1e..fdf940224d 100644 --- a/src/configuration/InstanceConfigurationRegistry.ts +++ b/src/configuration/InstanceConfigurationRegistry.ts @@ -2,7 +2,7 @@ * 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.channelConfigsByType`, which holds the **server-provided channel-type configs**. + * Not to be confused with `client.channelServerConfigs`, which holds the **server-provided channel configs**. * * There are two ways in, over one mechanism: * diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index 32df70fbfd..6487027723 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -596,7 +596,8 @@ export class MessageComposer extends WithSubscriptions { * * 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 composers at all; this + * `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({})`. */ @@ -812,15 +813,18 @@ export class MessageComposer extends WithSubscriptions { }); /** - * The channel's server-side config (`client.channelConfigsByType[type]`) 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. + * 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.channelConfigsByTypeStore.subscribeWithSelector( - ({ configs }) => ({ channelConfig: configs[this.channel.type] }), + this.client.channelServerConfigsStore.subscribeWithSelector( + ({ configs }) => ({ channelConfig: configs[this.channel.cid] }), () => this.applyServerRestrictions(), ); diff --git a/src/types.ts b/src/types.ts index f148889b7a..3fc5ad390b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -680,8 +680,9 @@ export type CommandVariants = | keyof CustomCommandData; /** - * Server-provided channel configuration, keyed by **channel type** (`messaging`, `livestream`, …) — - * every field in `ChannelConfigWithInfo` is a type-level setting. Read it via `channel.serverConfig`. + * 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; diff --git a/test/unit/MessageComposer/linkPreviewsManager.test.ts b/test/unit/MessageComposer/linkPreviewsManager.test.ts index e7afe3e122..ed2d743edf 100644 --- a/test/unit/MessageComposer/linkPreviewsManager.test.ts +++ b/test/unit/MessageComposer/linkPreviewsManager.test.ts @@ -430,8 +430,8 @@ describe('LinkPreviewsManager', () => { const setup2 = (url_enrichment: boolean) => { const client = new StreamChat('apiKey'); client.user = { id: 'user' } as never; - client.channelConfigsByTypeStore.partialNext({ - configs: { channelType: { url_enrichment } as never }, + client.channelServerConfigsStore.partialNext({ + configs: { 'channelType:channelId': { url_enrichment } as never }, }); const channel = client.channel('channelType', 'channelId'); const composer = new MessageComposer({ @@ -457,8 +457,8 @@ describe('LinkPreviewsManager', () => { composer.linkPreviewsManager.enabled = true; composer.linkPreviewsManager.enabled = false; // changed their mind, while masked - client.channelConfigsByTypeStore.partialNext({ - configs: { channelType: { url_enrichment: true } as never }, + client.channelServerConfigsStore.partialNext({ + configs: { 'channelType:channelId': { url_enrichment: true } as never }, }); expect(composer.linkPreviewsManager.enabled).toBe(false); @@ -469,8 +469,8 @@ describe('LinkPreviewsManager', () => { composer.linkPreviewsManager.enabled = true; - client.channelConfigsByTypeStore.partialNext({ - configs: { channelType: { url_enrichment: true } as never }, + client.channelServerConfigsStore.partialNext({ + configs: { 'channelType:channelId': { url_enrichment: true } as never }, }); expect(composer.linkPreviewsManager.enabled).toBe(true); diff --git a/test/unit/MessageComposer/messageComposer.test.ts b/test/unit/MessageComposer/messageComposer.test.ts index b3a5d195b4..378fe08354 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -106,13 +106,15 @@ const setup = ({ const mockClient = new StreamChat('test-api-key'); mockClient.user = user; const channelType = 'messaging'; + const channelId = 'test-channel-id'; if (channelConfig) { - // Keyed by channel type, not cid — see `Configs`. + // 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.channelConfigsByType[channelType] = 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); diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index b80ca57323..1f4816bb02 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -2729,7 +2729,7 @@ describe('Channel lastMessage', async () => { it('should return last message - system message is ignored when skip_last_msg_update_for_system_msgs: true', () => { client._addChannelConfig({ - type: channel.type, + cid: channel.cid, config: { skip_last_msg_update_for_system_msgs: true }, }); channel.state = new ChannelState(channel); @@ -2951,11 +2951,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 }, }, }, @@ -2967,6 +2971,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 3961cad488..88caf8b314 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.channelConfigsByType).to.deep.equal({}); + expect(client.channelServerConfigs).to.deep.equal({}); expect(client.wsConnection).to.be.null; expect(client.wsPromise).to.be.null; @@ -102,7 +102,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.channelConfigsByType).to.not.equal(b.channelConfigsByType); + 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 63f364c55f..b87e269b2b 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -122,47 +122,47 @@ describe('StreamChat getInstance', () => { }); describe('StreamChat config(s) store', () => { - it('initializes channelConfigsByTypeStore and keeps configs access backward compatible', () => { + it('initializes channelServerConfigsStore and keeps configs access backward compatible', () => { const client = new StreamChat('key', 'secret'); - expect(client.channelConfigsByType).to.eql({}); - expect(client.channelConfigsByTypeStore.getLatestValue()).to.eql({ configs: {} }); + expect(client.channelServerConfigs).to.eql({}); + expect(client.channelServerConfigsStore.getLatestValue()).to.eql({ configs: {} }); const nextConfigs = { 'messaging:next': { typing_events: true } }; - client.channelConfigsByType = nextConfigs; + client.channelServerConfigs = nextConfigs; - expect(client.channelConfigsByType).to.equal(nextConfigs); - expect(client.channelConfigsByTypeStore.getLatestValue()).to.eql({ + expect(client.channelServerConfigs).to.equal(nextConfigs); + expect(client.channelServerConfigsStore.getLatestValue()).to.eql({ configs: nextConfigs, }); }); - it('updates channelConfigsByTypeStore through _addChannelConfig when cache is enabled', () => { + it('updates channelServerConfigsStore through _addChannelConfig when cache is enabled', () => { const client = new StreamChat('key', 'secret'); client._addChannelConfig({ - type: 'messaging', + cid: 'messaging:general', config: { replies: true }, }); - expect(client.channelConfigsByTypeStore.getLatestValue()).to.eql({ + expect(client.channelServerConfigsStore.getLatestValue()).to.eql({ configs: { - // Keyed by channel type, so one entry serves every channel of that type. - messaging: { 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 channelConfigsByTypeStore 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({ - type: 'messaging', + cid: 'messaging:general', config: { replies: true }, }); - expect(client.channelConfigsByTypeStore.getLatestValue()).to.eql({ configs: {} }); + expect(client.channelServerConfigsStore.getLatestValue()).to.eql({ configs: {} }); }); }); @@ -839,7 +839,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.channelConfigsByType).length).to.be.equal(0); + expect(Object.keys(client.channelServerConfigs).length).to.be.equal(0); sinon.restore(); }); diff --git a/test/unit/configuration/channel.config.test.ts b/test/unit/configuration/channel.config.test.ts index 173aa04569..27178c1703 100644 --- a/test/unit/configuration/channel.config.test.ts +++ b/test/unit/configuration/channel.config.test.ts @@ -257,8 +257,8 @@ describe("the 'channel' configuration key", () => { */ describe('typing and read events', () => { const withServerConfig = (config: Record, id = 'channel-id') => { - client.channelConfigsByTypeStore.partialNext({ - configs: { messaging: config as never }, + client.channelServerConfigsStore.partialNext({ + configs: { [`messaging:${id}`]: config as never }, }); return openChannel(id); }; @@ -301,14 +301,14 @@ describe("the 'channel' configuration key", () => { 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 - // `getConfig()` as undefined, so the restriction states nothing and the defaults stand. Without + // `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.channelConfigsByTypeStore.partialNext({ - configs: { messaging: { read_events: false } as never }, + client.channelServerConfigsStore.partialNext({ + configs: { [channel.cid]: { read_events: false } as never }, }); expect(channel.configState.getLatestValue().readEvents.enabled).toBe(false); diff --git a/test/unit/configuration/configBoundaries.test.ts b/test/unit/configuration/configBoundaries.test.ts index c63a959e4e..68c36083ef 100644 --- a/test/unit/configuration/configBoundaries.test.ts +++ b/test/unit/configuration/configBoundaries.test.ts @@ -169,7 +169,7 @@ describe('the composer resolves through the shared controller', () => { it('retains an updateConfig request across a re-resolution (retainPatches)', () => { const client = getClientWithUser({ id: 'user' }); client._addChannelConfig({ - type: 'messaging', + cid: 'messaging:c-layer', config: { shared_locations: false } as never, }); const composer = client.channel('messaging', 'c-layer').messageComposer; @@ -184,7 +184,7 @@ describe('the composer resolves through the shared controller', () => { expect(composer.requestedConfig.location.enabled).toBe(true); client._addChannelConfig({ - type: 'messaging', + cid: 'messaging:c-layer', config: { shared_locations: true } as never, }); @@ -249,7 +249,7 @@ describe('the composer resolves through the shared controller', () => { it('applies the server ceiling on every resolution (applyAuthority)', () => { const client = getClientWithUser({ id: 'user' }); client._addChannelConfig({ - type: 'messaging', + cid: 'messaging:c-bounds', config: { max_message_length: 100 } as never, }); const composer = client.channel('messaging', 'c-bounds').messageComposer; diff --git a/test/unit/configuration/configPublishing.test.ts b/test/unit/configuration/configPublishing.test.ts index 02d7316c23..8a02f57cfd 100644 --- a/test/unit/configuration/configPublishing.test.ts +++ b/test/unit/configuration/configPublishing.test.ts @@ -12,11 +12,13 @@ import type { StreamChat } from '../../../src/client'; * 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 for the same - * channel type on each response, so `_addChannelConfig` replaced the stored one, the by-type selector in - * `MessageComposer.subscribeChannelConfigChanged` fired, and every live composer re-resolved. Measured on a - * 10-channel page with three open composers: **30 publishes and 30 subscriber runs, down to 3** — one per - * composer, for the config genuinely arriving the first time. + * 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: * @@ -43,32 +45,32 @@ describe('configuration publishes skip no-ops', () => { describe('at the source — client._addChannelConfig', () => { it('ignores a config deep-equal to the one already stored', () => { client._addChannelConfig({ - type: 'messaging', + cid: 'messaging:general', config: { ...serverConfig } as never, }); - const first = client.channelConfigsByType['messaging']; + const first = client.channelServerConfigs['messaging:general']; client._addChannelConfig({ - type: 'messaging', + cid: 'messaging:general', config: { ...serverConfig } as never, }); // Same object kept, so the store never published and nothing downstream woke up. - expect(client.channelConfigsByType['messaging']).toBe(first); + expect(client.channelServerConfigs['messaging:general']).toBe(first); }); it('does not notify the store for a repeated identical config', () => { client._addChannelConfig({ - type: 'messaging', + cid: 'messaging:general', config: { ...serverConfig } as never, }); const listener = vi.fn(); - client.channelConfigsByTypeStore.subscribe(listener); + client.channelServerConfigsStore.subscribe(listener); listener.mockClear(); for (let i = 0; i < 10; i++) { client._addChannelConfig({ - type: 'messaging', + cid: 'messaging:general', config: { ...serverConfig } as never, }); } @@ -78,41 +80,45 @@ describe('configuration publishes skip no-ops', () => { it('still stores a config that genuinely changed', () => { client._addChannelConfig({ - type: 'messaging', + cid: 'messaging:general', config: { ...serverConfig } as never, }); client._addChannelConfig({ - type: 'messaging', + cid: 'messaging:general', config: { ...serverConfig, max_message_length: 120 } as never, }); - expect(client.channelConfigsByType['messaging']).toMatchObject({ + expect(client.channelServerConfigs['messaging:general']).toMatchObject({ max_message_length: 120, }); }); it('keeps a repeated channel query from waking live composers', () => { - const composers = ['a', 'b', 'c'].map((id) => { - const composer = client.channel('messaging', id).messageComposer; - composer.registerSubscriptions(); - return composer; - }); - // The config arrives for the first time: every composer should hear about this one. - client._addChannelConfig({ - type: 'messaging', - config: { ...serverConfig } as never, + 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++) { - client._addChannelConfig({ - type: 'messaging', - config: { ...serverConfig } as never, - }); + channels.forEach((channel) => + client._addChannelConfig({ + cid: channel.cid, + config: { ...serverConfig } as never, + }), + ); } expect(publishes).toBe(0); @@ -122,10 +128,11 @@ describe('configuration publishes skip no-ops', () => { // 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 composer = client.channel('messaging', channelResponse.id).messageComposer; + const channel = client.channel('messaging', channelResponse.id); + const composer = channel.messageComposer; composer.registerSubscriptions(); client._addChannelConfig({ - type: 'messaging', + cid: channel.cid, config: { ...serverConfig } as never, }); @@ -133,7 +140,7 @@ describe('configuration publishes skip no-ops', () => { for (let i = 0; i < 10; i++) { client._addChannelConfig({ - type: 'messaging', + cid: channel.cid, config: { ...serverConfig } as never, }); } @@ -142,16 +149,17 @@ describe('configuration publishes skip no-ops', () => { }); it('does wake them when the server config actually changes', () => { - const composer = client.channel('messaging', channelResponse.id).messageComposer; + const channel = client.channel('messaging', channelResponse.id); + const composer = channel.messageComposer; composer.registerSubscriptions(); client._addChannelConfig({ - type: 'messaging', + cid: channel.cid, config: { ...serverConfig } as never, }); expect(composer.config.text.maxLengthOnSend).toBe(5000); client._addChannelConfig({ - type: 'messaging', + cid: channel.cid, config: { ...serverConfig, max_message_length: 120 } as never, }); @@ -201,11 +209,12 @@ describe('configuration publishes skip no-ops', () => { 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({ - type: 'messaging', + cid: channel.cid, config: { shared_locations: false } as never, }); - const composer = client.channel('messaging', channelResponse.id).messageComposer; + const composer = channel.messageComposer; composer.registerSubscriptions(); composer.updateConfig({ location: { enabled: true } }); expect(composer.config.location.enabled).toBe(false); @@ -215,7 +224,7 @@ describe('configuration publishes skip no-ops', () => { listener.mockClear(); client._addChannelConfig({ - type: 'messaging', + cid: channel.cid, config: { shared_locations: true } as never, }); diff --git a/test/unit/configuration/configState.unification.test.ts b/test/unit/configuration/configState.unification.test.ts index 6b2b93d7d7..83dd6d2b31 100644 --- a/test/unit/configuration/configState.unification.test.ts +++ b/test/unit/configuration/configState.unification.test.ts @@ -167,7 +167,7 @@ describe('resolved configuration is reactive on the classes that were converted expect(channel.configState).toBeDefined(); expect(channel.config).toBe(channel.configState.getLatestValue()); - expect(channel.serverConfig).toBe(client.channelConfigsByType.messaging); + expect(channel.serverConfig).toBe(client.channelServerConfigs[channel.cid]); }); /** diff --git a/test/unit/configuration/instanceConfiguration.integration.test.ts b/test/unit/configuration/instanceConfiguration.integration.test.ts index bfd8d36d68..1b5ae384e9 100644 --- a/test/unit/configuration/instanceConfiguration.integration.test.ts +++ b/test/unit/configuration/instanceConfiguration.integration.test.ts @@ -3,6 +3,7 @@ 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'; @@ -35,7 +36,7 @@ describe('instance configuration — cross-instance', () => { }); /** Populate the channel's server-side config, as `query`/`watch` would. */ const setServerConfig = (channel: Channel, config: Record) => - client._addChannelConfig({ type: channel.type, config } as never); + 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', () => { @@ -347,17 +348,41 @@ describe('instance configuration — cross-instance', () => { }); }); - describe('server channel configuration is cached by type', () => { - it('serves every channel of a type from one entry', () => { + /** + * 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, but the config belongs to the *type* — keying by cid used to leave it - // reporting nothing until it was queried itself. - expect(b.serverConfig?.shared_locations).toBe(false); - expect(Object.keys(client.channelConfigsByType)).toEqual(['messaging']); + // `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', () => { @@ -369,15 +394,108 @@ describe('instance configuration — cross-instance', () => { expect(livestream.serverConfig).toBeUndefined(); }); - it('reaches a composer built before the config arrived, for any channel of the type', () => { + 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(); - // Config arrives via `a`'s query; `b`'s composer is watching the same type entry. + // `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(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, + ); }); }); diff --git a/test/unit/configuration/serverAuthority.test.ts b/test/unit/configuration/serverAuthority.test.ts index 1ff9c00fb2..3f2618fe70 100644 --- a/test/unit/configuration/serverAuthority.test.ts +++ b/test/unit/configuration/serverAuthority.test.ts @@ -300,6 +300,7 @@ describe('narrowing and recovery, together', () => { ...mockChannelQueryResponse, channel: { ...mockChannelQueryResponse.channel, + cid: 'messaging:recovery-channel', id: 'recovery-channel', config: { ...mockChannelQueryResponse.channel.config, @@ -314,6 +315,7 @@ describe('narrowing and recovery, together', () => { ...mockChannelQueryResponse, channel: { ...mockChannelQueryResponse.channel, + cid: 'messaging:recovery-channel', id: 'recovery-channel', config: { ...mockChannelQueryResponse.channel.config, @@ -473,10 +475,9 @@ describe("the channel type's max_message_length caps the composer", () => { /** * `ChannelResponse.config` is optional — the `notification.message_new` payload is one route that can omit - * it — and `_addChannelConfig` stored whatever it was handed. Keyed by cid that voided one channel's - * config; keyed by **type** (DEC-26) it voids every channel of the type, and since the composer reads - * `getConfig()` for `shared_locations` and `max_message_length`, the result is a restriction silently - * lifted rather than a cache miss. + * 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', () => { @@ -486,9 +487,9 @@ describe('an absent server config cannot un-learn a known one', () => { }).channel; client._addChannelConfig(response); - client._addChannelConfig({ type: response.type, config: undefined }); + client._addChannelConfig({ cid: response.cid, config: undefined }); - expect(client.channelConfigsByType[response.type]).toEqual(response.config); + expect(client.channelServerConfigs[response.cid]).toEqual(response.config); }); it('keeps the server restriction in force on a live composer', () => { diff --git a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts index c32be9abdf..8417327c6a 100644 --- a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts +++ b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts @@ -57,7 +57,7 @@ describe('MessageDeliveryReporter', () => { channel = client.channel(channelType, channelId); channel.initialized = true; - client.channelConfigsByType[channel.type] = { + client.channelServerConfigs[channel.cid] = { created_at: '', delivery_events: true, read_events: false, @@ -112,7 +112,7 @@ describe('MessageDeliveryReporter', () => { return channel; }); channels.forEach((ch) => { - client.channelConfigsByType[ch.type] = { + client.channelServerConfigs[ch.cid] = { created_at: '', delivery_events: true, read_events: false, @@ -157,7 +157,7 @@ describe('MessageDeliveryReporter', () => { }); it('does nothing when delievry events are disabled in channel config', async () => { - // Through the store, not by mutating `channelConfigsByType`: the flag is reconciled into + // 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, { @@ -213,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.channelConfigsByType[thread.channel.type] = { + client.channelServerConfigs[thread.channel.cid] = { created_at: '', delivery_events: true, read_events: false, @@ -306,7 +306,7 @@ describe('MessageDeliveryReporter', () => { const ch2 = client.channel('messaging', 'ch2'); ch2.initialized = true; - client.channelConfigsByType[ch1.type] = { + client.channelServerConfigs[ch1.cid] = { created_at: '', delivery_events: true, read_events: false, @@ -314,7 +314,7 @@ describe('MessageDeliveryReporter', () => { updated_at: '', }; - client.channelConfigsByType[ch2.type] = { + client.channelServerConfigs[ch2.cid] = { created_at: '', delivery_events: true, read_events: false, @@ -457,7 +457,7 @@ describe('MessageDeliveryReporter', () => { return channel; }); channels.forEach((ch) => { - client.channelConfigsByType[ch.type] = { + client.channelServerConfigs[ch.cid] = { created_at: '', delivery_events: true, read_events: false, diff --git a/test/unit/test-utils/stubServerConfig.ts b/test/unit/test-utils/stubServerConfig.ts index 04187313ff..7e3c08aa50 100644 --- a/test/unit/test-utils/stubServerConfig.ts +++ b/test/unit/test-utils/stubServerConfig.ts @@ -1,9 +1,9 @@ import type { Channel } from '../../../src/channel'; /** - * Sets the channel type's server configuration in tests. + * Sets a channel's server configuration in tests. * - * Writes through `client.channelConfigsByTypeStore` — the real place — rather than stubbing the + * 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 @@ -18,12 +18,12 @@ export const stubServerConfig = ( initial: Record | undefined, ) => { const client = (channel as Channel).getClient?.(); - const type = (channel as Channel).type; + const cid = (channel as Channel).cid; - if (client && type) { + if (client && cid) { const write = (next: Record | undefined) => { - client.channelConfigsByTypeStore.partialNext({ - configs: { ...client.channelConfigsByType, [type]: next } as never, + client.channelServerConfigsStore.partialNext({ + configs: { ...client.channelServerConfigs, [cid]: next } as never, }); }; write(initial); diff --git a/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md index 688d1aa020..aa3b7b7d15 100644 --- a/v9-to-v10-migration-guide-methods.md +++ b/v9-to-v10-migration-guide-methods.md @@ -616,7 +616,7 @@ 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: the channel _type's_ server configuration (`ChannelConfigWithInfo`). 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. +- `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 bdc47d7666..b58653ea17 100644 --- a/v9-to-v10-migration-guide-other.md +++ b/v9-to-v10-migration-guide-other.md @@ -533,11 +533,13 @@ useStateStore(channel.configState, ({ readEvents }) => ({ enabled: readEvents.en | Before | After | Returns | | --------------------- | ---------------------- | ----------------------------------------------------------------------------------- | -| `channel.getConfig()` | `channel.serverConfig` | The channel **type's** server configuration — `ChannelConfigWithInfo`, 37 fields | +| `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. From 84bb900fcc32be9525f1fcae4f10eec5efdc6ec7 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 21 Aug 2026 11:16:20 +0200 Subject: [PATCH 15/22] fix(LiveLocationManager): keep config subscription active until the last subscriber leaves --- docs/instance-configuration.md | 7 +- src/LiveLocationManager.ts | 59 +++++++-- test/unit/LiveLocationManager.test.ts | 123 ++++++++++++++++++ .../selfRegisteringEntities.test.ts | 17 ++- v9-to-v10-migration-guide-other.md | 1 + 5 files changed, 189 insertions(+), 18 deletions(-) diff --git a/docs/instance-configuration.md b/docs/instance-configuration.md index a28f933e59..29937ae564 100644 --- a/docs/instance-configuration.md +++ b/docs/instance-configuration.md @@ -179,8 +179,11 @@ 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.unregisterSubscriptions()` (which it already needs) or `searchController.dispose()`. +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 open key space diff --git a/src/LiveLocationManager.ts b/src/LiveLocationManager.ts index 1af3dc1fa2..b527610fb8 100644 --- a/src/LiveLocationManager.ts +++ b/src/LiveLocationManager.ts @@ -87,7 +87,7 @@ export class LiveLocationManager extends WithSubscriptions { /** The shared configuration machinery — see {@link ConfigController}. */ private readonly configController: ConfigController; - /** Teardown for this manager's configuration subscription, released by {@link unregisterSubscriptions}. */ + /** Teardown for this manager's configuration subscription, released by {@link dispose}. */ private unsubscribeConfiguration?: Unsubscribe; /** @@ -124,20 +124,32 @@ export class LiveLocationManager extends WithSubscriptions { }); // Last statement of the constructor, so a setup function sees a whole object. Registered here rather - // than 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 + // 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: client.config, + config: this.client.config, key: 'liveLocationManager', applyConfig: (config) => this.initializeConfig(config), reinitializeConfig: () => this.initializeConfig( - client.config.getConfig('liveLocationManager') ?? undefined, + this.client.config.getConfig('liveLocationManager') ?? undefined, ), }); - } + }; /** The current resolved configuration. `Readonly` — change it through {@link updateConfig}. */ get config(): Readonly { @@ -161,19 +173,44 @@ 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()); }; - public unregisterSubscriptions = () => { - const released = super.unregisterSubscriptions(); - // Ref-counted: only the last caller actually tears down, and the configuration subscription is not - // one of the ref-counted ones — it was registered by the constructor, so it is released here. + /** + * 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; - return released; }; get messages() { 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/configuration/selfRegisteringEntities.test.ts b/test/unit/configuration/selfRegisteringEntities.test.ts index e5b22c7b88..7478b9eaef 100644 --- a/test/unit/configuration/selfRegisteringEntities.test.ts +++ b/test/unit/configuration/selfRegisteringEntities.test.ts @@ -55,7 +55,7 @@ describe('entities that register themselves', () => { ); }); - it('runs a setup function, and its teardown on unregister', () => { + it('runs a setup function, and its teardown on dispose', () => { const teardown = vi.fn(); const setup = vi.fn(() => teardown); client.config.setSetupFunction('liveLocationManager', setup); @@ -65,20 +65,27 @@ describe('entities that register themselves', () => { 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('stops hearing changes once unregistered', () => { + 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); - expect(manager.config.minUpdateThrottleMs).toBe( - DEFAULT_LIVE_LOCATION_MANAGER_CONFIG.minUpdateThrottleMs, - ); + manager.dispose(); + client.config.set({ liveLocationManager: { minUpdateThrottleMs: 5_000 } }); + + expect(manager.config.minUpdateThrottleMs).toBe(7_000); }); }); diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md index b58653ea17..12778b0238 100644 --- a/v9-to-v10-migration-guide-other.md +++ b/v9-to-v10-migration-guide-other.md @@ -682,3 +682,4 @@ For each source file that touches the SDK: 17. **Fix upload call sites.** `channel.sendFile` / `sendImage` / `client.uploadFile_` / `uploadImage_` take `string | File` now — no `Buffer`, no readable streams. Pass `contentType` explicitly when the source is a React-Native URI string. 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. **Polyfill `atob`** if your React Native / Hermes target lacks it (`typeof atob === 'undefined'`); `UserFromToken` depends on it during `connectUser`. +20. **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. From 00a6f705a749b4f77229f532c1fd97e61829b70d Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 21 Aug 2026 12:18:13 +0200 Subject: [PATCH 16/22] fix: freeze Channel config object --- docs/instance-configuration.md | 11 ++-- src/channel.ts | 27 ++++++--- .../unit/configuration/channel.config.test.ts | 56 +++++++++++++++++++ 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/docs/instance-configuration.md b/docs/instance-configuration.md index 29937ae564..b1b130b783 100644 --- a/docs/instance-configuration.md +++ b/docs/instance-configuration.md @@ -699,11 +699,12 @@ state while notifying nobody — and points you at `updateConfig`. It does **not `composer.config.text.publishTypingEvents = false`, because `Readonly` is shallow. Runtime freezing covers that gap, and how far it reaches differs by class: -- **`MessageComposer`** deep-freezes 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 `location` and `text` are copied on every single resolution - because the server's restrictions and upper bounds name them, which left the two most-configured subtrees - writable. +- **`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. diff --git a/src/channel.ts b/src/channel.ts index 4a1e78d3e3..5be6b9b47a 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -19,6 +19,7 @@ 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'; @@ -428,13 +429,25 @@ export class Channel extends ChannelApi { initialSlice: declarativeConfig as Partial | undefined, // Nested groups: naming `typingEvents.enabled` must not drop `readEvents`. mergeSlice: 'deep', - applyAuthority: (requested) => ({ - ...(mergeServerRestrictions(requested, this.serverRestrictions) as ChannelConfig), - // Assigned rather than merged: `mergeServerRestrictions` treats an array as an interior node - // and hands it to the deep merge, which would combine the two lists. The server owns this one - // outright, so it replaces. - availableCommands: this.serverConfig?.commands ?? [], - }), + // 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 diff --git a/test/unit/configuration/channel.config.test.ts b/test/unit/configuration/channel.config.test.ts index 27178c1703..bc42b761cd 100644 --- a/test/unit/configuration/channel.config.test.ts +++ b/test/unit/configuration/channel.config.test.ts @@ -255,6 +255,62 @@ describe("the 'channel' configuration key", () => { * 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. + */ + 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({ From cb9f320c7bccc12140f03b41446d7e7387c6f65f Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 21 Aug 2026 12:40:30 +0200 Subject: [PATCH 17/22] fix: apply authority when ConfigController.patch is applied --- docs/instance-configuration.md | 5 ++ src/configuration/ConfigController.ts | 35 ++++++++++--- .../configuration/ConfigController.test.ts | 49 +++++++++++++++++++ 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/docs/instance-configuration.md b/docs/instance-configuration.md index b1b130b783..d7fbfb6576 100644 --- a/docs/instance-configuration.md +++ b/docs/instance-configuration.md @@ -439,6 +439,11 @@ server changing its mind. Every other entity writes a patch straight into the re 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. diff --git a/src/configuration/ConfigController.ts b/src/configuration/ConfigController.ts index 3d89a6058f..a039e11748 100644 --- a/src/configuration/ConfigController.ts +++ b/src/configuration/ConfigController.ts @@ -78,6 +78,11 @@ export type ConfigControllerOptions> = { * 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; /** @@ -85,8 +90,9 @@ export type ConfigControllerOptions> = { * 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, which is the point: a restriction applied only at - * construction stops holding the first time anything else updates the configuration. + * `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; }; @@ -203,16 +209,31 @@ export class ConfigController< /** * Applies a partial configuration, skipping the write when every field already matches. * - * Copied on the way in, because `mergeWith` reuses a source subtree verbatim where the target has - * nothing — so without this the entity would hold the caller's object, and a later mutation of it would - * change resolved configuration with no notification. + * 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. - this.write({ ...this.value, ...owned } as TConfig); + // 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( diff --git a/test/unit/configuration/ConfigController.test.ts b/test/unit/configuration/ConfigController.test.ts index 88afafc83c..2d7e7fe2f7 100644 --- a/test/unit/configuration/ConfigController.test.ts +++ b/test/unit/configuration/ConfigController.test.ts @@ -148,6 +148,55 @@ describe('ConfigController', () => { }); }); + /** + * `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(); From 46e9c5e5d4f216f0bf36c1af3839530b8a1a7510 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 21 Aug 2026 13:03:23 +0200 Subject: [PATCH 18/22] fix(copyConfigPatch): prevent infinite recursion --- src/configuration/utils/copyConfigPatch.ts | 22 +++++- .../configuration/copyConfigPatch.test.ts | 67 +++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 test/unit/configuration/copyConfigPatch.test.ts diff --git a/src/configuration/utils/copyConfigPatch.ts b/src/configuration/utils/copyConfigPatch.ts index 3addb4ea3c..9269941e69 100644 --- a/src/configuration/utils/copyConfigPatch.ts +++ b/src/configuration/utils/copyConfigPatch.ts @@ -21,25 +21,41 @@ import { isWalkableRecord } from '../../utils/objectPath'; * 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 */ -export const copyConfigPatch = (value: T): T => { +const copyInto = (value: T, copies: WeakMap): T => { if (Array.isArray(value)) { - return value.map((entry) => copyConfigPatch(entry)) as unknown as T; + 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] = copyConfigPatch((value as Record)[key]); + 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/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); + }); +}); From 147174aaee029c1e091358bc5f754911cf9795a3 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 21 Aug 2026 13:45:36 +0200 Subject: [PATCH 19/22] fix: use ConfigurationController for Thread config state management --- docs/instance-configuration.md | 28 ++++----- src/channel.ts | 37 ++++++++++- src/thread.ts | 53 ++++++++++++---- .../unit/configuration/channel.config.test.ts | 42 +++++++++++++ .../defaultConfigImmutability.test.ts | 2 + test/unit/configuration/thread.config.test.ts | 61 +++++++++++++++++++ v9-to-v10-migration-guide-other.md | 9 +++ 7 files changed, 200 insertions(+), 32 deletions(-) diff --git a/docs/instance-configuration.md b/docs/instance-configuration.md index d7fbfb6576..3607e654dd 100644 --- a/docs/instance-configuration.md +++ b/docs/instance-configuration.md @@ -678,23 +678,17 @@ const unsubscribe = channel.messagePaginator.configState.subscribe(({ pageSize } }); ``` -Every configurable object has all three — `MessageComposer`, every paginator, `MessageOperations`, -`client.notifications`, `client.reminders`, `client.threads`, `client.messageDeliveryReporter`, -`SearchController`, `LiveLocationManager` — with one exception: - -| entity | `configState` | `config` | `updateConfig` | -| --------------- | ------------- | -------- | -------------- | -| everything else | yes | yes | yes | -| `Thread` | yes | — | — | - -`Channel` was an exception too, 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, so `Channel` now has `config` like everything else. - -`Thread` still has the store alone. Its instance configuration is one field wide (`requestHandlers`) and its -only writer wants the store anyway, so the getter would exist purely to make this table square. Read it as -`thread.configState.getLatestValue()`. +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. diff --git a/src/channel.ts b/src/channel.ts index 5be6b9b47a..f66c659057 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -209,6 +209,39 @@ export type ChannelConfig = { * 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 }, @@ -426,7 +459,7 @@ export class Channel extends ChannelApi { this.configController = new ConfigController({ defaults: DEFAULT_CHANNEL_CONFIG, - initialSlice: declarativeConfig as Partial | undefined, + 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 @@ -526,7 +559,7 @@ export class Channel extends ChannelApi { // 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(declarativeConfig as Partial); + 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. diff --git a/src/thread.ts b/src/thread.ts index 00d6714d69..11b4e2972a 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -32,10 +32,11 @@ import type { CustomThreadData } from './custom_types'; import { MessageComposer } from './messageComposer'; import { MessageOperations } from './messageOperations'; import { WithSubscriptions } from './utils/WithSubscriptions'; -import { isEqual } from './utils/mergeWith/mergeWithCore'; 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, @@ -93,8 +94,18 @@ export type ThreadConfig = { }; }; +/** + * 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; @@ -335,15 +346,15 @@ export class Thread extends WithSubscriptions { * `requestHandlers`; the paginator derives its own configuration. */ initializeConfig(declarativeConfig?: ThreadDeclarativeConfig): void { - // Replaces rather than merges: a handler dropped from the declarative tree must disappear. - // Guarded against a no-op publish exactly as `Channel.initializeConfig` is — same freshly allocated - // object, same `alsoWatch` re-run, and `useThreadRequestHandlers` subscribes to this store too. - const nextRequestHandlers = declarativeConfig?.requestHandlers; - if ( - !isEqual(this.configState.getLatestValue().requestHandlers, nextRequestHandlers) - ) { - this.configState.next({ requestHandlers: nextRequestHandlers }); - } + // 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( @@ -364,9 +375,25 @@ export class Thread extends WithSubscriptions { ); } - /** This thread's resolved configuration — the shape every configurable class exposes. */ + /** + * 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.configState.getLatestValue(); + 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() { diff --git a/test/unit/configuration/channel.config.test.ts b/test/unit/configuration/channel.config.test.ts index bc42b761cd..4fa1d57272 100644 --- a/test/unit/configuration/channel.config.test.ts +++ b/test/unit/configuration/channel.config.test.ts @@ -260,6 +260,48 @@ describe("the 'channel' configuration key", () => { * 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', diff --git a/test/unit/configuration/defaultConfigImmutability.test.ts b/test/unit/configuration/defaultConfigImmutability.test.ts index 4832b7b626..84f46955ce 100644 --- a/test/unit/configuration/defaultConfigImmutability.test.ts +++ b/test/unit/configuration/defaultConfigImmutability.test.ts @@ -8,6 +8,7 @@ import { DEFAULT_MESSAGE_OPERATIONS_CONFIG } from '../../../src/messageOperation 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'; /** @@ -35,6 +36,7 @@ describe('package default configurations are immutable', () => { DEFAULT_NOTIFICATION_MANAGER_CONFIG, DEFAULT_PAGINATION_OPTIONS, DEFAULT_REMINDER_MANAGER_CONFIG, + DEFAULT_THREAD_CONFIG, DEFAULT_THREAD_MANAGER_CONFIG, }; diff --git a/test/unit/configuration/thread.config.test.ts b/test/unit/configuration/thread.config.test.ts index 3436c70c28..0f1c58fd6e 100644 --- a/test/unit/configuration/thread.config.test.ts +++ b/test/unit/configuration/thread.config.test.ts @@ -23,6 +23,67 @@ describe("the 'thread' configuration key", () => { 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 } } }); diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md index 12778b0238..b9801a57c4 100644 --- a/v9-to-v10-migration-guide-other.md +++ b/v9-to-v10-migration-guide-other.md @@ -544,6 +544,15 @@ The two are **not** interchangeable. Only six flags have a resolved counterpart `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` From 2641855e0a37848ca2efa52eb1d94f193b3e2d01 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 21 Aug 2026 15:27:23 +0200 Subject: [PATCH 20/22] fix: do not allow to add custom keys to entity configuration service --- docs/instance-configuration.md | 114 ++++++++---------- .../InstanceConfigurationRegistry.ts | 77 +++++++----- src/configuration/keys.ts | 4 +- src/configuration/types.ts | 62 ++++++---- .../utils/applyInstanceConfiguration.ts | 25 ++-- src/configuration/utils/index.ts | 6 +- src/index.ts | 7 +- .../InstanceConfigurationRegistry.test.ts | 26 ++-- 8 files changed, 169 insertions(+), 152 deletions(-) diff --git a/docs/instance-configuration.md b/docs/instance-configuration.md index 3607e654dd..eb6c8cbaa0 100644 --- a/docs/instance-configuration.md +++ b/docs/instance-configuration.md @@ -185,12 +185,11 @@ Release the subscription when you are done with the instance — `liveLocationMa configuration there would let the first one to leave stop a still-live instance from tracking `client.config`. -### Two setters, one open key space +### Two setters, one declared key space -`setConfig(key, subtree)` accepts **any** key, so a class of your own participates without changing -this package. `set(tree)` is a typed contract and rejects top-level keys it does not know — which is -what makes a typo in the whole-tree form a compile error rather than a silent no-op. To use a custom key -with `set`, augment `InstanceConfigTree` (see [Custom keys](#6-custom-keys)). +`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). --- @@ -385,15 +384,15 @@ object read it. 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 | the open key space (`'channel'`, `'messageComposer'`, a custom key) | 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 | +| | `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 @@ -401,7 +400,8 @@ registered", never "what is in effect". The resolver is the mirror image: the re 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: +`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 } }) @@ -554,9 +554,6 @@ constructed — `pageSize` is 10 for a bare paginator and 100 for the channel me 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()`. -Built-in keys only: a key you registered through module augmentation has no entry, so merge in -`client.config.getTree()` if you need those too. - ### 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 @@ -719,8 +716,8 @@ client.config.getTree(); // { messagePaginator: { pageSize: 50 }, client: { notifications: { durations: { error: 10_000 } } } } ``` -Custom keys are included. 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 +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 @@ -874,63 +871,50 @@ narrowed by the server, the SDK logs it at debug level so the no-op is at least --- -## 6. Custom keys +## 6. The key space is closed -The key space is open, so a class of your own — or a downstream SDK's — can use the same mechanism. -Augment both interfaces, then wire the class with the exported helper: +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 -import { applyInstanceConfiguration, type StreamChat } from 'stream-chat'; +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 +``` -class MyWidget { - config = { pollIntervalMs: 5_000, theme: 'light' as 'light' | 'dark' }; - private unsubscribe: () => void; - - constructor(private client: StreamChat) { - this.unsubscribe = applyInstanceConfiguration({ - args: { widget: this }, - config: client.config, - key: 'myWidget', - applyConfig: (next) => Object.assign(this.config, next), - reinitializeConfig: () => this.initializeConfig(), - }); - } +**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. - /** Re-derives from current inputs — what `client.config.reset()` calls. */ - initializeConfig() { - this.config = { pollIntervalMs: 5_000, theme: 'light' }; - Object.assign(this.config, this.client.config.getConfig('myWidget') ?? {}); - } +`ConfigController` stays exported for the part that _is_ worth reusing: the resolution itself — frozen +defaults, one layer order, a skipped write when nothing moved, and a reactive `configState`. Own the +class, own its configuration, and let the controller do the resolving: - destroy() { - this.unsubscribe(); - } -} +```ts +import { ConfigController } from 'stream-chat'; + +type MyWidgetConfig = { enabled: boolean; pollIntervalMs: number }; -declare module 'stream-chat' { - interface InstanceSetupFunctionArgs { - myWidget: { widget: MyWidget }; +class MyWidget { + private readonly configController = new ConfigController({ + defaults: { enabled: true, pollIntervalMs: 5_000 }, + }); + + get configState() { + return this.configController.state; } - interface InstanceConfigTree { - myWidget: { pollIntervalMs?: number; theme?: 'light' | 'dark' }; + get config() { + return this.configController.value; + } + updateConfig(patch: Partial) { + this.configController.patch(patch); } } ``` -Then configure it exactly like a built-in key: - -```ts -client.config.set({ myWidget: { pollIntervalMs: 1_000 } }); -client.config.setSetupFunction('myWidget', ({ widget }) => widget.onUpdate(handler)); -``` - -`applyInstanceConfiguration` gives you the same guarantees the built-ins have — immediate application, -teardown before re-apply, error containment — so do not hand-roll the subscription. - -**The cost of an open key space:** a typo is a valid custom key. `setSetupFunction('cahnnel', fn)` cannot -be rejected without breaking extensibility, so it silently does nothing. The SDK logs at debug level when -a function is registered for a key that is neither built-in nor has a subscriber. Using `set(tree)` -instead of `setConfig` gives you a compile error for the same mistake. +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. --- diff --git a/src/configuration/InstanceConfigurationRegistry.ts b/src/configuration/InstanceConfigurationRegistry.ts index fdf940224d..ecc9368b40 100644 --- a/src/configuration/InstanceConfigurationRegistry.ts +++ b/src/configuration/InstanceConfigurationRegistry.ts @@ -14,9 +14,9 @@ * Declarative configuration is applied before the setup function, so a setup function always wins for * the same field. * - * Keys are **open**: the four built-ins are typed for autocomplete, but any string works, so a - * downstream SDK or an integrator can register a key for a class of its own. Stores are therefore - * created lazily — a key must work whether the setter or the subscriber arrives first. + * 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. @@ -28,8 +28,9 @@ import { mergeWith } from '../utils/mergeWith'; import { isEqual } from '../utils/mergeWith/mergeWithCore'; import { copyConfigPatch } from './utils/copyConfigPatch'; import { getPath, hasPath, isWalkableRecord } from '../utils/objectPath'; -import { BUILT_IN_INSTANCE_KEYS, CONSTRUCTION_ONLY_CONFIG_PATHS } from './keys'; +import { CONSTRUCTION_ONLY_CONFIG_PATHS, INSTANCE_CONFIG_TREE_KEYS } from './keys'; import type { + InstanceConfigKey, InstanceConfigOf, InstanceConfigState, InstanceConfigTree, @@ -62,7 +63,7 @@ export type ConfiguredInstance = { // 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>; +type AnyConfigStore = StateStore>; export class InstanceConfigurationRegistry { /** @@ -140,10 +141,12 @@ export class InstanceConfigurationRegistry { } /** The declarative-configuration store for a key, created on first access. */ - getConfigState(key: K): StateStore> { + getConfigState( + key: K, + ): StateStore> { let store = this.configStates.get(key); if (!store) { - store = new StateStore>({ config: null }); + store = new StateStore>({ config: null }); this.configStates.set(key, store); } return store as unknown as StateStore>; @@ -161,7 +164,7 @@ export class InstanceConfigurationRegistry { key: K, setupFunction: InstanceSetupFunction | null, ): void { - this.debugIfKeyLooksUnused(key); + this.warnIfKeyLooksUnknown(key); this.getSetupState(key).partialNext({ setupFunction }); } @@ -182,16 +185,19 @@ export class InstanceConfigurationRegistry { // 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, subtree as DeepPartial>); + this.setConfig( + key as InstanceConfigKey, + subtree as DeepPartial>, + ); } } /** Registers declarative configuration for one key, deep-merged into what is already there. */ - setConfig( + setConfig( key: K, config: DeepPartial>, ): void { - this.debugIfKeyLooksUnused(key); + this.warnIfKeyLooksUnknown(key); this.warnAboutLateConstructionOnlyPaths(key, config); const store = this.getConfigState(key); @@ -209,7 +215,9 @@ export class InstanceConfigurationRegistry { store.partialNext({ config: next }); } - getConfig(key: K): DeepPartial> | null { + getConfig( + key: K, + ): DeepPartial> | null { return this.getConfigState(key).getLatestValue().config; } @@ -225,15 +233,17 @@ export class InstanceConfigurationRegistry { * instead of "five empty subtrees". This is *registered intent*, not resolved values — for those, read * the instance's `config`. */ - getTree(): DeepPartial & Record { + getTree(): DeepPartial { const tree: Record = {}; - for (const key of this.configStates.keys()) { + // 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 & Record; + return tree as DeepPartial; } // ------------------------------------------------------------------------- @@ -254,7 +264,7 @@ export class InstanceConfigurationRegistry { * middleware, added subscriptions. The contract is that configuration returns to its derived * baseline, not that the object returns to factory state. */ - reset(key?: InstanceSetupKey): void { + reset(key?: InstanceConfigKey): void { const keys = key === undefined ? new Set([ @@ -267,11 +277,16 @@ export class InstanceConfigurationRegistry { this.resetting = true; try { for (const currentKey of keys) { - this.getConfigState(currentKey).partialNext({ config: null }); + 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. - this.getSetupState(currentKey).partialNext({ setupFunction: null }); + // + // 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; @@ -326,7 +341,7 @@ export class InstanceConfigurationRegistry { * * @internal */ - registerInstance(key: InstanceSetupKey, instance: ConfiguredInstance): () => void { + registerInstance(key: InstanceConfigKey, instance: ConfiguredInstance): () => void { let set = this.liveInstances.get(key); if (!set) { set = new Set(); @@ -343,7 +358,7 @@ export class InstanceConfigurationRegistry { } /** @internal */ - hasLiveInstances(key: InstanceSetupKey): boolean { + hasLiveInstances(key: InstanceConfigKey): boolean { return (this.liveInstances.get(key)?.size ?? 0) > 0; } @@ -352,18 +367,22 @@ export class InstanceConfigurationRegistry { // ------------------------------------------------------------------------- /** - * An open key space means a typo — `'cahnnel'` — is a valid custom key that silently does nothing. - * It cannot be rejected without breaking extensibility, and a warning would fire on the legitimate - * "register before the instance subscribes" ordering, so the message stays at debug level. + * 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 debugIfKeyLooksUnused(key: InstanceSetupKey): void { - if ((BUILT_IN_INSTANCE_KEYS as readonly string[]).includes(key)) return; + private warnIfKeyLooksUnknown(key: InstanceConfigKey): void { + if ((INSTANCE_CONFIG_TREE_KEYS as readonly string[]).includes(key)) return; if (this.hasLiveInstances(key)) return; logger .withExtraTags(key) - .debug( - 'Configuration registered for a key that is not built in and has no subscriber yet. This is ' + - 'expected if the owning class subscribes later; otherwise check the key spelling.', + .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.', ); } @@ -379,7 +398,7 @@ export class InstanceConfigurationRegistry { * `setConfig` calls, 100 warnings. */ private warnAboutLateConstructionOnlyPaths( - key: InstanceSetupKey, + key: InstanceConfigKey, config: DeepPartial>, ): void { if (!this.hasLiveInstances(key)) return; // nothing constructed yet — these will apply diff --git a/src/configuration/keys.ts b/src/configuration/keys.ts index 79a21026dc..9e5914dd41 100644 --- a/src/configuration/keys.ts +++ b/src/configuration/keys.ts @@ -7,8 +7,8 @@ import type { InstanceConfigTree, InstanceSetupFunctionArgs } from './types'; */ /** - * The keys this package wires itself. Used to scope diagnostics — never to reject a caller's key, - * which would defeat the point of an open key space. + * 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. diff --git a/src/configuration/types.ts b/src/configuration/types.ts index 46e3b365d4..8c450229e9 100644 --- a/src/configuration/types.ts +++ b/src/configuration/types.ts @@ -26,37 +26,40 @@ import type { DeepPartial } from '../types.utility'; /** * Maps a configuration key to the argument its setup function receives. * - * Augment this interface to register a key for a class this package does not know about — the same - * module-augmentation pattern used by the `Custom*Data` interfaces in `custom_types.ts`: - * - * ```ts - * declare module 'stream-chat' { - * interface InstanceSetupFunctionArgs { - * myWidget: { widget: MyWidget }; - * } - * } - * ``` + * 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 interface InstanceSetupFunctionArgs { +export type InstanceSetupFunctionArgs = { channel: { channel: Channel }; client: { client: StreamChat }; liveLocationManager: { liveLocationManager: LiveLocationManager }; messageComposer: { composer: MessageComposer }; searchController: { searchController: SearchController }; thread: { thread: Thread }; -} +}; -/** The six built-in keys, plus any key an integrator or a downstream SDK registers. */ -export type InstanceSetupKey = keyof InstanceSetupFunctionArgs | (string & {}); +/** + * 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 = - K extends keyof InstanceSetupFunctionArgs - ? InstanceSetupFunctionArgs[K] - : Record; +export type InstanceSetupFunctionArgsOf = + InstanceSetupFunctionArgs[K]; export type InstanceSetupTearDownFunction = () => void; @@ -65,11 +68,11 @@ export type InstanceSetupTearDownFunction = () => void; * 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 = ( +export type InstanceSetupFunction = ( args: InstanceSetupFunctionArgsOf, ) => void | InstanceSetupTearDownFunction; -export type InstanceSetupState = { +export type InstanceSetupState = { setupFunction: InstanceSetupFunction | null; }; @@ -168,7 +171,7 @@ export type ClientDeclarativeConfig = { * `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 interface InstanceConfigTree { +export type InstanceConfigTree = { channel: ChannelDeclarativeConfig; client: ClientDeclarativeConfig; /** @@ -199,12 +202,19 @@ export interface InstanceConfigTree { */ searchController: Partial; thread: ThreadDeclarativeConfig; -} +}; + +/** + * 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 = K extends keyof InstanceConfigTree - ? InstanceConfigTree[K] - : Record; +export type InstanceConfigOf = InstanceConfigTree[K]; -export type InstanceConfigState = { +export type InstanceConfigState = { config: DeepPartial> | null; }; diff --git a/src/configuration/utils/applyInstanceConfiguration.ts b/src/configuration/utils/applyInstanceConfiguration.ts index c44a23b986..7ba5435307 100644 --- a/src/configuration/utils/applyInstanceConfiguration.ts +++ b/src/configuration/utils/applyInstanceConfiguration.ts @@ -4,6 +4,7 @@ import type { InstanceConfigurationRegistry, } from '../InstanceConfigurationRegistry'; import type { + InstanceConfigKey, InstanceConfigOf, InstanceSetupFunctionArgsOf, InstanceSetupKey, @@ -14,6 +15,7 @@ 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; @@ -31,7 +33,7 @@ export type ApplyInstanceConfigurationParams = { * 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 InstanceSetupKey[]; + 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. @@ -53,9 +55,7 @@ export type ApplyInstanceConfigurationParams = { * 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 — including one written outside this package for a custom key. Pair it with a - * `ConfigController`, which is the single place the *resolution* semantics live; between them an outside - * class gets exactly what a built-in one gets: + * identically: * * - applies whatever is already registered, immediately; * - re-applies on every change to either slot, declarative configuration first and the setup function @@ -64,18 +64,11 @@ export type ApplyInstanceConfigurationParams = { * - contains errors — a throwing setup function, teardown or applier is logged and never propagates, * so it cannot break `client.channel()` or a `Thread` construction. * - * @example - * ```ts - * class MyWidget { - * private unsubscribe = applyInstanceConfiguration({ - * config: client.config, - * key: 'myWidget', - * args: { widget: this }, - * applyConfig: (next) => Object.assign(this.config, next), - * reinitializeConfig: () => this.initializeConfig(), - * }); - * } - * ``` + * 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, diff --git a/src/configuration/utils/index.ts b/src/configuration/utils/index.ts index 246d414922..be895cff34 100644 --- a/src/configuration/utils/index.ts +++ b/src/configuration/utils/index.ts @@ -1,5 +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. `copyConfigPatch`, `deepFreezeConfig` and -// `declarativeSlices` are `@internal` and are imported by path instead. -export * from './applyInstanceConfiguration'; +// 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/index.ts b/src/index.ts index 661feeccd9..4d1fc0a3d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,11 +2,10 @@ export * from './client'; export * from './client_state'; export * from './channel'; export * from './channel_state'; -// Don't use * here: `export *` can break module augmentation of `InstanceSetupFunctionArgs` and -// `InstanceConfigTree`, the same reason the `Custom*Data` interfaces below are listed explicitly. +// 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 -export { applyInstanceConfiguration } from './configuration/utils/applyInstanceConfiguration'; -export type { ApplyInstanceConfigurationParams } from './configuration/utils/applyInstanceConfiguration'; export { ConfigController } from './configuration/ConfigController'; export type { ConfigControllerOptions } from './configuration/ConfigController'; // Named in the signatures of `client.config.set` / `setConfig`, so a caller has to be able to write it. diff --git a/test/unit/configuration/InstanceConfigurationRegistry.test.ts b/test/unit/configuration/InstanceConfigurationRegistry.test.ts index 2a5bc36b23..eaa1a24d17 100644 --- a/test/unit/configuration/InstanceConfigurationRegistry.test.ts +++ b/test/unit/configuration/InstanceConfigurationRegistry.test.ts @@ -185,12 +185,22 @@ describe('InstanceConfigurationRegistry', () => { chatLoggerSystem.restoreDefaults(); }); - it('logs at debug for a custom key with no subscriber', () => { - service.setSetupFunction('cahnnel', noop); + 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('debug'); - expect(records[0].message).toContain('not built in and has no subscriber'); + 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', () => { @@ -200,10 +210,12 @@ describe('InstanceConfigurationRegistry', () => { expect(records).toEqual([]); }); - it('stays silent for a custom key that already has a subscriber', () => { - service.registerInstance('myWidget', { reinitializeConfig: noop }); + 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', noop); + service.setSetupFunction('myWidget' as never, noop); expect(records).toEqual([]); }); From 62f7152b1869b5043ca78a6942876e6288b4221a Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 21 Aug 2026 16:46:18 +0200 Subject: [PATCH 21/22] refactor: do not export ConfigController --- docs/instance-configuration.md | 61 ++++++++++++++++----------- src/configuration/ConfigController.ts | 21 +++++---- src/index.ts | 2 - 3 files changed, 46 insertions(+), 38 deletions(-) diff --git a/docs/instance-configuration.md b/docs/instance-configuration.md index eb6c8cbaa0..c7f6e2d2fc 100644 --- a/docs/instance-configuration.md +++ b/docs/instance-configuration.md @@ -809,6 +809,17 @@ Call it on **every** route that resolves configuration, not just at construction 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: @@ -869,6 +880,22 @@ re-resolved, so reading it back after the server narrows a field does not tell y `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 @@ -887,35 +914,19 @@ client.config.setSetupFunction('cahnnel', fn); // ✗ does not compile 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. -`ConfigController` stays exported for the part that _is_ worth reusing: the resolution itself — frozen -defaults, one layer order, a skipped write when nothing moved, and a reactive `configState`. Own the -class, own its configuration, and let the controller do the resolving: - -```ts -import { ConfigController } from 'stream-chat'; - -type MyWidgetConfig = { enabled: boolean; pollIntervalMs: number }; - -class MyWidget { - private readonly configController = new ConfigController({ - defaults: { enabled: true, pollIntervalMs: 5_000 }, - }); - - get configState() { - return this.configController.state; - } - get config() { - return this.configController.value; - } - updateConfig(patch: Partial) { - this.configController.patch(patch); - } -} -``` +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 diff --git a/src/configuration/ConfigController.ts b/src/configuration/ConfigController.ts index a039e11748..d06cc94ccd 100644 --- a/src/configuration/ConfigController.ts +++ b/src/configuration/ConfigController.ts @@ -123,18 +123,15 @@ const layer = (target: T, source?: Partial): T => { * 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. * - * **Public, so a class registered under a custom key gets the same behaviour as a built-in one.** - * `applyInstanceConfiguration` subscribes such a class to its key; this is what resolves the value once - * the slice arrives. Without it, an outside class would hand-roll the derivation and be free to - * reintroduce every bug this consolidated — leaking a shared default, publishing when nothing moved, - * storing a read-once field without applying it. + * 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. * - * The entity keeps the shape every configurable class exposes by forwarding: + * An entity keeps the shape every configurable class exposes by forwarding: * * ```ts - * class MyWidget { - * private readonly configController = new ConfigController({ - * defaults: DEFAULT_MY_WIDGET_CONFIG, + * class MyEntity { + * private readonly configController = new ConfigController({ + * defaults: DEFAULT_MY_ENTITY_CONFIG, * onChanged: (next, previous) => { * if (next.pollIntervalMs !== previous.pollIntervalMs) this.restartPolling(); * }, @@ -142,10 +139,12 @@ const layer = (target: T, source?: Partial): T => { * * 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); } + * updateConfig(patch: Partial) { this.configController.patch(patch); } + * initializeConfig(slice?: Partial) { this.configController.initialize(slice); } * } * ``` + * + * @internal */ export class ConfigController< TConfig extends Record, diff --git a/src/index.ts b/src/index.ts index 4d1fc0a3d1..944f07de3c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,8 +6,6 @@ export * from './channel_state'; // 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 -export { ConfigController } from './configuration/ConfigController'; -export type { ConfigControllerOptions } from './configuration/ConfigController'; // 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 { From 67ee9cf21598de441d07c7cab3ce22a4aa60eac5 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 21 Aug 2026 18:07:45 +0200 Subject: [PATCH 22/22] fix: subscribe CooldownTimer to Channel state changes --- src/CooldownTimer.ts | 50 ++++++++++++---------------- src/channel.ts | 14 ++------ test/unit/CooldownTimer.test.ts | 58 +++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 42 deletions(-) diff --git a/src/CooldownTimer.ts b/src/CooldownTimer.ts index 058d6e096c..c57dee068c 100644 --- a/src/CooldownTimer.ts +++ b/src/CooldownTimer.ts @@ -65,48 +65,38 @@ export class CooldownTimer extends WithSubscriptions { } /** - * Opt-in event handling for a timer driven on its own. + * 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. * - * **Nothing in this package calls this**, and that is deliberate rather than an oversight: the owning - * `Channel` refreshes the timer imperatively from `query()`, from its `message.new` and `channel.updated` - * handlers, and from `updatePartial()`. Each subscription below therefore duplicates a refresh the - * channel already performs — `channel.updated` unconditionally, which is broader than the guard here. - * - * So do not read a subscription here as the thing that makes a case work. It shipped in v9.50.3 - * unregistered too, and a `capabilities.changed` handler was added here on the assumption it closed a - * gap; the gap was real but the fix was inert, and it is `Channel.updatePartial` that closes it. - * - * Kept because it is released surface an integrator can still use to drive a timer the channel does not. + * `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(), + ), ); - // `canSkipCooldown` derives from `own_capabilities`, which can change without `cooldown` changing — and - // the guard above filters exactly that case out. Unguarded on purpose: `refresh()` already no-ops - // unless one of its inputs actually moved. - this.addUnsubscribeFunction( - this.channel.on('capabilities.changed', () => { - this.refresh(); - }).unsubscribe, - ); + // 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/channel.ts b/src/channel.ts index bb403a652f..8edf334523 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -372,6 +372,7 @@ export class Channel extends ChannelApi { this.messageReceiptsTracker.registerSubscriptions(); this.cooldownTimer = new CooldownTimer({ channel: this }); + this.cooldownTimer.registerSubscriptions(); this.messageOperations = new MessageOperations({ ingest: (m) => { @@ -1036,12 +1037,6 @@ export class Channel extends ChannelApi { this.state.syncStateFromChannelData(this.data, previousData); // If the capabiltities are changed, we trigger the `capabilities.changed` event. if (capabilitiesChanged) { - // `canSkipCooldown` is derived from `own_capabilities` and stored, so it has to be recomputed here. - // This channel drives its cooldown timer — `query()` and the `channel.updated` handler refresh it the - // same way — and this was the one route that announced a capability change without doing so, leaving - // a granted or revoked `skip-slow-mode` unobserved. The timer's own `capabilities.changed` - // subscription does not cover it: nothing registers the timer's subscriptions. - this.cooldownTimer.refresh(); this.getClient().dispatchEvent({ type: 'capabilities.changed', cid: this.cid, @@ -2184,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({ @@ -2715,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 @@ -2922,7 +2913,6 @@ export class Channel extends ChannelApi { }; channel.data = newChannelData; channel.state.syncStateFromChannelData(channel.data, previousChannelData); - this.cooldownTimer.refresh(); } break; case 'reaction.new': @@ -3210,7 +3200,7 @@ export class Channel extends ChannelApi { // 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/test/unit/CooldownTimer.test.ts b/test/unit/CooldownTimer.test.ts index 1f6531fcce..e42f190d3b 100644 --- a/test/unit/CooldownTimer.test.ts +++ b/test/unit/CooldownTimer.test.ts @@ -34,6 +34,64 @@ describe('CooldownTimer', () => { * 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' });