From 328c7c267a3f682136f491a9ae5ca968eff13458 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 3 Sep 2025 15:41:16 +0200 Subject: [PATCH 01/48] feat: support multiple channel lists with ChannelPaginatorsOrchestrator --- src/ChannelPaginatorsOrchestrator.ts | 327 ++++++++++ src/EventHandlerPipeline.ts | 163 +++++ src/index.ts | 2 + src/pagination/BasePaginator.ts | 202 +++++- src/pagination/ChannelPaginator.ts | 206 +++++++ src/pagination/FilterBuilder.ts | 5 +- src/pagination/ReminderPaginator.ts | 4 +- src/pagination/filterCompiler.ts | 192 ++++++ src/pagination/index.ts | 1 + src/pagination/sortCompiler.ts | 97 +++ src/pagination/types.normalization.ts | 7 + src/pagination/utility.normalization.ts | 108 ++++ src/pagination/utility.queryChannel.ts | 77 +++ src/pagination/utility.search.ts | 56 ++ .../ChannelPaginatorsOrchestrator.test.ts | 580 ++++++++++++++++++ test/unit/EventHandlerPipeline.test.ts | 525 ++++++++++++++++ test/unit/LiveLocationManager.test.ts | 2 + test/unit/pagination/BasePaginator.test.ts | 467 +++++++++++++- test/unit/pagination/ChannelPaginator.test.ts | 441 +++++++++++++ test/unit/pagination/FilterBuilder.test.ts | 2 +- test/unit/pagination/filterCompiler.test.ts | 368 +++++++++++ test/unit/pagination/sortCompiler.test.ts | 267 ++++++++ 22 files changed, 4094 insertions(+), 5 deletions(-) create mode 100644 src/ChannelPaginatorsOrchestrator.ts create mode 100644 src/EventHandlerPipeline.ts create mode 100644 src/pagination/ChannelPaginator.ts create mode 100644 src/pagination/filterCompiler.ts create mode 100644 src/pagination/sortCompiler.ts create mode 100644 src/pagination/types.normalization.ts create mode 100644 src/pagination/utility.normalization.ts create mode 100644 src/pagination/utility.queryChannel.ts create mode 100644 src/pagination/utility.search.ts create mode 100644 test/unit/ChannelPaginatorsOrchestrator.test.ts create mode 100644 test/unit/EventHandlerPipeline.test.ts create mode 100644 test/unit/pagination/ChannelPaginator.test.ts create mode 100644 test/unit/pagination/filterCompiler.test.ts create mode 100644 test/unit/pagination/sortCompiler.test.ts diff --git a/src/ChannelPaginatorsOrchestrator.ts b/src/ChannelPaginatorsOrchestrator.ts new file mode 100644 index 0000000000..481eba2029 --- /dev/null +++ b/src/ChannelPaginatorsOrchestrator.ts @@ -0,0 +1,327 @@ +import { EventHandlerPipeline } from './EventHandlerPipeline'; +import { WithSubscriptions } from './utils/WithSubscriptions'; +import type { Event, EventTypes } from './types'; +import type { ChannelPaginator } from './pagination'; +import type { StreamChat } from './client'; +import type { Unsubscribe } from './store'; +import { StateStore } from './store'; +import type { + EventHandlerPipelineHandler, + InsertEventHandlerPayload, + LabeledEventHandler, +} from './EventHandlerPipeline'; +import { getChannel } from './pagination/utility.queryChannel'; +import type { Channel } from './channel'; + +type ChannelPaginatorsOrchestratorEventHandlerContext = { + orchestrator: ChannelPaginatorsOrchestrator; +}; + +type SupportedEventType = EventTypes | (string & {}); + +const reEmit: EventHandlerPipelineHandler< + ChannelPaginatorsOrchestratorEventHandlerContext +> = ({ event, ctx: { orchestrator } }) => { + if (!event.cid) return; + const channel = orchestrator.client.activeChannels[event.cid]; + if (!channel) return; + orchestrator.paginators.forEach((paginator) => { + const items = paginator.items; + if (paginator.findItem(channel) && items) { + paginator.state.partialNext({ items: [...items] }); + } + }); +}; + +const removeItem: EventHandlerPipelineHandler< + ChannelPaginatorsOrchestratorEventHandlerContext +> = ({ event, ctx: { orchestrator } }) => { + if (!event.cid) return; + const channel = orchestrator.client.activeChannels[event.cid]; + orchestrator.paginators.forEach((paginator) => { + paginator.removeItem({ id: event.cid, item: channel }); + }); +}; + +const updateLists: EventHandlerPipelineHandler< + ChannelPaginatorsOrchestratorEventHandlerContext +> = async ({ event, ctx: { orchestrator } }) => { + let channel: Channel | undefined = undefined; + if (event.cid) { + channel = orchestrator.client.activeChannels[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 = + orchestrator.client.activeChannels[`${event.channel_type}:${event.channel_id}`]; + } else if (event.channel) { + channel = orchestrator.client.activeChannels[event.channel.cid]; + } else { + return; + } + + if (!channel) { + const [type, id] = event.cid + ? event.cid.split(':') + : [event.channel_type, event.channel_id]; + + channel = await getChannel({ + client: orchestrator.client, + id, + type, + }); + } + + if (!channel) return; + + // todo: can these state updates be made atomic across all the paginators? + // maybe we could add to state store API that would allow to queue changes and then commit? + orchestrator.paginators.forEach((paginator) => { + if (paginator.matchesFilter(channel)) { + // todo: does it make sense to move channel at the top of the items array (original implementation) + // if items are supposed to be ordered by the sort object? + paginator.ingestItem(channel); + } else { + // remove if it does not match the filter anymore + paginator.removeItem({ item: channel }); + } + }); +}; + +// todo: we have to make sure that client.activeChannels is always up-to-date +const channelDeletedHandler: LabeledEventHandler = + { + handle: removeItem, + id: 'ChannelPaginatorsOrchestrator:default-handler:channel.deleted', + }; + +// fixme: is it ok, remove item just because its property hidden is switched to hidden: true? What about offset cursor, should we update it? +const channelHiddenHandler: LabeledEventHandler = + { + handle: removeItem, + id: 'ChannelPaginatorsOrchestrator:default-handler:channel.hidden', + }; + +// fixme: this handler should not be handled by the orchestrator 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', + }; + +// fixme: this handler should not be handled by the orchestrator 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', + }; + +const channelVisibleHandler: LabeledEventHandler = + { + handle: updateLists, + id: 'ChannelPaginatorsOrchestrator:default-handler:channel.visible', + }; + +// 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', + }; + +const messageNewHandler: LabeledEventHandler = + { + handle: updateLists, + id: 'ChannelPaginatorsOrchestrator:default-handler:message.new', + }; + +const notificationAddedToChannelHandler: LabeledEventHandler = + { + handle: updateLists, + id: 'ChannelPaginatorsOrchestrator:default-handler:notification.added_to_channel', + }; + +const notificationMessageNewHandler: LabeledEventHandler = + { + handle: updateLists, + id: 'ChannelPaginatorsOrchestrator:default-handler:notification.message_new', + }; + +const notificationRemovedFromChannelHandler: LabeledEventHandler = + { + handle: removeItem, + id: 'ChannelPaginatorsOrchestrator: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 } }) => { + const eventUser = event.user; + if (!eventUser?.id) return; + orchestrator.paginators.forEach((paginator) => { + const paginatorItems = paginator.items; + if (!paginatorItems) return; + let updated = false; + paginatorItems.forEach((channel) => { + if (channel.state.members[eventUser.id]) { + channel.state.members[eventUser.id].user = event.user; + updated = true; + } + if (channel.state.membership.user?.id === eventUser.id) { + channel.state.membership.user = eventUser; + updated = true; + } + }); + if (updated) { + // fixme: user is not reactive and so the whole list has to be re-rendered + paginator.state.partialNext({ items: [...paginatorItems] }); + } + }); + }, + id: 'ChannelPaginatorsOrchestrator:default-handler:user.presence.changed', + }; + +export type ChannelPaginatorsOrchestratorState = { + paginators: ChannelPaginator[]; +}; + +type EventHandlers = Partial< + Record< + SupportedEventType, + LabeledEventHandler[] + > +>; + +export type ChannelPaginatorsOrchestratorOptions = { + client: StreamChat; + paginators?: ChannelPaginator[]; + eventHandlers?: EventHandlers; +}; + +export class ChannelPaginatorsOrchestrator extends WithSubscriptions { + client: StreamChat; + state: StateStore; + protected pipelines = new Map< + SupportedEventType, + EventHandlerPipeline + >(); + + protected static readonly defaultEventHandlers: EventHandlers = { + 'channel.deleted': [channelDeletedHandler], + 'channel.hidden': [channelHiddenHandler], + 'channel.updated': [channelUpdatedHandler], + 'channel.truncated': [channelTruncatedHandler], + '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, + }: ChannelPaginatorsOrchestratorOptions) { + super(); + this.client = client; + this.state = new StateStore({ paginators: paginators ?? [] }); + const finalEventHandlers = + eventHandlers ?? ChannelPaginatorsOrchestrator.getDefaultHandlers(); + for (const [type, handlers] of Object.entries(finalEventHandlers)) { + if (handlers) this.ensurePipeline(type).replaceAll(handlers); + } + } + + get paginators(): ChannelPaginator[] { + return this.state.getLatestValue().paginators; + } + + /** + * Returns deep copy of default handlers mapping. + * The defaults can be enriched with custom handlers or the custom handlers can be replaced. + */ + static getDefaultHandlers(): EventHandlers { + const src = ChannelPaginatorsOrchestrator.defaultEventHandlers; + const out: EventHandlers = {}; + for (const [type, handlers] of Object.entries(src)) { + if (!handlers) continue; + out[type as SupportedEventType] = [...handlers]; + } + return out; + } + + getPaginatorById(id: string) { + return this.paginators.find((p) => p.id === id); + } + + /** + * If paginator already exists → remove old, reinsert at new index. + * If index not provided → append at the end. + * If index provided → insert (or move) at that index. + * @param paginator + * @param index + */ + insertPaginator({ paginator, index }: { paginator: ChannelPaginator; index?: number }) { + const paginators = [...this.paginators]; + const existingIndex = paginators.findIndex((p) => p.id === paginator.id); + if (existingIndex > -1) { + paginators.splice(existingIndex, 1); + } + const validIndex = Math.max( + 0, + Math.min(index ?? paginators.length, paginators.length), + ); + paginators.splice(validIndex, 0, paginator); + this.state.partialNext({ paginators }); + } + + addEventHandler({ + eventType, + ...payload + }: { + eventType: SupportedEventType; + } & InsertEventHandlerPayload): Unsubscribe { + return this.ensurePipeline(eventType).insert(payload); + } + + /** Subscribe to WS (and more buses via attachBus) */ + registerSubscriptions(): Unsubscribe { + if (!this.hasSubscriptions) { + this.addUnsubscribeFunction( + // todo: maybe we should have a wrapper here to decide, whether the event is a LocalEventBus event or else supported by client + this.client.on((event: Event) => { + const pipe = this.pipelines.get(event.type); + if (pipe) { + pipe.run(event, this.ctx); + } + }).unsubscribe, + ); + } + + this.incrementRefCount(); + return () => this.unregisterSubscriptions(); + } + + ensurePipeline( + eventType: SupportedEventType, + ): EventHandlerPipeline { + let pipe = this.pipelines.get(eventType); + if (!pipe) { + pipe = new EventHandlerPipeline({ + id: `ChannelPaginatorsOrchestrator:${eventType}`, + }); + this.pipelines.set(eventType, pipe); + } + return pipe; + } + + private get ctx(): ChannelPaginatorsOrchestratorEventHandlerContext { + return { orchestrator: this }; + } +} diff --git a/src/EventHandlerPipeline.ts b/src/EventHandlerPipeline.ts new file mode 100644 index 0000000000..c2b63b976a --- /dev/null +++ b/src/EventHandlerPipeline.ts @@ -0,0 +1,163 @@ +import { generateUUIDv4 } from './utils'; +import type { Event } from './types'; +import type { Unsubscribe } from './store'; + +export type EventHandlerResult = { action: 'stop' }; // event processing run will be cancelled + +export type InsertEventHandlerPayload> = { + handle: EventHandlerPipelineHandler; + index?: number; + id?: string; + replace?: boolean; + revertOnUnsubscribe?: boolean; +}; + +export type EventHandlerPipelineHandler> = (payload: { + event: Event; + ctx: CTX; +}) => EventHandlerResult | void | Promise; + +export type LabeledEventHandler> = { + handle: EventHandlerPipelineHandler; + id?: string; +}; + +export class EventHandlerPipeline = {}> { + id: string; + protected handlers: LabeledEventHandler[] = []; + private runnerExecutionPromise = Promise.resolve(); + + constructor({ id }: { id: string }) { + this.id = id; + } + + get size(): number { + return this.handlers.length; + } + + /** + * Insert a handler into the pipeline at the given index. + * + * - If `replace` is `true` and the index is within bounds, the existing handler + * at that position will be replaced by the new one. + * - If `revertOnUnsubscribe` is also `true`, then calling the returned + * unsubscribe will both remove the inserted handler *and* restore the + * previously replaced handler at the same index. + * - If `replace` is `false` (default), the new handler is inserted at the index + * (or appended if the index is greater than the pipeline size). Unsubscribe + * will only remove this handler. + * + * @param handler The handler function to insert. + * @param index Target index in the pipeline (clamped to valid range). + * @param replace If true, replace existing handler at index instead of inserting. + * @param revertOnUnsubscribe If true, restore the replaced handler when unsubscribing. + * @returns An unsubscribe function that removes (and optionally restores) the handler. + */ + + insert({ + handle, + id, + index, + replace = false, + revertOnUnsubscribe, + }: InsertEventHandlerPayload): Unsubscribe { + const validIndex = Math.max( + 0, + Math.min(index ?? this.handlers.length, this.handlers.length), + ); + const handler: LabeledEventHandler = { + handle, + id: id ?? generateUUIDv4(), + }; + + if (replace && validIndex < this.handlers.length) { + const old = this.handlers[validIndex]; + this.handlers[validIndex] = handler; + return () => { + this.remove(handler); + if (revertOnUnsubscribe) this.handlers.splice(validIndex, 0, old); + }; + } else { + this.handlers.splice(validIndex, 0, handler); + return () => this.remove(handler); + } + } + + remove(h: LabeledEventHandler | EventHandlerPipelineHandler): void { + const index = this.handlers.findIndex((handler) => + typeof (h as LabeledEventHandler).handle === 'function' + ? (h as LabeledEventHandler).handle === handler.handle + : h === handler.handle, + ); + if (index >= 0) this.handlers.splice(index, 1); + } + + replaceAll(handlers: LabeledEventHandler[]): void { + this.handlers = handlers.slice(); + } + + clear(): void { + this.handlers = []; + } + + /** + * Queue an event for processing. Events are processed serially, in the order + * `run` is called. Returns a promise that resolves/rejects for this specific + * event’s processing, while the internal chain continues (errors won’t break it). + */ + run(event: Event, ctx: CTX): Promise { + let resolveTask!: () => void; + let rejectTask!: (e: unknown) => void; + // Per-task promise the caller can await + const taskPromise = new Promise((res, rej) => { + resolveTask = res; + rejectTask = rej; + }); + + // Queue this event’s work + this.runnerExecutionPromise = this.runnerExecutionPromise + .then(async () => { + try { + await this.processOne(event, ctx); + resolveTask(); + } catch (e) { + // Reject this task’s promise, but keep the chain alive. + rejectTask(e); + } + }) + .catch((e) => { + console.error(`[pipeline:${this.id}] execution error`, e); + // Ensure the chain remains resolved for the next enqueue: + this.runnerExecutionPromise = Promise.resolve(); + }); + + return taskPromise; + } + + /** + * Wait until all queued events have been processed. + */ + async drain(): Promise { + await this.runnerExecutionPromise; + } + + /** + * Process a single event through a stable snapshot of handlers to avoid + * mid-iteration mutations (insert/remove) affecting this run. + */ + private async processOne(event: Event, ctx: CTX): Promise { + const snapshot = this.handlers.slice(); + for (let i = 0; i < snapshot.length; i++) { + const handler = snapshot[i]; + try { + const result = await handler.handle({ event, ctx }); + if (result?.action === 'stop') return; + } catch { + console.error(`[pipeline:${this.id}] handler failed`, { + handlerId: handler.id ?? 'unknown', + handlerIndex: i, + }); + } + } + } +} diff --git a/src/index.ts b/src/index.ts index 5f5daf375f..eeb1ce6c68 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,3 +58,5 @@ export { promoteChannel, } from './utils'; export { FixedSizeQueueCache } from './utils/FixedSizeQueueCache'; +export * from './ChannelPaginatorsOrchestrator'; +export * from './EventHandlerPipeline'; diff --git a/src/pagination/BasePaginator.ts b/src/pagination/BasePaginator.ts index 7f73f0f53b..8c40930f0f 100644 --- a/src/pagination/BasePaginator.ts +++ b/src/pagination/BasePaginator.ts @@ -1,9 +1,15 @@ +import { binarySearchInsertIndex } from './sortCompiler'; +import { itemMatchesFilter } from './filterCompiler'; import { StateStore } from '../store'; import { debounce, type DebouncedFunc } from '../utils'; +import type { FieldToDataResolver } from './types.normalization'; +import { locateOnPlateauAlternating, locateOnPlateauScanOneSide } from './utility.search'; + +const noOrderChange = () => 0; type PaginationDirection = 'next' | 'prev'; type Cursor = { next: string | null; prev: string | null }; -export type PaginationQueryParams = { direction: PaginationDirection }; +export type PaginationQueryParams = { direction?: PaginationDirection }; export type PaginationQueryReturnValue = { items: T[] } & { next?: string; prev?: string; @@ -41,12 +47,39 @@ export abstract class BasePaginator { pageSize: number; protected _executeQueryDebounced!: DebouncedExecQueryFunction; protected _isCursorPagination = false; + /** + * Comparison function used to keep items in a paginator sorted. + * + * The comparator must follow the standard contract of `Array.prototype.sort`: + * - return a negative number if `a` should come before `b` + * - return a positive number if `a` should come after `b` + * - return 0 if they are considered equal for ordering + * + * Typical implementations are generated from a "sort spec" (e.g. `{ field: 1, otherField: -1 }`) + * so that insertion and pagination can maintain the same order as the backend. + * + * Notes: + * - The comparator must be deterministic: the same inputs always return + * the same result. + * - If multiple fields are used, they are evaluated in order of normalized sort ({ direction: AscDesc; field: keyof T }[]) + * until a non-zero comparison is found. + * - Equality (0) does not imply object identity; it only means neither item + * is considered greater than the other by the sort rules. + */ + sortComparator: (a: T, b: T) => number; + /** + * Allows defining data extraction logic for filter fields like member.user.name or members + * @protected + */ + protected _filterFieldToDataResolvers: FieldToDataResolver[]; protected constructor(options?: PaginatorOptions) { const { debounceMs, pageSize } = { ...DEFAULT_PAGINATION_OPTIONS, ...options }; this.pageSize = pageSize; this.state = new StateStore>(this.initialState); this.setDebounceOptions({ debounceMs }); + this.sortComparator = noOrderChange; + this._filterFieldToDataResolvers = []; } get lastQueryError() { @@ -97,6 +130,173 @@ export abstract class BasePaginator { abstract filterQueryResults(items: T[]): T[] | Promise; + protected buildFilters(): object | null { + return null; // === no filters' + } + + getItemId(item: T): string { + return (item as { id: string }).id; + } + + matchesFilter(item: T): boolean { + const filters = this.buildFilters(); + + // no filters => accept all + if (filters == null) return true; + + return itemMatchesFilter(item, filters, { + resolvers: this._filterFieldToDataResolvers, + }); + } + + ingestItem(ingestedItem: T): boolean { + const items = this.items ?? []; + const id = this.getItemId(ingestedItem); + + // If it doesn't match this paginator's filters, remove if present and exit. + const existingIndex = items.findIndex((ch) => this.getItemId(ch) === id); + if (!this.matchesFilter(ingestedItem)) { + if (existingIndex >= 0) { + const next = items.slice(); + next.splice(existingIndex, 1); + this.state.partialNext({ items: next }); + return true; // list changed (item removed) + } + return false; // no change + } + + // Build comparator once per call (you can cache it when sort changes). + + const next = items.slice(); + + if (existingIndex >= 0) { + // Update existing: remove then re-insert at the correct position + next.splice(existingIndex, 1); + } + + // Find insertion index via binary search: first index where existing > ingestionItem + const insertAt = binarySearchInsertIndex({ + needle: ingestedItem, + sortedArray: next, + compare: this.sortComparator, + }); + + next.splice(insertAt, 0, ingestedItem); + this.state.partialNext({ items: next }); + return true; // list changed (added or repositioned) + } + + /** + * Removes item from the paginator's state. + * It is preferable to provide item for better search performance. + * @param id + * @param item + */ + removeItem({ id, item }: { id?: string; item?: T }): boolean { + if (!id && !item) return false; + let index: number; + if (item) { + const location = this.locateByItem(item); + index = location.index; + } else { + index = this.items?.findIndex((i) => this.getItemId(i) === id) ?? -1; + } + + if (index === -1) return false; + const newItems = [...(this.items ?? [])]; + newItems.splice(index, 1); + this.state.partialNext({ items: newItems }); + return true; + } + + contains(item: T): boolean { + return !!this.items?.find((i) => this.getItemId(i) === this.getItemId(item)); + } + + /** + * Find the exact index of `needle` by ID (via getItemId) under the current sortComparator. + * Returns: + * - `index`: actual index if found, otherwise -1 + * - `insertionIndex`: lower-bound position where `needle` would be inserted + * to preserve order (always defined). + * + * Time: O(log n) + O(k) for a tie plateau of size k (unless comparator has ID tiebreaker). + * + * ### Usage examples + * + * ```ts + * const { index, insertionIndex } = paginator.locateByItem(channel); + * + * if (index > -1) { + * // Found -> e.g. remove the item + * items.splice(index, 1); + * } else { + * // Insert new at the right position + * items.splice(insertionIndex, 0, channel); + * } + * ``` + */ + public locateByItem( + needle: T, + options?: { alternatePlateauScan?: boolean }, + ): { index: number; insertionIndex: number } { + const items = this.items ?? []; + if (items.length === 0) return { index: -1, insertionIndex: 0 }; + + const insertionIndex = binarySearchInsertIndex({ + needle, + sortedArray: items, + compare: this.sortComparator, + }); + + // quick neighbor checks + const id = this.getItemId(needle); + const left = insertionIndex - 1; + if (left >= 0 && this.sortComparator(items[left], needle) === 0) { + if (this.getItemId(items[left]) === id) return { index: left, insertionIndex }; + } + if ( + insertionIndex < items.length && + this.sortComparator(items[insertionIndex], needle) === 0 + ) { + if (this.getItemId(items[insertionIndex]) === id) + return { index: insertionIndex, insertionIndex }; + } + + // plateau scan + const index = + (options?.alternatePlateauScan ?? true) + ? locateOnPlateauAlternating( + items, + needle, + this.sortComparator, + this.getItemId.bind(this), + insertionIndex, + ) + : locateOnPlateauScanOneSide( + items, + needle, + this.sortComparator, + this.getItemId.bind(this), + insertionIndex, + ); + + return { index, insertionIndex }; + } + + findItem(needle: T, options?: { alternatePlateauScan?: boolean }): T | undefined { + const { index } = this.locateByItem(needle, options); + return index > -1 ? (this.items ?? [])[index] : undefined; + } + + setFilterResolvers(resolvers: FieldToDataResolver[]) { + this._filterFieldToDataResolvers = resolvers; + } + + addFilterResolvers(resolvers: FieldToDataResolver[]) { + this._filterFieldToDataResolvers.push(...resolvers); + } + setDebounceOptions = ({ debounceMs }: PaginatorDebounceOptions) => { this._executeQueryDebounced = debounce(this.executeQuery.bind(this), debounceMs); }; diff --git a/src/pagination/ChannelPaginator.ts b/src/pagination/ChannelPaginator.ts new file mode 100644 index 0000000000..559e7b31c2 --- /dev/null +++ b/src/pagination/ChannelPaginator.ts @@ -0,0 +1,206 @@ +import type { + PaginationQueryParams, + PaginationQueryReturnValue, + PaginatorOptions, + PaginatorState, +} from './BasePaginator'; +import { BasePaginator } from './BasePaginator'; +import type { FilterBuilderOptions } from './FilterBuilder'; +import { FilterBuilder } from './FilterBuilder'; +import { makeComparator } from './sortCompiler'; +import { generateUUIDv4 } from '../utils'; +import type { StreamChat } from '../client'; +import type { Channel } from '../channel'; +import type { ChannelFilters, ChannelOptions, ChannelSort } from '../types'; +import type { FieldToDataResolver, PathResolver } from './types.normalization'; +import { resolveDotPathValue } from './utility.normalization'; + +const DEFAULT_BACKEND_SORT: ChannelSort = { last_message_at: -1, updated_at: -1 }; // {last_updated: -1} + +export type ChannelPaginatorState = PaginatorState; + +export type ChannelPaginatorRequestOptions = Partial< + Omit +>; + +export type ChannelPaginatorOptions = { + client: StreamChat; + filterBuilderOptions?: FilterBuilderOptions; + filters?: ChannelFilters; + id?: string; + paginatorOptions?: PaginatorOptions; + requestOptions?: ChannelPaginatorRequestOptions; + sort?: ChannelSort | ChannelSort[]; +}; + +const pinnedFilterResolver: FieldToDataResolver = { + matchesField: (field) => field === 'pinned', + resolve: (channel) => !!channel.state.membership.pinned_at, +}; + +const membersFilterResolver: FieldToDataResolver = { + matchesField: (field) => field === 'members', + resolve: (channel) => + channel.state.members + ? Object.values(channel.state.members).reduce((ids, member) => { + if (member.user?.id) { + ids.push(member.user?.id); + } + return ids; + }, []) + : [], +}; + +const memberUserNameFilterResolver: FieldToDataResolver = { + matchesField: (field) => field === 'member.user.name', + resolve: (channel) => + channel.state.members + ? Object.values(channel.state.members).reduce((names, member) => { + if (member.user?.name) { + names.push(member.user.name); + } + return names; + }, []) + : [], +}; + +const dataFieldFilterResolver: FieldToDataResolver = { + matchesField: () => true, + resolve: (channel, path) => resolveDotPathValue(channel.data, path), +}; + +// very, very unfortunately channel data is dispersed btw Channel.data and Channel.state +const channelSortPathResolver: PathResolver = (channel, path) => { + switch (path) { + case 'last_message_at': + return channel.state.last_message_at; + case 'has_unread': { + const userId = channel.getClient().user?.id; + return !!(userId && channel.state.read[userId].unread_messages); + } + case 'last_updated': { + // combination of last_message_at and updated_at + const lastMessageAt = channel.state.last_message_at?.getTime() ?? 0; + const updatedAt = channel.data?.updated_at + ? new Date(channel.data?.updated_at).getTime() + : 0; + return lastMessageAt >= updatedAt ? lastMessageAt : updatedAt; + } + case 'pinned_at': + return channel.state.membership.pinned_at; + case 'unread_count': { + const userId = channel.getClient().user?.id; + return userId ? channel.state.read[userId].unread_messages : 0; + } + default: + return resolveDotPathValue(channel.data, 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 +export class ChannelPaginator extends BasePaginator { + // state: StateStore; + private client: StreamChat; + protected _filters: ChannelFilters | undefined; + protected _sort: ChannelSort | ChannelSort[] | undefined; + protected _options: ChannelPaginatorRequestOptions | undefined; + private _id: string; + sortComparator: (a: Channel, b: Channel) => number; + filterBuilder: FilterBuilder; + + constructor({ + client, + id, + filterBuilderOptions, + filters, + paginatorOptions, + requestOptions, + sort, + }: ChannelPaginatorOptions) { + super(paginatorOptions); + const definedSort = sort ?? DEFAULT_BACKEND_SORT; + this.client = client; + this._id = id ?? `channel-paginator-${generateUUIDv4()}`; + this._sort = definedSort; + this._filters = filters; + this._options = requestOptions; + 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.setFilterResolvers([ + pinnedFilterResolver, + membersFilterResolver, + memberUserNameFilterResolver, + dataFieldFilterResolver, + ]); + } + + get id() { + return this._id; + } + + get filters(): ChannelFilters | undefined { + return this._filters; + } + + get sort(): ChannelSort | undefined { + return this._sort; + } + + get options(): ChannelOptions | undefined { + return this._options; + } + + set filters(filters: ChannelFilters | undefined) { + this._filters = filters; + this.resetState(); + } + + set sort(sort: ChannelSort | ChannelSort[] | undefined) { + this._sort = sort; + this.sortComparator = makeComparator({ + sort: this.sort ?? DEFAULT_BACKEND_SORT, + }); + this.resetState(); + } + + set options(options: ChannelPaginatorRequestOptions | undefined) { + this._options = options; + this.resetState(); + } + + getItemId(item: Channel): string { + return item.cid; + } + + buildFilters = (): ChannelFilters => + this.filterBuilder.buildFilters({ + baseFilters: { ...this.filters }, + }); + + query = async ({ direction }: PaginationQueryParams = {}): Promise< + PaginationQueryReturnValue + > => { + if (direction) { + console.warn('Direction is not supported with channel pagination.'); + } + const filters = this.buildFilters(); + const options: ChannelOptions = { + ...this.options, + limit: this.pageSize, + offset: this.offset, + }; + const items = await this.client.queryChannels(filters, this.sort, options); + return { items }; + }; + + filterQueryResults = (items: Channel[]) => items; +} diff --git a/src/pagination/FilterBuilder.ts b/src/pagination/FilterBuilder.ts index 9945dc9a29..53182a2c94 100644 --- a/src/pagination/FilterBuilder.ts +++ b/src/pagination/FilterBuilder.ts @@ -31,7 +31,10 @@ export type FilterBuilderGenerators< }; }; -export type FilterBuilderOptions> = { +export type FilterBuilderOptions< + TFilters, + TContext extends Record = Record, +> = { initialFilterConfig?: FilterBuilderGenerators; initialContext?: TContext; }; diff --git a/src/pagination/ReminderPaginator.ts b/src/pagination/ReminderPaginator.ts index ff81b5dc91..789354cd8c 100644 --- a/src/pagination/ReminderPaginator.ts +++ b/src/pagination/ReminderPaginator.ts @@ -37,7 +37,9 @@ export class ReminderPaginator extends BasePaginator { query = async ({ direction, - }: PaginationQueryParams): Promise> => { + }: Required): Promise< + PaginationQueryReturnValue + > => { const cursor = this.cursor?.[direction]; const { reminders: items, diff --git a/src/pagination/filterCompiler.ts b/src/pagination/filterCompiler.ts new file mode 100644 index 0000000000..a60f745167 --- /dev/null +++ b/src/pagination/filterCompiler.ts @@ -0,0 +1,192 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + arraysEqualAsSets, + asArray, + compare, + isIterableButNotString, + normalizeComparedValues, + resolveDotPathValue, + toIterableArray, + tokenize, +} from './utility.normalization'; +import type { FieldToDataResolver } from './types.normalization'; +import type { QueryFilters } from '../types'; + +export type ItemMatchesFilterOptions = { + /** Custom resolvers to extract values from an item given a path */ + resolvers?: ReadonlyArray>; +}; + +export function itemMatchesFilter( + item: T, + filter: QueryFilters, + options: ItemMatchesFilterOptions, +): boolean { + const resolvers = options.resolvers ?? []; + const resolverValueCache = new Map(); + + const resolveOnce = (field: string) => { + if (resolverValueCache.has(field)) return resolverValueCache.get(field); + const resolver = resolvers?.find((resolver) => resolver.matchesField(field)) ?? { + resolve: resolveDotPathValue, + }; + const value = resolver.resolve(item, field); + resolverValueCache.set(field, value); + return value; + }; + + const matches = (filterNode: QueryFilters): boolean => { + if (!filterNode || typeof filterNode !== 'object') return true; + + if (filterNode.$and) return filterNode.$and.every((n) => matches(n)); + if (filterNode.$or) return filterNode.$or.some((n) => matches(n)); + if (filterNode.$nor) return !filterNode.$nor.some((n) => matches(n)); + + for (const [field, condition] of Object.entries(filterNode)) { + const itemPropertyValue = resolveOnce(field); + + if ( + typeof condition !== 'object' || + condition === null || + Array.isArray(condition) + ) { + if (!equalsOp(itemPropertyValue, condition)) return false; + continue; + } + + for (const [op, filterValue] of Object.entries(condition)) { + switch (op) { + case '$eq': + if (!equalsOp(itemPropertyValue, filterValue)) return false; + break; + case '$ne': + if (equalsOp(itemPropertyValue, filterValue)) return false; + break; + + case '$in': + if (!inSetOp(itemPropertyValue, asArray(filterValue))) return false; + break; + case '$nin': + if (inSetOp(itemPropertyValue, asArray(filterValue))) return false; + break; + + case '$gt': + if (!orderedCompareOp(itemPropertyValue, filterValue, (c) => c > 0)) + return false; + break; + case '$gte': + if (!orderedCompareOp(itemPropertyValue, filterValue, (c) => c >= 0)) + return false; + break; + case '$lt': + if (!orderedCompareOp(itemPropertyValue, filterValue, (c) => c < 0)) + return false; + break; + case '$lte': + if (!orderedCompareOp(itemPropertyValue, filterValue, (c) => c <= 0)) + return false; + break; + + case '$exists': + if (!!itemPropertyValue !== !!filterValue) return false; + break; + case '$contains': + if (!containsOp(itemPropertyValue, filterValue)) return false; + break; + case '$autocomplete': + if (!autoCompleteOp(itemPropertyValue, filterValue)) return false; + break; + default: + return false; + } + } + } + return true; + }; + return matches(filter); +} + +/** + * Duplicates ignored for array–array equality: ['a','a','b'] equals ['b','a']. + * + * Empty arrays: [] equals []; a scalar never equals []. + * + * This reuses your normalizeComparedValues so '1' equals 1, ISO dates compare correctly, etc. + * + * $gt/$gte/$lt/$lte remain scalar-only (return false if either side is iterable), as you wanted. + * + * $in/$nin left may be scalar or iterable; the right is a list. + * @param a + * @param b + * @param ok + */ +function orderedCompareOp(a: any, b: any, ok: (c: number) => boolean): boolean { + if (isIterableButNotString(a) || isIterableButNotString(b)) return false; + const n = normalizeComparedValues(a, b); + if (n.kind === 'incomparable') return false; + return ok(compare(n.a, n.b)); +} + +function equalsOp(left: any, right: any): boolean { + const leftIsIter = isIterableButNotString(left); + const rightIsIter = isIterableButNotString(right); + + if (!leftIsIter && !rightIsIter) { + // scalar vs scalar + const n = normalizeComparedValues(left, right); + if (n.kind === 'incomparable') return Object.is(left, right); + return n.a === n.b; + } + + if (leftIsIter && rightIsIter) { + // array vs array → set equality (order-insensitive) + const a = toIterableArray(left); + const b = toIterableArray(right); + return arraysEqualAsSets(a, b); + } + + // one side scalar, the other iterable → membership + if (leftIsIter) { + const a = toIterableArray(left); + return a.some((elem) => equalsOp(elem, right)); + } else { + const b = toIterableArray(right); + return b.some((elem) => equalsOp(left, elem)); + } +} + +function inSetOp(a: any, arr: any[]): boolean { + return arr.some((b) => equalsOp(a, b)); +} + +function containsOp(value: any, needle: any): boolean { + if (Array.isArray(value)) return value.includes(needle); + if (typeof value === 'string' && typeof needle === 'string') + return value.includes(needle); + return false; +} + +/** + * A value matches an autocomplete query if: + * - value is string: every query token is a prefix of some token in the value + * - value is string[]: any element matches as above + * - query can be string (tokenized) or string[] + */ +function autoCompleteOp(value: any, query: any): boolean { + if (value == null || query == null) return false; + + const queryTokens: string[] = Array.isArray(query) + ? query.map(String).flatMap(tokenize) + : tokenize(String(query)); + if (queryTokens.length === 0) return false; + + const matchOneString = (s: string): boolean => { + const valTokens = tokenize(s); + return queryTokens.every((qt) => valTokens.some((vt) => vt.includes(qt))); + }; + + if (typeof value === 'string') return matchOneString(value); + if (Array.isArray(value)) + return value.some((v) => typeof v === 'string' && matchOneString(v)); + return false; +} diff --git a/src/pagination/index.ts b/src/pagination/index.ts index 19e2a53b80..733c5efe8c 100644 --- a/src/pagination/index.ts +++ b/src/pagination/index.ts @@ -1,3 +1,4 @@ export * from './BasePaginator'; +export * from './ChannelPaginator'; export * from './FilterBuilder'; export * from './ReminderPaginator'; diff --git a/src/pagination/sortCompiler.ts b/src/pagination/sortCompiler.ts new file mode 100644 index 0000000000..b56e9cd13f --- /dev/null +++ b/src/pagination/sortCompiler.ts @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + compare, + resolveDotPathValue as defaultResolvePathValue, + normalizeComparedValues, +} from './utility.normalization'; +import { normalizeQuerySort } from '../utils'; +import type { AscDesc } from '../types'; +import type { Comparator, PathResolver } from './types.normalization'; + +export function binarySearchInsertIndex({ + compare, + needle, + sortedArray, +}: { + sortedArray: T[]; + needle: T; + compare: Comparator; +}): number { + let low = 0; + let high = sortedArray.length; + + while (low < high) { + const middle = (low + high) >>> 1; // fast floor((low+high)/2) + const comparisonResult = compare(sortedArray[middle], needle); + + // We want the first position where existing > needle to insert before it + if (comparisonResult > 0) { + high = middle; + } else { + low = middle + 1; + } + } + + return low; +} + +/** + * Negative number (< 0) → a comes before b + * + * Zero (0) → leave a and b unchanged relative to each other + * (but they can still move relative to others — sort in JS is not guaranteed stable in older engines, though modern V8/Node/Chrome/Firefox make it stable) + * + * Positive number (> 0) → a comes after b + * @param sort + * @param resolvePathValue + * @param tiebreaker + */ +export function makeComparator< + T, + S extends Record | Record[], +>({ + sort, + resolvePathValue = defaultResolvePathValue, + tiebreaker = (a, b) => compare((a as any).cid, (b as any).cid), +}: { + sort: S; + resolvePathValue?: PathResolver; + tiebreaker?: Comparator; +}): Comparator { + const terms = normalizeQuerySort(sort); + + return (a: T, b: T) => { + for (const { field: path, direction } of terms) { + const leftValue = resolvePathValue(a, path); + const rightValue = resolvePathValue(b, path); + const normalized = normalizeComparedValues(leftValue, rightValue); + let comparison: number; + switch (normalized.kind) { + case 'date': + case 'number': + case 'string': + case 'boolean': + comparison = compare(normalized.a, normalized.b); + break; + default: + // deterministic fallback: null/undefined last; else string compare + if (leftValue == null && rightValue == null) comparison = 0; + else if (leftValue == null) comparison = 1; + else if (rightValue == null) comparison = -1; + else { + const stringLeftValue = String(leftValue), + stringRightValue = String(rightValue); + comparison = + stringLeftValue === stringRightValue + ? 0 + : stringLeftValue < stringRightValue + ? -1 + : 1; + } + } + if (comparison !== 0) return direction === 1 ? comparison : -comparison; + } + return tiebreaker ? tiebreaker(a, b) : 0; + }; +} diff --git a/src/pagination/types.normalization.ts b/src/pagination/types.normalization.ts new file mode 100644 index 0000000000..1932a5bc73 --- /dev/null +++ b/src/pagination/types.normalization.ts @@ -0,0 +1,7 @@ +export type PathResolver = (item: DataSource, field: string) => unknown; +export type Comparator = (left: T, right: T) => number; + +export type FieldToDataResolver = { + matchesField: (field: string) => boolean; + resolve: PathResolver; +}; diff --git a/src/pagination/utility.normalization.ts b/src/pagination/utility.normalization.ts new file mode 100644 index 0000000000..c7df10aef9 --- /dev/null +++ b/src/pagination/utility.normalization.ts @@ -0,0 +1,108 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export function asArray(v: any): any[] { + return Array.isArray(v) ? v : [v]; +} + +export function isISODateString(x: any): x is string { + return typeof x === 'string' && x.includes('T') && !Number.isNaN(Date.parse(x)); +} + +export function toEpochMillis(x: any): number | null { + if (x instanceof Date) return x.getTime(); + if (typeof x === 'number' && Number.isFinite(x)) return x; // treat as epoch ms + if (isISODateString(x)) return Date.parse(x); + return null; +} + +export function toNumberLike(x: any): number | null { + if (typeof x === 'number' && Number.isFinite(x)) return x; + if (typeof x === 'string' && x.trim() !== '') { + const n = Number(x); + if (Number.isFinite(n)) return n; + } + return null; +} + +export function normalizeComparedValues(a: any, b: any) { + const Ad = toEpochMillis(a), + Bd = toEpochMillis(b); + if (Ad !== null && Bd !== null) return { kind: 'date', a: Ad, b: Bd }; + + const An = toNumberLike(a), + Bn = toNumberLike(b); + if (An !== null && Bn !== null) return { kind: 'number', a: An, b: Bn }; + + if (typeof a === 'string' && typeof b === 'string') return { kind: 'string', a, b }; + if (typeof a === 'boolean' && typeof b === 'boolean') return { kind: 'boolean', a, b }; + + return { kind: 'incomparable', a, b }; +} + +export function normKey(x: unknown): string { + // Use your normalizeComparedValues to coerce pairs; here we need a unary form. + // We can piggyback by normalizing x against itself: + const n = normalizeComparedValues(x, x); + switch (n.kind) { + case 'date': + case 'number': + case 'string': + case 'boolean': + return `${n.kind}:${String(n.a)}`; + default: + // fallback: use JSON-like string with type tag for determinism + return `other:${String(x)}`; + } +} + +export function compare(a: any, b: any): number { + if (a === b) return 0; + return a < b ? -1 : 1; +} + +export function arraysEqualAsSets(aList: unknown[], bList: unknown[]): boolean { + // de-duplicate by normalized key + const aKeys = new Set(aList.map(normKey)); + const bKeys = new Set(bList.map(normKey)); + if (aKeys.size !== bKeys.size) return false; + for (const k of aKeys) if (!bKeys.has(k)) return false; + return true; +} + +export function normalizeString(s: string): string { + return s.normalize('NFKC').toLowerCase().trim(); +} + +export function normalizeStringAccentInsensitive(s: string): string { + return s + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim(); +} + +export function tokenize(s: string): string[] { + // split on whitespace; keep simple & deterministic + return normalizeString(s).split(/\s+/).filter(Boolean); +} + +// dot-path accessor +export function resolveDotPathValue(obj: any, path: string): unknown[] { + return path + .split('.') + .reduce((reduced, key) => (!reduced ? undefined : reduced[key]), obj); +} + +export function isIterableButNotString(v: unknown): v is Iterable { + return ( + v != null && + typeof v !== 'string' && + typeof (v as any)[Symbol.iterator] === 'function' + ); +} + +export function toIterableArray(v: unknown): unknown[] { + if (Array.isArray(v)) return v; + if (isIterableButNotString(v)) return Array.from(v as Iterable); + return [v]; // scalar as a single-element list +} diff --git a/src/pagination/utility.queryChannel.ts b/src/pagination/utility.queryChannel.ts new file mode 100644 index 0000000000..2a2fedd9b0 --- /dev/null +++ b/src/pagination/utility.queryChannel.ts @@ -0,0 +1,77 @@ +import type { ChannelQueryOptions, QueryChannelAPIResponse } from '../types'; +import type { StreamChat } from '../client'; +import type { Channel } from '../channel'; +import { generateChannelTempCid } from '../utils'; + +/** + * prevent from duplicate invocation of channel.watch() + * when events 'notification.message_new' and 'notification.added_to_channel' 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?: ChannelQueryOptions; + type?: string; +}; +/** + * Watches a channel, coalescing concurrent invocations for the same CID. + * If a watch is already in flight, this call waits for it to settle instead of + * issuing another network request. + * @param client + * @param members + * @param options + * @param type + * @param id + * @param channel + */ +export const getChannel = 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 + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const theChannel = channel || client.channel(type!, id, { members }); + + // 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 = theChannel?.id + ? theChannel.cid + : members && members.length + ? generateChannelTempCid(theChannel.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] = theChannel.watch(options); + await WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid]; + } finally { + delete WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL[originalCid]; + } + } + + return theChannel; +}; diff --git a/src/pagination/utility.search.ts b/src/pagination/utility.search.ts new file mode 100644 index 0000000000..4d1cec4195 --- /dev/null +++ b/src/pagination/utility.search.ts @@ -0,0 +1,56 @@ +export function locateOnPlateauAlternating( + items: readonly T[], + needle: T, + compare: (left: T, right: T) => number, + getItemId: (x: T) => string, + insertionIndex: number, +): number { + const targetId = getItemId(needle); + let leftIndex = insertionIndex - 1; + let rightIndex = insertionIndex; + + for (let step = 0; ; step++) { + const searchRight = step % 2 === 0; + + if (searchRight) { + if (rightIndex < items.length && compare(items[rightIndex], needle) === 0) { + if (getItemId(items[rightIndex]) === targetId) return rightIndex; + rightIndex++; + continue; + } + } else { + if (leftIndex >= 0 && compare(items[leftIndex], needle) === 0) { + if (getItemId(items[leftIndex]) === targetId) return leftIndex; + leftIndex--; + continue; + } + } + + const rightOut = + rightIndex >= items.length || compare(items[rightIndex], needle) !== 0; + const leftOut = leftIndex < 0 || compare(items[leftIndex], needle) !== 0; + if (rightOut && leftOut) break; // plateau exhausted + } + + return -1; +} + +export function locateOnPlateauScanOneSide( + items: readonly T[], + needle: T, + compare: (left: T, right: T) => number, + getItemId: (x: T) => string, + insertionIndex: number, +): number { + const targetId = getItemId(needle); + + // scan left + for (let i = insertionIndex - 1; i >= 0 && compare(items[i], needle) === 0; i--) { + if (getItemId(items[i]) === targetId) return i; + } + // scan right + for (let i = insertionIndex; i < items.length && compare(items[i], needle) === 0; i++) { + if (getItemId(items[i]) === targetId) return i; + } + return -1; +} diff --git a/test/unit/ChannelPaginatorsOrchestrator.test.ts b/test/unit/ChannelPaginatorsOrchestrator.test.ts new file mode 100644 index 0000000000..e27ada86ec --- /dev/null +++ b/test/unit/ChannelPaginatorsOrchestrator.test.ts @@ -0,0 +1,580 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getClientWithUser } from './test-utils/getClient'; +import { + Channel, + ChannelPaginator, + ChannelResponse, + EventTypes, + type StreamChat, +} from '../../src'; +import { ChannelPaginatorsOrchestrator } from '../../src/ChannelPaginatorsOrchestrator'; +vi.mock('../../src/pagination/utility.queryChannel', async () => { + return { + getChannel: vi.fn(async ({ client, id, type }) => { + return client.channel(type, id); + }), + }; +}); +import { getChannel as mockGetChannel } from '../../src/pagination/utility.queryChannel'; + +describe('ChannelPaginatorsOrchestrator', () => { + let client: StreamChat; + + beforeEach(() => { + client = getClientWithUser(); + vi.clearAllMocks(); + }); + + 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); + + // @ts-expect-error accessing protected property + expect(orchestrator.pipelines.size).toBe(Object.keys(defaultHandlers).length); + }); + + it('initiates with custom options', () => { + const paginator = new ChannelPaginator({ client }); + const customChannelVisibleHandler = vi.fn(); + const customChannelDeletedHandler = vi.fn(); + const customEventHandler = vi.fn(); + + // @ts-expect-error accessing protected property + const defaultHandlers = ChannelPaginatorsOrchestrator.defaultEventHandlers; + const eventHandlers = ChannelPaginatorsOrchestrator.getDefaultHandlers(); + + eventHandlers['channel.visible'] = [ + ...(eventHandlers['channel.visible'] ?? []), + { + id: 'channel.visible:custom', + handle: customChannelVisibleHandler, + }, + ]; + + eventHandlers['channel.deleted'] = [ + { + id: 'channel.deleted:custom', + handle: customChannelDeletedHandler, + }, + ]; + + eventHandlers['custom.event'] = [ + { + id: 'custom.event', + handle: customEventHandler, + }, + ]; + + const orchestrator = new ChannelPaginatorsOrchestrator({ + client, + eventHandlers, + paginators: [paginator], + }); + expect(orchestrator.paginators).toHaveLength(1); + expect(orchestrator.getPaginatorById(paginator.id)).toStrictEqual(paginator); + // @ts-expect-error accessing protected property + expect(orchestrator.pipelines.size).toBe(Object.keys(defaultHandlers).length + 1); + + // @ts-expect-error accessing protected property + expect(orchestrator.pipelines.get('channel.visible').size).toBe(2); + // @ts-expect-error accessing protected property + expect(orchestrator.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( + eventHandlers['channel.visible'][1].id, + ); + + // @ts-expect-error accessing protected property + expect(orchestrator.pipelines.get('channel.deleted').size).toBe(1); + // @ts-expect-error accessing protected property + expect(orchestrator.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); + // @ts-expect-error accessing protected property + expect(orchestrator.pipelines.get('custom.event').handlers[0].id).toBe( + eventHandlers['custom.event'][0].id, + ); + }); + }); + + describe('registerSubscriptions', () => { + it('subscribes only once', async () => { + const onSpy = vi.spyOn(client, 'on'); + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + orchestrator.registerSubscriptions(); + orchestrator.registerSubscriptions(); + expect(onSpy).toHaveBeenCalledTimes(1); + }); + + it('routes events to correct pipelines', async () => { + const customChannelDeletedHandler = vi.fn(); + const customEventHandler = vi.fn(); + + const eventHandlers = ChannelPaginatorsOrchestrator.getDefaultHandlers(); + + eventHandlers['channel.deleted'] = [ + { + id: 'channel.deleted:custom', + handle: customChannelDeletedHandler, + }, + ]; + + eventHandlers['custom.event'] = [ + { + id: 'custom.event', + handle: customEventHandler, + }, + ]; + + const orchestrator = new ChannelPaginatorsOrchestrator({ client, eventHandlers }); + orchestrator.registerSubscriptions(); + + const channelDeletedEvent = { type: 'channel.deleted', cid: 'x' } as const; + + client.dispatchEvent(channelDeletedEvent); + + await vi.waitFor(() => { + expect(customChannelDeletedHandler).toHaveBeenCalledTimes(1); + expect(customChannelDeletedHandler).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: { orchestrator }, + event: channelDeletedEvent, + }), + ); + }); + + const customEvent = { type: 'custom.event' as EventTypes, x: 'abc' } as const; + + client.dispatchEvent(customEvent); + + await vi.waitFor(() => { + expect(customEventHandler).toHaveBeenCalledTimes(1); + expect(customEventHandler).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: { orchestrator }, + event: customEvent, + }), + ); + }); + }); + }); + + describe('insertPaginator', () => { + it('appends when no index is provided', () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const p1 = new ChannelPaginator({ client }); + const p2 = new ChannelPaginator({ client }); + + orchestrator.insertPaginator({ paginator: p1 }); + orchestrator.insertPaginator({ paginator: p2 }); + + expect(orchestrator.paginators.map((p) => p.id)).toEqual([p1.id, p2.id]); + }); + + it('inserts at specific index', () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ 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 }); + + expect(orchestrator.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 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 }); + + // 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]); + }); + + it('clamps out-of-bounds index', () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ 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 + + expect(orchestrator.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 channelUpdatedHandler = vi.fn(); + const unsubscribe = orchestrator.addEventHandler({ + eventType: 'channel.updated', + id: 'custom', + handle: channelUpdatedHandler, + }); + + orchestrator.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 }, + event: channelUpdatedEvent, + }); + }); + + // Unsubscribe the custom handler and ensure it no longer fires + unsubscribe(); + client.dispatchEvent(channelUpdatedEvent); + + // still 1 call total (did not increment) + expect(channelUpdatedHandler).toHaveBeenCalledTimes(1); + }); + }); + + 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'); + expect(p1).toBe(p2); + }); + }); + + // Helper to create a minimal channel with needed state + function makeChannel(cid: string) { + const [type, id] = cid.split(':'); + return client.channel(type, id); + } + + describe('channel.deleted', () => { + 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 orchestrator = new ChannelPaginatorsOrchestrator({ + client, + paginators: [p1, p2], + }); + client.activeChannels[cid] = ch; + + orchestrator.registerSubscriptions(); + client.dispatchEvent({ type: 'channel.deleted', 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 }); + }); + }); + + 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(); + + client.dispatchEvent({ type: 'channel.deleted' } 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'); + + 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 }); + }); + }); + }); + + describe.each(['channel.hidden', 'notification.removed_from_channel'] as EventTypes[])( + '%s', + (eventType) => { + it('removes the channel from all paginators', async () => { + const cid = 'messaging:2'; + 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 orchestrator = new ChannelPaginatorsOrchestrator({ + client, + paginators: [p1, p2], + }); + client.activeChannels[cid] = ch; + + orchestrator.registerSubscriptions(); + client.dispatchEvent({ type: eventType, cid } as const); + + await vi.waitFor(() => { + // client.activeChannels contains the hidden channel, therefore the search is performed with item + expect(r1).toHaveBeenCalledWith({ id: ch.cid, item: ch }); + expect(r2).toHaveBeenCalledWith({ id: ch.cid, item: ch }); + }); + }); + + 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(); + + 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'); + + orchestrator.insertPaginator({ paginator: p }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ type: eventType, cid: 'messaging:404' }); // no such channel + await vi.waitFor(() => { + expect(r).toHaveBeenCalledWith({ id: 'messaging:404', item: undefined }); + }); + }); + }, + ); + + describe.each(['channel.updated', 'channel.truncated'] as EventTypes[])( + '%s', + (eventType) => { + it('re-emits item lists for paginators that already contain the channel', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const ch = makeChannel('messaging:3'); + client.activeChannels[ch.cid] = ch; + + const p1 = new ChannelPaginator({ client }); + const p2 = new ChannelPaginator({ client }); + p1.state.partialNext({ items: [ch] }); + vi.spyOn(p1, 'findItem').mockReturnValue(ch); + vi.spyOn(p2, 'findItem').mockReturnValue(undefined); + const partialNextSpy1 = vi.spyOn(p1.state, 'partialNext'); + const partialNextSpy2 = vi.spyOn(p2.state, 'partialNext'); + + orchestrator.insertPaginator({ paginator: p1 }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ type: eventType, cid: ch.cid }); + await vi.waitFor(() => { + expect(partialNextSpy2).toHaveBeenCalledTimes(0); + expect(partialNextSpy1).toHaveBeenCalledTimes(1); + const last = partialNextSpy1.mock.calls.at(-1)![0]; + expect(last.items!.length).toBe(1); + expect(last.items![0]).toStrictEqual(ch); + }); + }); + }, + ); + + describe.each([ + 'channel.visible', + 'member.updated', + 'message.new', + 'notification.added_to_channel', + 'notification.message_new', + ] as EventTypes[])('%s', (eventType) => { + it('ingests when matchesFilter, removes when not', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const ch = makeChannel('messaging:5'); + client.activeChannels[ch.cid] = ch; + + const p = new ChannelPaginator({ client }); + const matchesFilterSpy = vi.spyOn(p, 'matchesFilter').mockReturnValue(true); + const ingestItemSpy = vi.spyOn(p, 'ingestItem').mockReturnValue(true); + const removeItemSpy = vi.spyOn(p, 'removeItem').mockReturnValue(true); + + orchestrator.insertPaginator({ paginator: p }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ type: eventType, cid: ch.cid }); + await vi.waitFor(() => { + expect(matchesFilterSpy).toHaveBeenCalledWith(ch); + expect(ingestItemSpy).toHaveBeenCalledWith(ch); + expect(removeItemSpy).not.toHaveBeenCalled(); + }); + + matchesFilterSpy.mockReturnValue(false); + client.dispatchEvent({ type: eventType, cid: 'messaging:5' }); + + await vi.waitFor(() => { + expect(removeItemSpy).toHaveBeenCalledWith({ item: ch }); + expect(ingestItemSpy).toHaveBeenCalledTimes(1); + }); + }); + + it('loads channel by (type,id) when not in activeChannels', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + + const p = new ChannelPaginator({ client }); + const removeItemSpy = vi.spyOn(p, 'removeItem').mockReturnValue(true); + const ingestItemSpy = vi.spyOn(p, 'ingestItem').mockReturnValue(true); + vi.spyOn(p, 'matchesFilter').mockReturnValue(true); + orchestrator.insertPaginator({ paginator: p }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ + type: eventType, + channel_type: 'messaging', + channel_id: '6', + }); + + await vi.waitFor(() => { + expect(mockGetChannel).toHaveBeenCalledWith({ + client, + id: '6', + type: 'messaging', + }); + const ch = makeChannel('messaging:6'); + expect(ingestItemSpy).toHaveBeenCalledWith(ch); + expect(removeItemSpy).not.toHaveBeenCalled(); + }); + }); + + it('uses event.channel if provided', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const ch = makeChannel('messaging:7'); + client.activeChannels[ch.cid] = ch; + + const p = new ChannelPaginator({ client }); + + const removeItemSpy = vi.spyOn(p, 'removeItem').mockReturnValue(true); + const ingestItemSpy = vi.spyOn(p, 'ingestItem').mockReturnValue(true); + vi.spyOn(p, 'matchesFilter').mockReturnValue(true); + + orchestrator.insertPaginator({ paginator: p }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ + type: eventType, + channel: { cid: 'messaging:7' } as ChannelResponse, + }); + await vi.waitFor(() => { + expect(ingestItemSpy).toHaveBeenCalledWith(ch); + expect(removeItemSpy).not.toHaveBeenCalled(); + }); + }); + + it('removes channel if does not match the filter anymore', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const ch = makeChannel('messaging:7'); + client.activeChannels[ch.cid] = ch; + + const p = new ChannelPaginator({ client }); + + const removeItemSpy = vi.spyOn(p, 'removeItem').mockReturnValue(true); + const ingestItemSpy = vi.spyOn(p, 'ingestItem').mockReturnValue(true); + vi.spyOn(p, 'matchesFilter').mockReturnValue(false); + + orchestrator.insertPaginator({ paginator: p }); + orchestrator.registerSubscriptions(); + + client.dispatchEvent({ + type: eventType, + channel: { cid: 'messaging:7' } as ChannelResponse, + }); + await vi.waitFor(() => { + expect(ingestItemSpy).not.toHaveBeenCalled(); + expect(removeItemSpy).toHaveBeenCalledWith({ item: ch }); + }); + }); + }); + + 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 ch1 = makeChannel('messaging:13'); + ch1.state.members = { + u1: { user: { id: 'u1', name: 'Old' } }, + u3: { user: { id: 'u3', name: 'Old3' } }, + }; + ch1.state.membership = { user: { id: 'u1', name: 'Old' } }; + + const ch2 = makeChannel('messaging:14'); + ch2.state.members = { + u1: { user: { id: 'u1', name: 'Old' } }, + u2: { user: { id: 'u2', name: 'Old2' } }, + u3: { user: { id: 'u3', name: 'Old3' } }, + }; + ch2.state.membership = { user: { id: 'u1', name: 'Old' } }; + + client.activeChannels[ch1.cid] = ch1; + client.activeChannels[ch2.cid] = ch2; + + const p = new ChannelPaginator({ client }); + p.state.partialNext({ items: [ch1, ch2] }); + const partialNextSpy = vi.spyOn(p.state, 'partialNext'); + + orchestrator.insertPaginator({ paginator: p }); + orchestrator.registerSubscriptions(); + + // user u1 presence changed + client.dispatchEvent({ + type: 'user.presence.changed', + user: { id: 'u1', name: 'NewName' }, + }); + + await vi.waitFor(() => { + expect(ch1.state.members['u1'].user?.name).toBe('NewName'); + expect(ch1.state.members['u3'].user?.name).toBe('Old3'); + + expect(ch2.state.members['u1'].user?.name).toBe('NewName'); + expect(ch2.state.members['u2'].user?.name).toBe('Old2'); + expect(ch2.state.members['u3'].user?.name).toBe('Old3'); + + expect(ch1.state.membership.user?.name).toBe('NewName'); + expect(ch2.state.membership.user?.name).toBe('NewName'); + expect(partialNextSpy).toHaveBeenCalledTimes(1); + expect(partialNextSpy).toHaveBeenCalledWith({ items: [ch1, ch2] }); + }); + + // Now user without id → ignored + partialNextSpy.mockClear(); + client.dispatchEvent({ type: 'user.presence.changed', user: {} as any }); + expect(partialNextSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/unit/EventHandlerPipeline.test.ts b/test/unit/EventHandlerPipeline.test.ts new file mode 100644 index 0000000000..67a0ce934e --- /dev/null +++ b/test/unit/EventHandlerPipeline.test.ts @@ -0,0 +1,525 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + EventHandlerPipeline, + type LabeledEventHandler, +} from '../../src/EventHandlerPipeline'; + +type TestEvent = { type: string; payload?: any }; +type TestCtx = { tag: string }; + +const makeEvt = (type: string): TestEvent => ({ type }); +const ctx: TestCtx = { tag: 'ctx' }; + +describe('EventHandlerPipeline', () => { + let pipeline: EventHandlerPipeline; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + pipeline = new EventHandlerPipeline({ id: 'test-pipe' }); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + describe('constructor & size', () => { + it('initializes with id and zero handlers', () => { + expect(pipeline.id).toBe('test-pipe'); + expect(pipeline.size).toBe(0); + }); + }); + + describe('insert', () => { + it('appends by default when no index', async () => { + const calls: string[] = []; + const h1 = { + id: 'h1', + handle: () => { + calls.push('h1'); + }, + }; + const h2 = { + id: 'h2', + handle: () => { + calls.push('h2'); + }, + }; + + pipeline.insert(h1); + pipeline.insert(h2); + + expect(pipeline.size).toBe(2); + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('x'), ctx).then(() => { + expect(calls).toEqual(['h1', 'h2']); + }); + }); + + it('inserts at clamped index (negative -> 0, too large -> append)', () => { + const order: string[] = []; + const a = { + id: 'a', + handle: () => { + order.push('a'); + }, + }; + const b = { + id: 'b', + handle: () => { + order.push('b'); + }, + }; + const c = { + id: 'c', + handle: () => { + order.push('c'); + }, + }; + const d = { + id: 'd', + handle: () => { + order.push('d'); + }, + }; + + pipeline.insert(a); // [a] + pipeline.insert(b); // [a,b] + pipeline.insert({ ...c, index: -10 }); // clamp to 0 => [c,a,b] + pipeline.insert({ ...d, index: 999 }); // append => [c,a,b,d] + + expect(pipeline.size).toBe(4); + // @ts-expect-error passing custom event type + return pipeline.run(makeEvt('e'), ctx).then(() => { + expect(order).toEqual(['c', 'a', 'b', 'd']); + }); + }); + + it('replace=false inserts and unsubscribe removes only target handler', async () => { + const calls: string[] = []; + const a = { + id: 'a', + handle: () => { + calls.push('a'); + }, + }; + const b = { + id: 'b', + handle: () => { + calls.push('b'); + }, + }; + + const unsubA = pipeline.insert({ ...a, index: 0, replace: false }); + const unsubB = pipeline.insert({ ...b, index: 0, replace: false }); + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('x'), ctx); + expect(calls).toEqual(['b', 'a']); + + unsubB(); // remove only b + expect(pipeline.size).toBe(1); + + // reset the array contents + calls.length = 0; + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('y'), ctx); + expect(calls).toEqual(['a']); + + unsubA(); + expect(pipeline.size).toBe(0); + }); + + it('replace=true replaces existing handler and revertOnUnsubscribe restores it', async () => { + const calls: string[] = []; + const orig = { + id: 'orig', + handle: () => { + calls.push('orig'); + }, + }; + const repl = { + id: 'repl', + handle: () => { + calls.push('repl'); + }, + }; + + // seed + pipeline.insert({ ...orig, index: 0 }); + // replace at 0 with repl + const unsub = pipeline.insert({ + ...repl, + index: 0, + replace: true, + revertOnUnsubscribe: true, + }); + + // handlers: [repl] + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('1'), ctx); + expect(calls).toEqual(['repl']); + + // unsubscribe => remove repl and restore orig at index 0 + unsub(); + calls.length = 0; + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('2'), ctx); + expect(calls).toEqual(['orig']); + }); + + it('replace=true at index >= length behaves like insert (does not revert)', async () => { + const calls: string[] = []; + const a = { + id: 'a', + handle: () => { + calls.push('a'); + }, + }; + const repl = { + id: 'repl', + handle: () => { + calls.push('repl'); + }, + }; + + pipeline.insert(a); // [a] + const unsub = pipeline.insert({ + ...repl, + index: 5, + replace: true, + revertOnUnsubscribe: true, + }); //[a,repl] + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('x'), ctx); + expect(calls).toEqual(['a', 'repl']); // reverse exec + + unsub(); // should only remove repl; no original to restore + calls.length = 0; + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('y'), ctx); + expect(calls).toEqual(['a']); + }); + }); + + describe('remove', () => { + it('removes by handler object identity', async () => { + const out: string[] = []; + const h1: LabeledEventHandler = { + id: 'h1', + handle: () => { + out.push('h1'); + }, + }; + const h2: LabeledEventHandler = { + id: 'h2', + handle: () => { + out.push('h2'); + }, + }; + + pipeline.insert(h1); + pipeline.insert(h2); + pipeline.remove(h2); // remove by object + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('evt'), ctx); + expect(out).toEqual(['h1']); // reverse exec; only h1 left + }); + + it('removes by function reference', async () => { + const out: string[] = []; + const fn = () => { + out.push('fn'); + }; + const h1: LabeledEventHandler = { id: 'h1', handle: fn }; + pipeline.insert(h1); + pipeline.remove(fn); // remove by function ref + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('evt'), ctx); + expect(out).toEqual([]); // removed + }); + + it('no-op remove for unknown handler', async () => { + const out: string[] = []; + const fn = () => { + out.push('a'); + }; + pipeline.remove(fn); // nothing inserted yet + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('evt'), ctx); // no errors + expect(out).toEqual([]); + expect(pipeline.size).toBe(0); + }); + }); + + describe('replaceAll & clear', () => { + it('replaceAll swaps the entire handler list', async () => { + const out: string[] = []; + const a = { + id: 'a', + handle: () => { + out.push('a'); + }, + }; + const b = { + id: 'b', + handle: () => { + out.push('b'); + }, + }; + const c = { + id: 'c', + handle: () => { + out.push('c'); + }, + }; + + pipeline.insert(a); + pipeline.insert(b); + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('e'), ctx); + expect(out).toEqual(['a', 'b']); + out.length = 0; + + pipeline.replaceAll([c]); + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('e2'), ctx); + expect(out).toEqual(['c']); + expect(pipeline.size).toBe(1); + }); + + it('clear removes all handlers', async () => { + const out: string[] = []; + pipeline.insert({ + id: 'a', + handle: () => { + out.push('a'); + }, + }); + pipeline.insert({ + id: 'b', + handle: () => { + out.push('b'); + }, + }); + expect(pipeline.size).toBe(2); + + pipeline.clear(); + expect(pipeline.size).toBe(0); + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('e'), ctx); + expect(out).toEqual([]); // nothing ran + }); + }); + + describe('run / drain / execution order', () => { + it('serializes events: second run waits for the first to finish', async () => { + const seen: string[] = []; + let hAsyncHandlerRunCount = 0; + let resolveRun1!: () => void; + const hAsync = { + id: 'async', + handle: () => + new Promise((res) => { + if (hAsyncHandlerRunCount === 0) { + resolveRun1 = () => { + seen.push('A-done'); + res(); + }; + ++hAsyncHandlerRunCount; + } else { + setTimeout(() => { + seen.push('A-done'); + res(); + }, 0); + } + seen.push('A-start'); + }), + }; + + const hSync = { + id: 'sync', + handle: () => { + seen.push('B-run'); + }, + }; + + pipeline.insert(hAsync); + pipeline.insert(hSync); + + // @ts-expect-error passing custom event type + const eventRun1 = pipeline.run(makeEvt('ev1'), ctx); + // @ts-expect-error passing custom event type + const eventRun2 = pipeline.run(makeEvt('ev2'), ctx); + + // At this point, first run has started (A-start), + // but the hSync is not run until we resolveRun1 and then eventRun1 can be resolved + await Promise.resolve(); // tick microtasks + expect(seen).toEqual(['A-start']); + + resolveRun1(); + await eventRun1; + expect(seen).toEqual(['A-start', 'A-done', 'B-run']); + + // Now second event runs + await eventRun2; + + // total should be 6 entries + expect(seen).toEqual(['A-start', 'A-done', 'B-run', 'A-start', 'A-done', 'B-run']); + }); + + it('drain waits for the last queued event to finish', async () => { + const marks: string[] = []; + let handlerRunCount = 0; + let resolveLater!: () => void; + + pipeline.insert({ + id: 'hold', + handle: () => + new Promise((res) => { + if (handlerRunCount === 0) { + resolveLater = () => { + marks.push('released'); + res(); + }; + ++handlerRunCount; + } else { + setTimeout(() => { + marks.push('released'); + res(); + }, 0); + } + marks.push('held'); + }), + }); + + // @ts-expect-error passing custom event type + pipeline.run(makeEvt('e1'), ctx); + // @ts-expect-error passing custom event type + pipeline.run(makeEvt('e2'), ctx); + const drained = pipeline.drain(); + + await Promise.resolve(); + expect(marks).toEqual(['held']); // first event started + + resolveLater(); // finish first; second starts then finishes too + expect(marks).toEqual(['held', 'released']); // first event started + await drained; + expect(marks).toEqual(['held', 'released', 'held', 'released']); + }); + + it('stop action halts remaining handlers for that event only', async () => { + const order: string[] = []; + pipeline.insert({ + id: 'a', + handle: () => { + order.push('a'); + }, + }); + pipeline.insert({ + id: 'stopper', + handle: () => { + order.push('stopper'); + return { action: 'stop' }; + }, + }); + pipeline.insert({ + id: 'c', + handle: () => { + order.push('c'); + }, + }); + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('e'), ctx); + expect(order).toEqual(['a', 'stopper']); + }); + + it('handler exceptions are logged but do not break processing', async () => { + const order: string[] = []; + const before = { + id: 'before', + handle: () => { + order.push('before'); + }, + }; + + const boom = { + id: 'boom', + handle: () => { + order.push('boom'); + throw new Error('fail'); + }, + }; + + const after = { + id: 'after', + handle: () => { + order.push('after'); + }, + }; + + pipeline.insert(before); + pipeline.insert(boom); + pipeline.insert(after); + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('e'), ctx); + // reverse exec: after -> boom -> before; boom throws but processing continues + expect(order).toEqual(['before', 'boom', 'after']); + expect(consoleErrorSpy).toHaveBeenCalled(); // logged + }); + + it('snapshot isolation: handlers added during a run do not affect the current event', async () => { + const order: string[] = []; + + const late = { + id: 'late', + handle: () => { + order.push('late'); + }, + }; + const head = { + id: 'head', + handle: () => { + order.push('head'); + }, + }; + const inserter = { + id: 'inserter', + handle: () => { + order.push('inserter'); + // insert a new handler while processing this event + pipeline.insert(late); + }, + }; + const tail = { + id: 'tail', + handle: () => { + order.push('tail'); + }, + }; + + pipeline.insert(head); + pipeline.insert(inserter); + pipeline.insert(tail); + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('e1'), ctx); + // 'late' must NOT run for e1 + expect(order).toEqual(['head', 'inserter', 'tail']); + + order.length = 0; + + // @ts-expect-error passing custom event type + await pipeline.run(makeEvt('e2'), ctx); + // For the next event, late is present + expect(order).toEqual(['head', 'inserter', 'tail', 'late']); + }); + }); +}); diff --git a/test/unit/LiveLocationManager.test.ts b/test/unit/LiveLocationManager.test.ts index 1148106375..922c55fe77 100644 --- a/test/unit/LiveLocationManager.test.ts +++ b/test/unit/LiveLocationManager.test.ts @@ -74,7 +74,9 @@ describe('LiveLocationManager', () => { watchLocation, }); expect(manager.deviceId).toEqual(deviceId); + // @ts-expect-error accessing private property expect(manager.getDeviceId).toEqual(getDeviceId); + // @ts-expect-error accessing private property expect(manager.watchLocation).toEqual(watchLocation); expect(manager.state.getLatestValue()).toEqual({ messages: new Map(), diff --git a/test/unit/pagination/BasePaginator.test.ts b/test/unit/pagination/BasePaginator.test.ts index 1f988e22e2..30bd6bfd16 100644 --- a/test/unit/pagination/BasePaginator.test.ts +++ b/test/unit/pagination/BasePaginator.test.ts @@ -1,23 +1,34 @@ import { describe, expect, it, vi } from 'vitest'; import { + AscDesc, BasePaginator, DEFAULT_PAGINATION_OPTIONS, PaginationQueryParams, PaginationQueryReturnValue, type PaginatorOptions, -} from '../../../src/pagination'; + QueryFilters, +} from '../../../src'; import { sleep } from '../../../src/utils'; +import { makeComparator } from '../../../src/pagination/sortCompiler'; const toNextTick = async () => { const sleepPromise = sleep(0); vi.advanceTimersByTime(0); await sleepPromise; }; + type TestItem = { id: string; + name?: string; + teams?: string[]; + blocked?: boolean; + createdAt?: string; // date string + age?: number; }; class Paginator extends BasePaginator { + sort: QueryFilters | undefined; + sortComparator: (a: TestItem, b: TestItem) => number = vi.fn(); queryResolve: Function = vi.fn(); queryReject: Function = vi.fn(); queryPromise: Promise> | null = null; @@ -26,6 +37,7 @@ class Paginator extends BasePaginator { constructor(options: PaginatorOptions = {}) { super(options); } + query(params: PaginationQueryParams): Promise> { const promise = new Promise>( (queryResolve, queryReject) => { @@ -57,7 +69,10 @@ describe('BasePaginator', () => { cursor: undefined, offset: 0, }); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(0); }); + it('initiates with custom options', () => { const paginator = new Paginator({ pageSize: 1 }); expect(paginator.pageSize).not.toBe(DEFAULT_PAGINATION_OPTIONS.pageSize); @@ -225,4 +240,454 @@ describe('BasePaginator', () => { expect(paginator.cursor).toEqual({ next: 'next1', prev: 'prev1' }); }); }); + describe('item management', () => { + const item: TestItem = { + id: 'id1', + name: 'test', + age: 100, + teams: ['abc', 'efg'], + }; + + const item2 = { + ...item, + id: 'id2', + name: 'test2', + age: 101, + }; + + const item3 = { + ...item, + id: 'id3', + name: 'test3', + age: 102, + }; + + describe('matchesFilter', () => { + it('returns true if no filter is provided', async () => { + const paginator = new Paginator(); + expect(paginator.matchesFilter(item)).toBeTruthy(); + }); + it('returns false if does not match the filter', async () => { + const paginator = new Paginator(); + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + name: { $eq: 'test1' }, + }); + expect(paginator.matchesFilter(item)).toBeFalsy(); + }); + it('returns true if item matches the filter', async () => { + const paginator = new Paginator(); + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + $or: [{ name: { $eq: 'test1' } }, { teams: { $contains: 'abc' } }], + }); + expect(paginator.matchesFilter(item)).toBeTruthy(); + }); + }); + + describe('ingestItem', () => { + it('exists but does not match the filter anymore removes the item', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item3, item2, item], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $eq: ['abc', 'efg'] }, // required membership in these two teams + }); + + const adjustedItem = { + ...item, + teams: ['efg'], // removed from the team abc + }; + + expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item removed + expect(paginator.items).toHaveLength(2); + }); + + it('exists and matches the filter updates the item', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item, item2, item3], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + age: { $gt: 100 }, + }); + + paginator.sort = { age: 1 }; + + const adjustedItem = { + ...item, + age: 103, + }; + + expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item updated + expect(paginator.items).toHaveLength(3); + expect(paginator.items![0]).toStrictEqual(item2); + expect(paginator.items![1]).toStrictEqual(item3); + expect(paginator.items![2]).toStrictEqual(adjustedItem); + }); + + it('does not exist and does not match the filter results in no action', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + age: { $gt: 100 }, + }); + + const adjustedItem = { + ...item, + id: 'id2', + name: 'test2', + }; + + expect(paginator.ingestItem(adjustedItem)).toBeFalsy(); // no action + expect(paginator.items).toHaveLength(1); + expect(paginator.items![0]).toStrictEqual(item); + }); + + it('does not exist and matches the filter inserts according to default sort order (append)', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item3, item], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + + expect(paginator.ingestItem(item2)).toBeTruthy(); + expect(paginator.items).toHaveLength(3); + expect(paginator.items![0]).toStrictEqual(item3); + expect(paginator.items![1]).toStrictEqual(item); + expect(paginator.items![2]).toStrictEqual(item2); + }); + + it('does not exist and matches the filter inserts according to sort order', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item3, item], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + expect(paginator.ingestItem(item2)).toBeTruthy(); + expect(paginator.items).toHaveLength(3); + expect(paginator.items![0]).toStrictEqual(item3); + expect(paginator.items![1]).toStrictEqual(item2); + expect(paginator.items![2]).toStrictEqual(item); + }); + }); + + describe('removeItem', () => { + it('removes existing item', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item3, item2, item], + }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + expect(paginator.removeItem({ item: item3 })).toBeTruthy(); + expect(paginator.items).toHaveLength(2); + expect(paginator.items![0]).toStrictEqual(item2); + expect(paginator.items![1]).toStrictEqual(item); + }); + + it('results in no action for non-existent item', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item2, item], + }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + expect(paginator.removeItem({ item: item3 })).toBeFalsy(); + expect(paginator.items).toHaveLength(2); + expect(paginator.items![0]).toStrictEqual(item2); + expect(paginator.items![1]).toStrictEqual(item); + }); + }); + + describe('contains', () => { + it('returns true if the item exists', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item3, item2, item], + }); + expect(paginator.contains(item3)).toBeTruthy(); + }); + + it('returns false if the items does not exist', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item2, item], + }); + expect(paginator.contains(item3)).toBeFalsy(); + }); + }); + + describe('locateByItem', () => { + const a: TestItem = { id: 'a', age: 30, name: 'A' }; + const b: TestItem = { id: 'b', age: 25, name: 'B' }; + const c: TestItem = { id: 'c', age: 25, name: 'C' }; + const d: TestItem = { id: 'd', age: 20, name: 'D' }; + + const tieBreakerById = (l: TestItem, r: TestItem) => + l.id < r.id ? -1 : l.id > r.id ? 1 : 0; + + it('returns {index:-1, insertionIndex:0} for empty list', () => { + const paginator = new Paginator(); + const res = paginator.locateByItem(a); + expect(res).toEqual({ index: -1, insertionIndex: 0 }); + }); + + it('finds an existing item on a tie plateau (no ID tiebreaker)', () => { + const paginator = new Paginator(); + // comparator: age desc only (ties produce a plateau) + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + // items are already sorted by age desc + paginator.state.partialNext({ items: [a, b, c, d] }); + + const res = paginator.locateByItem(c); + expect(res.index).toBe(2); // c is at index 2 in [a, b, c, d] + // insertionIndex for identical key (age 25) is after the plateau + expect(res.insertionIndex).toBe(3); + }); + + it('returns insertion index when not found on a tie plateau (no ID tiebreaker)', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + paginator.state.partialNext({ items: [a, b, c, d] }); + + // same sort keys as b/c but different id; not present + const x: TestItem = { id: 'x', age: 25, name: 'X' }; + const res = paginator.locateByItem(x); + // insertion point should be after the 25-plateau (after c at index 2) + expect(res.index).toBe(-1); + expect(res.insertionIndex).toBe(3); + }); + + it('finds exact index with ID tiebreaker in comparator (pure O(log n))', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + // tie-breaker on id asc guarantees a total order + tiebreaker: tieBreakerById, + }); + + // With tiebreaker, the order within age==25 is by id asc: b (id 'b'), then c (id 'c') + paginator.state.partialNext({ items: [a, b, c, d] }); + + const res = paginator.locateByItem(c); + expect(res.index).toBe(2); + // In this setting the insertionIndex is deterministic but not strictly needed when found + expect(res.insertionIndex).toBeGreaterThanOrEqual(2); + }); + + it('computes insertion at the beginning when needle sorts before all items', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + tiebreaker: tieBreakerById, + }); + paginator.state.partialNext({ items: [a, b, c, d] }); + + const z: TestItem = { id: 'z', age: 40, name: 'Z' }; // highest age → goes to front + const res = paginator.locateByItem(z); + expect(res.index).toBe(-1); + expect(res.insertionIndex).toBe(0); + }); + + it('computes insertion at the end when needle sorts after all items', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + tiebreaker: tieBreakerById, + }); + paginator.state.partialNext({ items: [a, b, c, d] }); + + const z: TestItem = { id: 'z', age: 10, name: 'Z' }; // lowest age → goes to end + const res = paginator.locateByItem(z); + expect(res.index).toBe(-1); + expect(res.insertionIndex).toBe(4); + }); + + it('checks both immediate neighbors before plateau scan (fast path)', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + paginator.state.partialNext({ items: [a, b, c, d] }); + + // needle equal to left neighbor of insertionIndex + const resLeftNeighbor = paginator.locateByItem(c); + expect(resLeftNeighbor.index).toBe(2); + + // needle equal to right neighbor (craft by duplicating c’s sort but different id not present) + const y: TestItem = { id: 'y', age: 25, name: 'Y' }; + const resRightNeighbor = paginator.locateByItem(y); + expect(resRightNeighbor.index).toBe(-1); + expect(resRightNeighbor.insertionIndex).toBe(3); + }); + }); + + describe('findItem', () => { + const a: TestItem = { id: 'a', age: 30 }; + const b: TestItem = { id: 'b', age: 25 }; + const c: TestItem = { id: 'c', age: 25 }; + const d: TestItem = { id: 'd', age: 20 }; + + it('returns the exact item instance when present', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + paginator.state.partialNext({ items: [a, b, c, d] }); + + // Same identity object: + expect(paginator.findItem(c)).toBe(c); + + // Same identity by id but different object reference still matches by locateByItem: + const cClone = { ...c }; + expect(paginator.findItem(cClone)).toBe(c); + }); + + it('returns undefined when not present', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + paginator.state.partialNext({ items: [a, b, d] }); + + const needle: TestItem = { id: 'x', age: 25 }; + expect(paginator.findItem(needle)).toBeUndefined(); + }); + + it('works with an ID tie-breaker comparator as well', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + tiebreaker: (l: TestItem, r: TestItem) => + l.id < r.id ? -1 : l.id > r.id ? 1 : 0, + }); + paginator.state.partialNext({ items: [a, b, c, d] }); + + expect(paginator.findItem(c)).toBe(c); + const x: TestItem = { id: 'x', age: 25 }; + expect(paginator.findItem(x)).toBeUndefined(); + }); + + it('handles empty list', () => { + const paginator = new Paginator(); + expect(paginator.findItem({ id: 'z' })).toBeUndefined(); + }); + }); + + describe('filter resolvers', () => { + const resolvers1 = [{ matchesField: () => true, resolve: () => 'abc' }]; + const resolvers2 = [ + { matchesField: () => false, resolve: () => 'efg' }, + { matchesField: () => true, resolve: () => 'hij' }, + ]; + it('get overridden with setFilterResolvers', () => { + const paginator = new Paginator(); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(0); + + paginator.setFilterResolvers(resolvers1); + + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(resolvers1.length); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toStrictEqual(resolvers1); + + paginator.setFilterResolvers(resolvers2); + + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(resolvers2.length); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toStrictEqual(resolvers2); + + paginator.setFilterResolvers([]); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(0); + }); + + it('get expanded with addFilterResolvers', () => { + const paginator = new Paginator(); + paginator.addFilterResolvers(resolvers1); + + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toStrictEqual(resolvers1); + + paginator.addFilterResolvers(resolvers2); + + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toStrictEqual([ + ...resolvers1, + ...resolvers2, + ]); + + paginator.addFilterResolvers([]); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toStrictEqual([ + ...resolvers1, + ...resolvers2, + ]); + }); + }); + }); }); diff --git a/test/unit/pagination/ChannelPaginator.test.ts b/test/unit/pagination/ChannelPaginator.test.ts new file mode 100644 index 0000000000..b33b18a8bc --- /dev/null +++ b/test/unit/pagination/ChannelPaginator.test.ts @@ -0,0 +1,441 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + Channel, + type ChannelFilters, + ChannelOptions, + ChannelPaginator, + ChannelSort, + DEFAULT_PAGINATION_OPTIONS, + type FilterBuilderGenerators, + type StreamChat, +} from '../../../src'; +import { getClientWithUser } from '../test-utils/getClient'; +import type { FieldToDataResolver } from '../../../src/pagination/types.normalization'; + +const user = { id: 'custom-id' }; + +describe('ChannelPaginator', () => { + let client: StreamChat; + let channel1: Channel; + let channel2: Channel; + + beforeEach(() => { + client = getClientWithUser(user); + + channel1 = new Channel(client, 'type', 'id1', {}); + channel1.state.last_message_at = new Date('1972-01-01T08:39:35.235Z'); + channel1.data!.updated_at = '1972-01-01T08:39:35.235Z'; + + channel2 = new Channel(client, 'type', 'id1', {}); + channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); + channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; + }); + + it('initiates with defaults', () => { + const paginator = new ChannelPaginator({ client }); + expect(paginator.pageSize).toBe(DEFAULT_PAGINATION_OPTIONS.pageSize); + expect(paginator.state.getLatestValue()).toEqual({ + hasNext: true, + hasPrev: true, + isLoading: false, + items: undefined, + lastQueryError: undefined, + cursor: undefined, + offset: 0, + }); + expect(paginator.id.startsWith('channel-paginator')).toBeTruthy(); + expect(paginator.sortComparator).toBeDefined(); + + channel1.state.last_message_at = new Date('1970-01-01T08:39:35.235Z'); + channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; + + channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); + channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; + + expect(paginator.sortComparator(channel1, channel2)).toBe(1); // channel2 comes before channel1 + expect(paginator.filterBuilder.buildFilters()).toStrictEqual({}); + expect( + paginator.filterBuilder.buildFilters({ baseFilters: paginator.filters }), + ).toStrictEqual({}); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(4); + }); + + it('initiates with options', () => { + const customId = 'custom-id'; + const filterGenerators: FilterBuilderGenerators = { + custom: { + enabled: true, + generate: (context) => context, + }, + }; + const initialFilterBuilderContext = { x: 'y' }; + + channel1.data!.created_at = '1970-01-01T08:39:35.235Z'; + channel2.data!.created_at = '1971-01-01T08:39:35.235Z'; + + const paginator = new ChannelPaginator({ + client, + id: customId, + filterBuilderOptions: { + initialContext: initialFilterBuilderContext, + initialFilterConfig: filterGenerators, + }, + filters: { type: 'type' }, + paginatorOptions: { pageSize: 2 }, + requestOptions: { member_limit: 5 }, + sort: { created_at: 1 }, + }); + expect(paginator.pageSize).toBe(2); + expect(paginator.state.getLatestValue()).toEqual({ + hasNext: true, + hasPrev: true, + isLoading: false, + items: undefined, + lastQueryError: undefined, + cursor: undefined, + offset: 0, + }); + expect(paginator.id.startsWith(customId)).toBeTruthy(); + + expect(paginator.sortComparator(channel1, channel2)).toBe(-1); // channel1 comes before channel2 + expect(paginator.filterBuilder.buildFilters()).toStrictEqual({ + ...initialFilterBuilderContext, + }); + expect( + paginator.filterBuilder.buildFilters({ baseFilters: paginator.filters }), + ).toStrictEqual({ + type: 'type', + ...initialFilterBuilderContext, + }); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(4); + }); + + describe('sortComparator', () => { + const changeOrder = 1; + const keepOrder = -1; + it('should sort be default sort', () => { + const paginator = new ChannelPaginator({ client }); + expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); + + channel1.state.last_message_at = new Date('1970-01-01T08:39:35.235Z'); + channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; + + channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); + channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; + + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + }); + + it('should sort by non-existent attribute', () => { + const paginator = new ChannelPaginator({ client, sort: { created_at: 1 } }); + expect(paginator.sortComparator(channel1, channel2)).toBe(0); + }); + + it('should sort by attribute with the same values', () => { + const paginator = new ChannelPaginator({ client, sort: { created_at: 1 } }); + channel1.data!.created_at = '1971-01-01T08:39:35.235Z'; + channel2.data!.created_at = '1971-01-01T08:39:35.235Z'; + expect(paginator.sortComparator(channel1, channel2)).toBe(0); + }); + + it('should sort by created_at', () => { + const paginator = new ChannelPaginator({ client, sort: { created_at: 1 } }); + channel1.data!.created_at = '1972-01-01T08:39:35.235Z'; + channel2.data!.created_at = '1971-01-01T08:39:35.235Z'; + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + }); + it('should sort by has_unread', () => { + const paginator = new ChannelPaginator({ client, sort: { has_unread: 1 } }); + channel1.state.read[user.id] = { + last_read: new Date('1972-01-01T08:39:35.235Z'), + unread_messages: 10, + user, + }; + channel2.state.read[user.id] = { + last_read: new Date('1972-01-01T08:39:35.235Z'), + unread_messages: 0, + user, + }; + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + }); + it('should sort by last_message_at', () => { + const paginator = new ChannelPaginator({ client, sort: { last_message_at: 1 } }); + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + }); + it('should sort by last_updated', () => { + const paginator = new ChannelPaginator({ client, sort: { last_updated: 1 } }); + + // compares channel1.state.last_message_at with channel2.data!.updated_at + channel1.state.last_message_at = new Date('1975-01-01T08:39:35.235Z'); + channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; + channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); + channel2.data!.updated_at = '1973-01-01T08:39:35.235Z'; + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + + // compares channel2.state.last_message_at with channel1.data!.updated_at + channel1.state.last_message_at = new Date('1975-01-01T08:39:35.235Z'); + channel1.data!.updated_at = '1976-01-01T08:39:35.235Z'; + channel2.state.last_message_at = new Date('1978-01-01T08:39:35.235Z'); + channel2.data!.updated_at = '1973-01-01T08:39:35.235Z'; + expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); + }); + it('should sort by member_count', () => { + const paginator = new ChannelPaginator({ client, sort: { member_count: 1 } }); + channel1.data!.member_count = 2; + channel2.data!.member_count = 1; + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + }); + it('should sort by pinned_at', () => { + const paginator = new ChannelPaginator({ client, sort: { pinned_at: 1 } }); + channel1.state.membership = { pinned_at: '1972-01-01T08:39:35.235Z' }; + channel2.state.membership = { pinned_at: '1971-01-01T08:39:35.235Z' }; + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + + channel1.state.membership = { pinned_at: '1970-01-01T08:39:35.235Z' }; + channel2.state.membership = { pinned_at: '1971-01-01T08:39:35.235Z' }; + expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); + }); + it('should sort by unread_count', () => { + const paginator = new ChannelPaginator({ client, sort: { unread_count: 1 } }); + channel1.state.read[user.id] = { + last_read: new Date(), + unread_messages: 10, + user, + }; + channel2.state.read[user.id] = { + last_read: new Date(), + unread_messages: 0, + user, + }; + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + + channel1.state.read[user.id] = { + last_read: new Date(), + unread_messages: 10, + user, + }; + channel2.state.read[user.id] = { + last_read: new Date(), + unread_messages: 11, + user, + }; + expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); + }); + it('should sort by updated_at', () => { + const paginator = new ChannelPaginator({ client, sort: { updated_at: 1 } }); + + channel1.data!.updated_at = '1972-01-01T08:39:35.235Z'; + channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + + channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; + channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; + expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); + }); + it('should sort by custom field', () => { + // @ts-expect-error using field not declared among CustomChannelData + const paginator = new ChannelPaginator({ client, sort: { customField: 1 } }); + + // @ts-expect-error using field not declared among CustomChannelData + channel1.data!.customField = 'B'; + // @ts-expect-error using field not declared among CustomChannelData + channel2.data!.customField = 'A'; + expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); + + // @ts-expect-error using field not declared among CustomChannelData + channel1.data!.customField = 'A'; + // @ts-expect-error using field not declared among CustomChannelData + channel2.data!.customField = 'B'; + expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); + }); + }); + + describe('filter resolvers', () => { + it('resolves "pinned" field', () => { + const paginator = new ChannelPaginator({ + client, + filters: { members: { $in: [user.id] }, pinned: true }, + }); + + channel1.state.members = { + [user.id]: { user }, + ['other-member']: { user: { id: 'other-member' } }, + }; + + channel1.state.membership = { + user, + pinned_at: '2025-09-03T12:19:39.101089Z', + }; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + channel1.state.membership = { + user, + pinned_at: undefined, + }; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + + it('resolves "members" field', () => { + const paginator = new ChannelPaginator({ + client, + filters: { members: { $in: [user.id] } }, + }); + channel1.state.members = { + [user.id]: { user }, + ['other-member']: { user: { id: 'other-member' } }, + }; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + channel1.state.members = { + ['other-member']: { user: { id: 'other-member' } }, + }; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + + it('resolves "member.user.name" field', () => { + const paginator = new ChannelPaginator({ + client, + filters: { 'member.user.name': { $autocomplete: '-' } }, + }); + channel1.state.members = { + [user.id]: { user: { ...user, name: 'name' } }, + ['other-member']: { user: { id: 'other-member', name: 'na-me' } }, + }; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + channel1.state.members = { + [user.id]: { user: { ...user, name: 'name' } }, + }; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + + it('resolves ChannelResponse fields', () => { + const paginator = new ChannelPaginator({ client, filters: { blocked: true } }); + channel1.data!.blocked = true; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + channel1.data!.blocked = false; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + + it('resolves custom fields stored in channel.data', () => { + const paginator = new ChannelPaginator({ + client, + // @ts-expect-error declaring custom property field in filter + filters: { x: { $contains: 'specific' } }, + }); + // @ts-expect-error using undeclared custom property + channel1.data!.x = ['a', 'b', 'specific']; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + // @ts-expect-error using undeclared custom property + channel1.data!.x = undefined; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + + it('overrides filter resolvers', () => { + const resolver: FieldToDataResolver = { + matchesField: (field) => field === 'custom.nested', + resolve: (item, field) => { + // @ts-expect-error accessing undeclared custom property + return item.data!.custom?.nested; + }, + }; + + const paginator = new ChannelPaginator({ + client, + // @ts-expect-error using undeclared custom property + filters: { 'custom.nested': { $eq: 'x' } }, + }); + paginator.setFilterResolvers([resolver]); + + // @ts-expect-error using undeclared custom property + channel1.data!.custom = { nested: 'x' }; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + // @ts-expect-error using undeclared custom property + channel1.data!.custom = { nested: 'y' }; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + }); + + describe('setters', () => { + const stateAfterQuery = { + items: [channel1, channel2], + hasNext: false, + hasPrev: false, + offset: 10, + isLoading: false, + lastQueryError: undefined, + cursor: undefined, + }; + it('filters reset state', () => { + const paginator = new ChannelPaginator({ client }); + paginator.state.partialNext(stateAfterQuery); + expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + paginator.filters = {}; + expect(paginator.state.getLatestValue()).toStrictEqual(paginator.initialState); + }); + it('sort reset state', () => { + const paginator = new ChannelPaginator({ client }); + paginator.state.partialNext(stateAfterQuery); + expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + paginator.sort = {}; + expect(paginator.state.getLatestValue()).toStrictEqual(paginator.initialState); + }); + it('options reset state', () => { + const paginator = new ChannelPaginator({ client }); + paginator.state.partialNext(stateAfterQuery); + expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + paginator.options = {}; + expect(paginator.state.getLatestValue()).toStrictEqual(paginator.initialState); + }); + }); + + describe('query', () => { + it('is called with correct parameters', async () => { + const queryChannelsSpy = vi.spyOn(client, 'queryChannels').mockResolvedValue([]); + const filters: ChannelFilters = { name: 'A' }; + const sort: ChannelSort = { has_unread: -1 }; + const requestOptions: ChannelOptions = { message_limit: 3 }; + const paginator = new ChannelPaginator({ + client, + filters, + sort, + requestOptions, + filterBuilderOptions: { + initialFilterConfig: { + custom: { + enabled: true, + generate: (context: { num?: number }) => ({ + muted: { $eq: !!context.num }, + }), + }, + }, + initialContext: { num: 5 }, + }, + paginatorOptions: { pageSize: 22 }, + }); + + await paginator.query(); + expect(queryChannelsSpy).toHaveBeenCalledWith( + { + muted: { + $eq: true, + }, + name: 'A', + }, + { + has_unread: -1, + }, + { + limit: 22, + message_limit: 3, + offset: 0, + }, + ); + }); + }); +}); diff --git a/test/unit/pagination/FilterBuilder.test.ts b/test/unit/pagination/FilterBuilder.test.ts index 7be4dfb3fe..2935b4bf15 100644 --- a/test/unit/pagination/FilterBuilder.test.ts +++ b/test/unit/pagination/FilterBuilder.test.ts @@ -4,7 +4,7 @@ import { FilterBuilderGenerators, ExtendedQueryFilter, ExtendedQueryFilters, -} from '../../../src/pagination/FilterBuilder'; +} from '../../../src'; type BasicFilterFieldsSchema = { name: ExtendedQueryFilter; diff --git a/test/unit/pagination/filterCompiler.test.ts b/test/unit/pagination/filterCompiler.test.ts new file mode 100644 index 0000000000..38f96b6e11 --- /dev/null +++ b/test/unit/pagination/filterCompiler.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, it } from 'vitest'; +import { + ChannelData, + ChannelMemberResponse, + ChannelResponse, + ContainsOperator, + PrimitiveFilter, + QueryFilter, + QueryFilters, + RequireOnlyOne, +} from '../../../src'; +import { + itemMatchesFilter, + ItemMatchesFilterOptions, +} from '../../../src/pagination/filterCompiler'; +import { resolveDotPathValue } from '../../../src/pagination/utility.normalization'; + +type CustomChannelData = { + custom1?: string[]; + custom2?: string; + custom3?: number; + custom4?: boolean; + custom5?: string; + data?: { + members: ChannelMemberResponse[]; + }; + name?: string; +}; +type CustomChannelFilters = QueryFilters< + ContainsOperator> & { + archived?: boolean; + 'member.user.name'?: + | RequireOnlyOne<{ + $autocomplete?: string; + $eq?: string; + }> + | string; + + members?: + | RequireOnlyOne, '$in'>> + | RequireOnlyOne, '$eq'>> + | PrimitiveFilter; + name?: + | RequireOnlyOne< + { + $autocomplete?: string; + } & QueryFilter + > + | PrimitiveFilter; + pinned?: boolean; + } & { + [Key in keyof Omit]: + | RequireOnlyOne> + | PrimitiveFilter; + } +>; + +type TestChannel = ChannelData & CustomChannelData; + +const filter: CustomChannelFilters = { + $or: [ + { + $and: [ + { custom1: { $contains: 'a' } }, + { custom2: { $eq: '5' } }, + { custom3: { $lt: 10 } }, + { custom4: { $eq: true } }, + ], + }, + { + $and: [ + { custom1: { $contains: 'b' } }, + { custom2: { $eq: '15' } }, + { custom3: { $lt: 10 } }, + { custom4: { $eq: false } }, + ], + }, + { + $or: [ + { name: { $autocomplete: 'ith' } }, + { name: { $autocomplete: 'Sm' } }, + { 'member.user.name': { $autocomplete: 'ack' } }, + { blocked: true }, + { custom2: { $eq: '5' } }, + { custom2: { $lt: '2020-08-26T11:09:07.814Z' } }, + { custom2: { $gt: '2022-08-26T11:09:07.814Z' } }, + { custom3: { $gt: 10 } }, + { custom4: { $exists: true } }, + { custom1: { $contains: 'b' } }, + { custom5: { $in: ['Rob', 'Bob'] } }, + ], + }, + ], +}; + +const options: ItemMatchesFilterOptions = { + resolvers: [ + { + matchesField: () => true, + resolve: (item, path) => resolveDotPathValue(item, path), + }, + ], +}; + +describe('itemMatchesFilter', () => { + it('determines that data do not match the filter', () => { + const item: TestChannel = {}; + expect(itemMatchesFilter(item, filter, options)).toBeFalsy(); + }); + + it('determines that data match a primitive filter', () => { + const item: TestChannel = { blocked: true }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match a primitive filter', () => { + const item: TestChannel = { blocked: undefined }; + expect(itemMatchesFilter(item, filter, options)).toBeFalsy(); + }); + + it('determines that data match the $eq filter', () => { + const item: TestChannel = { custom2: '5' }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match the $eq filter', () => { + const item: TestChannel = { custom2: '55' }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data match the $ne filter', () => { + const item: TestChannel = {}; + expect( + itemMatchesFilter(item, { name: { $ne: 'Channel Bob' } }, options), + ).toBeTruthy(); + }); + + it('determines that data do not match the $ne filter', () => { + const item: TestChannel = { name: 'Channel Bob' }; + expect( + itemMatchesFilter(item, { name: { $ne: 'Channel Bob' } }, options), + ).toBeFalsy(); + }); + + it('determines that data match the number comparison filter', () => { + const item: TestChannel = { custom3: 11 }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match the number comparison filter', () => { + const item: TestChannel = { custom3: 10 }; + expect(itemMatchesFilter(item, filter, options)).toBeFalsy(); + }); + + it('determines that data match the date comparison filter', () => { + const item: TestChannel = { custom2: '2020-08-26T11:09:07.714Z' }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match the date comparison filter', () => { + const item: TestChannel = { custom2: '2021-08-26T11:09:07.714Z' }; + expect(itemMatchesFilter(item, filter, options)).toBeFalsy(); + }); + + it('determines that data match the $exists filter', () => { + // @ts-expect-error custom4 does not match the TestChannel definition + const item: TestChannel = { custom4: ['a', '5'] }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match the $exists filter', () => { + // @ts-expect-error custom3 does not match the TestChannel definition + const item: TestChannel = { custom3: ['a', 5] }; + expect(itemMatchesFilter(item, filter, options)).toBeFalsy(); + }); + + it('determines that data match the $autocomplete filter', () => { + const item: TestChannel = { name: 'Smith' }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match the $autocomplete filter', () => { + const item: TestChannel = { name: 'it' }; + expect(itemMatchesFilter(item, filter, options)).toBeFalsy(); + }); + + it('determines that data match the $contains filter', () => { + const item: TestChannel = { custom1: ['a', 'b', 'c'] }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match the $contains filter', () => { + const item: TestChannel = { custom1: ['a', 'bb', 'c'] }; + expect(itemMatchesFilter(item, filter, options)).toBeFalsy(); + }); + + it('determines that data match the $in filter', () => { + const item: TestChannel = { custom5: 'Rob' }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match the $in filter', () => { + const item: TestChannel = { custom5: 'Ro' }; + expect(itemMatchesFilter(item, filter, options)).toBeFalsy(); + }); + + it('determines that data match the $nin filter', () => { + const item: TestChannel = { custom5: 'Ro' }; + expect( + itemMatchesFilter( + item, + { custom5: { $nin: ['Rob', 'Bob'] } }, + options, + ), + ).toBeTruthy(); + }); + + it('determines that data do not match the $nin filter', () => { + const item: TestChannel = { custom5: 'Rob' }; + expect( + itemMatchesFilter( + item, + { custom5: { $nin: ['Rob', 'Bob'] } }, + options, + ), + ).toBeFalsy(); + }); + + it('determines that data match the $and filter', () => { + const item: TestChannel = { + custom1: ['x', 'b', 'y'], + custom2: '15', + custom3: 9, + custom4: false, + }; + expect(itemMatchesFilter(item, filter, options)).toBeTruthy(); + }); + + it('determines that data do not match the $and filter', () => { + const item: TestChannel = { + custom1: ['x', 'b', 'y'], + custom2: '15', + custom3: 10, + custom4: false, + }; + const andFilters = filter.$or!.slice(0, 2); + // @ts-ignore + expect( + itemMatchesFilter(item, { $or: andFilters }, options), + ).toBeFalsy(); + }); + + it('determines that data match the $nor filter', () => { + const item: TestChannel = { + custom1: ['x', 'y'], + // @ts-expect-error custom2 does not match the TestChannel definition + custom2: { a: 'b' }, + // @ts-expect-error custom3 does not match the TestChannel definition + custom3: true, + custom4: false, + }; + expect( + itemMatchesFilter(item, { $nor: filter.$or }, options), + ).toBeTruthy(); + }); + + it('determines that data do not match the $nor filter', () => { + // matches the 2nd $and + const item: TestChannel = { + custom1: ['x', 'b', 'y'], + custom2: '15', + custom3: 9, + custom4: false, + }; + expect( + itemMatchesFilter(item, { $nor: filter.$or }, options), + ).toBeFalsy(); + }); + + it('determines that data match filter by property dot path', () => { + const item: TestChannel = { + data: { + members: [ + { user: { id: '1', name: 'Jack' } }, + { user: { id: '2', name: 'Bob' } }, + { user: { id: '3', name: 'Mark' } }, + ], + }, + }; + + expect( + itemMatchesFilter( + item, + { 'member.user.name': { $autocomplete: 'rk' } }, + { + resolvers: [ + { + matchesField: (field) => field === 'member.user.name', + resolve: (item) => { + return item.data?.members.map(({ user }) => user?.name) ?? []; + }, + }, + ], + }, + ), + ).toBeTruthy(); + }); + + it('determines that data match filter by $eq: array', () => { + const item: TestChannel = { + data: { + members: [ + { user: { id: '123', name: 'Jack' } }, + { user: { id: '234', name: 'Bob' } }, + { user: { id: '345', name: 'Mark' } }, + ], + }, + }; + + // has to match all the ids + expect( + itemMatchesFilter( + item, + { members: { $eq: ['345', '123', '234'] } }, + { + resolvers: [ + { + matchesField: (field) => field === 'members', + resolve: (item) => { + return item.data?.members.map(({ user }) => user?.id) ?? []; + }, + }, + ], + }, + ), + ).toBeTruthy(); + }); + + it('determines that data do not match filter by $eq: array', () => { + const item: TestChannel = { + data: { + members: [ + { user: { id: '123', name: 'Jack' } }, + { user: { id: '234', name: 'Bob' } }, + { user: { id: '345', name: 'Mark' } }, + ], + }, + }; + + // one id is missing + expect( + itemMatchesFilter( + item, + { members: { $eq: ['123', '234'] } }, + { + resolvers: [ + { + matchesField: (field) => field === 'members', + resolve: (item) => { + return item.data?.members.map(({ user }) => user?.id) ?? []; + }, + }, + ], + }, + ), + ).toBeFalsy(); + }); +}); diff --git a/test/unit/pagination/sortCompiler.test.ts b/test/unit/pagination/sortCompiler.test.ts new file mode 100644 index 0000000000..500ab3eafd --- /dev/null +++ b/test/unit/pagination/sortCompiler.test.ts @@ -0,0 +1,267 @@ +// sortCompiler.spec.ts +import { describe, it, expect } from 'vitest'; +import { + binarySearchInsertIndex, + makeComparator, +} from '../../../src/pagination/sortCompiler'; +import { resolveDotPathValue as defaultResolvePathValue } from '../../../src/pagination/utility.normalization'; +import type { AscDesc } from '../../../src'; + +// Minimal item type for tests +type Item = { + cid: string; // tie-breaker field (default tiebreak compares by cid) + v?: unknown; // primary field for many tests + nested?: { x?: unknown }; // nested field for dot-path tests +}; + +// Small utility: sort a shallow copy and return cids to verify ordering +function orderByComparator(items: Item[], cmp: (a: Item, b: Item) => number): string[] { + return [...items].sort(cmp).map((i) => i.cid); +} + +/** + * Helper to build a comparator with optional resolvePathValue override. + */ +function toComparator( + sort: Record | Array>, + resolvePathValue = defaultResolvePathValue, +) { + return makeComparator | Array>>({ + sort, + resolvePathValue, + }); +} + +describe('makeComparator', () => { + it('sorts numbers ascending/descending', () => { + const items: Item[] = [ + { cid: 'c', v: 10 }, + { cid: 'a', v: 2 }, + { cid: 'b', v: 2 }, // equal to test tie-breaker by cid + { cid: 'd', v: 100 }, + ]; + + const asc = toComparator({ v: 1 }); + expect(orderByComparator(items, asc)).toEqual(['a', 'b', 'c', 'd']); + + const desc = toComparator({ v: -1 }); + expect(orderByComparator(items, desc)).toEqual(['d', 'c', 'a', 'b']); + }); + + it('sorts strings ascending/descending with tie-break on cid', () => { + const items: Item[] = [ + { cid: '2', v: 'beta' }, + { cid: '1', v: 'alpha' }, + { cid: '4', v: 'alpha' }, // same string as cid=1; tie-break by cid + { cid: '3', v: 'gamma' }, + ]; + + const asc = toComparator({ v: 1 }); + expect(orderByComparator(items, asc)).toEqual(['1', '4', '2', '3']); + + const desc = toComparator({ v: -1 }); + expect(orderByComparator(items, desc)).toEqual(['3', '2', '1', '4']); + }); + + it('sorts booleans (false < true)', () => { + const items: Item[] = [ + { cid: 'c', v: true }, + { cid: 'a', v: false }, + { cid: 'b', v: false }, + ]; + + const asc = toComparator({ v: 1 }); + expect(orderByComparator(items, asc)).toEqual(['a', 'b', 'c']); + + const desc = toComparator({ v: -1 }); + expect(orderByComparator(items, desc)).toEqual(['c', 'a', 'b']); + }); + + it('sorts dates (Date objects) descending', () => { + const items: Item[] = [ + { cid: 'a', v: new Date('2023-01-01T00:00:00Z') }, + { cid: 'b', v: new Date('2024-01-01T00:00:00Z') }, + { cid: 'c', v: new Date('2022-06-15T00:00:00Z') }, + ]; + + const asc = toComparator({ v: 1 }); + expect(orderByComparator(items, asc)).toEqual(['c', 'a', 'b']); + + const desc = toComparator({ v: -1 }); + expect(orderByComparator(items, desc)).toEqual(['b', 'a', 'c']); + }); + + it('sorts dates given as ISO strings equivalently to Date objects', () => { + const items: Item[] = [ + { cid: 'a', v: '2023-01-01T00:00:00Z' }, + { cid: 'b', v: '2024-01-01T00:00:00Z' }, + { cid: 'c', v: '2022-06-15T00:00:00Z' }, + ]; + + const asc = toComparator({ v: 1 }); + expect(orderByComparator(items, asc)).toEqual(['c', 'a', 'b']); + + const desc = toComparator({ v: -1 }); + expect(orderByComparator(items, desc)).toEqual(['b', 'a', 'c']); + }); + + it('sorts dates given as epoch ms (numbers) equivalently', () => { + const items: Item[] = [ + { cid: 'a', v: Date.parse('2023-01-01T00:00:00Z') }, + { cid: 'b', v: Date.parse('2024-01-01T00:00:00Z') }, + { cid: 'c', v: Date.parse('2022-06-15T00:00:00Z') }, + ]; + + const asc = toComparator({ v: 1 }); + expect(orderByComparator(items, asc)).toEqual(['c', 'a', 'b']); + + const desc = toComparator({ v: -1 }); + expect(orderByComparator(items, desc)).toEqual(['b', 'a', 'c']); + }); + + it('uses resolvePathValue for nested paths', () => { + const items: Item[] = [ + { cid: 'a', nested: { x: 100 } }, + { cid: 'b', nested: { x: 50 } }, + { cid: 'c', nested: { x: 75 } }, + ]; + + const cmp = toComparator({ 'nested.x': 1 }); + expect(orderByComparator(items, cmp)).toEqual(['b', 'c', 'a']); + }); + + it('applies multi-field sorting in order (then uses cid tiebreaker)', () => { + const items: Item[] = [ + { cid: '3', v: 1, nested: { x: 5 } }, + { cid: '1', v: 1, nested: { x: 10 } }, + { cid: '2', v: 1, nested: { x: 10 } }, + { cid: '4', v: 2, nested: { x: 0 } }, + ]; + + // First by v asc, then nested.x desc; if both equal, tie-break by cid asc + const cmp = toComparator([{ v: 1 }, { 'nested.x': -1 }]); + expect(orderByComparator(items, cmp)).toEqual(['1', '2', '3', '4']); + }); + + it('fallback ordering: null/undefined come last (ascending) and first (descending)', () => { + const items: Item[] = [ + { cid: 'a', v: 10 }, + { cid: 'b', v: undefined }, + { cid: 'c', v: null }, + { cid: 'd', v: 5 }, + ]; + + const asc = toComparator({ v: 1 }); + expect(orderByComparator(items, asc)).toEqual(['d', 'a', 'b', 'c']); // null/undefined last + + const desc = toComparator({ v: -1 }); + expect(orderByComparator(items, desc)).toEqual(['b', 'c', 'a', 'd']); // null/undefined first + }); + + it('applies custom tiebreaker when provided', () => { + const items: Item[] = [ + { cid: 'b', v: 1 }, + { cid: 'a', v: 1 }, + { cid: 'c', v: 1 }, + ]; + + const customTiebreaker = (l: Item, r: Item) => r.cid.localeCompare(l.cid); + + const cmp = makeComparator>({ + sort: { v: 1 }, // all v equal + resolvePathValue: defaultResolvePathValue, + tiebreaker: customTiebreaker, + }); + + expect(orderByComparator(items, cmp)).toEqual(['c', 'b', 'a']); + }); + + it('accepts array sort spec and object sort spec equivalently', () => { + const items: Item[] = [ + { cid: '3', v: 2 }, + { cid: '1', v: 1 }, + { cid: '2', v: 1 }, + ]; + + const arrayBasedComparator = toComparator([{ v: 1 }]); + const objectBasedComparator = toComparator({ v: 1 }); + + expect(orderByComparator(items, arrayBasedComparator)).toEqual(['1', '2', '3']); + expect(orderByComparator(items, objectBasedComparator)).toEqual(['1', '2', '3']); + }); +}); + +describe('binarySearchInsertIndex', () => { + it('inserts at beginning, middle, and end as expected', () => { + const items: Item[] = [ + { cid: 'a', v: 10 }, + { cid: 'b', v: 20 }, + { cid: 'c', v: 30 }, + { cid: 'd', v: 40 }, + ]; + const cmp = toComparator({ v: 1 }); + + // Insert before all + let index = binarySearchInsertIndex({ + sortedArray: items, + needle: { cid: 'x', v: 5 }, + compare: cmp, + }); + expect(index).toBe(0); + + // Insert in the middle + index = binarySearchInsertIndex({ + sortedArray: items, + needle: { cid: 'y', v: 25 }, + compare: cmp, + }); + expect(index).toBe(2); // between 20 and 30 + + // Insert after all + index = binarySearchInsertIndex({ + sortedArray: items, + needle: { cid: 'z', v: 50 }, + compare: cmp, + }); + expect(index).toBe(4); + }); + + it('inserts after equal values block (stable position after equals)', () => { + const items: Item[] = [ + { cid: 'a', v: 10 }, + { cid: 'b', v: 10 }, + { cid: 'c', v: 10 }, + ]; + const cmp = toComparator({ v: 1 }); + + const index = binarySearchInsertIndex({ + sortedArray: items, + needle: { cid: 'x', v: 10 }, + compare: cmp, + }); + + // By design, our binary search returns the first position where existing > needle. + // For equals, it advances to the right of the equal block. + expect(index).toBe(3); + }); + + it('respects multi-field comparator (e.g., secondary key decides insertion point)', () => { + const items: Item[] = [ + { cid: '2', v: 1, nested: { x: 5 } }, + { cid: '1', v: 1, nested: { x: 10 } }, // comes earlier due to nested.x desc + { cid: '3', v: 2, nested: { x: 0 } }, + ]; + const cmp = toComparator([{ v: 1 }, { 'nested.x': -1 }]); + + // Needle with same v=1 but nested.x=7 should go between cid=1 (x=10) and cid=2 (x=5) + const index = binarySearchInsertIndex({ + sortedArray: orderByComparator(items, cmp).map( + (cid) => items.find((i) => i.cid === cid)!, + ) as Item[], + needle: { cid: 'x', v: 1, nested: { x: 7 } }, + compare: cmp, + }); + + expect(index).toBe(1); // after the 10, before the 5 + }); +}); From 65fd6cded496d14eaa462ca60103844c754b28d0 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 15 Sep 2025 16:09:24 +0200 Subject: [PATCH 02/48] feat: allow to boost paginator items and lock item order --- src/ChannelPaginatorsOrchestrator.ts | 74 +-- src/pagination/BasePaginator.ts | 128 ++++- .../ChannelPaginatorsOrchestrator.test.ts | 122 ++++- test/unit/pagination/BasePaginator.test.ts | 459 ++++++++++++++++-- 4 files changed, 680 insertions(+), 103 deletions(-) diff --git a/src/ChannelPaginatorsOrchestrator.ts b/src/ChannelPaginatorsOrchestrator.ts index 481eba2029..6eceb7b1a7 100644 --- a/src/ChannelPaginatorsOrchestrator.ts +++ b/src/ChannelPaginatorsOrchestrator.ts @@ -13,7 +13,7 @@ import type { import { getChannel } from './pagination/utility.queryChannel'; import type { Channel } from './channel'; -type ChannelPaginatorsOrchestratorEventHandlerContext = { +export type ChannelPaginatorsOrchestratorEventHandlerContext = { orchestrator: ChannelPaginatorsOrchestrator; }; @@ -73,12 +73,20 @@ const updateLists: EventHandlerPipelineHandler< if (!channel) return; - // todo: can these state updates be made atomic across all the paginators? - // maybe we could add to state store API that would allow to queue changes and then commit? orchestrator.paginators.forEach((paginator) => { if (paginator.matchesFilter(channel)) { - // todo: does it make sense to move channel at the top of the items array (original implementation) - // if items are supposed to be ordered by the sort object? + const channelBoost = paginator.getBoost(channel.cid); + if ( + [ + 'message.new', + 'notification.message_new', + 'notification.added_to_channel', + 'channel.visible', + ].includes(event.type) && + (!channelBoost || channelBoost.seq < paginator.maxBoostSeq) + ) { + paginator.boost(channel.cid, { seq: paginator.maxBoostSeq + 1 }); + } paginator.ingestItem(channel); } else { // remove if it does not match the filter anymore @@ -87,20 +95,13 @@ const updateLists: EventHandlerPipelineHandler< }); }; -// todo: we have to make sure that client.activeChannels is always up-to-date +// we have to make sure that client.activeChannels is always up-to-date const channelDeletedHandler: LabeledEventHandler = { handle: removeItem, id: 'ChannelPaginatorsOrchestrator:default-handler:channel.deleted', }; -// fixme: is it ok, remove item just because its property hidden is switched to hidden: true? What about offset cursor, should we update it? -const channelHiddenHandler: LabeledEventHandler = - { - handle: removeItem, - id: 'ChannelPaginatorsOrchestrator:default-handler:channel.hidden', - }; - // fixme: this handler should not be handled by the orchestrator but as Channel does not have reactive state, // we need to re-emit the whole list to reflect the changes const channelUpdatedHandler: LabeledEventHandler = @@ -188,7 +189,7 @@ export type ChannelPaginatorsOrchestratorState = { paginators: ChannelPaginator[]; }; -type EventHandlers = Partial< +export type ChannelPaginatorsOrchestratorEventHandlers = Partial< Record< SupportedEventType, LabeledEventHandler[] @@ -198,7 +199,7 @@ type EventHandlers = Partial< export type ChannelPaginatorsOrchestratorOptions = { client: StreamChat; paginators?: ChannelPaginator[]; - eventHandlers?: EventHandlers; + eventHandlers?: ChannelPaginatorsOrchestratorEventHandlers; }; export class ChannelPaginatorsOrchestrator extends WithSubscriptions { @@ -209,19 +210,19 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { EventHandlerPipeline >(); - protected static readonly defaultEventHandlers: EventHandlers = { - 'channel.deleted': [channelDeletedHandler], - 'channel.hidden': [channelHiddenHandler], - 'channel.updated': [channelUpdatedHandler], - 'channel.truncated': [channelTruncatedHandler], - '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: ChannelPaginatorsOrchestratorEventHandlers = + { + 'channel.deleted': [channelDeletedHandler], + 'channel.updated': [channelUpdatedHandler], + 'channel.truncated': [channelTruncatedHandler], + '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, @@ -242,13 +243,17 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { return this.state.getLatestValue().paginators; } + private get ctx(): ChannelPaginatorsOrchestratorEventHandlerContext { + return { orchestrator: 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(): EventHandlers { + static getDefaultHandlers(): ChannelPaginatorsOrchestratorEventHandlers { const src = ChannelPaginatorsOrchestrator.defaultEventHandlers; - const out: EventHandlers = {}; + const out: ChannelPaginatorsOrchestratorEventHandlers = {}; for (const [type, handlers] of Object.entries(src)) { if (!handlers) continue; out[type as SupportedEventType] = [...handlers]; @@ -321,7 +326,10 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { return pipe; } - private get ctx(): ChannelPaginatorsOrchestratorEventHandlerContext { - return { orchestrator: this }; - } + reload = async () => + await Promise.allSettled( + this.paginators.map(async (paginator) => { + await paginator.reload(); + }), + ); } diff --git a/src/pagination/BasePaginator.ts b/src/pagination/BasePaginator.ts index 8c40930f0f..18e96f0877 100644 --- a/src/pagination/BasePaginator.ts +++ b/src/pagination/BasePaginator.ts @@ -35,16 +35,19 @@ export type PaginatorState = { export type PaginatorOptions = { /** The number of milliseconds to debounce the search query. The default interval is 300ms. */ debounceMs?: number; + /** Will prevent changing the index of existing items */ + lockItemOrder?: boolean; pageSize?: number; }; export const DEFAULT_PAGINATION_OPTIONS: Required = { debounceMs: 300, + lockItemOrder: false, pageSize: 10, } as const; export abstract class BasePaginator { state: StateStore>; - pageSize: number; + config: Required; protected _executeQueryDebounced!: DebouncedExecQueryFunction; protected _isCursorPagination = false; /** @@ -72,10 +75,16 @@ export abstract class BasePaginator { * @protected */ protected _filterFieldToDataResolvers: FieldToDataResolver[]; + /** + * Ephemeral priority for attention UX without breaking sort invariants + * @protected + */ + protected boosts = new Map(); + protected _maxBoostSeq: number = 0; protected constructor(options?: PaginatorOptions) { - const { debounceMs, pageSize } = { ...DEFAULT_PAGINATION_OPTIONS, ...options }; - this.pageSize = pageSize; + this.config = { ...DEFAULT_PAGINATION_OPTIONS, ...options }; + const { debounceMs } = this.config; this.state = new StateStore>(this.initialState); this.setDebounceOptions({ debounceMs }); this.sortComparator = noOrderChange; @@ -126,6 +135,19 @@ export abstract class BasePaginator { return this.state.getLatestValue().offset; } + get pageSize() { + return this.config.pageSize; + } + + /** Single point of truth: always use the effective comparator */ + get effectiveComparator() { + return this.boostComparator; + } + + get maxBoostSeq() { + return this._maxBoostSeq; + } + abstract query(params: PaginationQueryParams): Promise>; abstract filterQueryResults(items: T[]): T[] | Promise; @@ -149,15 +171,77 @@ export abstract class BasePaginator { }); } + protected clearExpiredBoosts(now = Date.now()) { + for (const [id, b] of this.boosts) if (now > b.until) this.boosts.delete(id); + this._maxBoostSeq = Math.max( + ...Array.from(this.boosts.values()).map((boost) => boost.seq), + 0, + ); + } + + /** Comparator that consults boosts first, then falls back to sortComparator */ + protected boostComparator = (a: T, b: T): number => { + const now = Date.now(); + this.clearExpiredBoosts(now); + + const idA = this.getItemId(a); + const idB = this.getItemId(b); + const boostA = this.getBoost(idA); + const boostB = this.getBoost(idB); + + const aIsBoosted = !!(boostA && now <= boostA.until); + const bIsBoosted = !!(boostB && now <= boostB.until); + + if (aIsBoosted && !bIsBoosted) return -1; + if (!aIsBoosted && bIsBoosted) return 1; + + if (aIsBoosted && bIsBoosted) { + // higher seq wins + const seqDistance = (boostB.seq ?? 0) - (boostA.seq ?? 0); + if (seqDistance !== 0) return seqDistance > 0 ? 1 : -1; + // fall through to normal comparator for stability + } + return this.sortComparator(a, b); + }; + + /** Public API to manage boosts */ + boost(id: string, opts?: { ttlMs?: number; until?: number; seq?: number }) { + const now = Date.now(); + const until = opts?.until ?? (opts?.ttlMs != null ? now + opts.ttlMs : now + 15000); // default 15s + + if (typeof opts?.seq === 'number' && opts.seq > this._maxBoostSeq) { + this._maxBoostSeq = opts.seq; + } + + const seq = opts?.seq ?? 0; + this.boosts.set(id, { until, seq }); + } + + getBoost(id: string) { + return this.boosts.get(id); + } + + removeBoost(id: string) { + this.boosts.delete(id); + this._maxBoostSeq = Math.max( + ...Array.from(this.boosts.values()).map((boost) => boost.seq), + 0, + ); + } + + isBoosted(id: string) { + const boost = this.getBoost(id); + return !!(boost && Date.now() <= boost.until); + } + ingestItem(ingestedItem: T): boolean { const items = this.items ?? []; const id = this.getItemId(ingestedItem); - + const next = items.slice(); // If it doesn't match this paginator's filters, remove if present and exit. const existingIndex = items.findIndex((ch) => this.getItemId(ch) === id); if (!this.matchesFilter(ingestedItem)) { if (existingIndex >= 0) { - const next = items.slice(); next.splice(existingIndex, 1); this.state.partialNext({ items: next }); return true; // list changed (item removed) @@ -165,21 +249,20 @@ export abstract class BasePaginator { return false; // no change } - // Build comparator once per call (you can cache it when sort changes). - - const next = items.slice(); - if (existingIndex >= 0) { // Update existing: remove then re-insert at the correct position next.splice(existingIndex, 1); } - // Find insertion index via binary search: first index where existing > ingestionItem - const insertAt = binarySearchInsertIndex({ - needle: ingestedItem, - sortedArray: next, - compare: this.sortComparator, - }); + const insertAt = + this.config.lockItemOrder && existingIndex >= 0 + ? existingIndex + : // Find insertion index via binary search: first index where existing > ingestionItem + binarySearchInsertIndex({ + needle: ingestedItem, + sortedArray: next, + compare: this.effectiveComparator, + }); next.splice(insertAt, 0, ingestedItem); this.state.partialNext({ items: next }); @@ -246,18 +329,18 @@ export abstract class BasePaginator { const insertionIndex = binarySearchInsertIndex({ needle, sortedArray: items, - compare: this.sortComparator, + compare: this.effectiveComparator, }); // quick neighbor checks const id = this.getItemId(needle); const left = insertionIndex - 1; - if (left >= 0 && this.sortComparator(items[left], needle) === 0) { + if (left >= 0 && this.effectiveComparator(items[left], needle) === 0) { if (this.getItemId(items[left]) === id) return { index: left, insertionIndex }; } if ( insertionIndex < items.length && - this.sortComparator(items[insertionIndex], needle) === 0 + this.effectiveComparator(items[insertionIndex], needle) === 0 ) { if (this.getItemId(items[insertionIndex]) === id) return { index: insertionIndex, insertionIndex }; @@ -269,14 +352,14 @@ export abstract class BasePaginator { ? locateOnPlateauAlternating( items, needle, - this.sortComparator, + this.effectiveComparator, this.getItemId.bind(this), insertionIndex, ) : locateOnPlateauScanOneSide( items, needle, - this.sortComparator, + this.effectiveComparator, this.getItemId.bind(this), insertionIndex, ); @@ -381,4 +464,9 @@ export abstract class BasePaginator { prevDebounced = () => { this._executeQueryDebounced({ direction: 'prev' }); }; + + reload = async () => { + this.resetState(); + await this.next(); + }; } diff --git a/test/unit/ChannelPaginatorsOrchestrator.test.ts b/test/unit/ChannelPaginatorsOrchestrator.test.ts index e27ada86ec..0d29234e95 100644 --- a/test/unit/ChannelPaginatorsOrchestrator.test.ts +++ b/test/unit/ChannelPaginatorsOrchestrator.test.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getClientWithUser } from './test-utils/getClient'; import { - Channel, ChannelPaginator, ChannelResponse, EventTypes, @@ -259,6 +258,22 @@ describe('ChannelPaginatorsOrchestrator', () => { }); }); + describe('reload', () => { + it('calls reload on all the paginators', async () => { + const paginator1 = new ChannelPaginator({ client }); + const paginator2 = new ChannelPaginator({ client }); + vi.spyOn(paginator1, 'reload').mockResolvedValue(); + vi.spyOn(paginator2, 'reload').mockResolvedValue(); + const orchestrator = new ChannelPaginatorsOrchestrator({ + client, + paginators: [paginator1, paginator2], + }); + await orchestrator.reload(); + expect(paginator1.reload).toHaveBeenCalledTimes(1); + expect(paginator2.reload).toHaveBeenCalledTimes(1); + }); + }); + // Helper to create a minimal channel with needed state function makeChannel(cid: string) { const [type, id] = cid.split(':'); @@ -320,7 +335,7 @@ describe('ChannelPaginatorsOrchestrator', () => { }); }); - describe.each(['channel.hidden', 'notification.removed_from_channel'] as EventTypes[])( + describe.each(['notification.removed_from_channel'] as EventTypes[])( '%s', (eventType) => { it('removes the channel from all paginators', async () => { @@ -522,6 +537,109 @@ describe('ChannelPaginatorsOrchestrator', () => { }); }); + it.each([ + 'message.new', + 'notification.message_new', + 'notification.added_to_channel', + 'channel.visible', + ] as EventTypes[])( + 'boosts ingested channel on %s if the item is not already boosted at the top', + async (eventType) => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now.getTime()); + + const orchestrator = new ChannelPaginatorsOrchestrator({ 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(); + + // @ts-expect-error accessing protected property + expect(paginator.boosts.size).toBe(0); + + client.dispatchEvent({ type: eventType, cid: ch.cid }); + + await vi.waitFor(() => { + // @ts-expect-error accessing protected property + expect(Array.from(paginator.boosts.entries())).toEqual([ + [ch.cid, { seq: 1, until: now.getTime() + 15000 }], + ]); + }); + + client.dispatchEvent({ type: eventType, cid: ch.cid }); + await vi.waitFor(() => { + // already at the top + // @ts-expect-error accessing protected property + expect(Array.from(paginator.boosts.entries())).toEqual([ + [ch.cid, { seq: 1, until: now.getTime() + 15000 }], + ]); + }); + + matchesFilterSpy.mockReturnValue(false); + client.dispatchEvent({ type: eventType, cid: ch.cid }); + + await vi.waitFor(() => { + // @ts-expect-error accessing protected property + expect(Array.from(paginator.boosts.entries())).toEqual([ + [ch.cid, { seq: 1, until: now.getTime() + 15000 }], + ]); + }); + + matchesFilterSpy.mockReturnValue(true); + // @ts-expect-error accessing protected property + paginator._maxBoostSeq = 1000; + client.dispatchEvent({ type: eventType, cid: ch.cid }); + await vi.waitFor(() => { + // some other channel has a higher boost + // @ts-expect-error accessing protected property + expect(Array.from(paginator.boosts.entries())).toEqual([ + [ch.cid, { seq: 1001, until: now.getTime() + 15000 }], + ]); + }); + + nowSpy.mockRestore(); + vi.useRealTimers(); + }, + ); + + it.each([ + 'channel.updated', + 'channel.truncated', + 'member.updated', + 'user.presence.changed', + ] as EventTypes[])('does not boost ingested channel on %s', async (eventType) => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now.getTime()); + + const orchestrator = new ChannelPaginatorsOrchestrator({ 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(); + + // @ts-expect-error accessing protected property + expect(paginator.boosts.size).toBe(0); + + client.dispatchEvent({ type: eventType, cid: ch.cid }); + + await vi.waitFor(() => { + // @ts-expect-error accessing protected property + expect(paginator.boosts.size).toBe(0); + }); + }); + 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 }); diff --git a/test/unit/pagination/BasePaginator.test.ts b/test/unit/pagination/BasePaginator.test.ts index 30bd6bfd16..bf4aa43e84 100644 --- a/test/unit/pagination/BasePaginator.test.ts +++ b/test/unit/pagination/BasePaginator.test.ts @@ -88,6 +88,7 @@ describe('BasePaginator', () => { }); }); }); + describe('pagination API', () => { it('paginates to next pages', async () => { const paginator = new Paginator(); @@ -240,6 +241,7 @@ describe('BasePaginator', () => { expect(paginator.cursor).toEqual({ next: 'next1', prev: 'prev1' }); }); }); + describe('item management', () => { const item: TestItem = { id: 'id1', @@ -286,55 +288,159 @@ describe('BasePaginator', () => { }); describe('ingestItem', () => { - it('exists but does not match the filter anymore removes the item', () => { - const paginator = new Paginator(); + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'exists but does not match the filter anymore removes the item %s', + (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder }); + paginator.state.partialNext({ + items: [item3, item2, item], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $eq: ['abc', 'efg'] }, // required membership in these two teams + }); + + const adjustedItem = { + ...item, + teams: ['efg'], // removed from the team abc + }; + + expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item removed + expect(paginator.items).toHaveLength(2); + }, + ); + + it.each([ + [' adjusts the order on lockItemOrder: false', false], + [' does not adjust the order on lockItemOrder: true', true], + ])('exists and matches the filter updates the item and %s', (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder }); paginator.state.partialNext({ - items: [item3, item2, item], + items: [item, item2, item3], }); // @ts-expect-error accessing protected property paginator.buildFilters = () => ({ - teams: { $eq: ['abc', 'efg'] }, // required membership in these two teams + age: { $gt: 100 }, }); + paginator.sort = { age: 1 }; + const adjustedItem = { ...item, - teams: ['efg'], // removed from the team abc + age: 103, }; - expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item removed - expect(paginator.items).toHaveLength(2); + expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item updated + expect(paginator.items).toHaveLength(3); + + if (lockItemOrder) { + expect(paginator.items).toStrictEqual([adjustedItem, item2, item3]); + } else { + expect(paginator.items).toStrictEqual([item2, item3, adjustedItem]); + } }); - it('exists and matches the filter updates the item', () => { + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'does not exist and does not match the filter results in no action %s', + (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder }); + paginator.state.partialNext({ + items: [item], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + age: { $gt: 100 }, + }); + + const adjustedItem = { + ...item, + id: 'id2', + name: 'test2', + }; + + expect(paginator.ingestItem(adjustedItem)).toBeFalsy(); // no action + expect(paginator.items).toStrictEqual([item]); + }, + ); + + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'does not exist and matches the filter inserts according to default sort order (append) %s', + (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder }); + paginator.state.partialNext({ + items: [item3, item], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + + expect(paginator.ingestItem(item2)).toBeTruthy(); + expect(paginator.items).toStrictEqual([item3, item, item2]); + }, + ); + + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'does not exist and matches the filter inserts according to sort order %s', + (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder }); + paginator.state.partialNext({ + items: [item3, item], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + expect(paginator.ingestItem(item2)).toBeTruthy(); + expect(paginator.items).toHaveLength(3); + expect(paginator.items![0]).toStrictEqual(item3); + expect(paginator.items![1]).toStrictEqual(item2); + expect(paginator.items![2]).toStrictEqual(item); + }, + ); + + it('reflects the boost priority on lockItemOrder: false for newly ingested items', () => { const paginator = new Paginator(); paginator.state.partialNext({ - items: [item, item2, item3], + items: [item3, item], }); // @ts-expect-error accessing protected property paginator.buildFilters = () => ({ - age: { $gt: 100 }, + teams: { $contains: 'abc' }, }); - paginator.sort = { age: 1 }; - - const adjustedItem = { - ...item, - age: 103, - }; - - expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item updated - expect(paginator.items).toHaveLength(3); - expect(paginator.items![0]).toStrictEqual(item2); - expect(paginator.items![1]).toStrictEqual(item3); - expect(paginator.items![2]).toStrictEqual(adjustedItem); + paginator.boost(item2.id); + expect(paginator.ingestItem(item2)).toBeTruthy(); + expect(paginator.items).toStrictEqual([item2, item3, item]); }); - it('does not exist and does not match the filter results in no action', () => { + it('reflects the boost priority on lockItemOrder: false for existing items recently boosted', () => { const paginator = new Paginator(); paginator.state.partialNext({ - items: [item], + items: [item, item2, item3], }); // @ts-expect-error accessing protected property @@ -342,37 +448,45 @@ describe('BasePaginator', () => { age: { $gt: 100 }, }); + paginator.sort = { age: 1 }; + const adjustedItem = { - ...item, - id: 'id2', - name: 'test2', + ...item2, + age: 103, }; + paginator.boost(item2.id); + expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item updated + expect(paginator.items).toHaveLength(3); - expect(paginator.ingestItem(adjustedItem)).toBeFalsy(); // no action - expect(paginator.items).toHaveLength(1); - expect(paginator.items![0]).toStrictEqual(item); + expect(paginator.items).toStrictEqual([adjustedItem, item, item3]); }); - it('does not exist and matches the filter inserts according to default sort order (append)', () => { - const paginator = new Paginator(); + it('does not reflect the boost priority on lockItemOrder: true', () => { + const paginator = new Paginator({ lockItemOrder: true }); paginator.state.partialNext({ - items: [item3, item], + items: [item, item2, item3], }); // @ts-expect-error accessing protected property paginator.buildFilters = () => ({ - teams: { $contains: 'abc' }, + age: { $gt: 100 }, }); - expect(paginator.ingestItem(item2)).toBeTruthy(); + paginator.sort = { age: 1 }; + + const adjustedItem = { + ...item2, + age: 103, + }; + paginator.boost(item2.id); + expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item updated expect(paginator.items).toHaveLength(3); - expect(paginator.items![0]).toStrictEqual(item3); - expect(paginator.items![1]).toStrictEqual(item); - expect(paginator.items![2]).toStrictEqual(item2); + + expect(paginator.items).toStrictEqual([item, adjustedItem, item3]); }); - it('does not exist and matches the filter inserts according to sort order', () => { - const paginator = new Paginator(); + it('reflects the boost priority on lockItemOrder: true when ingesting a new item', () => { + const paginator = new Paginator({ lockItemOrder: true }); paginator.state.partialNext({ items: [item3, item], }); @@ -381,16 +495,10 @@ describe('BasePaginator', () => { paginator.buildFilters = () => ({ teams: { $contains: 'abc' }, }); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ sort: { age: -1 } }); + paginator.boost(item2.id); expect(paginator.ingestItem(item2)).toBeTruthy(); - expect(paginator.items).toHaveLength(3); - expect(paginator.items![0]).toStrictEqual(item3); - expect(paginator.items![1]).toStrictEqual(item2); - expect(paginator.items![2]).toStrictEqual(item); + expect(paginator.items).toStrictEqual([item2, item3, item]); }); }); @@ -430,6 +538,29 @@ describe('BasePaginator', () => { }); }); + describe('reload', () => { + it('starts the pagination from the beginning', async () => { + const a: TestItem = { id: 'a', age: 30 }; + const b: TestItem = { id: 'b', age: 25 }; + const c: TestItem = { id: 'c', age: 25 }; + const d: TestItem = { id: 'd', age: 20 }; + + const paginator = new Paginator(); + const nextSpy = vi.spyOn(paginator, 'next').mockResolvedValue(); + paginator.state.next({ + hasNext: false, + hasPrev: false, + isLoading: false, + items: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }], + offset: 4, + }); + await paginator.reload(); + expect(nextSpy).toHaveBeenCalledTimes(1); + expect(paginator.state.getLatestValue()).toStrictEqual(paginator.initialState); + nextSpy.mockRestore(); + }); + }); + describe('contains', () => { it('returns true if the item exists', () => { const paginator = new Paginator(); @@ -689,5 +820,237 @@ describe('BasePaginator', () => { ]); }); }); + + describe('item boosting', () => { + const a = { id: 'a', age: 10, name: 'A' } as TestItem; + const b = { id: 'b', age: 20, name: 'B' } as TestItem; + const c = { id: 'c', age: 30, name: 'C' } as TestItem; + + const byIdAsc = (l: TestItem, r: TestItem) => + l.id < r.id ? -1 : l.id > r.id ? 1 : 0; + + describe('clearExpiredBoosts', () => { + it('removes expired boosts and updates maxBoostSeq', () => { + const paginator = new Paginator(); + // @ts-expect-error accessing protected property + paginator.boosts.clear(); + const now = 1000000; + + paginator.boost('fresh', { until: now + 1000, seq: 1 }); + paginator.boost('stale', { until: now - 1, seq: 5 }); + + // @ts-expect-error accessing protected method + paginator.clearExpiredBoosts(now); + + // @ts-expect-error accessing protected property + expect(Array.from(paginator.boosts.keys())).toEqual(['fresh']); + expect(paginator.maxBoostSeq).toBe(1); + }); + + it('sets maxBoostSeq to 0 when no boosts remain', () => { + const paginator = new Paginator(); + // two expired boosts at "now" + paginator.boost('x', { until: 1000, seq: 1 }); + paginator.boost('y', { until: 1500, seq: 3 }); + + // @ts-expect-error accessing protected method + paginator.clearExpiredBoosts(10000); + + // @ts-expect-error accessing protected property + expect(paginator.boosts.size).toBe(0); + expect(paginator.maxBoostSeq).toBe(0); + }); + }); + + describe('boostComparator', () => { + it('prioritizes boosted over non-boosted', () => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + + const paginator = new Paginator(); + paginator.sortComparator = byIdAsc; + + // Boost only "a" + paginator.boost('b', { ttlMs: 10000, seq: 0 }); + + // @ts-expect-error: protected method + expect(paginator.boostComparator(a, b)).toBe(1); // a after b + // @ts-expect-error + expect(paginator.boostComparator(b, a)).toBe(-1); // b stays before a + + // Let boost expire + vi.setSystemTime(new Date(now.getTime() + 11000)); + // @ts-expect-error + expect(paginator.boostComparator(a, b)).toBe(-1); // fallback to byIdAsc + vi.useRealTimers(); + }); + + it('when both boosted, higher seq comes first; ties fall back to sortComparator', () => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + + const paginator = new Paginator(); + // Fallback comparator id asc + paginator.sortComparator = byIdAsc; + + paginator.boost('a', { ttlMs: 60000, seq: 1 }); + paginator.boost('b', { ttlMs: 60000, seq: 3 }); + + // b has higher seq → should come first → comparator(a,b) > 0 + // @ts-expect-error + expect(paginator.boostComparator(a, b)).toBe(1); + // reverse check + // @ts-expect-error + expect(paginator.boostComparator(b, a)).toBe(-1); + + // Equal seq → fall back to sortComparator (id asc => a before b) + paginator.boost('a', { ttlMs: 60000, seq: 2 }); + paginator.boost('b', { ttlMs: 60000, seq: 2 }); + // @ts-expect-error + expect(paginator.boostComparator(a, b)).toBe(-1); + + vi.useRealTimers(); + }); + + it('ignores expired boosts automatically during comparison', () => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + + const paginator = new Paginator(); + paginator.sortComparator = byIdAsc; + + paginator.boost('b', { ttlMs: 5000, seq: 10 }); + // Initially boosted + // @ts-expect-error + expect(paginator.boostComparator(a, b)).toBe(1); + + // Advance beyond TTL so boost is expired; comparator should fall back + vi.setSystemTime(new Date(now.getTime() + 6000)); + // @ts-expect-error + expect(paginator.boostComparator(a, b)).toBe(-1); // byIdAsc, not boost + vi.useRealTimers(); + }); + }); + + describe('boost', () => { + it('assigns default TTL (15s) and default seq=0; updates maxBoostSeq only upward', () => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + + const paginator = new Paginator(); + + paginator.boost('k'); // default 15s, seq 0 + const b1 = paginator.getBoost('k')!; + expect(b1.seq).toBe(0); + expect(b1.until).toBe(now.getTime() + 15000); + expect(paginator.maxBoostSeq).toBe(0); + + // Raise max seq + paginator.boost('m', { ttlMs: 1000, seq: 5 }); + expect(paginator.maxBoostSeq).toBe(5); + + // Lower seq should NOT decrease maxBoostSeq + paginator.boost('n', { ttlMs: 1000, seq: 2 }); + expect(paginator.maxBoostSeq).toBe(5); + + vi.useRealTimers(); + }); + + it('accepts explicit until and seq', () => { + const paginator = new Paginator(); + paginator.boost('z', { until: 42, seq: 7 }); + const b = paginator.getBoost('z')!; + expect(b.until).toBe(42); + expect(b.seq).toBe(7); + expect(paginator.maxBoostSeq).toBe(7); + }); + }); + + describe('getBoost', () => { + it('returns the boost record when present; otherwise undefined', () => { + const paginator = new Paginator(); + expect(paginator.getBoost('missing')).toBeUndefined(); + paginator.boost('a', { ttlMs: 1000, seq: 1 }); + const b = paginator.getBoost('a'); + expect(b).toBeDefined(); + expect(b!.seq).toBe(1); + }); + }); + + describe('removeBoost', () => { + it('removes a boost and recalculates maxBoostSeq', () => { + const paginator = new Paginator(); + paginator.boost('a', { ttlMs: 60000, seq: 1 }); + paginator.boost('b', { ttlMs: 60000, seq: 5 }); + paginator.boost('c', { ttlMs: 60000, seq: 2 }); + expect(paginator.maxBoostSeq).toBe(5); + + paginator.removeBoost('b'); // remove current max + expect(paginator.getBoost('b')).toBeUndefined(); + expect(paginator.maxBoostSeq).toBe(2); + + paginator.removeBoost('c'); + expect(paginator.getBoost('c')).toBeUndefined(); + expect(paginator.maxBoostSeq).toBe(1); + + paginator.removeBoost('a'); + expect(paginator.getBoost('a')).toBeUndefined(); + expect(paginator.maxBoostSeq).toBe(0); + }); + }); + + describe('isBoosted', () => { + it('returns true when boost exists and now <= until; false otherwise', () => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + + const paginator = new Paginator(); + expect(paginator.isBoosted('x')).toBe(false); + + paginator.boost('x', { ttlMs: 5000, seq: 0 }); + expect(paginator.isBoosted('x')).toBe(true); + + // Exactly at until is still considered boosted per <= check + vi.setSystemTime(new Date(now.getTime() + 5000)); + expect(paginator.isBoosted('x')).toBe(true); + + // After until → false + vi.setSystemTime(new Date(now.getTime() + 5001)); + expect(paginator.isBoosted('x')).toBe(false); + + vi.useRealTimers(); + }); + }); + + describe('integration: ingestion respects boostComparator implicitly', () => { + it('newly ingested boosted items float above non-boosted regardless of fallback sort', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00Z')); + + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: 1 }, // ascending age (so normally a < b < c by age) + }); + paginator.state.partialNext({ items: [a, b] }); + + // Boost "c" before ingest → it should be placed ahead of non-boosted even though age is highest + paginator.boost('c', { ttlMs: 60000, seq: 1 }); + expect(paginator.ingestItem(c)).toBeTruthy(); + + // c should be first due to boost, then a, then b (fallback sort would place c last otherwise) + expect(paginator.items!.map((i) => i.id)).toEqual(['c', 'a', 'b']); + + vi.useRealTimers(); + }); + }); + }); }); }); From 69b421f8d385a910a0ba796a3a6ce4a2370e3467 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 7 Nov 2025 14:23:04 +0100 Subject: [PATCH 03/48] feat: support missing ChannelManager features in BasePaginator Support setting paginator items directly, optional request retries, offline DB in ChannelPaginator, identification of pagination restart based on query shape change. --- src/ChannelPaginatorsOrchestrator.ts | 260 +++++---- src/EventHandlerPipeline.ts | 53 +- src/pagination/BasePaginator.ts | 353 ++++++++++-- src/pagination/ChannelPaginator.ts | 207 ++++++- src/pagination/ReminderPaginator.ts | 41 +- src/utils.ts | 2 +- src/utils/mergeWith/mergeWithCore.ts | 304 +++++----- .../ChannelPaginatorsOrchestrator.test.ts | 207 +++++-- test/unit/EventHandlerPipeline.test.ts | 68 ++- .../MessageReceiptsTracker.test.ts | 45 ++ test/unit/pagination/BasePaginator.test.ts | 538 +++++++++++++++++- test/unit/pagination/ChannelPaginator.test.ts | 242 +++++--- test/unit/utils/mergeWith.test.ts | 151 ++++- 13 files changed, 1919 insertions(+), 552 deletions(-) diff --git a/src/ChannelPaginatorsOrchestrator.ts b/src/ChannelPaginatorsOrchestrator.ts index 6eceb7b1a7..b3aecabc9e 100644 --- a/src/ChannelPaginatorsOrchestrator.ts +++ b/src/ChannelPaginatorsOrchestrator.ts @@ -7,6 +7,7 @@ import type { Unsubscribe } from './store'; import { StateStore } from './store'; import type { EventHandlerPipelineHandler, + FindEventHandlerParams, InsertEventHandlerPayload, LabeledEventHandler, } from './EventHandlerPipeline'; @@ -17,11 +18,32 @@ export type ChannelPaginatorsOrchestratorEventHandlerContext = { orchestrator: ChannelPaginatorsOrchestrator; }; +type EventHandlerContext = ChannelPaginatorsOrchestratorEventHandlerContext; + type SupportedEventType = EventTypes | (string & {}); -const reEmit: EventHandlerPipelineHandler< - ChannelPaginatorsOrchestratorEventHandlerContext -> = ({ event, ctx: { orchestrator } }) => { +const getCachedChannelFromEvent = ( + event: Event, + 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 reEmit: EventHandlerPipelineHandler = ({ + event, + ctx: { orchestrator }, +}) => { if (!event.cid) return; const channel = orchestrator.client.activeChannels[event.cid]; if (!channel) return; @@ -33,9 +55,10 @@ const reEmit: EventHandlerPipelineHandler< }); }; -const removeItem: EventHandlerPipelineHandler< - ChannelPaginatorsOrchestratorEventHandlerContext -> = ({ event, ctx: { orchestrator } }) => { +const removeItem: EventHandlerPipelineHandler = ({ + event, + ctx: { orchestrator }, +}) => { if (!event.cid) return; const channel = orchestrator.client.activeChannels[event.cid]; orchestrator.paginators.forEach((paginator) => { @@ -43,21 +66,26 @@ const removeItem: EventHandlerPipelineHandler< }); }; -const updateLists: EventHandlerPipelineHandler< - ChannelPaginatorsOrchestratorEventHandlerContext -> = async ({ event, ctx: { orchestrator } }) => { - let channel: Channel | undefined = undefined; - if (event.cid) { - channel = orchestrator.client.activeChannels[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 = - orchestrator.client.activeChannels[`${event.channel_type}:${event.channel_id}`]; - } else if (event.channel) { - channel = orchestrator.client.activeChannels[event.channel.cid]; - } else { - return; - } +// todo: documentation: show how to implement allowNewMessagesFromUnfilteredChannels just by inserting event handler +// at the start of the handler pipeline and filter out events for unknown channels +export const ignoreEventsForUnknownChannels: EventHandlerPipelineHandler< + EventHandlerContext +> = ({ event, ctx: { orchestrator } }) => { + const channel: Channel | undefined = getCachedChannelFromEvent( + event, + orchestrator.client.activeChannels, + ); + if (!channel) return { action: 'stop' }; +}; + +const updateLists: EventHandlerPipelineHandler = async ({ + event, + ctx: { orchestrator }, +}) => { + let channel: Channel | undefined = getCachedChannelFromEvent( + event, + orchestrator.client.activeChannels, + ); if (!channel) { const [type, id] = event.cid @@ -96,104 +124,91 @@ const updateLists: EventHandlerPipelineHandler< }; // we have to make sure that client.activeChannels is always up-to-date -const channelDeletedHandler: LabeledEventHandler = - { - handle: removeItem, - id: 'ChannelPaginatorsOrchestrator:default-handler:channel.deleted', - }; +const channelDeletedHandler: LabeledEventHandler = { + handle: removeItem, + id: 'ChannelPaginatorsOrchestrator:default-handler:channel.deleted', +}; // fixme: this handler should not be handled by the orchestrator 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', - }; +const channelUpdatedHandler: LabeledEventHandler = { + handle: reEmit, + id: 'ChannelPaginatorsOrchestrator:default-handler:channel.updated', +}; // fixme: this handler should not be handled by the orchestrator 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', - }; - -const channelVisibleHandler: LabeledEventHandler = - { - handle: updateLists, - id: 'ChannelPaginatorsOrchestrator:default-handler:channel.visible', - }; +const channelTruncatedHandler: LabeledEventHandler = { + handle: reEmit, + id: 'ChannelPaginatorsOrchestrator:default-handler:channel.truncated', +}; + +const channelVisibleHandler: LabeledEventHandler = { + handle: updateLists, + id: 'ChannelPaginatorsOrchestrator:default-handler:channel.visible', +}; // 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', - }; - -const messageNewHandler: LabeledEventHandler = - { - handle: updateLists, - id: 'ChannelPaginatorsOrchestrator:default-handler:message.new', - }; - -const notificationAddedToChannelHandler: LabeledEventHandler = - { - handle: updateLists, - id: 'ChannelPaginatorsOrchestrator:default-handler:notification.added_to_channel', - }; - -const notificationMessageNewHandler: LabeledEventHandler = - { - handle: updateLists, - id: 'ChannelPaginatorsOrchestrator:default-handler:notification.message_new', - }; - -const notificationRemovedFromChannelHandler: LabeledEventHandler = - { - handle: removeItem, - id: 'ChannelPaginatorsOrchestrator:default-handler:notification.removed_from_channel', - }; +const memberUpdatedHandler: LabeledEventHandler = { + handle: updateLists, + id: 'ChannelPaginatorsOrchestrator:default-handler:member.updated', +}; + +const messageNewHandler: LabeledEventHandler = { + handle: updateLists, + id: 'ChannelPaginatorsOrchestrator:default-handler:message.new', +}; + +const notificationAddedToChannelHandler: LabeledEventHandler = { + handle: updateLists, + id: 'ChannelPaginatorsOrchestrator:default-handler:notification.added_to_channel', +}; + +const notificationMessageNewHandler: LabeledEventHandler = { + handle: updateLists, + id: 'ChannelPaginatorsOrchestrator:default-handler:notification.message_new', +}; + +const notificationRemovedFromChannelHandler: LabeledEventHandler = { + handle: removeItem, + id: 'ChannelPaginatorsOrchestrator: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 } }) => { - const eventUser = event.user; - if (!eventUser?.id) return; - orchestrator.paginators.forEach((paginator) => { - const paginatorItems = paginator.items; - if (!paginatorItems) return; - let updated = false; - paginatorItems.forEach((channel) => { - if (channel.state.members[eventUser.id]) { - channel.state.members[eventUser.id].user = event.user; - updated = true; - } - if (channel.state.membership.user?.id === eventUser.id) { - channel.state.membership.user = eventUser; - updated = true; - } - }); - if (updated) { - // fixme: user is not reactive and so the whole list has to be re-rendered - paginator.state.partialNext({ items: [...paginatorItems] }); +const userPresenceChangedHandler: LabeledEventHandler = { + handle: ({ event, ctx: { orchestrator } }) => { + const eventUser = event.user; + if (!eventUser?.id) return; + orchestrator.paginators.forEach((paginator) => { + const paginatorItems = paginator.items; + if (!paginatorItems) return; + let updated = false; + paginatorItems.forEach((channel) => { + if (channel.state.members[eventUser.id]) { + channel.state.members[eventUser.id].user = event.user; + updated = true; + } + if (channel.state.membership.user?.id === eventUser.id) { + channel.state.membership.user = eventUser; + updated = true; } }); - }, - id: 'ChannelPaginatorsOrchestrator:default-handler:user.presence.changed', - }; + if (updated) { + // fixme: user is not reactive and so the whole list has to be re-rendered + paginator.state.partialNext({ items: [...paginatorItems] }); + } + }); + }, + id: 'ChannelPaginatorsOrchestrator:default-handler:user.presence.changed', +}; export type ChannelPaginatorsOrchestratorState = { paginators: ChannelPaginator[]; }; export type ChannelPaginatorsOrchestratorEventHandlers = Partial< - Record< - SupportedEventType, - LabeledEventHandler[] - > + Record[]> >; export type ChannelPaginatorsOrchestratorOptions = { @@ -205,14 +220,15 @@ export type ChannelPaginatorsOrchestratorOptions = { export class ChannelPaginatorsOrchestrator extends WithSubscriptions { client: StreamChat; state: StateStore; - protected pipelines = new Map< + protected _pipelines = new Map< SupportedEventType, - EventHandlerPipeline + EventHandlerPipeline >(); protected static readonly defaultEventHandlers: ChannelPaginatorsOrchestratorEventHandlers = { 'channel.deleted': [channelDeletedHandler], + 'channel.hidden': [channelDeletedHandler], 'channel.updated': [channelUpdatedHandler], 'channel.truncated': [channelTruncatedHandler], 'channel.visible': [channelVisibleHandler], @@ -243,7 +259,11 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { return this.state.getLatestValue().paginators; } - private get ctx(): ChannelPaginatorsOrchestratorEventHandlerContext { + get pipelines(): Map> { + return this._pipelines; + } + + private get ctx(): EventHandlerContext { return { orchestrator: this }; } @@ -291,17 +311,39 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { ...payload }: { eventType: SupportedEventType; - } & InsertEventHandlerPayload): Unsubscribe { + } & InsertEventHandlerPayload): Unsubscribe { return this.ensurePipeline(eventType).insert(payload); } + setEventHandlers({ + eventType, + handlers, + }: { + eventType: SupportedEventType; + handlers: LabeledEventHandler[]; + }) { + return this.ensurePipeline(eventType).replaceAll(handlers); + } + + removeEventHandlers({ + eventType, + handlers, + }: { + eventType: SupportedEventType; + handlers: FindEventHandlerParams[]; + }) { + const pipeline = this._pipelines.get(eventType); + if (!pipeline) return; + handlers.forEach((params) => pipeline.remove(params)); + } + /** Subscribe to WS (and more buses via attachBus) */ registerSubscriptions(): Unsubscribe { if (!this.hasSubscriptions) { this.addUnsubscribeFunction( // todo: maybe we should have a wrapper here to decide, whether the event is a LocalEventBus event or else supported by client this.client.on((event: Event) => { - const pipe = this.pipelines.get(event.type); + const pipe = this._pipelines.get(event.type); if (pipe) { pipe.run(event, this.ctx); } @@ -315,13 +357,13 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { ensurePipeline( eventType: SupportedEventType, - ): EventHandlerPipeline { - let pipe = this.pipelines.get(eventType); + ): EventHandlerPipeline { + let pipe = this._pipelines.get(eventType); if (!pipe) { - pipe = new EventHandlerPipeline({ + pipe = new EventHandlerPipeline({ id: `ChannelPaginatorsOrchestrator:${eventType}`, }); - this.pipelines.set(eventType, pipe); + this._pipelines.set(eventType, pipe); } return pipe; } diff --git a/src/EventHandlerPipeline.ts b/src/EventHandlerPipeline.ts index c2b63b976a..925e7c1e64 100644 --- a/src/EventHandlerPipeline.ts +++ b/src/EventHandlerPipeline.ts @@ -2,6 +2,12 @@ import { generateUUIDv4 } from './utils'; import type { Event } from './types'; import type { Unsubscribe } from './store'; +type MatchById = { id: string | RegExp; regexMatch?: boolean }; +export type FindEventHandlerParams> = { + handler?: LabeledEventHandler | EventHandlerPipelineHandler; + idMatch?: MatchById; +}; + export type EventHandlerResult = { action: 'stop' }; // event processing run will be cancelled export type InsertEventHandlerPayload> = { @@ -35,6 +41,27 @@ export class EventHandlerPipeline = {}> { return this.handlers.length; } + findIndex({ handler, idMatch }: FindEventHandlerParams): number { + let index = -1; + if (handler) { + index = this.handlers.findIndex((existingHandler) => + typeof (handler as LabeledEventHandler).handle === 'function' + ? (handler as LabeledEventHandler).handle === existingHandler.handle + : handler === existingHandler.handle, + ); + } + + if (idMatch) { + index = this.handlers.findIndex((h) => { + if (!h.id) return false; + if (idMatch.regexMatch || idMatch.id instanceof RegExp) + return !!h.id.match(idMatch.id); + return h.id === idMatch.id; + }); + } + return index; + } + /** * Insert a handler into the pipeline at the given index. * @@ -53,7 +80,6 @@ export class EventHandlerPipeline = {}> { * @param revertOnUnsubscribe If true, restore the replaced handler when unsubscribing. * @returns An unsubscribe function that removes (and optionally restores) the handler. */ - insert({ handle, id, @@ -74,22 +100,29 @@ export class EventHandlerPipeline = {}> { const old = this.handlers[validIndex]; this.handlers[validIndex] = handler; return () => { - this.remove(handler); + this.remove({ handler }); if (revertOnUnsubscribe) this.handlers.splice(validIndex, 0, old); }; } else { this.handlers.splice(validIndex, 0, handler); - return () => this.remove(handler); + return () => this.remove({ handler }); } } - remove(h: LabeledEventHandler | EventHandlerPipelineHandler): void { - const index = this.handlers.findIndex((handler) => - typeof (h as LabeledEventHandler).handle === 'function' - ? (h as LabeledEventHandler).handle === handler.handle - : h === handler.handle, - ); - if (index >= 0) this.handlers.splice(index, 1); + /** + * Remove handler by: + * - handler function identity or + * - by id that could be an exact match or + * - match by regexp. + * @param params {FindEventHandlerParams} + */ + remove(params: FindEventHandlerParams): void { + let index = this.findIndex(params); + // need to perform n+1 searches in case the search is done by regex => there can be multiple matches + while (index > -1) { + this.handlers.splice(index, 1); + index = this.findIndex(params); + } } replaceAll(handlers: LabeledEventHandler[]): void { diff --git a/src/pagination/BasePaginator.ts b/src/pagination/BasePaginator.ts index 18e96f0877..6912554dfe 100644 --- a/src/pagination/BasePaginator.ts +++ b/src/pagination/BasePaginator.ts @@ -1,15 +1,33 @@ import { binarySearchInsertIndex } from './sortCompiler'; import { itemMatchesFilter } from './filterCompiler'; -import { StateStore } from '../store'; -import { debounce, type DebouncedFunc } from '../utils'; +import { isPatch, StateStore, type ValueOrPatch } from '../store'; +import { debounce, type DebouncedFunc, sleep } from '../utils'; import type { FieldToDataResolver } from './types.normalization'; import { locateOnPlateauAlternating, locateOnPlateauScanOneSide } from './utility.search'; +import { isEqual } from '../utils/mergeWith/mergeWithCore'; +import { DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES } from '../constants'; const noOrderChange = () => 0; type PaginationDirection = 'next' | 'prev'; -type Cursor = { next: string | null; prev: string | null }; -export type PaginationQueryParams = { direction?: PaginationDirection }; +export type PaginatorCursor = { next: string | null; prev: string | null }; +type StateResetPolicy = 'auto' | 'yes' | 'no' | (string & {}); + +export type PaginationQueryShapeChangeIdentifier = ( + prevQueryShape?: S, + nextQueryShape?: S, +) => boolean; + +export type PaginationQueryParams = { + direction?: PaginationDirection; + /** Data that define the query (filters, sort, ...) */ + queryShape?: Q; + /** Per-call override of the reset behavior. */ + reset?: StateResetPolicy; + /** Should retry the failed request given number of times. Default is 0. */ + retryCount?: number; +}; + export type PaginationQueryReturnValue = { items: T[] } & { next?: string; prev?: string; @@ -17,39 +35,76 @@ export type PaginationQueryReturnValue = { items: T[] } & { export type PaginatorDebounceOptions = { debounceMs: number; }; -type DebouncedExecQueryFunction = DebouncedFunc< - (params: { direction: PaginationDirection }) => Promise +type DebouncedExecQueryFunction = DebouncedFunc< + (params: PaginationQueryParams) => Promise >; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type PaginatorState = { +export type PaginatorState = { hasNext: boolean; hasPrev: boolean; isLoading: boolean; items: T[] | undefined; lastQueryError?: Error; - cursor?: Cursor; + cursor?: PaginatorCursor; offset?: number; }; -export type PaginatorOptions = { +export type PaginatorOptions = { /** The number of milliseconds to debounce the search query. The default interval is 300ms. */ debounceMs?: number; - /** Will prevent changing the index of existing items */ + /** + * Function containing custom logic that decides, whether the next pagination query to be executed should be considered the first page query. + * It makes sense to consider the next query as the first page query if filters, sort, options etc. (query params) excluding the page size have changed. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + hasPaginationQueryShapeChanged?: PaginationQueryShapeChangeIdentifier; + /** Custom function to retrieve items pages and optionally return a cursor in case of cursor pagination. */ + doRequest?: (queryParams: Q) => Promise<{ items: T[]; cursor?: PaginatorCursor }>; + /** In case of cursor pagination, specify the initial cursor value. */ + initialCursor?: PaginatorCursor; + /** In case of offset pagination, specify the initial offset value. */ + initialOffset?: number; + /** Will prevent changing the index of existing items. */ lockItemOrder?: boolean; + /** The item page size to be requested from the server. */ pageSize?: number; + /** Prevent silencing the errors thrown during the pagination execution. Default is false. */ + throwErrors?: boolean; }; -export const DEFAULT_PAGINATION_OPTIONS: Required = { + +type OptionalPaginatorConfigFields = + | 'doRequest' + | 'initialCursor' + | 'initialOffset' + | 'throwErrors'; + +export type BasePaginatorConfig = Pick< + PaginatorOptions, + OptionalPaginatorConfigFields +> & + Required, OptionalPaginatorConfigFields>>; + +const baseHasPaginationQueryShapeChanged: PaginationQueryShapeChangeIdentifier< + unknown +> = (prevQueryShape, nextQueryShape) => !isEqual(prevQueryShape, nextQueryShape); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const DEFAULT_PAGINATION_OPTIONS: BasePaginatorConfig = { debounceMs: 300, lockItemOrder: false, pageSize: 10, + hasPaginationQueryShapeChanged: baseHasPaginationQueryShapeChanged, + throwErrors: false, } as const; -export abstract class BasePaginator { +export abstract class BasePaginator { state: StateStore>; - config: Required; - protected _executeQueryDebounced!: DebouncedExecQueryFunction; + config: BasePaginatorConfig; + protected _executeQueryDebounced!: DebouncedExecQueryFunction; protected _isCursorPagination = false; + /** Last effective query shape produced by subclass for the most recent request. */ + protected _lastQueryShape?: Q; + protected _nextQueryShape?: Q; /** * Comparison function used to keep items in a paginator sorted. * @@ -82,10 +137,23 @@ export abstract class BasePaginator { protected boosts = new Map(); protected _maxBoostSeq: number = 0; - protected constructor(options?: PaginatorOptions) { - this.config = { ...DEFAULT_PAGINATION_OPTIONS, ...options }; + protected constructor({ + initialCursor, + initialOffset, + ...options + }: PaginatorOptions = {}) { + this.config = { + ...DEFAULT_PAGINATION_OPTIONS, + initialCursor, + initialOffset, + ...options, + }; const { debounceMs } = this.config; - this.state = new StateStore>(this.initialState); + this.state = new StateStore>({ + ...this.initialState, + cursor: initialCursor, + offset: initialOffset ?? 0, + }); this.setDebounceOptions({ debounceMs }); this.sortComparator = noOrderChange; this._filterFieldToDataResolvers = []; @@ -111,15 +179,24 @@ export abstract class BasePaginator { return this.state.getLatestValue().isLoading; } - get initialState(): PaginatorState { + /** Signals that the paginator has not performed any query so far */ + get isInitialized() { + return typeof this._lastQueryShape !== 'undefined'; + } + + get isOfflineSupportEnabled() { + return false; + } + + get initialState(): PaginatorState { return { hasNext: true, hasPrev: true, //todo: check if optimistic value does not cause problems in UI isLoading: false, - items: undefined, + items: undefined, // todo: maybe should be null? lastQueryError: undefined, - cursor: undefined, - offset: 0, + cursor: this.config.initialCursor, + offset: this.config.initialOffset ?? 0, }; } @@ -139,6 +216,18 @@ export abstract class BasePaginator { return this.config.pageSize; } + set pageSize(size: number) { + this.config.pageSize = size; + } + + set initialCursor(cursor: PaginatorCursor) { + this.config.initialCursor = cursor; + } + + set initialOffset(offset: number) { + this.config.initialOffset = offset; + } + /** Single point of truth: always use the effective comparator */ get effectiveComparator() { return this.boostComparator; @@ -148,12 +237,40 @@ export abstract class BasePaginator { return this._maxBoostSeq; } - abstract query(params: PaginationQueryParams): Promise>; + abstract query( + params: PaginationQueryParams, + ): Promise>; abstract filterQueryResults(items: T[]): T[] | Promise; + /** + * Subclasses must return the query shape. + */ + protected getNextQueryShape({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + direction, + }: Pick, 'direction'> = {}): Q { + throw new Error('Paginator.getNextQueryShape() is not implemented'); + } + + /** + * Decide whether a param change between queries requires a state reset. + * Default: deep inequality => reset. + * Subclasses can override to implement domain rules + * (e.g. ChannelPaginator filters {cid: { $in: string[]}} with different CIDs may be required not to lead to reset). + */ + protected shouldResetStateBeforeQuery( + prevQueryShape: unknown | undefined, + nextQueryShape: unknown | undefined, + ): boolean { + return ( + typeof prevQueryShape === 'undefined' || + this.config.hasPaginationQueryShapeChanged(prevQueryShape, nextQueryShape) + ); + } + protected buildFilters(): object | null { - return null; // === no filters' + return null; // === no filters } getItemId(item: T): string { @@ -372,6 +489,29 @@ export abstract class BasePaginator { return index > -1 ? (this.items ?? [])[index] : undefined; } + setItems(valueOrFactory: ValueOrPatch, cursor?: PaginatorCursor) { + this.state.next((current) => { + const { items: currentItems = [] } = current; + const newItems = isPatch(valueOrFactory) + ? valueOrFactory(currentItems) + : valueOrFactory; + + // If the references between the two values are the same, just return the + // current state; otherwise trigger a state change. + if (currentItems === newItems) { + return current; + } + const newState = { ...current, items: newItems }; + + if (cursor) { + newState.cursor = cursor; + } else { + newState.offset = newItems.length; + } + return newState; + }); + } + setFilterResolvers(resolvers: FieldToDataResolver[]) { this._filterFieldToDataResolvers = resolvers; } @@ -384,9 +524,24 @@ export abstract class BasePaginator { this._executeQueryDebounced = debounce(this.executeQuery.bind(this), debounceMs); }; - canExecuteQuery = (direction: PaginationDirection) => - (!this.isLoading && direction === 'next' && this.hasNext) || - (direction === 'prev' && this.hasPrev); + protected canExecuteQuery = ({ + direction, + reset, + }: { direction: PaginationDirection } & Pick, 'reset'>) => + !this.isLoading && + (reset === 'yes' || + (direction === 'next' && this.hasNext) || + (direction === 'prev' && this.hasPrev)); + + isFirstPageQuery = ( + params: { queryShape?: unknown } & Pick, 'reset'>, + ): boolean => { + if (typeof this.items === 'undefined') return true; + if (params.reset === 'yes') return true; + if (params.reset === 'no') return false; + + return this.shouldResetStateBeforeQuery(this._lastQueryShape, params.queryShape); + }; protected getStateBeforeFirstQuery(): PaginatorState { return { @@ -411,39 +566,112 @@ export abstract class BasePaginator { }; } - async executeQuery({ direction }: { direction: PaginationDirection }) { - if (!this.canExecuteQuery(direction)) return; - const isFirstPage = typeof this.items === 'undefined'; + preloadFirstPageFromOfflineDb = ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + params: PaginationQueryParams, + ): Promise | T[] | undefined => undefined; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + populateOfflineDbAfterQuery = (params: { + items: T[] | undefined; + queryShape: Q | undefined; + }): Promise | T[] | undefined => undefined; + + protected async runQueryRetryable( + params: PaginationQueryParams = {}, + ): Promise | null> { + const { retryCount } = params; + try { + return await this.query(params); + } catch (e) { + // If the offline support is enabled, and there are items in the DB, we should not report the error. + const isOfflineSupportEnabledWithItems = + this.isOfflineSupportEnabled && (this.items ?? []).length > 0; + if (!isOfflineSupportEnabledWithItems) { + this.state.partialNext({ lastQueryError: e as Error }); + } + + const nextRetryCount = (retryCount ?? 0) - 1; + if (nextRetryCount > 0) { + // not swapping isLoading flag to false as the load has not finished yet + await sleep(DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES); + return await this.runQueryRetryable({ + ...params, + retryCount: nextRetryCount, + }); + } + if (this.config.throwErrors) { + this.state.partialNext({ isLoading: false }); + throw e; + } + return null; + } + } + + async executeQuery({ + direction = 'next', + queryShape: forcedQueryShape, // todo: remove it? + reset, + retryCount = 0, + }: PaginationQueryParams = {}) { + const queryShape = forcedQueryShape ?? this.getNextQueryShape({ direction }); + if (!this.canExecuteQuery({ direction, reset })) return; + + const isFirstPage = this.isFirstPageQuery({ queryShape, reset }); if (isFirstPage) { - this.state.next(this.getStateBeforeFirstQuery()); + const state = this.getStateBeforeFirstQuery(); + // preload from the offline DB only if no successful HTTP request has been run previously + let items: T[] | undefined = undefined; + if (!this.isInitialized) { + items = + (await this.preloadFirstPageFromOfflineDb({ + direction, + queryShape, + reset, + retryCount, + })) ?? state.items; + } + this.state.next({ ...state, items }); } else { this.state.partialNext({ isLoading: true }); } - const stateUpdate: Partial> = {}; - try { - const results = await this.query({ direction }); - if (!results) return; - const { items, next, prev } = results; - if (isFirstPage && (next || prev)) { - this._isCursorPagination = true; - } + this._nextQueryShape = queryShape; + const results = await this.runQueryRetryable({ + direction, + queryShape, + reset, + retryCount, + }); + this._lastQueryShape = this._nextQueryShape; + this._nextQueryShape = undefined; - if (this._isCursorPagination) { - stateUpdate.cursor = { next: next || null, prev: prev || null }; - stateUpdate.hasNext = !!next; - stateUpdate.hasPrev = !!prev; - } else { - stateUpdate.offset = (this.offset ?? 0) + items.length; - stateUpdate.hasNext = items.length === this.pageSize; - } + // if the request failed the value is null, loading finished + if (!results) { + this.state.partialNext({ isLoading: false }); + return; + } - stateUpdate.items = await this.filterQueryResults(items); - } catch (e) { - stateUpdate.lastQueryError = e as Error; - } finally { - this.state.next(this.getStateAfterQuery(stateUpdate, isFirstPage)); + const stateUpdate: Partial> = { lastQueryError: undefined }; + + const { items, next, prev } = results; + if (isFirstPage && (next || prev)) { + this._isCursorPagination = true; } + + if (this._isCursorPagination) { + stateUpdate.cursor = { next: next || null, prev: prev || null }; + stateUpdate.hasNext = !!next; + stateUpdate.hasPrev = !!prev; + } else { + stateUpdate.offset = (this.offset ?? 0) + items.length; + stateUpdate.hasNext = items.length === this.pageSize; + } + + stateUpdate.items = await this.filterQueryResults(items); + const state = this.getStateAfterQuery(stateUpdate, isFirstPage); + this.state.next(state); + this.populateOfflineDbAfterQuery({ items: state.items, queryShape }); } cancelScheduledQuery() { @@ -454,19 +682,24 @@ export abstract class BasePaginator { this.state.next(this.initialState); } - next = () => this.executeQuery({ direction: 'next' }); + next = (params: Omit, 'direction' | 'queryShape'> = {}) => + this.executeQuery({ direction: 'next', ...params }); - prev = () => this.executeQuery({ direction: 'prev' }); + prev = (params: Omit, 'direction' | 'queryShape'> = {}) => + this.executeQuery({ direction: 'prev', ...params }); - nextDebounced = () => { - this._executeQueryDebounced({ direction: 'next' }); + nextDebounced = ( + params: Omit, 'direction' | 'queryShape'> = {}, + ) => { + this._executeQueryDebounced({ direction: 'next', ...params }); }; - prevDebounced = () => { - this._executeQueryDebounced({ direction: 'prev' }); + prevDebounced = ( + params: Omit, 'direction' | 'queryShape'> = {}, + ) => { + this._executeQueryDebounced({ direction: 'prev', ...params }); }; reload = async () => { - this.resetState(); - await this.next(); + await this.next({ reset: 'yes' }); }; } diff --git a/src/pagination/ChannelPaginator.ts b/src/pagination/ChannelPaginator.ts index 559e7b31c2..6609ff1d7a 100644 --- a/src/pagination/ChannelPaginator.ts +++ b/src/pagination/ChannelPaginator.ts @@ -1,6 +1,7 @@ import type { PaginationQueryParams, PaginationQueryReturnValue, + PaginationQueryShapeChangeIdentifier, PaginatorOptions, PaginatorState, } from './BasePaginator'; @@ -11,12 +12,26 @@ import { makeComparator } from './sortCompiler'; import { generateUUIDv4 } from '../utils'; import type { StreamChat } from '../client'; import type { Channel } from '../channel'; -import type { ChannelFilters, ChannelOptions, ChannelSort } from '../types'; +import type { + ChannelFilters, + ChannelOptions, + ChannelSort, + ChannelStateOptions, +} from '../types'; import type { FieldToDataResolver, PathResolver } from './types.normalization'; import { resolveDotPathValue } from './utility.normalization'; +import type { ValueOrPatch } from '../store'; +import { isEqual } from '../utils/mergeWith/mergeWithCore'; const DEFAULT_BACKEND_SORT: ChannelSort = { last_message_at: -1, updated_at: -1 }; // {last_updated: -1} +export type ChannelQueryShape = { + filters: ChannelFilters; + sort?: ChannelSort; + options?: ChannelOptions; + stateOptions?: ChannelStateOptions; +}; + export type ChannelPaginatorState = PaginatorState; export type ChannelPaginatorRequestOptions = Partial< @@ -25,14 +40,35 @@ export type ChannelPaginatorRequestOptions = Partial< export type ChannelPaginatorOptions = { client: StreamChat; + channelStateOptions?: ChannelStateOptions; filterBuilderOptions?: FilterBuilderOptions; filters?: ChannelFilters; id?: string; - paginatorOptions?: PaginatorOptions; + paginatorOptions?: PaginatorOptions; requestOptions?: ChannelPaginatorRequestOptions; sort?: ChannelSort | ChannelSort[]; }; +const getQueryShapeRelevantChannelOptions = (options: ChannelOptions) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { limit: _, member_limit: __, message_limit: ___, ...relevantShape } = options; + return relevantShape; +}; + +const hasPaginationQueryShapeChanged: PaginationQueryShapeChangeIdentifier< + ChannelQueryShape +> = (prevQueryShape, nextQueryShape) => + !isEqual( + { + ...prevQueryShape, + options: getQueryShapeRelevantChannelOptions(prevQueryShape?.options ?? {}), + }, + { + ...nextQueryShape, + options: getQueryShapeRelevantChannelOptions(nextQueryShape?.options ?? {}), + }, + ); + const pinnedFilterResolver: FieldToDataResolver = { matchesField: (field) => field === 'pinned', resolve: (channel) => !!channel.state.membership.pinned_at, @@ -99,17 +135,19 @@ 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 -export class ChannelPaginator extends BasePaginator { - // state: StateStore; +export class ChannelPaginator extends BasePaginator { + private readonly _id: string; private client: StreamChat; - protected _filters: ChannelFilters | undefined; + protected _staticFilters: ChannelFilters | undefined; protected _sort: ChannelSort | ChannelSort[] | undefined; protected _options: ChannelPaginatorRequestOptions | undefined; - private _id: string; + protected _channelStateOptions: ChannelStateOptions | undefined; + protected _nextQueryShape: ChannelQueryShape | undefined; sortComparator: (a: Channel, b: Channel) => number; filterBuilder: FilterBuilder; constructor({ + channelStateOptions, client, id, filterBuilderOptions, @@ -118,13 +156,14 @@ export class ChannelPaginator extends BasePaginator { requestOptions, sort, }: ChannelPaginatorOptions) { - super(paginatorOptions); + super({ hasPaginationQueryShapeChanged, ...paginatorOptions }); const definedSort = sort ?? DEFAULT_BACKEND_SORT; this.client = client; this._id = id ?? `channel-paginator-${generateUUIDv4()}`; this._sort = definedSort; - this._filters = filters; + this._staticFilters = filters; this._options = requestOptions; + this._channelStateOptions = channelStateOptions; this.filterBuilder = new FilterBuilder(filterBuilderOptions); this.sortComparator = makeComparator({ sort: definedSort, @@ -147,8 +186,12 @@ export class ChannelPaginator extends BasePaginator { return this._id; } - get filters(): ChannelFilters | undefined { - return this._filters; + get isOfflineSupportEnabled() { + return !!this.client.offlineDb; + } + + get staticFilters(): ChannelFilters | undefined { + return this._staticFilters; } get sort(): ChannelSort | undefined { @@ -159,9 +202,12 @@ export class ChannelPaginator extends BasePaginator { return this._options; } - set filters(filters: ChannelFilters | undefined) { - this._filters = filters; - this.resetState(); + get channelStateOptions(): ChannelStateOptions | undefined { + return this._channelStateOptions; + } + + set staticFilters(filters: ChannelFilters | undefined) { + this._staticFilters = filters; } set sort(sort: ChannelSort | ChannelSort[] | undefined) { @@ -169,12 +215,14 @@ export class ChannelPaginator extends BasePaginator { this.sortComparator = makeComparator({ sort: this.sort ?? DEFAULT_BACKEND_SORT, }); - this.resetState(); } set options(options: ChannelPaginatorRequestOptions | undefined) { this._options = options; - this.resetState(); + } + + set channelStateOptions(options: ChannelStateOptions | undefined) { + this._channelStateOptions = options; } getItemId(item: Channel): string { @@ -183,24 +231,127 @@ export class ChannelPaginator extends BasePaginator { buildFilters = (): ChannelFilters => this.filterBuilder.buildFilters({ - baseFilters: { ...this.filters }, + baseFilters: { ...this.staticFilters }, }); - query = async ({ direction }: PaginationQueryParams = {}): Promise< - PaginationQueryReturnValue - > => { - if (direction) { - console.warn('Direction is not supported with channel pagination.'); - } - const filters = this.buildFilters(); - const options: ChannelOptions = { - ...this.options, - limit: this.pageSize, - offset: this.offset, + // invoked inside BasePaginator.executeQuery() to keep it as a query descriptor; + protected getNextQueryShape(): ChannelQueryShape { + const shape: ChannelQueryShape = { + filters: this.buildFilters(), + options: { + ...this.options, + limit: this.pageSize, + offset: this.offset, + }, }; - const items = await this.client.queryChannels(filters, this.sort, options); + + if (this.sort) { + shape.sort = this.sort; + } + + if (this.channelStateOptions) { + shape.stateOptions = this.channelStateOptions; + } + return shape; + } + + preloadFirstPageFromOfflineDb = async ({ + direction, + queryShape, + reset, + }: PaginationQueryParams) => { + if ( + !this.client.offlineDb?.getChannelsForQuery || + !this.client.user?.id || + !queryShape + ) + return undefined; + + try { + const channelsFromDB = await this.client.offlineDb.getChannelsForQuery({ + userId: this.client.user.id, + filters: queryShape.filters, + sort: queryShape.sort, + }); + + 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 + }); + + 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) { + this.client.logger('error', (error as Error).message); + if (this.config.throwErrors) throw error; + } + return; + }; + + populateOfflineDbAfterQuery = ({ + items, + queryShape, + }: { + items?: Channel[]; + queryShape?: ChannelQueryShape; + }) => { + if (!items || !queryShape) return undefined; + + this.client.offlineDb?.executeQuerySafely( + (db) => + db.upsertCidsForQuery({ + cids: items.map((channel) => channel.cid), + filters: queryShape.filters, + sort: queryShape.sort, + }), + { method: 'upsertCidsForQuery' }, + ); + }; + + 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; + let items: Channel[]; + if (this.config.doRequest) { + items = (await this.config.doRequest(this._nextQueryShape)).items; + } else { + items = await this.client.queryChannels(filters, sort, options, stateOptions); + } return { items }; }; filterQueryResults = (items: Channel[]) => items; + + setItems(valueOrFactory: ValueOrPatch) { + super.setItems(valueOrFactory); + + if (!this.client.offlineDb) return; + + const { items: channels = [], sort } = this; + const filters = this.buildFilters(); + + this.client.offlineDb?.executeQuerySafely( + (db) => + db.upsertCidsForQuery({ + cids: channels.map((channel) => channel.cid), + filters, + sort, + }), + { method: 'upsertCidsForQuery' }, + ); + } } diff --git a/src/pagination/ReminderPaginator.ts b/src/pagination/ReminderPaginator.ts index 789354cd8c..9bbf56c5ce 100644 --- a/src/pagination/ReminderPaginator.ts +++ b/src/pagination/ReminderPaginator.ts @@ -4,10 +4,18 @@ import type { PaginationQueryReturnValue, PaginatorOptions, } from './BasePaginator'; -import type { ReminderFilters, ReminderResponse, ReminderSort } from '../types'; +import type { + QueryRemindersOptions, + ReminderFilters, + ReminderResponse, + ReminderSort, +} from '../types'; import type { StreamChat } from '../client'; -export class ReminderPaginator extends BasePaginator { +export class ReminderPaginator extends BasePaginator< + ReminderResponse, + QueryRemindersOptions +> { private client: StreamChat; protected _filters: ReminderFilters | undefined; protected _sort: ReminderSort | undefined; @@ -30,27 +38,34 @@ export class ReminderPaginator extends BasePaginator { this.resetState(); } - constructor(client: StreamChat, options?: PaginatorOptions) { + constructor( + client: StreamChat, + options?: PaginatorOptions, + ) { super(options); this.client = client; } - query = async ({ + protected getNextQueryShape({ direction, - }: Required): Promise< - PaginationQueryReturnValue - > => { + }: Required< + Pick, 'direction'> + >): QueryRemindersOptions { const cursor = this.cursor?.[direction]; - const { - reminders: items, - next, - prev, - } = await this.client.queryReminders({ + return { filter: this.filters, sort: this.sort, limit: this.pageSize, [direction]: cursor, - }); + }; + } + + query = async ({ + queryShape, + }: PaginationQueryParams): Promise< + PaginationQueryReturnValue + > => { + const { reminders: items, next, prev } = await this.client.queryReminders(queryShape); return { items, next, prev }; }; diff --git a/src/utils.ts b/src/utils.ts index ef255ccb72..472b540586 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1327,7 +1327,7 @@ export const runDetached = ( onErrorCallback?: (error: Error) => void | Promise; }, ) => { - const { context, onSuccessCallback = () => undefined, onErrorCallback } = options ?? {}; + const { context, onSuccessCallback, onErrorCallback } = options ?? {}; const defaultOnError = (error: Error) => { console.log(`An error has occurred in context ${context}: ${error}`); }; diff --git a/src/utils/mergeWith/mergeWithCore.ts b/src/utils/mergeWith/mergeWithCore.ts index 234c9dec0e..0288b9d2f3 100644 --- a/src/utils/mergeWith/mergeWithCore.ts +++ b/src/utils/mergeWith/mergeWithCore.ts @@ -44,177 +44,162 @@ export const isClassInstance = (value: unknown): boolean => { return value.constructor && value.constructor !== Object; }; +type PairMemo = WeakMap>; + +function memoHasOrAdd(memo: PairMemo, a: object, b: object): boolean { + const set = memo.get(a); + if (set && set.has(b)) return true; + if (set) set.add(b); + else memo.set(a, new WeakSet([b])); + return false; +} + /** - * Performs a deep comparison between two values to determine if they are equivalent. - * This is similar to Lodash's isEqual implementation but simplified. + * Deep semantic equality with cycle safety and symbol-key support. + * Keeps your existing semantics: + * - Dates/RegExps compared by value + * - "Class instances" are treated atomically (unequal unless ===) + * - NaN equals NaN; -0 equals 0 (same as ===) */ export const isEqual = ( value1: unknown, value2: unknown, - compareStack = new Set<[unknown, unknown]>(), - objectStack1 = new WeakSet(), - objectStack2 = new WeakSet(), + pairMemo: PairMemo = new WeakMap(), ): boolean => { - // Handle simple equality cases first - if (value1 === value2) return true; - - // If either is null/undefined, they're not equal (already checked ===) + if (value1 === value2) return true; // includes -0 === 0 if (value1 == null || value2 == null) return false; - // Get the type of both values - const type1 = typeof value1; - const type2 = typeof value2; + const t1 = typeof value1; + const t2 = typeof value2; + if (t1 !== t2) return false; - // Different types mean they're not equal - if (type1 !== type2) return false; - - // Handle non-object types that need special comparison - if (type1 !== 'object') { - // Special case for NaN + if (t1 !== 'object') { + // NaN handling // eslint-disable-next-line no-self-compare - if (value1 !== value1 && value2 !== value2) return true; - return value1 === value2; + return value1 !== value1 && value2 !== value2 ? true : value1 === value2; } - // At this point, both values are objects - const obj1 = value1 as object; - const obj2 = value2 as object; - - // Check for circular references in each object - if (objectStack1.has(obj1) || objectStack2.has(obj2)) { - // If either object has been seen before, consider them equal - // if they're both in a circular reference - return objectStack1.has(obj1) && objectStack2.has(obj2); - } + // Objects + const o1 = value1 as object; + const o2 = value2 as object; - // Add objects to their respective stacks - objectStack1.add(obj1); - objectStack2.add(obj2); + // Fast path for tag mismatch + const tag1 = Object.prototype.toString.call(o1); + const tag2 = Object.prototype.toString.call(o2); + if (tag1 !== tag2) return false; - // Handle Date objects - needs to be before the class instance check - if (value1 instanceof Date && value2 instanceof Date) { - objectStack1.delete(obj1); - objectStack2.delete(obj2); - return value1.getTime() === value2.getTime(); + // Special cases before instance test + if (o1 instanceof Date && o2 instanceof Date) { + return (o1 as Date).getTime() === (o2 as Date).getTime(); } - - // Handle RegExp objects - needs to be before the class instance check - if (value1 instanceof RegExp && value2 instanceof RegExp) { - objectStack1.delete(obj1); - objectStack2.delete(obj2); - return value1.toString() === value2.toString(); - } - - // If either is a class instance, use reference equality (already checked above) - if (isClassInstance(value1) || isClassInstance(value2)) { - // Clean up before returning - objectStack1.delete(obj1); - objectStack2.delete(obj2); - return false; + if (o1 instanceof RegExp && o2 instanceof RegExp) { + const r1 = o1 as RegExp, + r2 = o2 as RegExp; + return r1.source === r2.source && r1.flags === r2.flags; } - // Handle arrays - const isArray1 = Array.isArray(value1); - const isArray2 = Array.isArray(value2); + // Handle Set comparison + // Two sets are equal if they have the same size and + // every value in one has an equivalent value in the + // other (using deep equality). + // Cannot use the same item for multiple matches in another set. + if (value1 instanceof Set && value2 instanceof Set) { + if (value1.size !== value2.size) return false; + if (memoHasOrAdd(pairMemo, value1, value2)) return true; + + const unmatched = new Set(value2); + + for (const v1 of value1) { + let matched = false; + for (const v2 of unmatched) { + if (isEqual(v1, v2, pairMemo)) { + unmatched.delete(v2); // consume the match + matched = true; + break; + } + } + if (!matched) return false; + } - if (isArray1 !== isArray2) { - // Clean up before returning - objectStack1.delete(obj1); - objectStack2.delete(obj2); - return false; + return unmatched.size === 0; } - if (isArray1 && isArray2) { - const arr1 = value1 as unknown[]; - const arr2 = value2 as unknown[]; + // Handle Map comparison + if (value1 instanceof Map && value2 instanceof Map) { + if (value1.size !== value2.size) return false; - if (arr1.length !== arr2.length) { - // Clean up before returning - objectStack1.delete(obj1); - objectStack2.delete(obj2); - return false; - } + if (memoHasOrAdd(pairMemo, value1, value2)) return true; - // Check for circular references in the comparison context - const pairKey: [unknown, unknown] = [value1, value2]; - if (compareStack.has(pairKey)) { - // Clean up before returning - objectStack1.delete(obj1); - objectStack2.delete(obj2); - return true; - } - compareStack.add(pairKey); - - // Compare each element - for (let i = 0; i < arr1.length; i++) { - if (!isEqual(arr1[i], arr2[i], compareStack, objectStack1, objectStack2)) { - compareStack.delete(pairKey); - // Clean up before returning - objectStack1.delete(obj1); - objectStack2.delete(obj2); - return false; - } - } + const unmatched = new Set(value2); // tracks entries in map2 not yet matched - compareStack.delete(pairKey); - objectStack1.delete(obj1); - objectStack2.delete(obj2); - return true; - } + for (const [k1, v1] of value1) { + let matchedEntry: [unknown, unknown] | null = null; - // Handle plain objects - const plainObj1 = value1 as Record; - const plainObj2 = value2 as Record; + for (const entry of unmatched) { + const [k2, v2] = entry as [unknown, unknown]; + if (isEqual(k1, k2, pairMemo) && isEqual(v1, v2, pairMemo)) { + matchedEntry = entry; + break; + } + } - const keys1 = Object.keys(plainObj1); - const keys2 = Object.keys(plainObj2); + if (!matchedEntry) return false; // nothing matched this entry + unmatched.delete(matchedEntry); // consume it + } - // If key counts differ, objects aren't equal - if (keys1.length !== keys2.length) { - // Clean up before returning - objectStack1.delete(obj1); - objectStack2.delete(obj2); - return false; + return unmatched.size === 0; } - // Verify all keys in obj2 are in obj1 (we already checked counts, so this - // also ensures all keys in obj1 are in obj2) - for (const key of keys2) { - if (!Object.prototype.hasOwnProperty.call(plainObj1, key)) { - // Clean up before returning - objectStack1.delete(obj1); - objectStack2.delete(obj2); - return false; + // Treat non-plain instances atomically (your current rule) + if (isClassInstance(o1) || isClassInstance(o2)) return false; + + // Cycle guard (pairwise) + if (memoHasOrAdd(pairMemo, o1, o2)) return true; + + // Arrays (respect holes vs undefined) + if (Array.isArray(o1)) { + const a1 = value1 as unknown[], + a2 = value2 as unknown[]; + if (a1.length !== a2.length) return false; + for (let i = 0; i < a1.length; i++) { + const has1 = i in a1, + has2 = i in a2; + if (has1 !== has2) return false; + if (has1 && !isEqual(a1[i], a2[i], pairMemo)) return false; } - } - - // Check for circular references in the comparison context - const pairKey: [unknown, unknown] = [value1, value2]; - if (compareStack.has(pairKey)) { - // Clean up before returning - objectStack1.delete(obj1); - objectStack2.delete(obj2); - return true; - } - compareStack.add(pairKey); - - // Compare each property's value - for (const key of keys1) { - if ( - !isEqual(plainObj1[key], plainObj2[key], compareStack, objectStack1, objectStack2) - ) { - compareStack.delete(pairKey); - // Clean up before returning - objectStack1.delete(obj1); - objectStack2.delete(obj2); - return false; + // Compare enumerable non-index props as well (to align with objects) + const extraKeys1 = Reflect.ownKeys(o1) + .filter((k) => typeof k !== 'string' || isNaN(+k)) + .filter((k) => Object.prototype.propertyIsEnumerable.call(o1, k)); + const extraKeys2 = Reflect.ownKeys(o2) + .filter((k) => typeof k !== 'string' || isNaN(+k)) + .filter((k) => Object.prototype.propertyIsEnumerable.call(o2, k)); + if (extraKeys1.length !== extraKeys2.length) return false; + for (const k of extraKeys1) { + if (!Object.prototype.hasOwnProperty.call(o2, k)) return false; + // @ts-expect-error index signature + if (!isEqual(o1[k], o2[k], pairMemo)) return false; } + return true; } - compareStack.delete(pairKey); - // Clean up before returning successful comparison - objectStack1.delete(obj1); - objectStack2.delete(obj2); + // Plain objects (string + symbol enumerable own keys) + const keys1 = Reflect.ownKeys(o1).filter((k) => + Object.prototype.propertyIsEnumerable.call(o1, k), + ); + const keys2 = Reflect.ownKeys(o2).filter((k) => + Object.prototype.propertyIsEnumerable.call(o2, k), + ); + if (keys1.length !== keys2.length) return false; + + // enforce same prototype to avoid {} == Object.create(null, ...) + if (Object.getPrototypeOf(o1) !== Object.getPrototypeOf(o2)) return false; + + for (const k of keys1) { + if (!Object.prototype.hasOwnProperty.call(o2, k)) return false; + // @ts-expect-error index signature + if (!isEqual(o1[k], o2[k], pairMemo)) return false; + } return true; }; @@ -241,13 +226,7 @@ function compareAndBuildDiff( modified: unknown, parentDiffNode: DiffNode, key?: string | symbol, - /** - * Tracks pairs of objects being compared - * - It stores pairs of values that are being compared `[original, modified]` - * - This helps detect when we're comparing the same pair of objects again - * - It prevents infinite recursion when comparing complex object structures - */ - compareStack = new Set<[unknown, unknown]>(), + pairMemo: PairMemo = new WeakMap(), /** * Tracks individual objects that are being processed in the current traversal path * - It's used to detect when we encounter the same object multiple times in a single traversal path @@ -257,9 +236,7 @@ function compareAndBuildDiff( objectStack = new Set(), ): void { // If values are equal, no diff to record - if (isEqual(original, modified, new Set(compareStack))) { - return; - } + if (isEqual(original, modified)) return; // Handle additions (value in modified but not in original) if (original === undefined || original === null) { @@ -335,16 +312,20 @@ function compareAndBuildDiff( parentDiffNode.children[String(key)] = currentDiffNode; } - // Check for circular references in comparison - const pairKey: [unknown, unknown] = [original, modified]; - if (compareStack.has(pairKey)) { - // Remove from object stack before returning - if (typeof original === 'object' && original !== null) { - objectStack.delete(original); + // Pairwise cycle check (prevents infinite recursion across the *pair*) + if ( + typeof original === 'object' && + original !== null && + typeof modified === 'object' && + modified !== null + ) { + if (memoHasOrAdd(pairMemo, original as object, modified as object)) { + // already visited this exact pair in this diff traversal + // (prevents infinite recursion), so stop here + if (typeof original === 'object') objectStack.delete(original); + return; } - return; } - compareStack.add(pairKey); // Process all keys from both objects const allKeys = new Set([ @@ -380,17 +361,12 @@ function compareAndBuildDiff( modifiedValue, currentDiffNode, childKey, - compareStack, + pairMemo, objectStack, ); } - compareStack.delete(pairKey); - - // Remove from object stack before returning - if (typeof original === 'object' && original !== null) { - objectStack.delete(original); - } + if (typeof original === 'object' && original !== null) objectStack.delete(original); } export function createMergeCore(options: { trackDiff?: boolean } = {}) { diff --git a/test/unit/ChannelPaginatorsOrchestrator.test.ts b/test/unit/ChannelPaginatorsOrchestrator.test.ts index 0d29234e95..be4a09966b 100644 --- a/test/unit/ChannelPaginatorsOrchestrator.test.ts +++ b/test/unit/ChannelPaginatorsOrchestrator.test.ts @@ -31,7 +31,6 @@ describe('ChannelPaginatorsOrchestrator', () => { const orchestrator = new ChannelPaginatorsOrchestrator({ client }); expect(orchestrator.paginators).toHaveLength(0); - // @ts-expect-error accessing protected property expect(orchestrator.pipelines.size).toBe(Object.keys(defaultHandlers).length); }); @@ -74,17 +73,15 @@ describe('ChannelPaginatorsOrchestrator', () => { }); expect(orchestrator.paginators).toHaveLength(1); expect(orchestrator.getPaginatorById(paginator.id)).toStrictEqual(paginator); - // @ts-expect-error accessing protected property expect(orchestrator.pipelines.size).toBe(Object.keys(defaultHandlers).length + 1); + expect(orchestrator.pipelines.get('channel.visible')?.size).toBe(2); // @ts-expect-error accessing protected property - expect(orchestrator.pipelines.get('channel.visible').size).toBe(2); - // @ts-expect-error accessing protected property - expect(orchestrator.pipelines.get('channel.visible').handlers[0].id).toBe( + expect(orchestrator.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(orchestrator.pipelines.get('channel.visible')?.handlers[1].id).toBe( eventHandlers['channel.visible'][1].id, ); @@ -249,6 +246,113 @@ describe('ChannelPaginatorsOrchestrator', () => { }); }); + describe('setEventHandler', () => { + it('replaces the existing handlers for a given event type', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ 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({ + eventType, + id: 'custom', + handle: channelUpdatedHandler1, + }); + + orchestrator.registerSubscriptions(); + + client.dispatchEvent(channelUpdatedEvent); + // event listeners are executed async + await vi.waitFor(() => { + expect(channelUpdatedHandler1).toHaveBeenCalledWith({ + ctx: { orchestrator }, + event: channelUpdatedEvent, + }); + }); + expect(channelUpdatedHandler1).toHaveBeenCalledTimes(1); + expect(channelUpdatedHandler2).toHaveBeenCalledTimes(0); + + orchestrator.setEventHandlers({ + eventType, + handlers: [{ id: 'custom2', handle: channelUpdatedHandler2 }], + }); + + client.dispatchEvent(channelUpdatedEvent); + await vi.waitFor(() => { + expect(channelUpdatedHandler2).toHaveBeenCalledWith({ + ctx: { orchestrator }, + event: channelUpdatedEvent, + }); + }); + + // Unsubscribe the custom handler and ensure it no longer fires + unsubscribe(); + + // still 1 call total (did not increment) + expect(channelUpdatedHandler1).toHaveBeenCalledTimes(1); + expect(channelUpdatedHandler2).toHaveBeenCalledTimes(1); + }); + }); + + describe('removeEventHandler', () => { + it('does not create a pipeline for which the event type is removed', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const eventType = 'channel.updatedX'; + + expect(orchestrator.pipelines.get(eventType)).toBeUndefined(); + orchestrator.removeEventHandlers({ + eventType, + handlers: [{ idMatch: { id: 'XXX' } }], + }); + expect(orchestrator.pipelines.get(eventType)).toBeUndefined(); + }); + + it('removes the existing handlers for a given event type', async () => { + const orchestrator = new ChannelPaginatorsOrchestrator({ client }); + const eventType = 'channel.updated'; + const channelUpdatedEvent = { type: eventType, cid: 'x' } as const; + const channelUpdatedHandler1 = vi.fn(); + const channelUpdatedHandler2 = vi.fn(); + orchestrator.setEventHandlers({ + eventType, + handlers: [ + { + id: 'custom1', + handle: channelUpdatedHandler1, + }, + { + id: 'custom2', + handle: channelUpdatedHandler2, + }, + ], + }); + + orchestrator.registerSubscriptions(); + // @ts-expect-error accessing protected property handlers + expect(orchestrator.pipelines.get(eventType).handlers).toHaveLength(2); + + client.dispatchEvent(channelUpdatedEvent); + // wait for async handler execution + await vi.waitFor(() => { + expect(channelUpdatedHandler1).toHaveBeenCalledTimes(1); + expect(channelUpdatedHandler2).toHaveBeenCalledTimes(1); + }); + + orchestrator.removeEventHandlers({ + eventType, + handlers: [{ idMatch: { id: 'custom', regexMatch: true } }], + }); + client.dispatchEvent(channelUpdatedEvent); + // wait for async handler execution + await vi.waitFor(() => { + expect(channelUpdatedHandler1).toHaveBeenCalledTimes(1); + expect(channelUpdatedHandler2).toHaveBeenCalledTimes(1); + }); + // @ts-expect-error accessing protected property handlers + expect(orchestrator.pipelines.get(eventType).handlers).toHaveLength(0); + }); + }); + describe('ensurePipeline', () => { it('returns the same pipeline instance for the same event type', () => { const orchestrator = new ChannelPaginatorsOrchestrator({ client }); @@ -280,63 +384,66 @@ describe('ChannelPaginatorsOrchestrator', () => { return client.channel(type, id); } - describe('channel.deleted', () => { - it('removes the channel from all paginators', async () => { - const cid = 'messaging:1'; - const ch = makeChannel(cid); + 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); - 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: 'channel.deleted', 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: 'channel.deleted' } 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: 'channel.deleted', cid: 'messaging:404' }); // no such channel + await vi.waitFor(() => { + expect(r).toHaveBeenCalledWith({ id: 'messaging:404', item: undefined }); + }); }); - }); - }); + }, + ); describe.each(['notification.removed_from_channel'] as EventTypes[])( - '%s', + 'event %s', (eventType) => { it('removes the channel from all paginators', async () => { const cid = 'messaging:2'; @@ -394,7 +501,7 @@ describe('ChannelPaginatorsOrchestrator', () => { ); describe.each(['channel.updated', 'channel.truncated'] as EventTypes[])( - '%s', + 'event %s', (eventType) => { it('re-emits item lists for paginators that already contain the channel', async () => { const orchestrator = new ChannelPaginatorsOrchestrator({ client }); @@ -430,7 +537,7 @@ describe('ChannelPaginatorsOrchestrator', () => { 'message.new', 'notification.added_to_channel', 'notification.message_new', - ] as EventTypes[])('%s', (eventType) => { + ] as EventTypes[])('event %s', (eventType) => { it('ingests when matchesFilter, removes when not', async () => { const orchestrator = new ChannelPaginatorsOrchestrator({ client }); const ch = makeChannel('messaging:5'); diff --git a/test/unit/EventHandlerPipeline.test.ts b/test/unit/EventHandlerPipeline.test.ts index 67a0ce934e..de47aaf6f3 100644 --- a/test/unit/EventHandlerPipeline.test.ts +++ b/test/unit/EventHandlerPipeline.test.ts @@ -203,6 +203,68 @@ describe('EventHandlerPipeline', () => { }); }); + describe('findIndex', () => { + const h1 = { + id: 'h1', + handle: () => { + console.log(1); + }, + }; + const h2 = { + id: 'h2', + handle: () => { + console.log(2); + }, + }; + + it('searches by handler function identity', () => { + const h3 = { + id: 'h2', + handle: () => { + console.log(2); + }, + }; + + pipeline.insert(h1); + pipeline.insert(h2); + expect(pipeline.findIndex({ handler: h1 })).toBe(0); + expect(pipeline.findIndex({ handler: h2 })).toBe(1); + expect(pipeline.findIndex({ handler: h3 })).toBe(-1); + }); + + it('searches by exact handler id match', () => { + const h3 = { + id: 'H2', + handle: () => { + console.log(2); + }, + }; + + pipeline.insert(h1); + pipeline.insert(h2); + expect(pipeline.findIndex({ idMatch: { id: h1.id } })).toBe(0); + expect(pipeline.findIndex({ idMatch: { id: h2.id } })).toBe(1); + expect(pipeline.findIndex({ idMatch: { id: h3.id } })).toBe(-1); + }); + + it('searches by handler id matching as regex', () => { + const h3 = { + id: 'H2', + handle: () => { + console.log(2); + }, + }; + + pipeline.insert(h1); + pipeline.insert(h2); + expect(pipeline.findIndex({ idMatch: { id: h1.id, regexMatch: true } })).toBe(0); + expect(pipeline.findIndex({ idMatch: { id: h2.id, regexMatch: true } })).toBe(1); + expect(pipeline.findIndex({ idMatch: { id: new RegExp(h3.id, 'i') } })).toBe(1); + expect(pipeline.findIndex({ idMatch: { id: h3.id, regexMatch: true } })).toBe(-1); + expect(pipeline.findIndex({ idMatch: { id: /h/ } })).toBe(0); + }); + }); + describe('remove', () => { it('removes by handler object identity', async () => { const out: string[] = []; @@ -221,7 +283,7 @@ describe('EventHandlerPipeline', () => { pipeline.insert(h1); pipeline.insert(h2); - pipeline.remove(h2); // remove by object + pipeline.remove({ handler: h2 }); // remove by object // @ts-expect-error passing custom event type await pipeline.run(makeEvt('evt'), ctx); @@ -235,7 +297,7 @@ describe('EventHandlerPipeline', () => { }; const h1: LabeledEventHandler = { id: 'h1', handle: fn }; pipeline.insert(h1); - pipeline.remove(fn); // remove by function ref + pipeline.remove({ handler: fn }); // remove by function ref // @ts-expect-error passing custom event type await pipeline.run(makeEvt('evt'), ctx); @@ -247,7 +309,7 @@ describe('EventHandlerPipeline', () => { const fn = () => { out.push('a'); }; - pipeline.remove(fn); // nothing inserted yet + pipeline.remove({ handler: fn }); // nothing inserted yet // @ts-expect-error passing custom event type await pipeline.run(makeEvt('evt'), ctx); // no errors diff --git a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts index 380aad2d21..df903c0d5f 100644 --- a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts +++ b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts @@ -439,6 +439,51 @@ describe('MessageDeliveryReadTracker', () => { ).toEqual([]); }); }); + + describe('groupUsersByLastReadMessage / groupUsersByLastDeliveredMessage', () => { + it('returns users for whom the given message is their exact *last* read/delivered', () => { + const a = U('a'); + const b = U('b'); + const c = U('c'); + const d = U('d'); // will share timestamp with m3 but different msgId via direct id override + const e = U('e'); // same for delivered side + const f = U('f'); // same for delivered side + + tracker.onMessageDelivered({ + user: c, + deliveredAt: iso(2000), + lastDeliveredMessageId: '2000', + }); + tracker.onMessageDelivered({ + user: a, + deliveredAt: iso(2000), + lastDeliveredMessageId: '2000', + }); + tracker.onMessageDelivered({ + user: e, + deliveredAt: iso(3000), + lastDeliveredMessageId: '3000', + }); + tracker.onMessageDelivered({ + user: f, + deliveredAt: iso(3000), + lastDeliveredMessageId: '3000', + }); + + tracker.onMessageRead({ user: a, readAt: iso(1000), lastReadMessageId: '1000' }); + tracker.onMessageRead({ user: d, readAt: iso(3000), lastReadMessageId: '3000' }); + tracker.onMessageRead({ user: b, readAt: iso(3000), lastReadMessageId: '3000' }); + + expect(tracker.groupUsersByLastDeliveredMessage()).toStrictEqual({ + '2000': [c, a], + '3000': [e, f, d, b], + }); + expect(tracker.groupUsersByLastReadMessage()).toStrictEqual({ + '1000': [a], + '3000': [d, b], + }); + }); + }); }); describe('ordering & movement in sorted arrays', () => { diff --git a/test/unit/pagination/BasePaginator.test.ts b/test/unit/pagination/BasePaginator.test.ts index bf4aa43e84..f82516e31e 100644 --- a/test/unit/pagination/BasePaginator.test.ts +++ b/test/unit/pagination/BasePaginator.test.ts @@ -5,11 +5,17 @@ import { DEFAULT_PAGINATION_OPTIONS, PaginationQueryParams, PaginationQueryReturnValue, + PaginatorCursor, type PaginatorOptions, + PaginatorState, + PrimitiveFilter, + QueryFilter, QueryFilters, + RequireOnlyOne, } from '../../../src'; import { sleep } from '../../../src/utils'; import { makeComparator } from '../../../src/pagination/sortCompiler'; +import { DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES } from '../../../src/constants'; const toNextTick = async () => { const sleepPromise = sleep(0); @@ -26,7 +32,16 @@ type TestItem = { age?: number; }; -class Paginator extends BasePaginator { +type QueryShape = { + filters: { + [Key in keyof TestItem]: + | RequireOnlyOne> + | PrimitiveFilter; + }; + sort: { [Key in keyof TestItem]?: AscDesc }; +}; + +class IncompletePaginator extends BasePaginator { sort: QueryFilters | undefined; sortComparator: (a: TestItem, b: TestItem) => number = vi.fn(); queryResolve: Function = vi.fn(); @@ -34,11 +49,13 @@ class Paginator extends BasePaginator { queryPromise: Promise> | null = null; mockClientQuery = vi.fn(); - constructor(options: PaginatorOptions = {}) { + constructor(options: PaginatorOptions = {}) { super(options); } - query(params: PaginationQueryParams): Promise> { + query( + params: PaginationQueryParams, + ): Promise> { const promise = new Promise>( (queryResolve, queryReject) => { this.queryResolve = queryResolve; @@ -55,11 +72,20 @@ class Paginator extends BasePaginator { } } +const defaultNextQueryShape: QueryShape = { filters: { id: 'test-id' }, sort: { id: 1 } }; + +class Paginator extends IncompletePaginator { + constructor(options: PaginatorOptions = {}) { + super(options); + } + + getNextQueryShape = vi.fn().mockReturnValue(defaultNextQueryShape); +} + describe('BasePaginator', () => { describe('constructor', () => { it('initiates with the defaults', () => { const paginator = new Paginator(); - expect(paginator.pageSize).toBe(DEFAULT_PAGINATION_OPTIONS.pageSize); expect(paginator.state.getLatestValue()).toEqual({ hasNext: true, hasPrev: true, @@ -69,30 +95,126 @@ describe('BasePaginator', () => { cursor: undefined, offset: 0, }); + expect(paginator.isInitialized).toBe(false); // @ts-expect-error accessing protected property expect(paginator._filterFieldToDataResolvers).toHaveLength(0); + expect(paginator.config.initialCursor).toBeUndefined(); + expect(paginator.config.initialOffset).toBeUndefined(); + expect(paginator.config.throwErrors).toBe(false); + expect(paginator.pageSize).toBe(DEFAULT_PAGINATION_OPTIONS.pageSize); + expect(paginator.config.debounceMs).toBe(DEFAULT_PAGINATION_OPTIONS.debounceMs); + expect(paginator.config.lockItemOrder).toBe( + DEFAULT_PAGINATION_OPTIONS.lockItemOrder, + ); + expect(paginator.config.hasPaginationQueryShapeChanged).toBe( + DEFAULT_PAGINATION_OPTIONS.hasPaginationQueryShapeChanged, + ); }); it('initiates with custom options', () => { - const paginator = new Paginator({ pageSize: 1 }); - expect(paginator.pageSize).not.toBe(DEFAULT_PAGINATION_OPTIONS.pageSize); - expect(paginator.pageSize).toBe(1); + const options: PaginatorOptions = { + debounceMs: DEFAULT_PAGINATION_OPTIONS.debounceMs - 100, + doRequest: () => Promise.resolve({ items: [{ id: 'test-id' }] }), + hasPaginationQueryShapeChanged: () => true, + initialCursor: { next: 'next', prev: 'prev' }, + initialOffset: 10, + lockItemOrder: !DEFAULT_PAGINATION_OPTIONS.lockItemOrder, + pageSize: DEFAULT_PAGINATION_OPTIONS.pageSize - 1, + throwErrors: true, + }; + const paginator = new Paginator(options); expect(paginator.state.getLatestValue()).toEqual({ hasNext: true, hasPrev: true, isLoading: false, items: undefined, lastQueryError: undefined, - cursor: undefined, - offset: 0, + cursor: options.initialCursor, + offset: options.initialOffset, }); + expect(paginator.isInitialized).toBe(false); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(0); + expect(paginator.config.initialCursor).toStrictEqual(options.initialCursor); + expect(paginator.config.initialOffset).toStrictEqual(options.initialOffset); + expect(paginator.config.throwErrors).toBe(options.throwErrors); + expect(paginator.pageSize).toBe(options.pageSize); + expect(paginator.config.hasPaginationQueryShapeChanged).toStrictEqual( + options.hasPaginationQueryShapeChanged, + ); + expect(paginator.config.debounceMs).toBe(options.debounceMs); + expect(paginator.config.lockItemOrder).toBe(options.lockItemOrder); }); }); describe('pagination API', () => { - it('paginates to next pages', async () => { + it('throws is the paginator does implement own getNextQueryShape', () => { + const paginator = new IncompletePaginator(); + // @ts-expect-error accessing protected property + expect(paginator.getNextQueryShape).toThrow( + 'Paginator.getNextQueryShape() is not implemented', + ); + }); + + describe('shouldResetStateBeforeQuery', () => { + const stateBeforeQuery: PaginatorState = { + hasNext: true, + hasPrev: true, + isLoading: false, + items: [{ id: 'test-item' }], + lastQueryError: undefined, + cursor: { next: 'next', prev: 'prev' }, + offset: 10, + }; + + const prevQueryShape: QueryShape = { filters: { id: 'a' }, sort: { id: 1 } }; + const nextQueryShape: QueryShape = { filters: { id: 'b' }, sort: { id: 1 } }; + + it('resets the state before a query when querying the first page', () => { + const paginator = new Paginator(); + const initialState = { ...stateBeforeQuery, items: undefined }; + paginator.state.next(initialState); + expect(paginator.state.getLatestValue()).toEqual(initialState); + // @ts-expect-error accessing protected property + expect(paginator.shouldResetStateBeforeQuery()).toBe(true); + }); + + it('resets the state before a query when query shape changed', () => { + const prevQueryShape: QueryShape = { filters: { id: 'a' }, sort: { id: 1 } }; + const nextQueryShape: QueryShape = { filters: { id: 'b' }, sort: { id: 1 } }; + const paginator = new Paginator(); + expect( + // @ts-expect-error accessing protected property + paginator.shouldResetStateBeforeQuery(prevQueryShape, nextQueryShape), + ).toBe(true); + expect( + // @ts-expect-error accessing protected property + paginator.shouldResetStateBeforeQuery(prevQueryShape, prevQueryShape), + ).toBe(false); + }); + + it('determines whether pagination state should be reset before a query using custom logic', () => { + const options = { + hasPaginationQueryShapeChanged: vi.fn().mockReturnValue(true), + }; + const paginator = new Paginator(options); + expect( + // @ts-expect-error accessing protected property + paginator.shouldResetStateBeforeQuery(prevQueryShape, nextQueryShape), + ).toBe(true); + expect( + // @ts-expect-error accessing protected property + paginator.shouldResetStateBeforeQuery(prevQueryShape, prevQueryShape), + ).toBe(true); + expect(options.hasPaginationQueryShapeChanged).toHaveBeenCalledTimes(2); + }); + }); + + it('paginates to next pages (cursor)', async () => { const paginator = new Paginator(); let nextPromise = paginator.next(); + // wait for the DB data first page load + await sleep(0); expect(paginator.isLoading).toBe(true); expect(paginator.hasNext).toBe(true); expect(paginator.hasPrev).toBe(true); @@ -104,7 +226,12 @@ describe('BasePaginator', () => { expect(paginator.hasPrev).toBe(true); expect(paginator.items).toEqual([{ id: 'id1' }]); expect(paginator.cursor).toEqual({ next: 'next1', prev: 'prev1' }); - expect(paginator.mockClientQuery).toHaveBeenCalledWith({ direction: 'next' }); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'next', + queryShape: defaultNextQueryShape, + reset: undefined, + retryCount: 0, + }); nextPromise = paginator.next(); expect(paginator.isLoading).toBe(true); @@ -127,6 +254,55 @@ describe('BasePaginator', () => { expect(paginator.isLoading).toBe(false); expect(paginator.mockClientQuery).toHaveBeenCalledTimes(3); }); + + it('paginates to next pages (offset)', async () => { + const paginator = new Paginator({ pageSize: 1 }); + let nextPromise = paginator.next(); + // wait for the DB data first page load + await sleep(0); + expect(paginator.isLoading).toBe(true); + expect(paginator.hasNext).toBe(true); + expect(paginator.hasPrev).toBe(true); + + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await nextPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasNext).toBe(true); + expect(paginator.hasPrev).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toBeUndefined(); + expect(paginator.offset).toBe(1); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'next', + queryShape: defaultNextQueryShape, + reset: undefined, + retryCount: 0, + }); + + nextPromise = paginator.next(); + expect(paginator.isLoading).toBe(true); + paginator.queryResolve({ items: [{ id: 'id2' }] }); + await nextPromise; + expect(paginator.hasNext).toBe(true); + expect(paginator.hasPrev).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); + expect(paginator.cursor).toBeUndefined(); + expect(paginator.offset).toBe(2); + + nextPromise = paginator.next(); + paginator.queryResolve({ items: [] }); + await nextPromise; + expect(paginator.hasNext).toBe(false); + expect(paginator.hasPrev).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); + expect(paginator.cursor).toBeUndefined(); + expect(paginator.offset).toBe(2); + + paginator.next(); + expect(paginator.isLoading).toBe(false); + expect(paginator.mockClientQuery).toHaveBeenCalledTimes(3); + }); + it('paginates to next pages debounced', async () => { vi.useFakeTimers(); const paginator = new Paginator({ debounceMs: 2000 }); @@ -136,6 +312,8 @@ describe('BasePaginator', () => { expect(paginator.hasNext).toBe(true); expect(paginator.hasPrev).toBe(true); vi.advanceTimersByTime(2000); + // await first page load from the DB + await toNextTick(); expect(paginator.isLoading).toBe(true); expect(paginator.hasNext).toBe(true); expect(paginator.hasPrev).toBe(true); @@ -148,7 +326,12 @@ describe('BasePaginator', () => { expect(paginator.hasPrev).toBe(true); expect(paginator.items).toEqual([{ id: 'id1' }]); expect(paginator.cursor).toEqual({ next: 'next1', prev: 'prev1' }); - expect(paginator.mockClientQuery).toHaveBeenCalledWith({ direction: 'next' }); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'next', + queryShape: defaultNextQueryShape, + reset: undefined, + retryCount: 0, + }); vi.useRealTimers(); }); @@ -156,6 +339,7 @@ describe('BasePaginator', () => { it('paginates to a previous page', async () => { const paginator = new Paginator(); let nextPromise = paginator.prev(); + await sleep(0); expect(paginator.isLoading).toBe(true); expect(paginator.hasNext).toBe(true); expect(paginator.hasPrev).toBe(true); @@ -167,7 +351,12 @@ describe('BasePaginator', () => { expect(paginator.hasPrev).toBe(true); expect(paginator.items).toEqual([{ id: 'id1' }]); expect(paginator.cursor).toEqual({ next: 'next1', prev: 'prev1' }); - expect(paginator.mockClientQuery).toHaveBeenCalledWith({ direction: 'prev' }); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'prev', + queryShape: defaultNextQueryShape, + reset: undefined, + retryCount: 0, + }); nextPromise = paginator.prev(); expect(paginator.isLoading).toBe(true); @@ -189,6 +378,7 @@ describe('BasePaginator', () => { paginator.prev(); expect(paginator.isLoading).toBe(false); }); + it('debounces the pagination to a previous page', async () => { vi.useFakeTimers(); const paginator = new Paginator({ debounceMs: 2000 }); @@ -198,6 +388,7 @@ describe('BasePaginator', () => { expect(paginator.hasNext).toBe(true); expect(paginator.hasPrev).toBe(true); vi.advanceTimersByTime(2000); + await toNextTick(); expect(paginator.isLoading).toBe(true); expect(paginator.hasNext).toBe(true); expect(paginator.hasPrev).toBe(true); @@ -210,13 +401,20 @@ describe('BasePaginator', () => { expect(paginator.hasPrev).toBe(true); expect(paginator.items).toEqual([{ id: 'id1' }]); expect(paginator.cursor).toEqual({ next: 'next1', prev: 'prev1' }); - expect(paginator.mockClientQuery).toHaveBeenCalledWith({ direction: 'prev' }); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'prev', + queryShape: defaultNextQueryShape, + reset: undefined, + retryCount: 0, + }); vi.useRealTimers(); }); it('prevents pagination if another query is in progress', async () => { const paginator = new Paginator(); const nextPromise1 = paginator.next(); + // wait for the first page load from the DB + await sleep(0); expect(paginator.isLoading).toBe(true); expect(paginator.mockClientQuery).toHaveBeenCalledTimes(1); const nextPromise2 = paginator.next(); @@ -225,20 +423,150 @@ describe('BasePaginator', () => { expect(paginator.mockClientQuery).toHaveBeenCalledTimes(1); }); + it('resets the state if the query shape changed', async () => { + const paginator = new Paginator({ pageSize: 1 }); + let nextPromise = paginator.next(); + await sleep(0); + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await nextPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasNext).toBe(true); + expect(paginator.hasPrev).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toBeUndefined(); + expect(paginator.offset).toBe(1); + + paginator.getNextQueryShape.mockReturnValueOnce({ + filters: { id: 'test' }, + sort: { id: -1 }, + }); + nextPromise = paginator.next(); + await sleep(0); + expect(paginator.isLoading).toBe(true); + expect(paginator.items).toBeUndefined(); + expect(paginator.offset).toBe(0); + paginator.queryResolve({ items: [{ id: 'id2' }] }); + await nextPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.items).toEqual([{ id: 'id2' }]); + expect(paginator.offset).toBe(1); + }); + + it('resets the state if forced', async () => { + const paginator = new Paginator({ pageSize: 1 }); + let nextPromise = paginator.next(); + await sleep(0); + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await nextPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasNext).toBe(true); + expect(paginator.hasPrev).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toBeUndefined(); + expect(paginator.offset).toBe(1); + + nextPromise = paginator.next({ reset: 'yes' }); + await sleep(0); + expect(paginator.isLoading).toBe(true); + expect(paginator.items).toBeUndefined(); + expect(paginator.offset).toBe(0); + paginator.queryResolve({ items: [{ id: 'id2' }] }); + await nextPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.items).toEqual([{ id: 'id2' }]); + expect(paginator.offset).toBe(1); + }); + + it('does not reset the state if forced', async () => { + const paginator = new Paginator({ pageSize: 1 }); + let nextPromise = paginator.next(); + await sleep(0); + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await nextPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasNext).toBe(true); + expect(paginator.hasPrev).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toBeUndefined(); + expect(paginator.offset).toBe(1); + + paginator.getNextQueryShape.mockReturnValueOnce({ + filters: { id: 'test' }, + sort: { id: -1 }, + }); + nextPromise = paginator.next({ reset: 'no' }); + await sleep(0); + expect(paginator.items).toStrictEqual([{ id: 'id1' }]); + expect(paginator.offset).toBe(1); + paginator.queryResolve({ items: [{ id: 'id2' }] }); + await nextPromise; + expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); + expect(paginator.offset).toBe(2); + }); + it('stores lastQueryError and clears it with the next successful query', async () => { const paginator = new Paginator(); let nextPromise = paginator.next(); + // wait for the first page load from DB + await sleep(0); const error = new Error('Failed'); paginator.queryReject(error); + // hand over to finish the cleanup and state update after the query execution + await sleep(0); + expect(paginator.lastQueryError).toEqual(error); + expect(paginator.isLoading).toEqual(false); + + nextPromise = paginator.next(); + paginator.queryResolve({ items: [{ id: 'id1' }], next: 'next1', prev: 'prev1' }); await nextPromise; + expect(paginator.lastQueryError).toBeUndefined(); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toEqual({ next: 'next1', prev: 'prev1' }); + }); + + it('throws error if enabled', async () => { + const paginator = new Paginator({ throwErrors: true }); + let nextPromise = paginator.next(); + // wait for the first page load from DB + await sleep(0); + const error = new Error('Failed'); + paginator.queryReject(error); + await expect(nextPromise).rejects.toThrowError(error); + // hand over to finish the cleanup and state update after the query execution + await sleep(0); expect(paginator.lastQueryError).toEqual(error); + expect(paginator.isLoading).toEqual(false); nextPromise = paginator.next(); + // wait for the first page load from DB + await sleep(0); + paginator.queryResolve({ items: [{ id: 'id1' }], next: 'next1', prev: 'prev1' }); + await nextPromise; + expect(paginator.lastQueryError).toBeUndefined(); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toEqual({ next: 'next1', prev: 'prev1' }); + }); + + it('retries the query', async () => { + vi.useFakeTimers(); + const paginator = new Paginator(); + let nextPromise = paginator.next({ retryCount: 2 }); + // wait for the first page load from DB + await toNextTick(); + const error = new Error('Failed'); + paginator.queryReject(error); + // hand over to finish the cleanup and state update after the query execution + await toNextTick(); + expect(paginator.lastQueryError).toEqual(error); + vi.advanceTimersByTime(DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES); + await toNextTick(); + paginator.queryResolve({ items: [{ id: 'id1' }], next: 'next1', prev: 'prev1' }); await nextPromise; expect(paginator.lastQueryError).toBeUndefined(); expect(paginator.items).toEqual([{ id: 'id1' }]); expect(paginator.cursor).toEqual({ next: 'next1', prev: 'prev1' }); + vi.useRealTimers(); }); }); @@ -538,15 +866,118 @@ describe('BasePaginator', () => { }); }); - describe('reload', () => { - it('starts the pagination from the beginning', async () => { - const a: TestItem = { id: 'a', age: 30 }; - const b: TestItem = { id: 'b', age: 25 }; - const c: TestItem = { id: 'c', age: 25 }; - const d: TestItem = { id: 'd', age: 20 }; + describe('setItems', () => { + it('overrides all the items in the state with provided value', () => { + const paginator = new Paginator(); + const items1 = [{ id: 'test-item1' }]; + const items2 = [{ id: 'test-item2' }]; + paginator.setItems(items1); + expect(paginator.items).toStrictEqual(items1); + paginator.setItems(items2); + expect(paginator.items).toStrictEqual(items2); + }); + + const items = [{ id: 'test-item1' }]; + const expectedStateEmissions = [ + { + cursor: undefined, + hasNext: true, + hasPrev: true, + isLoading: false, + items: undefined, + lastQueryError: undefined, + offset: 0, + }, + { + cursor: undefined, + hasNext: true, + hasPrev: true, + isLoading: false, + items, + lastQueryError: undefined, + offset: 1, + }, + ]; + + it('emits state change as long as the items are not the same', () => { + const paginator = new Paginator(); + const subscriptionHandler = vi.fn(); + const unsubscribe = paginator.state.subscribe(subscriptionHandler); + expect(subscriptionHandler).toHaveBeenCalledTimes(1); + expect(subscriptionHandler).toHaveBeenCalledWith( + expectedStateEmissions[0], + undefined, + ); + + paginator.setItems(items); + expect(paginator.items).toStrictEqual(items); + expect(subscriptionHandler).toHaveBeenCalledTimes(2); + expect(subscriptionHandler).toHaveBeenCalledWith( + expectedStateEmissions[1], + expectedStateEmissions[0], + ); + + // setting an object with the same reference + paginator.setItems(items); + expect(paginator.items).toStrictEqual(items); + expect(subscriptionHandler).toHaveBeenCalledTimes(2); + expect(subscriptionHandler).toHaveBeenCalledWith( + expectedStateEmissions[1], + expectedStateEmissions[0], + ); + + unsubscribe(); + }); + it('emits state change as long as the state factory returns objects with different reference', () => { const paginator = new Paginator(); - const nextSpy = vi.spyOn(paginator, 'next').mockResolvedValue(); + const subscriptionHandler = vi.fn(); + const unsubscribe = paginator.state.subscribe(subscriptionHandler); + + paginator.setItems(() => items); + expect(paginator.items).toStrictEqual(items); + // first call is on subscribe + expect(subscriptionHandler).toHaveBeenCalledTimes(2); + expect(subscriptionHandler).toHaveBeenCalledWith( + expectedStateEmissions[1], + expectedStateEmissions[0], + ); + + // setting an object with the same reference + paginator.setItems(() => items); + expect(paginator.items).toStrictEqual(items); + expect(subscriptionHandler).toHaveBeenCalledTimes(2); + expect(subscriptionHandler).toHaveBeenCalledWith( + expectedStateEmissions[1], + expectedStateEmissions[0], + ); + + unsubscribe(); + }); + + it('updates the cursor if provided', () => { + const paginator = new Paginator(); + const cursors: PaginatorCursor[] = [ + { next: 'next1', prev: 'prev1' }, + { next: 'next2', prev: 'prev1' }, + ]; + const subscriptionHandler = vi.fn(); + const unsubscribe = paginator.state.subscribe(subscriptionHandler); + + paginator.setItems(items, cursors[0]); + expect(subscriptionHandler).toHaveBeenCalledTimes(2); + expect(subscriptionHandler).toHaveBeenCalledWith( + { ...expectedStateEmissions[1], cursor: cursors[0], offset: 0 }, + { ...expectedStateEmissions[0], cursor: undefined, offset: 0 }, + ); + + unsubscribe(); + }); + }); + + describe('reload', () => { + it('starts the ended pagination from the beginning', async () => { + const paginator = new Paginator({ pageSize: 2 }); paginator.state.next({ hasNext: false, hasPrev: false, @@ -554,10 +985,67 @@ describe('BasePaginator', () => { items: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }], offset: 4, }); - await paginator.reload(); - expect(nextSpy).toHaveBeenCalledTimes(1); - expect(paginator.state.getLatestValue()).toStrictEqual(paginator.initialState); - nextSpy.mockRestore(); + let reloadPromise = paginator.reload(); + // wait for the DB data first page load + await sleep(0); + expect(paginator.isLoading).toBe(true); + expect(paginator.hasNext).toBe(true); + expect(paginator.hasPrev).toBe(true); + + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await reloadPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasNext).toBe(false); + expect(paginator.hasPrev).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toBeUndefined(); + expect(paginator.offset).toBe(1); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'next', + queryShape: defaultNextQueryShape, + reset: 'yes', + retryCount: 0, + }); + + reloadPromise = paginator.reload(); + // wait for the DB data first page load + await sleep(0); + expect(paginator.isLoading).toBe(true); + expect(paginator.hasNext).toBe(true); + expect(paginator.hasPrev).toBe(true); + + paginator.queryResolve({ items: [{ id: 'id2' }], next: 'next2' }); + await reloadPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasNext).toBe(true); + expect(paginator.hasPrev).toBe(false); + expect(paginator.items).toEqual([{ id: 'id2' }]); + expect(paginator.cursor).toStrictEqual({ next: 'next2', prev: null }); + expect(paginator.offset).toBe(0); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'next', + queryShape: defaultNextQueryShape, + reset: 'yes', + retryCount: 0, + }); + + // reset in another direction + reloadPromise = paginator.reload(); + // wait for the DB data first page load + await sleep(0); + expect(paginator.isLoading).toBe(true); + expect(paginator.hasNext).toBe(true); + expect(paginator.hasPrev).toBe(true); + expect(paginator.items).toBe(undefined); + + paginator.queryResolve({ items: [{ id: 'id2' }], next: 'next2' }); + await reloadPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasNext).toBe(true); + expect(paginator.hasPrev).toBe(false); + expect(paginator.items).toEqual([{ id: 'id2' }]); + expect(paginator.cursor).toStrictEqual({ next: 'next2', prev: null }); + expect(paginator.offset).toBe(0); }); }); diff --git a/test/unit/pagination/ChannelPaginator.test.ts b/test/unit/pagination/ChannelPaginator.test.ts index b33b18a8bc..08877482c7 100644 --- a/test/unit/pagination/ChannelPaginator.test.ts +++ b/test/unit/pagination/ChannelPaginator.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, MockInstance, vi } from 'vitest'; import { Channel, type ChannelFilters, @@ -7,10 +7,12 @@ import { ChannelSort, DEFAULT_PAGINATION_OPTIONS, type FilterBuilderGenerators, + PaginatorCursor, type StreamChat, } from '../../../src'; import { getClientWithUser } from '../test-utils/getClient'; import type { FieldToDataResolver } from '../../../src/pagination/types.normalization'; +import { MockOfflineDB } from '../offline-support/MockOfflineDB'; const user = { id: 'custom-id' }; @@ -31,85 +33,116 @@ describe('ChannelPaginator', () => { channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; }); - it('initiates with defaults', () => { - const paginator = new ChannelPaginator({ client }); - expect(paginator.pageSize).toBe(DEFAULT_PAGINATION_OPTIONS.pageSize); - expect(paginator.state.getLatestValue()).toEqual({ - hasNext: true, - hasPrev: true, - isLoading: false, - items: undefined, - lastQueryError: undefined, - cursor: undefined, - offset: 0, - }); - expect(paginator.id.startsWith('channel-paginator')).toBeTruthy(); - expect(paginator.sortComparator).toBeDefined(); - - channel1.state.last_message_at = new Date('1970-01-01T08:39:35.235Z'); - channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; + describe('constructor()', () => { + it('initiates with defaults', () => { + const paginator = new ChannelPaginator({ client }); + expect(paginator.pageSize).toBe(DEFAULT_PAGINATION_OPTIONS.pageSize); + expect(paginator.state.getLatestValue()).toEqual({ + hasNext: true, + hasPrev: true, + isLoading: false, + items: undefined, + lastQueryError: undefined, + cursor: undefined, + offset: 0, + }); + expect(paginator.id.startsWith('channel-paginator')).toBeTruthy(); + expect(paginator.sortComparator).toBeDefined(); - channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); - channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; + channel1.state.last_message_at = new Date('1970-01-01T08:39:35.235Z'); + channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; - expect(paginator.sortComparator(channel1, channel2)).toBe(1); // channel2 comes before channel1 - expect(paginator.filterBuilder.buildFilters()).toStrictEqual({}); - expect( - paginator.filterBuilder.buildFilters({ baseFilters: paginator.filters }), - ).toStrictEqual({}); - // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toHaveLength(4); - }); + channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); + channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; - it('initiates with options', () => { - const customId = 'custom-id'; - const filterGenerators: FilterBuilderGenerators = { - custom: { - enabled: true, - generate: (context) => context, - }, - }; - const initialFilterBuilderContext = { x: 'y' }; - - channel1.data!.created_at = '1970-01-01T08:39:35.235Z'; - channel2.data!.created_at = '1971-01-01T08:39:35.235Z'; - - const paginator = new ChannelPaginator({ - client, - id: customId, - filterBuilderOptions: { - initialContext: initialFilterBuilderContext, - initialFilterConfig: filterGenerators, - }, - filters: { type: 'type' }, - paginatorOptions: { pageSize: 2 }, - requestOptions: { member_limit: 5 }, - sort: { created_at: 1 }, + expect(paginator.sortComparator(channel1, channel2)).toBe(1); // channel2 comes before channel1 + expect(paginator.filterBuilder.buildFilters()).toStrictEqual({}); + expect( + paginator.filterBuilder.buildFilters({ baseFilters: paginator.staticFilters }), + ).toStrictEqual({}); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(4); + expect(paginator.config.doRequest).toBeUndefined(); }); - expect(paginator.pageSize).toBe(2); - expect(paginator.state.getLatestValue()).toEqual({ - hasNext: true, - hasPrev: true, - isLoading: false, - items: undefined, - lastQueryError: undefined, - cursor: undefined, - offset: 0, - }); - expect(paginator.id.startsWith(customId)).toBeTruthy(); - expect(paginator.sortComparator(channel1, channel2)).toBe(-1); // channel1 comes before channel2 - expect(paginator.filterBuilder.buildFilters()).toStrictEqual({ - ...initialFilterBuilderContext, - }); - expect( - paginator.filterBuilder.buildFilters({ baseFilters: paginator.filters }), - ).toStrictEqual({ - type: 'type', - ...initialFilterBuilderContext, + it('initiates with options', () => { + const customId = 'custom-id'; + const filterGenerators: FilterBuilderGenerators = { + custom: { + enabled: true, + generate: (context) => context, + }, + }; + const initialFilterBuilderContext = { x: 'y' }; + + channel1.data!.created_at = '1970-01-01T08:39:35.235Z'; + channel2.data!.created_at = '1971-01-01T08:39:35.235Z'; + const doRequest = () => Promise.resolve({ items: [channel1] }); + const hasPaginationQueryShapeChanged = () => true; + const paginatorOptions = { + debounceMs: 45000, + doRequest, + hasPaginationQueryShapeChanged, + initialCursor: { prev: 'prev', next: '' }, + initialOffset: 10, + lockItemOrder: true, + pageSize: 2, + throwErrors: true, + }; + + const paginator = new ChannelPaginator({ + client, + id: customId, + filterBuilderOptions: { + initialContext: initialFilterBuilderContext, + initialFilterConfig: filterGenerators, + }, + filters: { type: 'type' }, + paginatorOptions, + requestOptions: { member_limit: 5 }, + sort: { created_at: 1 }, + }); + expect(paginator.pageSize).toBe(2); + expect(paginator.state.getLatestValue()).toEqual({ + hasNext: true, + hasPrev: true, + isLoading: false, + items: undefined, + lastQueryError: undefined, + cursor: paginatorOptions.initialCursor, + offset: paginatorOptions.initialOffset, + }); + expect(paginator.id.startsWith(customId)).toBeTruthy(); + + expect(paginator.sortComparator(channel1, channel2)).toBe(-1); // channel1 comes before channel2 + expect(paginator.filterBuilder.buildFilters()).toStrictEqual({ + ...initialFilterBuilderContext, + }); + expect( + paginator.filterBuilder.buildFilters({ baseFilters: paginator.staticFilters }), + ).toStrictEqual({ + type: 'type', + ...initialFilterBuilderContext, + }); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(4); + expect(paginator.config.debounceMs).toStrictEqual(paginatorOptions.debounceMs); + expect(paginator.config.doRequest).toStrictEqual(doRequest); + expect(paginator.config.hasPaginationQueryShapeChanged).toStrictEqual( + hasPaginationQueryShapeChanged, + ); + expect(paginator.config.initialCursor).toStrictEqual( + paginatorOptions.initialCursor, + ); + expect(paginator.config.initialOffset).toStrictEqual( + paginatorOptions.initialOffset, + ); + expect(paginator.config.pageSize).toStrictEqual(paginatorOptions.pageSize); + expect(paginator.config.lockItemOrder).toStrictEqual( + paginatorOptions.lockItemOrder, + ); + expect(paginator.config.throwErrors).toStrictEqual(paginatorOptions.throwErrors); }); - // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toHaveLength(4); }); describe('sortComparator', () => { @@ -371,26 +404,72 @@ describe('ChannelPaginator', () => { lastQueryError: undefined, cursor: undefined, }; - it('filters reset state', () => { + + it('filters reset does not reset the paginator state', () => { const paginator = new ChannelPaginator({ client }); paginator.state.partialNext(stateAfterQuery); expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); - paginator.filters = {}; - expect(paginator.state.getLatestValue()).toStrictEqual(paginator.initialState); + paginator.staticFilters = {}; + expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + expect(paginator.staticFilters).toStrictEqual({}); }); - it('sort reset state', () => { + + it('sort reset does not reset the paginator state updates the comparator', () => { const paginator = new ChannelPaginator({ client }); paginator.state.partialNext(stateAfterQuery); expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + const originalComparator = paginator.sortComparator; paginator.sort = {}; - expect(paginator.state.getLatestValue()).toStrictEqual(paginator.initialState); + expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + expect(paginator.sort).toStrictEqual({}); + expect(paginator.sortComparator).not.toEqual(originalComparator); }); - it('options reset state', () => { + + it('options reset does not reset the paginator state', () => { const paginator = new ChannelPaginator({ client }); paginator.state.partialNext(stateAfterQuery); expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); paginator.options = {}; - expect(paginator.state.getLatestValue()).toStrictEqual(paginator.initialState); + expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + expect(paginator.options).toStrictEqual({}); + }); + + it('channelStateOptions reset does not reset the paginator state', () => { + const paginator = new ChannelPaginator({ client }); + paginator.state.partialNext(stateAfterQuery); + expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + paginator.channelStateOptions = {}; + expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + expect(paginator.channelStateOptions).toStrictEqual({}); + }); + }); + + describe('setItems', () => { + it('stores the new items in the offlineDB', async () => { + client.setOfflineDBApi(new MockOfflineDB({ client })); + (client.offlineDb!.initializeDB as unknown as MockInstance).mockReturnValue(true); + await client.offlineDb!.init(client.userID as string); + ( + client.offlineDb?.upsertCidsForQuery as unknown as MockInstance + ).mockImplementation(() => Promise.resolve(true)); + + const filters = { id: 'abc' }; + const sort = { id: 1 }; + const items1 = [channel1]; + + const paginator = new ChannelPaginator({ client }); + paginator.staticFilters = filters; + paginator.sort = sort; + + paginator.setItems(items1); + expect(paginator.items).toStrictEqual(items1); + expect( + client.offlineDb?.upsertCidsForQuery as unknown as MockInstance, + ).toHaveBeenCalledWith({ + cids: [channel1.cid], + filters, + sort, + }); }); }); @@ -435,6 +514,7 @@ describe('ChannelPaginator', () => { message_limit: 3, offset: 0, }, + undefined, // channelStateOptions ); }); }); diff --git a/test/unit/utils/mergeWith.test.ts b/test/unit/utils/mergeWith.test.ts index 555b6d791a..6d709f701b 100644 --- a/test/unit/utils/mergeWith.test.ts +++ b/test/unit/utils/mergeWith.test.ts @@ -638,10 +638,13 @@ describe('isEqual', () => { expect(isEqual(true, true)).toBe(true); expect(isEqual(null, null)).toBe(true); expect(isEqual(undefined, undefined)).toBe(true); + expect(isEqual(-0, 0)).toBe(true); }); it('should consider different primitives not equal', () => { expect(isEqual(42, 43)).toBe(false); + expect(isEqual('1', 1)).toBe(false); + expect(isEqual(1, true)).toBe(false); expect(isEqual('hello', 'world')).toBe(false); expect(isEqual(true, false)).toBe(false); expect(isEqual(null, undefined)).toBe(false); @@ -659,6 +662,7 @@ describe('isEqual', () => { expect(isEqual([1, 2, 3], [1, 2, 3])).toBe(true); expect(isEqual([1, 2, 3], [1, 2, 4])).toBe(false); expect(isEqual([1, 2], [1, 2, 3])).toBe(false); + expect(isEqual([1, 2], [2, 1])).toBe(false); expect(isEqual([], [])).toBe(true); }); @@ -666,6 +670,7 @@ describe('isEqual', () => { expect(isEqual([1, [2, 3]], [1, [2, 3]])).toBe(true); expect(isEqual([1, [2, 3]], [1, [2, 4]])).toBe(false); expect(isEqual([1, [2, [3]]], [1, [2, [3]]])).toBe(true); + expect(isEqual([1], [1, 2])).toBe(false); }); it('should compare objects by value', () => { @@ -684,12 +689,119 @@ describe('isEqual', () => { ); }); + it('ignores property order; compares by keys/values', () => { + expect(isEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true); + }); + it('should compare mixed nested structures', () => { expect(isEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] })).toBe(true); expect(isEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 3 }] })).toBe(false); expect(isEqual([{ a: 1 }, [2, 3]], [{ a: 1 }, [2, 3]])).toBe(true); }); + it('arrays: holes vs explicit undefined are not equal', () => { + const a = [, 1]; // hole at index 0 + const b = [undefined, 1]; + expect(isEqual(a, b)).toBe(false); + }); + + it('symbol keys: equal when both present and equal; unequal when missing or different', () => { + const s1 = Symbol('s'); + const s2 = Symbol('s'); // different identity even if same description + + expect(isEqual({ [s1]: 1 }, { [s1]: 1 })).toBe(true); + expect(isEqual({ [s1]: 1 }, { [s1]: 2 })).toBe(false); + expect(isEqual({ [s1]: 1 }, {})).toBe(false); + expect(isEqual({ [s1]: 1 }, { [s2]: 1 })).toBe(false); + }); + + it('sets: equal contents regardless of order', () => { + const a = new Set([1, 2, 3]); + const b = new Set([3, 2, 1]); + expect(isEqual(a, b)).toBe(true); + }); + + it('sets: unequal when contents differ', () => { + expect(isEqual(new Set([1, 2]), new Set([1, 3]))).toBe(false); + }); + + it('sets: deep equality of object elements', () => { + expect( + isEqual(new Set([{ id: 1 }, { id: 2 }]), new Set([{ id: 2 }, { id: 1 }])), + ).toBe(true); + expect( + isEqual(new Set([{ id: 1 }, { id: 1 }]), new Set([{ id: 2 }, { id: 1 }])), + ).toBe(false); + expect( + isEqual(new Set([{ id: 2 }, { id: 1 }]), new Set([{ id: 1 }, { id: 1 }])), + ).toBe(false); + }); + + it('sets: unequal sizes', () => { + expect(isEqual(new Set([1]), new Set([1, 2]))).toBe(false); + }); + + it('sets: identical references are always equal', () => { + const s = new Set([1]); + expect(isEqual(s, s)).toBe(true); + }); + + it('maps: same entries regardless of order', () => { + const a = new Map([ + ['x', 1], + ['y', 2], + ]); + const b = new Map([ + ['y', 2], + ['x', 1], + ]); + expect(isEqual(a, b)).toBe(true); + }); + + it('maps: unequal size', () => { + const a = new Map([['x', 1]]); + const b = new Map([ + ['x', 1], + ['y', 2], + ]); + expect(isEqual(a, b)).toBe(false); + }); + + it('maps: unequal value for same key', () => { + const a = new Map([['x', 1]]); + const b = new Map([['x', 2]]); + expect(isEqual(a, b)).toBe(false); + }); + + it('maps: deep key equality', () => { + const a = new Map([[{ id: 1 }, 'A']]); + const b = new Map([[{ id: 1 }, 'A']]); + expect(isEqual(a, b)).toBe(true); + }); + + it('maps: deep value equality', () => { + const a = new Map([['user', { name: 'Ann' }]]); + const b = new Map([['user', { name: 'Ann' }]]); + expect(isEqual(a, b)).toBe(true); + }); + + it('maps: duplicate keys or values require one-to-one pairing', () => { + const a = new Map([ + [{ id: 1 }, 'x'], + [{ id: 1 }, 'x'], + ]); + const b = new Map([ + [{ id: 1 }, 'x'], + [{ id: 2 }, 'x'], + ]); + expect(isEqual(a, b)).toBe(false); + }); + + it('maps: identical reference maps equal', () => { + const m = new Map([['a', 1]]); + expect(isEqual(m, m)).toBe(true); + }); + it('should handle Date objects', () => { const date1 = new Date('2023-01-01'); const date2 = new Date('2023-01-01'); @@ -698,6 +810,8 @@ describe('isEqual', () => { expect(isEqual(date1, date2)).toBe(true); expect(isEqual(date1, date3)).toBe(false); expect(isEqual({ date: date1 }, { date: date2 })).toBe(true); + // invalid dates compare false + expect(isEqual(new Date('x'), new Date('x'))).toBe(false); }); it('should handle RegExp objects', () => { @@ -708,6 +822,18 @@ describe('isEqual', () => { expect(isEqual(regex1, regex2)).toBe(true); expect(isEqual(regex1, regex3)).toBe(false); expect(isEqual({ regex: regex1 }, { regex: regex2 })).toBe(true); + expect(isEqual([regex1, regex2], [regex1, regex2])).toBe(true); + expect(isEqual([regex2, regex1], [regex1, regex2])).toBe(true); + expect(isEqual([regex3, regex1], [regex1, regex3])).toBe(false); + }); + + it('different object prototypes but same enumerable props', () => { + const a = { x: 1 }; + // creates an object without a prototype + const b = Object.create(null); + b.x = 1; + + expect(isEqual(a, b)).toBe(false); }); it('should handle class instances as not equal', () => { @@ -719,6 +845,13 @@ describe('isEqual', () => { expect(isEqual(file1, file1)).toBe(true); // Same reference is equal }); + it('typed arrays / buffers (treated atomically via instance rule)', () => { + const ta1 = new Uint8Array([1, 2, 3]); + const ta2 = new Uint8Array([1, 2, 3]); + expect(isEqual(ta1, ta2)).toBe(false); + expect(isEqual(ta1, ta1)).toBe(true); + }); + it('should handle circular references', () => { const obj1: any = { a: 1 }; obj1.self = obj1; @@ -748,15 +881,17 @@ describe('isEqual', () => { expect(isEqual(obj1, obj3)).toBe(true); expect(isEqual(obj1, obj5)).toBe(false); - }); - it('should compare object property keys correctly', () => { - // Objects with same keys but different order - expect(isEqual({ a: 1, b: 2, c: 3 }, { c: 3, b: 2, a: 1 })).toBe(true); + const a1: any = { n: 1 }, + a2: any = { n: 2 }; + a1.other = a2; + a2.other = a1; + + const b1: any = { n: 1 }, + b2: any = { n: 2 }; + b1.other = b2; + b2.other = b1; - // Ensure keys in second object are correctly checked - const obj1 = { a: 1, b: 2 }; - const obj2 = { a: 1, c: 3 }; - expect(isEqual(obj1, obj2)).toBe(false); + expect(isEqual(a1, b1)).toBe(true); }); }); From f4892c60d6ddcea250ed688cc837b489f0f98741 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 10 Nov 2025 15:39:59 +0100 Subject: [PATCH 04/48] feat: add filter resolvers for channel filters archived, app_banned, has_unread, last_updated --- src/pagination/ChannelPaginator.ts | 72 ++++++-- test/unit/pagination/ChannelPaginator.test.ts | 163 +++++++++++++++++- 2 files changed, 220 insertions(+), 15 deletions(-) diff --git a/src/pagination/ChannelPaginator.ts b/src/pagination/ChannelPaginator.ts index 6609ff1d7a..cb11ac7780 100644 --- a/src/pagination/ChannelPaginator.ts +++ b/src/pagination/ChannelPaginator.ts @@ -50,8 +50,15 @@ export type ChannelPaginatorOptions = { }; const getQueryShapeRelevantChannelOptions = (options: ChannelOptions) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { limit: _, member_limit: __, message_limit: ___, ...relevantShape } = options; + const { + /* eslint-disable @typescript-eslint/no-unused-vars */ + limit: _, + member_limit: __, + message_limit: ___, + offset: ____, + /* eslint-enable @typescript-eslint/no-unused-vars */ + ...relevantShape + } = options; return relevantShape; }; @@ -69,9 +76,45 @@ const hasPaginationQueryShapeChanged: PaginationQueryShapeChangeIdentifier< }, ); -const pinnedFilterResolver: FieldToDataResolver = { - matchesField: (field) => field === 'pinned', - resolve: (channel) => !!channel.state.membership.pinned_at, +const archivedFilterResolver: FieldToDataResolver = { + matchesField: (field) => field === 'archived', + resolve: (channel) => !!channel.state.membership.archived_at, +}; + +const appBannedFilterResolver: FieldToDataResolver = { + matchesField: (field) => field === 'app_banned', + resolve: (channel) => { + const ownUserId = channel.getClient().user?.id; + const otherMembers = Object.values(channel.state.members).filter( + ({ user }) => user?.id !== ownUserId, + ); + // Only applies to channels with exactly 2 members. + if (otherMembers.length !== 1) return false; + const otherMember = otherMembers[0]; + return otherMember.user?.banned ? 'only' : 'excluded'; + }, +}; + +const hasUnreadFilterResolver: FieldToDataResolver = { + matchesField: (field) => field === 'has_unread', + resolve: (channel) => { + const ownUserId = channel.getClient().user?.id; + return ownUserId && channel.state.read[ownUserId].unread_messages > 0; + }, +}; + +const lastUpdatedFilterResolver: FieldToDataResolver = { + matchesField: (field) => field === 'last_updated', + resolve: (channel) => { + // combination of last_message_at and updated_at + const lastMessageAt = channel.state.last_message_at?.getTime() ?? null; + const updatedAt = channel.data?.updated_at + ? new Date(channel.data?.updated_at).getTime() + : undefined; + return lastMessageAt !== null && updatedAt !== undefined + ? Math.max(lastMessageAt, updatedAt) + : (lastMessageAt ?? updatedAt); + }, }; const membersFilterResolver: FieldToDataResolver = { @@ -100,6 +143,11 @@ const memberUserNameFilterResolver: FieldToDataResolver = { : [], }; +const pinnedFilterResolver: FieldToDataResolver = { + matchesField: (field) => field === 'pinned', + resolve: (channel) => !!channel.state.membership.pinned_at, +}; + const dataFieldFilterResolver: FieldToDataResolver = { matchesField: () => true, resolve: (channel, path) => resolveDotPathValue(channel.data, path), @@ -111,16 +159,10 @@ const channelSortPathResolver: PathResolver = (channel, path) => { case 'last_message_at': return channel.state.last_message_at; case 'has_unread': { - const userId = channel.getClient().user?.id; - return !!(userId && channel.state.read[userId].unread_messages); + return hasUnreadFilterResolver.resolve(channel, path); } case 'last_updated': { - // combination of last_message_at and updated_at - const lastMessageAt = channel.state.last_message_at?.getTime() ?? 0; - const updatedAt = channel.data?.updated_at - ? new Date(channel.data?.updated_at).getTime() - : 0; - return lastMessageAt >= updatedAt ? lastMessageAt : updatedAt; + return lastUpdatedFilterResolver.resolve(channel, path) ?? 0; } case 'pinned_at': return channel.state.membership.pinned_at; @@ -175,6 +217,10 @@ export class ChannelPaginator extends BasePaginator }, }); this.setFilterResolvers([ + archivedFilterResolver, + appBannedFilterResolver, + hasUnreadFilterResolver, + lastUpdatedFilterResolver, pinnedFilterResolver, membersFilterResolver, memberUserNameFilterResolver, diff --git a/test/unit/pagination/ChannelPaginator.test.ts b/test/unit/pagination/ChannelPaginator.test.ts index 08877482c7..9ce1c0564a 100644 --- a/test/unit/pagination/ChannelPaginator.test.ts +++ b/test/unit/pagination/ChannelPaginator.test.ts @@ -61,7 +61,7 @@ describe('ChannelPaginator', () => { paginator.filterBuilder.buildFilters({ baseFilters: paginator.staticFilters }), ).toStrictEqual({}); // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toHaveLength(4); + expect(paginator._filterFieldToDataResolvers).toHaveLength(8); expect(paginator.config.doRequest).toBeUndefined(); }); @@ -125,7 +125,7 @@ describe('ChannelPaginator', () => { ...initialFilterBuilderContext, }); // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toHaveLength(4); + expect(paginator._filterFieldToDataResolvers).toHaveLength(8); expect(paginator.config.debounceMs).toStrictEqual(paginatorOptions.debounceMs); expect(paginator.config.doRequest).toStrictEqual(doRequest); expect(paginator.config.hasPaginationQueryShapeChanged).toStrictEqual( @@ -286,6 +286,165 @@ describe('ChannelPaginator', () => { }); describe('filter resolvers', () => { + const otherUserId = 'other-user'; + it('resolves field "archived"', () => { + const paginator = new ChannelPaginator({ + client, + filters: { members: { $in: [user.id] }, archived: true }, + }); + + channel1.state.members = { + [user.id]: { user }, + [otherUserId]: { user: { id: otherUserId } }, + }; + + channel1.state.membership = { + user, + archived_at: '2025-09-03T12:19:39.101089Z', + }; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + channel1.state.membership = { + user, + archived_at: undefined, + }; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + + it('resolves field "app_banned"', () => { + const paginator = new ChannelPaginator({ + client, + filters: { members: { $in: [user.id] }, app_banned: 'only' }, + }); + + channel1.state.members = { + [user.id]: { user }, + [otherUserId]: { user: { id: otherUserId, banned: true } }, + }; + + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + channel1.state.members[otherUserId].user!.banned = false; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + + // ===== excluded ==== + paginator.staticFilters = { members: { $in: [user.id] }, app_banned: 'excluded' }; + + channel1.state.members[otherUserId].user!.banned = true; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + + channel1.state.members[otherUserId].user!.banned = false; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + }); + + it('resolves field "has_unread"', () => { + const paginator = new ChannelPaginator({ + client, + filters: { has_unread: true }, + }); + + channel1.state.read = { + [user.id]: { last_read: new Date(2000), unread_messages: 0, user }, + [otherUserId]: { + last_read: new Date(1000), + unread_messages: 1, + user: { id: otherUserId }, + }, + }; + + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + + channel1.state.read[user.id].unread_messages = 1; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + }); + + describe('resolves field "last_updated"', () => { + it('for primitive filter', () => { + const paginator = new ChannelPaginator({ + client, + filters: { last_updated: new Date(1000).toISOString() }, + }); + channel1.data = { updated_at: undefined }; + channel1.state.last_message_at = new Date(1000); + + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + channel1.data = { updated_at: new Date(1000).toISOString() }; + channel1.state.last_message_at = null; + + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + channel1.data = { updated_at: undefined }; + channel1.state.last_message_at = null; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + + it.each([ + [ + '$eq', + [ + { val: 1000, expected: true }, + { val: 1001, expected: false }, + { val: 999, expected: false }, + ], + ], + [ + '$gt', + [ + { val: 1000, expected: false }, + { val: 1001, expected: true }, + { val: 999, expected: false }, + ], + ], + [ + '$gte', + [ + { val: 1000, expected: true }, + { val: 1001, expected: true }, + { val: 999, expected: false }, + ], + ], + [ + '$lt', + [ + { val: 1000, expected: false }, + { val: 1001, expected: false }, + { val: 999, expected: true }, + ], + ], + [ + '$lte', + [ + { val: 1000, expected: true }, + { val: 1001, expected: false }, + { val: 999, expected: true }, + ], + ], + ])('for operator %s', (operator, scenarios) => { + const paginator = new ChannelPaginator({ + client, + // @ts-expect-error operator in variable + filters: { last_updated: { [operator]: new Date(1000).toISOString() } }, + }); + + channel1.data = { updated_at: undefined }; + scenarios.forEach(({ val, expected }) => { + channel1.state.last_message_at = new Date(val); + expect(paginator.matchesFilter(channel1)).toBe(expected); + }); + + channel1.state.last_message_at = null; + scenarios.forEach(({ val, expected }) => { + channel1.data = { updated_at: new Date(val).toISOString() }; + expect(paginator.matchesFilter(channel1)).toBe(expected); + }); + + channel1.data = { updated_at: undefined }; + channel1.state.last_message_at = null; + expect(paginator.matchesFilter(channel1)).toBe(false); + }); + }); + it('resolves "pinned" field', () => { const paginator = new ChannelPaginator({ client, From aeaa92d9d55293b9b5d9da01341ff06fb8b50074 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 10 Nov 2025 17:28:52 +0100 Subject: [PATCH 05/48] feat: allow to keep channels in certain matching paginators and not in other matching paginators --- src/ChannelPaginatorsOrchestrator.ts | 118 ++++++++++++-- .../ChannelPaginatorsOrchestrator.test.ts | 150 +++++++++++++++++- 2 files changed, 251 insertions(+), 17 deletions(-) diff --git a/src/ChannelPaginatorsOrchestrator.ts b/src/ChannelPaginatorsOrchestrator.ts index b3aecabc9e..b9526b1eea 100644 --- a/src/ChannelPaginatorsOrchestrator.ts +++ b/src/ChannelPaginatorsOrchestrator.ts @@ -22,6 +22,52 @@ type EventHandlerContext = ChannelPaginatorsOrchestratorEventHandlerContext; type SupportedEventType = EventTypes | (string & {}); +/** + * Resolves which paginators should be the "owners" of a channel + * when the channel matches multiple paginator filters. + * + * Return a set of paginator ids that should keep/own the item. + * Returning an empty set means the channel will be removed everywhere. + */ +export type PaginatorOwnershipResolver = (args: { + channel: Channel; + matchingPaginators: ChannelPaginator[]; +}) => string[]; + +/** + * Convenience factory for a priority-based ownership resolver. + * - Provide an ordered list of paginator ids from highest to lowest priority. + * - If two or more paginators match a channel, the one with the highest priority wins. + * - If none of the matching paginator ids are in the priority list, all matches are kept (back-compat). + */ +export const createPriorityOwnershipResolver = ( + priority?: string[], +): PaginatorOwnershipResolver => { + if (!priority) { + return ({ matchingPaginators }) => matchingPaginators.map((p) => p.id); + } + const rank = new Map(priority.map((id, index) => [id, index])); + return ({ matchingPaginators }) => { + if (matchingPaginators.length <= 1) { + return matchingPaginators.map((p) => p.id); + } + // The winner is the first item in the sorted array of matching paginators + const winner = [...matchingPaginators].sort((a, b) => { + const rankA = rank.get(a.id); + const rankB = rank.get(b.id); + const valueA = rankA === undefined ? Number.POSITIVE_INFINITY : rankA; + const valueB = rankB === undefined ? Number.POSITIVE_INFINITY : rankB; + return valueA - valueB; + })[0]; + const winnerValue = rank.get(winner.id); + // If no explicit priority is set for any, keep all (preserve current behavior) + if (winnerValue === undefined) { + return matchingPaginators.map((p) => p.id); + } + return [winner.id]; + }; +}; + const getCachedChannelFromEvent = ( event: Event, cache: Record, @@ -101,25 +147,41 @@ const updateLists: EventHandlerPipelineHandler = async ({ if (!channel) return; + const matchingPaginators = orchestrator.paginators.filter((p) => + p.matchesFilter(channel), + ); + const matchingIds = new Set(matchingPaginators.map((p) => p.id)); + + const ownerIds = orchestrator.resolveOwnership(channel, matchingPaginators); + orchestrator.paginators.forEach((paginator) => { - if (paginator.matchesFilter(channel)) { - const channelBoost = paginator.getBoost(channel.cid); - if ( - [ - 'message.new', - 'notification.message_new', - 'notification.added_to_channel', - 'channel.visible', - ].includes(event.type) && - (!channelBoost || channelBoost.seq < paginator.maxBoostSeq) - ) { - paginator.boost(channel.cid, { seq: paginator.maxBoostSeq + 1 }); - } - paginator.ingestItem(channel); - } else { + if (!matchingIds.has(paginator.id)) { // remove if it does not match the filter anymore paginator.removeItem({ item: channel }); + return; + } + + // Only if owners are specified, the items is removed from the non-owner matching paginators + if (ownerIds.size > 0 && !ownerIds.has(paginator.id)) { + // matched, but not selected to own - remove to enforce exclusivity + paginator.removeItem({ item: channel }); + return; + } + + // Selected owner: optionally boost then ingest + const channelBoost = paginator.getBoost(channel.cid); + if ( + [ + 'message.new', + 'notification.message_new', + 'notification.added_to_channel', + 'channel.visible', + ].includes(event.type) && + (!channelBoost || channelBoost.seq < paginator.maxBoostSeq) + ) { + paginator.boost(channel.cid, { seq: paginator.maxBoostSeq + 1 }); } + paginator.ingestItem(channel); }); }; @@ -215,6 +277,13 @@ export type ChannelPaginatorsOrchestratorOptions = { client: StreamChat; paginators?: ChannelPaginator[]; eventHandlers?: ChannelPaginatorsOrchestratorEventHandlers; + /** + * Decide which paginator(s) should own a channel when multiple match. + * Defaults to keeping the channel in all matching paginators. + * Channels are kept only in the paginators that are listed in the ownershipResolver array. + * Empty ownershipResolver array means that the channel is kept in all matching paginators. + */ + ownershipResolver?: PaginatorOwnershipResolver | string[]; }; export class ChannelPaginatorsOrchestrator extends WithSubscriptions { @@ -224,6 +293,7 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { SupportedEventType, EventHandlerPipeline >(); + protected ownershipResolver?: PaginatorOwnershipResolver; protected static readonly defaultEventHandlers: ChannelPaginatorsOrchestratorEventHandlers = { @@ -244,10 +314,17 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { client, eventHandlers, paginators, + ownershipResolver, }: ChannelPaginatorsOrchestratorOptions) { super(); this.client = client; this.state = new StateStore({ paginators: paginators ?? [] }); + if (ownershipResolver) { + this.ownershipResolver = Array.isArray(ownershipResolver) + ? createPriorityOwnershipResolver(ownershipResolver) + : ownershipResolver; + } + const finalEventHandlers = eventHandlers ?? ChannelPaginatorsOrchestrator.getDefaultHandlers(); for (const [type, handlers] of Object.entries(finalEventHandlers)) { @@ -281,6 +358,17 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { return out; } + /** + * Which paginators should own the channel among the ones that matched. + * Default behavior keeps the channel in all matching paginators. + */ + resolveOwnership( + channel: Channel, + matchingPaginators: ChannelPaginator[], + ): Set { + return new Set(this.ownershipResolver?.({ channel, matchingPaginators }) ?? []); + } + getPaginatorById(id: string) { return this.paginators.find((p) => p.id === id); } diff --git a/test/unit/ChannelPaginatorsOrchestrator.test.ts b/test/unit/ChannelPaginatorsOrchestrator.test.ts index be4a09966b..a57fff97fb 100644 --- a/test/unit/ChannelPaginatorsOrchestrator.test.ts +++ b/test/unit/ChannelPaginatorsOrchestrator.test.ts @@ -6,7 +6,10 @@ import { EventTypes, type StreamChat, } from '../../src'; -import { ChannelPaginatorsOrchestrator } from '../../src/ChannelPaginatorsOrchestrator'; +import { + ChannelPaginatorsOrchestrator, + createPriorityOwnershipResolver, +} from '../../src/ChannelPaginatorsOrchestrator'; vi.mock('../../src/pagination/utility.queryChannel', async () => { return { getChannel: vi.fn(async ({ client, id, type }) => { @@ -24,6 +27,146 @@ describe('ChannelPaginatorsOrchestrator', () => { vi.clearAllMocks(); }); + describe('ownershipResolver', () => { + it('keeps channel in all matching paginators by default', async () => { + const ch = makeChannel('messaging:100'); + client.activeChannels[ch.cid] = ch; + + const p1 = new ChannelPaginator({ client, filters: { type: 'messaging' } }); + const p2 = new ChannelPaginator({ client, filters: { type: 'messaging' } }); + + const orchestrator = new ChannelPaginatorsOrchestrator({ + client, + paginators: [p1, p2], + }); + orchestrator.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(p1.items).toHaveLength(1); + expect(p1.items![0]).toStrictEqual(ch); + expect(p2.items).toHaveLength(1); + expect(p2.items![0]).toStrictEqual(ch); + }); + }); + + 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({ + client, + paginators: [pLow, pHigh], + ownershipResolver: createPriorityOwnershipResolver([pHigh.id, pLow.id]), + }); + + const ch = makeChannel('messaging:101'); + client.activeChannels[ch.cid] = ch; + + orchestrator.registerSubscriptions(); + client.dispatchEvent({ type: 'message.new', cid: ch.cid }); + + await vi.waitFor(() => { + expect(pHigh.items).toHaveLength(1); + expect(pHigh.items![0]).toStrictEqual(ch); + expect(pLow.items).toBeUndefined(); + }); + }); + + 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({ + client, + paginators: [pLow, pHigh], + ownershipResolver: () => [pHigh.id, pLow.id], + }); + + const ch = makeChannel('messaging:101'); + client.activeChannels[ch.cid] = ch; + + orchestrator.registerSubscriptions(); + client.dispatchEvent({ type: 'message.new', cid: ch.cid }); + + await vi.waitFor(() => { + expect(pHigh.items).toHaveLength(1); + expect(pHigh.items![0]).toStrictEqual(ch); + expect(pLow.items).toHaveLength(1); + expect(pLow.items![0]).toStrictEqual(ch); + }); + }); + + 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({ + client, + paginators: [pLow, pHigh], + ownershipResolver: [pHigh.id, pLow.id], + }); + + const ch = makeChannel('messaging:102'); + client.activeChannels[ch.cid] = ch; + + orchestrator.registerSubscriptions(); + client.dispatchEvent({ type: 'message.new', cid: ch.cid }); + + await vi.waitFor(() => { + expect(pHigh.items).toHaveLength(1); + expect(pHigh.items![0]).toStrictEqual(ch); + expect(pLow.items).toBeUndefined(); + }); + }); + + 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({ + client, + paginators: [pLow, pHigh], + ownershipResolver: [pHigh.id], + }); + + const ch = makeChannel('messaging:102'); + client.activeChannels[ch.cid] = ch; + + orchestrator.registerSubscriptions(); + client.dispatchEvent({ type: 'message.new', cid: ch.cid }); + + await vi.waitFor(() => { + expect(pHigh.items).toHaveLength(1); + expect(pHigh.items![0]).toStrictEqual(ch); + expect(pLow.items).toBeUndefined(); + }); + }); + + it('keeps items only in matching paginators if owner paginators are not matching', async () => { + 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({ + client, + paginators: [p1, p2, p3], + ownershipResolver: [p3.id], + }); + + const ch = makeChannel('messaging:102'); + client.activeChannels[ch.cid] = ch; + + orchestrator.registerSubscriptions(); + client.dispatchEvent({ type: 'message.new', cid: ch.cid }); + + await vi.waitFor(() => { + expect(p1.items).toHaveLength(1); + expect(p1.items![0]).toStrictEqual(ch); + expect(p2.items).toHaveLength(1); + expect(p2.items![0]).toStrictEqual(ch); + expect(p3.items).toBeUndefined(); + }); + }); + }); + describe('constructor', () => { it('initiates with default options', () => { // @ts-expect-error accessing protected property @@ -381,7 +524,10 @@ describe('ChannelPaginatorsOrchestrator', () => { // Helper to create a minimal channel with needed state function makeChannel(cid: string) { const [type, id] = cid.split(':'); - return client.channel(type, id); + const channel = client.channel(type, id); + channel.data!.type = type; + channel.data!.id = id; + return channel; } describe.each(['channel.deleted', 'channel.hidden'] as EventTypes[])( From 8938419ea1cd252d469a1b3dbee462e00340f8e1 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 11 Nov 2025 10:06:16 +0100 Subject: [PATCH 06/48] feat: allow to keep channels in certain matching paginators and not in other matching paginators --- src/ChannelPaginatorsOrchestrator.ts | 46 +++++++++++++++++++ src/pagination/ChannelPaginator.ts | 6 ++- .../ChannelPaginatorsOrchestrator.test.ts | 45 ++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/src/ChannelPaginatorsOrchestrator.ts b/src/ChannelPaginatorsOrchestrator.ts index b9526b1eea..077d0c5fb2 100644 --- a/src/ChannelPaginatorsOrchestrator.ts +++ b/src/ChannelPaginatorsOrchestrator.ts @@ -294,6 +294,8 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { EventHandlerPipeline >(); protected ownershipResolver?: PaginatorOwnershipResolver; + /** Track paginators already wrapped with ownership-aware filtering */ + protected ownershipFilterAppliedPaginators = new WeakSet(); protected static readonly defaultEventHandlers: ChannelPaginatorsOrchestratorEventHandlers = { @@ -330,6 +332,8 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { for (const [type, handlers] of Object.entries(finalEventHandlers)) { if (handlers) this.ensurePipeline(type).replaceAll(handlers); } + // Ensure ownership rules are applied to initial paginators' query results + this.paginators.forEach((p) => this.wrapPaginatorFiltering(p)); } get paginators(): ChannelPaginator[] { @@ -369,6 +373,46 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { return new Set(this.ownershipResolver?.({ channel, matchingPaginators }) ?? []); } + /** + * Filter a page of query results for a specific paginator according to ownership rules. + * If no owners are specified by the resolver, all matching paginators keep the item. + */ + protected filterItemsByOwnership({ + paginator, + items, + }: { + paginator: ChannelPaginator; + items: Channel[]; + }): Channel[] { + if (!items.length) return items; + const result: Channel[] = []; + for (const ch of items) { + const matchingPaginators = this.paginators.filter((p) => p.matchesFilter(ch)); + const ownerIds = this.resolveOwnership(ch, matchingPaginators); + const noOwnersOrPaginatorIsOwner = + ownerIds.size === 0 || ownerIds.has(paginator.id); + + if (noOwnersOrPaginatorIsOwner) { + result.push(ch); + } + } + return result; + } + + /** + * Wrap paginator.filterQueryResults so that ownership rules are applied whenever + * the paginator ingests results from a server query (first page and subsequent pages). + */ + protected wrapPaginatorFiltering(paginator: ChannelPaginator) { + if (this.ownershipFilterAppliedPaginators.has(paginator)) return; + const original = paginator.filterQueryResults.bind(paginator); + paginator.filterQueryResults = (items: Channel[]) => { + const filtered = original(items) as Channel[]; + return this.filterItemsByOwnership({ paginator, items: filtered }); + }; + this.ownershipFilterAppliedPaginators.add(paginator); + } + getPaginatorById(id: string) { return this.paginators.find((p) => p.id === id); } @@ -392,6 +436,8 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { ); paginators.splice(validIndex, 0, paginator); this.state.partialNext({ paginators }); + // Wrap newly inserted paginator to enforce ownership on query results + this.wrapPaginatorFiltering(paginator); } addEventHandler({ diff --git a/src/pagination/ChannelPaginator.ts b/src/pagination/ChannelPaginator.ts index cb11ac7780..57ca67be69 100644 --- a/src/pagination/ChannelPaginator.ts +++ b/src/pagination/ChannelPaginator.ts @@ -99,7 +99,11 @@ const hasUnreadFilterResolver: FieldToDataResolver = { matchesField: (field) => field === 'has_unread', resolve: (channel) => { const ownUserId = channel.getClient().user?.id; - return ownUserId && channel.state.read[ownUserId].unread_messages > 0; + return ( + ownUserId && + channel.state.read[ownUserId] && + channel.state.read[ownUserId].unread_messages > 0 + ); }, }; diff --git a/test/unit/ChannelPaginatorsOrchestrator.test.ts b/test/unit/ChannelPaginatorsOrchestrator.test.ts index a57fff97fb..28bde42cd1 100644 --- a/test/unit/ChannelPaginatorsOrchestrator.test.ts +++ b/test/unit/ChannelPaginatorsOrchestrator.test.ts @@ -165,6 +165,51 @@ describe('ChannelPaginatorsOrchestrator', () => { expect(p3.items).toBeUndefined(); }); }); + + it('applies ownership rules to paginators when they paginate', async () => { + const ch1 = makeChannel('messaging:101'); + const ch2 = makeChannel('messaging:102'); + const queryChannelSpy = vi.spyOn(client, 'queryChannels').mockResolvedValue([ch1]); + const p1 = new ChannelPaginator({ + client, + filters: { type: 'messaging' }, + id: 'p1', + paginatorOptions: { pageSize: 1 }, + }); + const p2 = new ChannelPaginator({ + client, + filters: { type: 'messaging' }, + id: 'p2', + paginatorOptions: { pageSize: 1 }, + }); + new ChannelPaginatorsOrchestrator({ + client, + paginators: [p1, p2], + ownershipResolver: [p2.id], + }); + + await Promise.all([p1, p2].map((p) => p.next())); + + await vi.waitFor(() => { + expect(p1.items).toHaveLength(0); + // even though ownership claimed by p2, it is still possible to request next page. + expect(p1.hasNext).toBe(true); + expect(p2.items).toHaveLength(1); + expect(p2.items).toStrictEqual([ch1]); + expect(p2.hasNext).toBe(true); + }); + + queryChannelSpy.mockResolvedValue([ch2]); + await Promise.all([p1, p2].map((p) => p.next())); + + await vi.waitFor(() => { + expect(p1.items).toHaveLength(0); + expect(p1.hasNext).toBe(true); + expect(p2.items).toHaveLength(2); + expect(p2.items).toStrictEqual([ch1, ch2]); + expect(p2.hasNext).toBe(true); + }); + }); }); describe('constructor', () => { From 03614c77598109972eebf3c6dd426bb10ea119a0 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 11 Nov 2025 10:36:24 +0100 Subject: [PATCH 07/48] fix: do not remove channel from paginator on channel.hidden --- src/ChannelPaginatorsOrchestrator.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ChannelPaginatorsOrchestrator.ts b/src/ChannelPaginatorsOrchestrator.ts index 077d0c5fb2..bbfc7f5bfe 100644 --- a/src/ChannelPaginatorsOrchestrator.ts +++ b/src/ChannelPaginatorsOrchestrator.ts @@ -300,7 +300,6 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { protected static readonly defaultEventHandlers: ChannelPaginatorsOrchestratorEventHandlers = { 'channel.deleted': [channelDeletedHandler], - 'channel.hidden': [channelDeletedHandler], 'channel.updated': [channelUpdatedHandler], 'channel.truncated': [channelTruncatedHandler], 'channel.visible': [channelVisibleHandler], From 01876963bdf49548fcab5117b9ebe32651a15d52 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 18 Nov 2025 15:50:19 +0100 Subject: [PATCH 08/48] chore: move paginators to a dedicated folder --- src/pagination/index.ts | 4 +--- .../{ => paginators}/BasePaginator.ts | 19 +++++++++------- .../{ => paginators}/ChannelPaginator.ts | 22 +++++++++---------- .../{ => paginators}/ReminderPaginator.ts | 4 ++-- src/pagination/paginators/index.ts | 3 +++ 5 files changed, 28 insertions(+), 24 deletions(-) rename src/pagination/{ => paginators}/BasePaginator.ts (97%) rename src/pagination/{ => paginators}/ChannelPaginator.ts (95%) rename src/pagination/{ => paginators}/ReminderPaginator.ts (96%) create mode 100644 src/pagination/paginators/index.ts diff --git a/src/pagination/index.ts b/src/pagination/index.ts index 733c5efe8c..2b0bd0d523 100644 --- a/src/pagination/index.ts +++ b/src/pagination/index.ts @@ -1,4 +1,2 @@ -export * from './BasePaginator'; -export * from './ChannelPaginator'; +export * from './paginators'; export * from './FilterBuilder'; -export * from './ReminderPaginator'; diff --git a/src/pagination/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts similarity index 97% rename from src/pagination/BasePaginator.ts rename to src/pagination/paginators/BasePaginator.ts index 6912554dfe..938dd64792 100644 --- a/src/pagination/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -1,11 +1,14 @@ -import { binarySearchInsertIndex } from './sortCompiler'; -import { itemMatchesFilter } from './filterCompiler'; -import { isPatch, StateStore, type ValueOrPatch } from '../store'; -import { debounce, type DebouncedFunc, sleep } from '../utils'; -import type { FieldToDataResolver } from './types.normalization'; -import { locateOnPlateauAlternating, locateOnPlateauScanOneSide } from './utility.search'; -import { isEqual } from '../utils/mergeWith/mergeWithCore'; -import { DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES } from '../constants'; +import { binarySearchInsertIndex } from '../sortCompiler'; +import { itemMatchesFilter } from '../filterCompiler'; +import { isPatch, StateStore, type ValueOrPatch } from '../../store'; +import { debounce, type DebouncedFunc, sleep } from '../../utils'; +import type { FieldToDataResolver } from '../types.normalization'; +import { + locateOnPlateauAlternating, + locateOnPlateauScanOneSide, +} from '../utility.search'; +import { isEqual } from '../../utils/mergeWith/mergeWithCore'; +import { DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES } from '../../constants'; const noOrderChange = () => 0; diff --git a/src/pagination/ChannelPaginator.ts b/src/pagination/paginators/ChannelPaginator.ts similarity index 95% rename from src/pagination/ChannelPaginator.ts rename to src/pagination/paginators/ChannelPaginator.ts index 57ca67be69..7a3a805a2d 100644 --- a/src/pagination/ChannelPaginator.ts +++ b/src/pagination/paginators/ChannelPaginator.ts @@ -6,22 +6,22 @@ import type { PaginatorState, } from './BasePaginator'; import { BasePaginator } from './BasePaginator'; -import type { FilterBuilderOptions } from './FilterBuilder'; -import { FilterBuilder } from './FilterBuilder'; -import { makeComparator } from './sortCompiler'; -import { generateUUIDv4 } from '../utils'; -import type { StreamChat } from '../client'; -import type { Channel } from '../channel'; +import type { FilterBuilderOptions } from '../FilterBuilder'; +import { FilterBuilder } from '../FilterBuilder'; +import { makeComparator } from '../sortCompiler'; +import { generateUUIDv4 } from '../../utils'; +import type { StreamChat } from '../../client'; +import type { Channel } from '../../channel'; import type { ChannelFilters, ChannelOptions, ChannelSort, ChannelStateOptions, -} from '../types'; -import type { FieldToDataResolver, PathResolver } from './types.normalization'; -import { resolveDotPathValue } from './utility.normalization'; -import type { ValueOrPatch } from '../store'; -import { isEqual } from '../utils/mergeWith/mergeWithCore'; +} from '../../types'; +import type { FieldToDataResolver, PathResolver } from '../types.normalization'; +import { resolveDotPathValue } from '../utility.normalization'; +import type { ValueOrPatch } from '../../store'; +import { isEqual } from '../../utils/mergeWith/mergeWithCore'; const DEFAULT_BACKEND_SORT: ChannelSort = { last_message_at: -1, updated_at: -1 }; // {last_updated: -1} diff --git a/src/pagination/ReminderPaginator.ts b/src/pagination/paginators/ReminderPaginator.ts similarity index 96% rename from src/pagination/ReminderPaginator.ts rename to src/pagination/paginators/ReminderPaginator.ts index 9bbf56c5ce..8cf23b914d 100644 --- a/src/pagination/ReminderPaginator.ts +++ b/src/pagination/paginators/ReminderPaginator.ts @@ -9,8 +9,8 @@ import type { ReminderFilters, ReminderResponse, ReminderSort, -} from '../types'; -import type { StreamChat } from '../client'; +} from '../../types'; +import type { StreamChat } from '../../client'; export class ReminderPaginator extends BasePaginator< ReminderResponse, diff --git a/src/pagination/paginators/index.ts b/src/pagination/paginators/index.ts new file mode 100644 index 0000000000..1c5fbb4d44 --- /dev/null +++ b/src/pagination/paginators/index.ts @@ -0,0 +1,3 @@ +export * from './BasePaginator'; +export * from './ChannelPaginator'; +export * from './ReminderPaginator'; From 53941efebdb5fb964840fc604f5f52d3c915d98e Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 21 Nov 2025 16:53:59 +0100 Subject: [PATCH 09/48] feat: introduce intervals to BasePaginator --- src/pagination/ItemIndex.ts | 97 ++ src/pagination/paginators/BasePaginator.ts | 1149 ++++++++++++++--- src/pagination/paginators/ChannelPaginator.ts | 19 +- src/pagination/sortCompiler.ts | 120 +- src/pagination/types.normalization.ts | 9 +- 5 files changed, 1175 insertions(+), 219 deletions(-) create mode 100644 src/pagination/ItemIndex.ts diff --git a/src/pagination/ItemIndex.ts b/src/pagination/ItemIndex.ts new file mode 100644 index 0000000000..18310c9e4a --- /dev/null +++ b/src/pagination/ItemIndex.ts @@ -0,0 +1,97 @@ +/** + * The ItemIndex is a canonical, ID-addressable storage layer for domain items. + * + * It provides a single source of truth for all items managed by one or more + * paginators, views, or interval caches. Instead of duplicating objects inside + * multiple paginated ranges, every item is stored exactly once in the ItemIndex + * and is referenced by ID from interval windows, caches, or UI layers. + * + * ## Purpose + * + * Pagination flows (especially those supporting random-access page jumps + * or “load-around-anchor” requests) require representing discontinuous windows + * of items. Attempting to store full item objects in every interval causes + * duplication, inconsistent updates, increased memory usage, and difficult + * merging logic. + * + * The ItemIndex solves this by: + * + * - Storing each item exactly once. + * - Making all intervals store only `itemIds: string[]` in sorted order. + * - Making paginators read visible items through `itemIndex.get(id)`. + * - Ensuring that any mutation of an item is immediately visible everywhere. + * + * ## Benefits + * + * - **Consistency:** Updates propagate automatically because intervals reference + * items by ID. No need to synchronize multiple arrays of objects. + * - **Efficiency:** Items are only stored once; intervals are lightweight lists + * of IDs. + * - **Scalability:** Supports multiple disjoint intervals (e.g. random jumps), + * merging of ranges, and multiple independent paginators sharing the same + * item set. + * - **Clean separation of concerns:** The paginator manages window boundaries; + * the ItemIndex manages object identity and update semantics. + * + * ## Typical Usage + * + * 1. A paginator fetches a page of items from the server. + * 2. It calls `itemIndex.setMany(fetchedItems)` to update the canonical store. + * 3. It constructs or updates an interval using the IDs only: + * `{ itemIds: fetchedItems.map(item => itemIndex.getId(item)) }` + * 4. The UI renders the active interval’s items using: + * `interval.itemIds.map(id => itemIndex.get(id))` + * + * ## Update Semantics + * + * Updates should always be performed through `setOne()` or `setMany()`. + * This ensures that: + * + * - The item object is replaced (immutable semantics). + * - All consumers reading via ID immediately observe the new value. + * + * The ItemIndex does not automatically re-sort intervals; interval or paginator + * logic may reorder their `itemIds` arrays when necessary. + * + * ## Notes + * + * - The ItemIndex does not apply filtering or sorting. Those are the paginator’s + * responsibilities. + * - The ItemIndex intentionally exposes only minimal CRUD operations to keep it + * predictable and side-effect-free. + * - Consumers should treat items as immutable snapshots. If mutation is needed, + * always create a new item instance and pass it to `setOne()`. + * + * @template T The domain item type managed by the index. + */ +export class ItemIndex { + private byId = new Map(); + + constructor(private getId: (item: T) => string) {} + + setMany(items: T[]) { + for (const item of items) { + this.byId.set(this.getId(item), item); + } + } + + setOne(item: T) { + this.byId.set(this.getId(item), item); + } + + get(id: string): T | undefined { + return this.byId.get(id); + } + + has(id: string): boolean { + return this.byId.has(id); + } + + remove(id: string) { + this.byId.delete(id); + } + + entries() { + return [...this.byId.entries()]; + } +} diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 938dd64792..43fdfd2b10 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -1,17 +1,270 @@ -import { binarySearchInsertIndex } from '../sortCompiler'; +import type { ItemLocation } from '../sortCompiler'; +import { binarySearch } from '../sortCompiler'; import { itemMatchesFilter } from '../filterCompiler'; import { isPatch, StateStore, type ValueOrPatch } from '../../store'; -import { debounce, type DebouncedFunc, sleep } from '../../utils'; -import type { FieldToDataResolver } from '../types.normalization'; import { - locateOnPlateauAlternating, - locateOnPlateauScanOneSide, -} from '../utility.search'; + debounce, + type DebouncedFunc, + generateUUIDv4, + normalizeQuerySort, + sleep, +} from '../../utils'; +import type { FieldToDataResolver } from '../types.normalization'; +import { ComparisonResult } from '../types.normalization'; +import type { ItemIndex } from '../ItemIndex'; import { isEqual } from '../../utils/mergeWith/mergeWithCore'; import { DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES } from '../../constants'; +import type { AscDesc } from '../..'; +import { + normalizeStringAccentInsensitive, + toEpochMillis, + toNumberLike, +} from '../utility.normalization'; const noOrderChange = () => 0; +const LIVE_HEAD_INTERVAL_ID = '__live_head__'; +const LIVE_TAIL_INTERVAL_ID = '__live_tail__'; +const MISSING_LOW = Number.NEGATIVE_INFINITY; // "smaller than anything" +const MISSING_HIGH = Number.POSITIVE_INFINITY; // "bigger than anything" + +type SortKeyScalar = number | string | null; + +/** + * Normalize a raw field value into a comparable scalar. + * + * Rules: + * - Date / ISO / epoch-like → epoch millis (number) + * - numeric-like string → number + * - boolean → boolean (or 0/1, see below) + * - string → normalized string (case/accent insensitive) + * - everything else → stringified fallback + */ +function normalizeForSort(x: unknown): SortKeyScalar { + // 1) Date-like + const d = toEpochMillis(x); + if (d !== null) return d; + + // 2) numeric-like + const n = toNumberLike(x); + if (n !== null) return n; + + // 3) boolean + if (typeof x === 'boolean') return x ? 1 : 0; + + // 4) string (accent-insensitive) + if (typeof x === 'string') { + return normalizeStringAccentInsensitive(x); + } + + // 5) fallback + return x == null ? null : String(x); +} + +/** + * Sortable value that represents the item according to the paginator’s comparator. + * A comparable key that lets you determine: + * “Does this item fall inside the sort boundaries of any given interval?” + */ +export type SortKey = number[]; + +// Encodes a string into a numeric sequence suitable for lexicographic comparison. +// 0 as a terminal sentinel ensures shorter prefix strings sort before longer ones (e.g. "a" before "aa"). +const STRING_SENTINEL_ASC = 0; + +function encodeStringComponents(s: string, direction: 1 | -1): number[] { + // Ascending: [charCode+1, ..., charCode+1, 0] + const base: number[] = []; + for (let i = 0; i < s.length; i++) { + base.push(s.charCodeAt(i) + 1); // > 0 + } + base.push(STRING_SENTINEL_ASC); // 0 < any charCode+1 + + // Descending = element-wise sign flip of the ascending sequence + if (direction === 1) return base; + return base.map((v) => -v); +} + +/** Compare two SortKeys. */ +export function compareSortKeys(a: SortKey, b: SortKey): number { + if (typeof a !== 'object' && typeof b !== 'object') { + return a < b + ? ComparisonResult.A_PRECEDES_B + : a > b + ? ComparisonResult.A_COMES_AFTER_B + : ComparisonResult.A_IS_EQUAL_TO_B; + } + + const arrA = a as (number | string)[]; + const arrB = b as (number | string)[]; + + const len = Math.min(arrA.length, arrB.length); + for (let i = 0; i < len; i++) { + if (arrA[i] < arrB[i]) return ComparisonResult.A_PRECEDES_B; + if (arrA[i] > arrB[i]) return ComparisonResult.A_COMES_AFTER_B; + } + + return arrA.length - arrB.length; +} + +function minSortKey(a: SortKey, b: SortKey): SortKey { + return compareSortKeys(a, b) <= 0 ? a : b; +} + +function maxSortKey(a: SortKey, b: SortKey): SortKey { + return compareSortKeys(a, b) >= 0 ? a : b; +} + +function mergeUniqueStrings(a: string[], b: string[]): string[] { + const set = new Set(a); + for (const id of b) { + if (!set.has(id)) { + set.add(id); + a.push(id); + } + } + return a; +} + +type Sort = Record; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type PathResolver = (item: T, path: string) => any; + +export type LogicalInterval = { + itemIds: string[]; + id: typeof LIVE_HEAD_INTERVAL_ID | typeof LIVE_TAIL_INTERVAL_ID; + /** Key of the first item according to sorting. */ + startKey: SortKey; + /** Key of the last item according to sorting. */ + endKey: SortKey; +}; + +export type Interval = { + itemIds: string[]; + id: string; + /** Key of the first item according to sorting. */ + startKey: SortKey; + /** Key of the last item according to sorting. */ + endKey: SortKey; + /** + * True if this interval represents the global head of the dataset + * under the current sortComparator. + * + * Cursor pagination: + * prev === null + * + * Offset pagination: + * offset === 0 + */ + isHead?: boolean; + /** + * True if this interval represents the global tail of the dataset + * under the current sortComparator. + * + * Cursor pagination: + * next === null + * + * Offset pagination: + * returnedItems.length < pageSize + */ + isTail?: boolean; +}; + +export type AnyInterval = Interval | LogicalInterval; + +export type ItemCoordinates = { + /** Location inside state.items (visible list) */ + state?: ItemLocation; + /** Location inside an interval (anchored or logical) */ + interval?: ItemLocation & { + interval: Interval | LogicalInterval; + }; +}; + +const isLiveHeadInterval = (interval: AnyInterval): interval is LogicalInterval => + interval.id === LIVE_HEAD_INTERVAL_ID; + +const isLiveTailInterval = (interval: AnyInterval): interval is LogicalInterval => + interval.id === LIVE_TAIL_INTERVAL_ID; + +/** + * Returns true if intervals A and B overlap. + * + * Overlap condition: + * A.startKey ≤ B.endKey AND B.startKey ≤ A.endKey + */ +function intervalsOverlap(a: Interval, b: Interval): boolean { + return ( + compareSortKeys(a.startKey, b.endKey) <= 0 && + compareSortKeys(b.startKey, a.endKey) <= 0 + ); +} + +function cloneInterval(interval: Interval): Interval { + return { + ...interval, + itemIds: [...interval.itemIds], + }; +} + +function mergeTwoAnchoredIntervals(preceding: Interval, following: Interval): Interval { + return { + ...preceding, + itemIds: mergeUniqueStrings([...preceding.itemIds], following.itemIds), + startKey: minSortKey(preceding.startKey, following.startKey), + endKey: maxSortKey(preceding.endKey, following.endKey), + isHead: preceding.isHead || following.isHead, + isTail: preceding.isTail || following.isTail, + }; +} + +/** + * Merges anchored intervals. Returns null if there are no intervals to merge. + */ +function mergeAnchoredIntervals(intervals: Interval[]): Interval | null { + if (intervals.length === 0) return null; + + const intervalsCopy = [...intervals]; + intervalsCopy.sort((a, b) => compareSortKeys(a.startKey, b.startKey)); + + let acc = cloneInterval(intervalsCopy[0]); + for (let i = 1; i < intervalsCopy.length; i++) { + const next = intervalsCopy[i]; + acc = mergeTwoAnchoredIntervals(acc, next); + } + + return acc; +} + +/** + * Whether a SortKey belongs to an anchored interval. + */ +function belongsToInterval(itemSortKey: SortKey, interval: Interval): boolean { + return ( + compareSortKeys(itemSortKey, interval.startKey) >= 0 && + compareSortKeys(itemSortKey, interval.endKey) <= 0 + ); +} + +export type MakeIntervalParams = { + page: T[]; + isHead?: boolean; + isTail?: boolean; +}; + +export type SetPaginatorItemsParams = { + valueOrFactory: ValueOrPatch; + cursor?: PaginatorCursor; + isFirstPage?: boolean; + isLastPage?: boolean; +}; + +type MergeIntervalsResult = { + logicalHead: LogicalInterval | null; + merged: Interval | null; + logicalTail: LogicalInterval | null; +}; + type PaginationDirection = 'next' | 'prev'; export type PaginatorCursor = { next: string | null; prev: string | null }; type StateResetPolicy = 'auto' | 'yes' | 'no' | (string & {}); @@ -67,6 +320,8 @@ export type PaginatorOptions = { initialCursor?: PaginatorCursor; /** In case of offset pagination, specify the initial offset value. */ initialOffset?: number; + /** If item index is provided, this index ensures updates in place and all consumers have access to a single source of data. */ + itemIndex?: ItemIndex; /** Will prevent changing the index of existing items. */ lockItemOrder?: boolean; /** The item page size to be requested from the server. */ @@ -79,6 +334,7 @@ type OptionalPaginatorConfigFields = | 'doRequest' | 'initialCursor' | 'initialOffset' + | 'itemIndex' | 'throwErrors'; export type BasePaginatorConfig = Pick< @@ -103,46 +359,38 @@ export const DEFAULT_PAGINATION_OPTIONS: BasePaginatorConfig = { export abstract class BasePaginator { state: StateStore>; config: BasePaginatorConfig; + + /** + * Intervals keep items in disconnected ranges. + * That is a scenario of jumping to non-sequential pages. + * Intervals are populated only if itemIndex is provided. + */ + protected _itemIntervals: Map = new Map(); + protected _activeIntervalId: string | undefined; + + /** + * ItemIndex is a canonical, ID-addressable storage layer for domain items. + * It serves as a single source of truth for all those that need to access the items + * outside of the paginator. + */ + protected _itemIndex: ItemIndex | undefined; + protected _executeQueryDebounced!: DebouncedExecQueryFunction; protected _isCursorPagination = false; /** Last effective query shape produced by subclass for the most recent request. */ protected _lastQueryShape?: Q; protected _nextQueryShape?: Q; - /** - * Comparison function used to keep items in a paginator sorted. - * - * The comparator must follow the standard contract of `Array.prototype.sort`: - * - return a negative number if `a` should come before `b` - * - return a positive number if `a` should come after `b` - * - return 0 if they are considered equal for ordering - * - * Typical implementations are generated from a "sort spec" (e.g. `{ field: 1, otherField: -1 }`) - * so that insertion and pagination can maintain the same order as the backend. - * - * Notes: - * - The comparator must be deterministic: the same inputs always return - * the same result. - * - If multiple fields are used, they are evaluated in order of normalized sort ({ direction: AscDesc; field: keyof T }[]) - * until a non-zero comparison is found. - * - Equality (0) does not imply object identity; it only means neither item - * is considered greater than the other by the sort rules. - */ + sortComparator: (a: T, b: T) => number; - /** - * Allows defining data extraction logic for filter fields like member.user.name or members - * @protected - */ protected _filterFieldToDataResolvers: FieldToDataResolver[]; - /** - * Ephemeral priority for attention UX without breaking sort invariants - * @protected - */ + protected boosts = new Map(); - protected _maxBoostSeq: number = 0; + protected _maxBoostSeq = 0; protected constructor({ initialCursor, initialOffset, + itemIndex, ...options }: PaginatorOptions = {}) { this.config = { @@ -160,8 +408,13 @@ export abstract class BasePaginator { this.setDebounceOptions({ debounceMs }); this.sortComparator = noOrderChange; this._filterFieldToDataResolvers = []; + this._itemIndex = itemIndex; } + // --------------------------------------------------------------------------- + // Basic getters + // --------------------------------------------------------------------------- + get lastQueryError() { return this.state.getLatestValue().lastQueryError; } @@ -194,9 +447,9 @@ export abstract class BasePaginator { get initialState(): PaginatorState { return { hasNext: true, - hasPrev: true, //todo: check if optimistic value does not cause problems in UI + hasPrev: true, isLoading: false, - items: undefined, // todo: maybe should be null? + items: undefined, lastQueryError: undefined, cursor: this.config.initialCursor, offset: this.config.initialOffset ?? 0, @@ -240,12 +493,42 @@ export abstract class BasePaginator { return this._maxBoostSeq; } + protected get itemIntervals(): AnyInterval[] { + return Array.from(this._itemIntervals.values()); + } + + protected get liveHeadLogical(): LogicalInterval | undefined { + const itv = this._itemIntervals.get(LIVE_HEAD_INTERVAL_ID); + return itv && isLiveHeadInterval(itv) ? itv : undefined; + } + + protected get liveTailLogical(): LogicalInterval | undefined { + const itv = this._itemIntervals.get(LIVE_TAIL_INTERVAL_ID); + return itv && isLiveTailInterval(itv) ? itv : undefined; + } + + protected get usesItemIntervalStorage(): boolean { + return !!this._itemIndex; + } + + // --------------------------------------------------------------------------- + // Abstracts + // --------------------------------------------------------------------------- + abstract query( params: PaginationQueryParams, ): Promise>; abstract filterQueryResults(items: T[]): T[] | Promise; + /** + * Should be implemented in child classes from the specific sort requirements followed by the child classes. + * Should return a value according to which the given item can be correctly inserted into the target item interval + * based on the current sort rules. + * @param item + */ + abstract computeSortKey(item: T): SortKey; + /** * Subclasses must return the query shape. */ @@ -256,41 +539,83 @@ export abstract class BasePaginator { throw new Error('Paginator.getNextQueryShape() is not implemented'); } - /** - * Decide whether a param change between queries requires a state reset. - * Default: deep inequality => reset. - * Subclasses can override to implement domain rules - * (e.g. ChannelPaginator filters {cid: { $in: string[]}} with different CIDs may be required not to lead to reset). - */ - protected shouldResetStateBeforeQuery( - prevQueryShape: unknown | undefined, - nextQueryShape: unknown | undefined, - ): boolean { - return ( - typeof prevQueryShape === 'undefined' || - this.config.hasPaginationQueryShapeChanged(prevQueryShape, nextQueryShape) - ); - } - protected buildFilters(): object | null { return null; // === no filters } - getItemId(item: T): string { - return (item as { id: string }).id; - } - matchesFilter(item: T): boolean { const filters = this.buildFilters(); - - // no filters => accept all if (filters == null) return true; - return itemMatchesFilter(item, filters, { resolvers: this._filterFieldToDataResolvers, }); } + setFilterResolvers(resolvers: FieldToDataResolver[]) { + this._filterFieldToDataResolvers = resolvers; + } + + addFilterResolvers(resolvers: FieldToDataResolver[]) { + this._filterFieldToDataResolvers.push(...resolvers); + } + + // --------------------------------------------------------------------------- + // Item accessors + // --------------------------------------------------------------------------- + getItemId(item: T): string { + return (item as { id: string }).id; + } + + getItem(id: string | undefined): T | undefined { + return typeof id === 'string' ? this._itemIndex?.get(id) : undefined; + } + + // --------------------------------------------------------------------------- + // Sort key generator (optional helper) + // --------------------------------------------------------------------------- + + /** + * Factory function to create a sort key generator. + * Sort key generation must be consistent with the comparator logic. + * + * The resulting SortKey is an array of numbers, e.g. + * [{last_updated_at}, {}] + */ + makeSortKeyGenerator({ + sort, + resolvePathValue, + }: { + sort: Sort | Sort[]; + resolvePathValue: PathResolver; + }): (item: T) => SortKey { + const normalizedSort = normalizeQuerySort(sort); // [{ field, direction }, ...] + + return (item: T): SortKey => { + const key: SortKey = []; + + for (const { field, direction } of normalizedSort) { + const raw = resolvePathValue(item, field); + const normalized = normalizeForSort(raw); + if (normalized === null) { + // No usable value → push a sentinel that depends on direction. + key.push(direction === 1 ? MISSING_LOW : MISSING_HIGH); + } else if (typeof normalized === 'number') { + key.push(direction === 1 ? normalized : -normalized); + } else { + // string + // If most of your sorts are numeric/date and string sorts are asc-only, + // you can just store the string as-is: + key.push(...encodeStringComponents(normalized, direction)); + } + } + return key; + }; + } + + // --------------------------------------------------------------------------- + // Boosts + // --------------------------------------------------------------------------- + protected clearExpiredBoosts(now = Date.now()) { for (const [id, b] of this.boosts) if (now > b.until) this.boosts.delete(id); this._maxBoostSeq = Math.max( @@ -299,7 +624,11 @@ export abstract class BasePaginator { ); } - /** Comparator that consults boosts first, then falls back to sortComparator */ + /** + * Applied by the effectiveComparator to take into consideration item boosts when sorting items. + * @param a + * @param b + */ protected boostComparator = (a: T, b: T): number => { const now = Date.now(); this.clearExpiredBoosts(now); @@ -316,25 +645,27 @@ export abstract class BasePaginator { if (!aIsBoosted && bIsBoosted) return 1; if (aIsBoosted && bIsBoosted) { - // higher seq wins const seqDistance = (boostB.seq ?? 0) - (boostA.seq ?? 0); if (seqDistance !== 0) return seqDistance > 0 ? 1 : -1; - // fall through to normal comparator for stability } return this.sortComparator(a, b); }; - /** Public API to manage boosts */ - boost(id: string, opts?: { ttlMs?: number; until?: number; seq?: number }) { + /** + * Increases the item's importance when sorting. + * @param itemId + * @param opts + */ + boost(itemId: string, opts?: { ttlMs?: number; until?: number; seq?: number }) { const now = Date.now(); - const until = opts?.until ?? (opts?.ttlMs != null ? now + opts.ttlMs : now + 15000); // default 15s + const until = opts?.until ?? (opts?.ttlMs != null ? now + opts.ttlMs : now + 15000); if (typeof opts?.seq === 'number' && opts.seq > this._maxBoostSeq) { this._maxBoostSeq = opts.seq; } const seq = opts?.seq ?? 0; - this.boosts.set(id, { until, seq }); + this.boosts.set(itemId, { until, seq }); } getBoost(id: string) { @@ -354,145 +685,536 @@ export abstract class BasePaginator { return !!(boost && Date.now() <= boost.until); } - ingestItem(ingestedItem: T): boolean { - const items = this.items ?? []; - const id = this.getItemId(ingestedItem); - const next = items.slice(); - // If it doesn't match this paginator's filters, remove if present and exit. - const existingIndex = items.findIndex((ch) => this.getItemId(ch) === id); - if (!this.matchesFilter(ingestedItem)) { - if (existingIndex >= 0) { - next.splice(existingIndex, 1); - this.state.partialNext({ items: next }); - return true; // list changed (item removed) + // --------------------------------------------------------------------------- + // Interval helpers + // --------------------------------------------------------------------------- + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + generateIntervalId(page: T[]): string { + return `interval-${generateUUIDv4()}`; + } + + intervalToItems(interval: Interval | LogicalInterval): T[] { + return interval.itemIds + .map((id) => this._itemIndex?.get(id)) + .filter((item): item is T => !!item); + } + + makeInterval({ page, isHead, isTail }: MakeIntervalParams): Interval { + const sorted = [...page].sort((a, b) => + compareSortKeys(this.computeSortKey(a), this.computeSortKey(b)), + ); + return { + id: this.generateIntervalId(page), + itemIds: sorted.map(this.getItemId.bind(this)), + startKey: this.computeSortKey(sorted[0]), + endKey: this.computeSortKey(sorted[sorted.length - 1]), + isHead, + isTail, + }; + } + + protected recomputeIntervalBoundaries(interval: AnyInterval): { + startKey: SortKey; + endKey: SortKey; + } { + // Recompute boundaries from the first and last items in the interval. + // Since ids are kept sorted by effectiveComparator, + // the first and last items define the correct startKey/endKey. + const ids = interval.itemIds; + const first = this.getItem(ids[0]); + const last = this.getItem(ids[ids.length - 1]); + + if (!first || !last) { + throw new Error('Invalid interval to recompute boundaries: empty item array'); + } + + const startKey = this.computeSortKey(first); + const endKey = first === last ? startKey : this.computeSortKey(last); + return { startKey, endKey }; + } + + // --------------------------------------------------------------------------- + // Locate items + // --------------------------------------------------------------------------- + + /** + * Locate item inside a specific interval using the same logic as locateByItem, + * but scoped to interval items. + */ + protected locateByItemInInterval({ + item, + interval, + }: { + item: T; + interval: Interval | LogicalInterval; + }): ItemLocation | null { + const ids = interval.itemIds; + + return binarySearch({ + needle: item, + length: ids.length, + getItemAt: (index: number) => this.getItem(ids[index]), + itemIdentityEquals: (item1, item2) => + this.getItemId(item1) === this.getItemId(item2), + compare: this.effectiveComparator.bind(this), + plateauScan: true, + }); + } + + protected locateIntervalForItem(item: T): AnyInterval | undefined { + if (this._itemIntervals.size === 0) return undefined; + + const itemSortKey = this.computeSortKey(item); + + for (const itv of this.itemIntervals) { + if (belongsToInterval(itemSortKey, itv)) { + return itv; } - return false; // no change } + } + + protected locateByItemInIntervals(item: T): ItemCoordinates['interval'] | undefined { + const interval = this.locateIntervalForItem(item); + if (!interval) return undefined; + const itemLocation = this.locateByItemInInterval({ item, interval }); + if (!itemLocation) return undefined; + return { interval, ...itemLocation }; + } - if (existingIndex >= 0) { - // Update existing: remove then re-insert at the correct position - next.splice(existingIndex, 1); + /** + * Locates the current position of the item and the index at which the item should be inserted + * according to effectiveComparator. + * @param item + */ + protected locateItemInState(item: T): ItemLocation | null { + const items = [...(this.items ?? [])]; + + return binarySearch({ + needle: item, + length: items.length, + getItemAt: (index: number) => items[index], + itemIdentityEquals: (item1, item2) => + this.getItemId(item1) === this.getItemId(item2), + compare: this.effectiveComparator.bind(this), + plateauScan: true, + }); + } + + protected locateByItem = (item: T): ItemCoordinates => { + const result: ItemCoordinates = {}; + + // 1. Search in visible state.items + const stateLoc = this.locateItemInState(item); + if (stateLoc) { + result.state = stateLoc; + } + + // 2. Search in intervals if interval-mode is active + if (this.usesItemIntervalStorage) { + const intervalLoc = this.locateByItemInIntervals(item); + if (intervalLoc) { + result.interval = intervalLoc; + } } - const insertAt = - this.config.lockItemOrder && existingIndex >= 0 - ? existingIndex - : // Find insertion index via binary search: first index where existing > ingestionItem - binarySearchInsertIndex({ - needle: ingestedItem, - sortedArray: next, - compare: this.effectiveComparator, - }); + return result; + }; - next.splice(insertAt, 0, ingestedItem); - this.state.partialNext({ items: next }); - return true; // list changed (added or repositioned) + findItem(needle: T): T | undefined { + const { state, interval } = this.locateByItem(needle); + if (state && state.current > -1) { + return (this.items ?? [])[state.current]; + } else if (interval && interval.current > -1) { + const id = interval.interval.itemIds[interval.current]; + return this.getItem(id); + } + return undefined; } + // --------------------------------------------------------------------------- + // Item ingestion + // --------------------------------------------------------------------------- + /** - * Removes item from the paginator's state. - * It is preferable to provide item for better search performance. - * @param id - * @param item + * Inserts an item ID into the interval in the correct sorted position, + * preserving interval ordering and updating start/end keys. + * Returns unchaged interval if the correct insertion position could not be determined. */ - removeItem({ id, item }: { id?: string; item?: T }): boolean { - if (!id && !item) return false; - let index: number; - if (item) { - const location = this.locateByItem(item); - index = location.index; - } else { - index = this.items?.findIndex((i) => this.getItemId(i) === id) ?? -1; + protected insertItemIdIntoInterval( + interval: I, + item: T, + ): I { + const id = this.getItemId(item); + const itemLocation = this.locateByItemInInterval({ item, interval }); + + if (!itemLocation) return interval; + + // If already at the correct position, nothing to change + if (itemLocation.current >= 0 && itemLocation.current === itemLocation.expected) { + return interval; } - if (index === -1) return false; - const newItems = [...(this.items ?? [])]; - newItems.splice(index, 1); - this.state.partialNext({ items: newItems }); - return true; + const ids = [...interval.itemIds]; + + // Adjust insertion index if we are removing the item before reinserting index. + // locateByItemInInterval() computed insertionIndex with the item still in the array. + let insertionIndex = itemLocation.expected; + if (itemLocation.current >= 0 && itemLocation.expected > itemLocation.current) { + insertionIndex--; + } + + // Remove existing occurrence if present + if (itemLocation.current >= 0) { + ids.splice(itemLocation.current, 1); + } + + // Insert at the new position + ids.splice(insertionIndex, 0, id); + + const intervalWithUpdatedIds = { + ...interval, + itemIds: ids, + }; + + const boundaries = this.recomputeIntervalBoundaries(intervalWithUpdatedIds); + + return { + ...intervalWithUpdatedIds, + ...boundaries, + }; } - contains(item: T): boolean { - return !!this.items?.find((i) => this.getItemId(i) === this.getItemId(item)); + /** + * Splits a logical interval by checking each item individually. + * Items overlapping anchoredInterval are merged into it. + * Others stay in a retained logical interval. + */ + protected mergeItemsFromLogicalInterval( + logical: LogicalInterval, + anchored: Interval, + ): { mergedAnchored: Interval; remainingLogical: LogicalInterval | null } { + const mergeIds: string[] = []; + const keepIds: string[] = []; + + for (const id of logical.itemIds) { + const item = this.getItem(id); + if (!item) { + keepIds.push(id); + continue; + } + + const key = this.computeSortKey(item); + + if (belongsToInterval(key, anchored)) mergeIds.push(id); + else keepIds.push(id); + } + + let merged = anchored; + for (const id of mergeIds) { + const item = this.getItem(id); + if (!item) continue; + merged = this.insertItemIdIntoInterval(merged, item); + } + + const remainingLogical = keepIds.length > 0 ? { ...logical, itemIds: keepIds } : null; + + return { + mergedAnchored: merged, + remainingLogical: remainingLogical && { + ...remainingLogical, + ...this.recomputeIntervalBoundaries(remainingLogical), + }, + }; } /** - * Find the exact index of `needle` by ID (via getItemId) under the current sortComparator. - * Returns: - * - `index`: actual index if found, otherwise -1 - * - `insertionIndex`: lower-bound position where `needle` would be inserted - * to preserve order (always defined). - * - * Time: O(log n) + O(k) for a tie plateau of size k (unless comparator has ID tiebreaker). - * - * ### Usage examples + * Merges all intervals (anchored + logical head/tail). + * Returns: + * - merged anchored interval (or null if none) + * - possibly reduced logical head / tail intervals + */ + protected mergeIntervals(intervals: AnyInterval[]): MergeIntervalsResult { + let logicalHead: LogicalInterval | null = null; + let logicalTail: LogicalInterval | null = null; + const anchored: Interval[] = []; + + // Separate logical vs anchored + for (const itv of intervals) { + if (isLiveHeadInterval(itv)) logicalHead = itv; + else if (isLiveTailInterval(itv)) logicalTail = itv; + else anchored.push(itv); + } + + // nothing to merge + if (anchored.length === 0 && logicalHead && logicalTail) { + return { logicalHead, merged: null, logicalTail }; + } + + // Merge anchored intervals into one interval (if possible) + const mergedAnchored = mergeAnchoredIntervals(anchored); + + // No anchored intervals → just return logical ones + if (!mergedAnchored) { + return { logicalHead, merged: null, logicalTail }; + } + + let merged = mergedAnchored; + + // Merge items from logical HEAD interval + if (logicalHead) { + const { mergedAnchored, remainingLogical } = this.mergeItemsFromLogicalInterval( + logicalHead, + merged, + ); + merged = mergedAnchored; + logicalHead = remainingLogical; + } + + // Merge items from logical TAIL interval + if (logicalTail) { + const { mergedAnchored, remainingLogical } = this.mergeItemsFromLogicalInterval( + logicalTail, + merged, + ); + merged = mergedAnchored; + logicalTail = remainingLogical; + } + + return { logicalHead, merged, logicalTail }; + } + + // --------------------------------------------------------------------------- + // Consume and manage items + // --------------------------------------------------------------------------- + + /** + * Ingests the whole page into intervals and returns the resulting anchored interval. + */ + protected ingestPage({ + page, + isHead, + isTail, + targetIntervalId, + }: { + page: T[]; + isHead?: boolean; + isTail?: boolean; + targetIntervalId?: string; + }): Interval | null { + if (!this._itemIndex || !page?.length) return null; + + for (const item of page) { + this._itemIndex.setOne(item); + } + + const pageInterval = this.makeInterval({ + page, + isHead, + isTail, + }); + + const targetInterval = targetIntervalId + ? this._itemIntervals.get(targetIntervalId) + : null; + + // Find intervals that overlap with this page + const overlapping: Interval[] = []; + for (const itv of this.itemIntervals) { + // target will be appended separately + if (targetInterval?.id === itv.id) continue; + if (intervalsOverlap(pageInterval, itv)) { + overlapping.push(itv); + } + } + const toMerge: AnyInterval[] = [...overlapping, pageInterval]; + + if (targetInterval) { + toMerge.push(targetInterval); + } + + const { logicalHead, merged, logicalTail } = this.mergeIntervals(toMerge); + + // Remove all intervals that participated + for (const itv of toMerge) { + this._itemIntervals.delete(itv.id); + } + + // Decide which anchored interval we keep for this page: + const resultingInterval = merged ?? pageInterval; + this._itemIntervals.set(resultingInterval.id, resultingInterval); + + // Store logical head/tail (if any) + if (logicalHead) { + this._itemIntervals.set(LIVE_HEAD_INTERVAL_ID, logicalHead); + } else { + this._itemIntervals.delete(LIVE_HEAD_INTERVAL_ID); + } + + if (logicalTail) { + this._itemIntervals.set(LIVE_TAIL_INTERVAL_ID, logicalTail); + } else { + this._itemIntervals.delete(LIVE_TAIL_INTERVAL_ID); + } + + return resultingInterval; + } + + /** + * Ingests a single item on live update. * - * ```ts - * const { index, insertionIndex } = paginator.locateByItem(channel); + * If intervals + itemIndex exist, tries to: + * - update the ItemIndex + * - find an anchored interval whose sort bounds contain the item + * - insert the item into that interval using locate+plateau logic + * - if this is the active interval, re-emit state.items from interval * - * if (index > -1) { - * // Found -> e.g. remove the item - * items.splice(index, 1); - * } else { - * // Insert new at the right position - * items.splice(insertionIndex, 0, channel); - * } - * ``` + * If no intervals or no itemIndex exist, falls back to the legacy list-based ingestion. */ - public locateByItem( - needle: T, - options?: { alternatePlateauScan?: boolean }, - ): { index: number; insertionIndex: number } { - const items = this.items ?? []; - if (items.length === 0) return { index: -1, insertionIndex: 0 }; - - const insertionIndex = binarySearchInsertIndex({ - needle, - sortedArray: items, - compare: this.effectiveComparator, - }); + ingestItem(ingestedItem: T): boolean { + // If we don't have itemIndex, manipulate only items array in paginator state and not intervals + // as intervals do not store the whole items and have to rely on _itemIndex + if (!this.usesItemIntervalStorage) { + const items = this.items ?? []; + const next = items.slice(); + const { current: existingIndex, expected: insertionIndex } = binarySearch({ + needle: ingestedItem, + length: items.length, + getItemAt: (index: number) => items[index], + itemIdentityEquals: (item1, item2) => + this.getItemId(item1) === this.getItemId(item2), + compare: this.effectiveComparator.bind(this), + plateauScan: true, + }); + + if (!this.matchesFilter(ingestedItem)) { + if (existingIndex >= 0) { + next.splice(existingIndex, 1); + this.state.partialNext({ items: next }); + return true; + } + return false; + } + + // override the existing item even though it already exists to make sure it is up-to-date + if (existingIndex >= 0) { + next.splice(existingIndex, 1); + } + + const insertAt = + this.config.lockItemOrder && existingIndex >= 0 ? existingIndex : insertionIndex; - // quick neighbor checks - const id = this.getItemId(needle); - const left = insertionIndex - 1; - if (left >= 0 && this.effectiveComparator(items[left], needle) === 0) { - if (this.getItemId(items[left]) === id) return { index: left, insertionIndex }; - } - if ( - insertionIndex < items.length && - this.effectiveComparator(items[insertionIndex], needle) === 0 - ) { - if (this.getItemId(items[insertionIndex]) === id) - return { index: insertionIndex, insertionIndex }; - } - - // plateau scan - const index = - (options?.alternatePlateauScan ?? true) - ? locateOnPlateauAlternating( - items, - needle, - this.effectiveComparator, - this.getItemId.bind(this), - insertionIndex, - ) - : locateOnPlateauScanOneSide( - items, - needle, - this.effectiveComparator, - this.getItemId.bind(this), - insertionIndex, - ); - - return { index, insertionIndex }; - } - - findItem(needle: T, options?: { alternatePlateauScan?: boolean }): T | undefined { - const { index } = this.locateByItem(needle, options); - return index > -1 ? (this.items ?? [])[index] : undefined; - } - - setItems(valueOrFactory: ValueOrPatch, cursor?: PaginatorCursor) { + next.splice(insertAt, 0, ingestedItem); + this.state.partialNext({ items: next }); + return true; + } + + // Always update the itemIndex if present + this._itemIndex?.setOne(ingestedItem); + + // Ingestion into anchored intervals + let targetInterval = this.locateIntervalForItem(ingestedItem); + + // if no page has been loaded yet or the anchored interval could not be found, + // because the relevant page has not been loaded yet, + // keep the incoming items in logical interval if falls outside of the head and tail boundaries + if (!targetInterval) { + let targetLogical: LogicalInterval | undefined; + // add to head or tail if item exceeds the total bounds + if (this._itemIntervals.size > 0) { + const intervalsArray = this.itemIntervals; + const [firstInterval, lastInterval] = [ + intervalsArray[0], + intervalsArray.slice(-1)[0], + ]; + const itemSortKey = this.computeSortKey(ingestedItem); + if ( + isLiveHeadInterval(firstInterval) && + compareSortKeys(itemSortKey, firstInterval.startKey) <= + ComparisonResult.A_PRECEDES_B + ) { + targetLogical = firstInterval; + } else if ( + isLiveTailInterval(lastInterval) && + compareSortKeys(itemSortKey, lastInterval.endKey) >= + ComparisonResult.A_COMES_AFTER_B + ) { + targetLogical = lastInterval; + } + // ingested item would fall somewhere inside the boundaries but relevant page has not been loaded yet + // and thus the interval is not identifiable + if (!targetLogical) return false; + + targetInterval = this.insertItemIdIntoInterval(targetLogical, ingestedItem); + } else { + // no page has been loaded yet + targetInterval = { + id: LIVE_HEAD_INTERVAL_ID, + itemIds: [this.getItemId(ingestedItem)], + startKey: this.computeSortKey(ingestedItem), + endKey: this.computeSortKey(ingestedItem), + }; + + if (!this._activeIntervalId) { + this._activeIntervalId = targetInterval.id; + } + } + } else { + targetInterval = this.insertItemIdIntoInterval(targetInterval, ingestedItem); + } + + this._itemIntervals.set(targetInterval.id, targetInterval); + + if (this._activeIntervalId === targetInterval.id) { + this.state.partialNext({ items: this.intervalToItems(targetInterval) }); + } + + return true; + } + + // --------------------------------------------------------------------------- + // Remove / contains + // --------------------------------------------------------------------------- + + removeItem({ id, item: inputItem }: { id?: string; item?: T }): boolean { + if (!id && !inputItem) return false; + const item = inputItem ?? this.getItem(id); + // not in item index, and no item provided (cannot locate by item), so we will not check intervals, + // only state items and sequentially + if (!this._itemIndex || !item) { + const index = this.items?.findIndex((i) => this.getItemId(i) === id) ?? -1; + if (index === -1) return false; + const newItems = [...(this.items ?? [])]; + newItems.splice(index, 1); + this.state.partialNext({ items: newItems }); + return true; + } + + const { state: stateLocation, interval: intervalLocation } = this.locateByItem(item); + + if (intervalLocation && intervalLocation.current > -1) { + const itemIds = [...intervalLocation.interval.itemIds]; + itemIds.splice(intervalLocation.current, 1); + const newInterval: AnyInterval = { ...intervalLocation.interval, itemIds }; + const boundaries = this.recomputeIntervalBoundaries(newInterval); + this._itemIntervals.set(newInterval.id, { ...newInterval, ...boundaries }); + } + + if (stateLocation && stateLocation.current > -1) { + const newItems = [...(this.items ?? [])]; + newItems.splice(stateLocation.current, 1); + this.state.partialNext({ items: newItems }); + } + return true; + } + + /** Sets the items in the state. If intervals are kept, the active interval will be updated */ + setItems({ + valueOrFactory, + cursor, + isFirstPage, + isLastPage, + }: SetPaginatorItemsParams) { this.state.next((current) => { const { items: currentItems = [] } = current; const newItems = isPatch(valueOrFactory) @@ -511,22 +1233,36 @@ export abstract class BasePaginator { } else { newState.offset = newItems.length; } + + const interval = this.ingestPage({ + page: newItems, + isHead: isFirstPage, + isTail: isLastPage, + }); + if (interval) this._activeIntervalId = interval.id; + return newState; }); } - setFilterResolvers(resolvers: FieldToDataResolver[]) { - this._filterFieldToDataResolvers = resolvers; - } - - addFilterResolvers(resolvers: FieldToDataResolver[]) { - this._filterFieldToDataResolvers.push(...resolvers); - } + // --------------------------------------------------------------------------- + // Debounce & query execution + // --------------------------------------------------------------------------- setDebounceOptions = ({ debounceMs }: PaginatorDebounceOptions) => { this._executeQueryDebounced = debounce(this.executeQuery.bind(this), debounceMs); }; + protected shouldResetStateBeforeQuery( + prevQueryShape: unknown | undefined, + nextQueryShape: unknown | undefined, + ): boolean { + return ( + typeof prevQueryShape === 'undefined' || + this.config.hasPaginationQueryShapeChanged(prevQueryShape, nextQueryShape) + ); + } + protected canExecuteQuery = ({ direction, reset, @@ -560,7 +1296,7 @@ export abstract class BasePaginator { const current = this.state.getLatestValue(); return { ...current, - lastQueryError: undefined, // reset lastQueryError that can be overridden by the stateUpdate + lastQueryError: undefined, ...stateUpdate, isLoading: false, items: isFirstPage @@ -587,7 +1323,6 @@ export abstract class BasePaginator { try { return await this.query(params); } catch (e) { - // If the offline support is enabled, and there are items in the DB, we should not report the error. const isOfflineSupportEnabledWithItems = this.isOfflineSupportEnabled && (this.items ?? []).length > 0; if (!isOfflineSupportEnabledWithItems) { @@ -596,7 +1331,6 @@ export abstract class BasePaginator { const nextRetryCount = (retryCount ?? 0) - 1; if (nextRetryCount > 0) { - // not swapping isLoading flag to false as the load has not finished yet await sleep(DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES); return await this.runQueryRetryable({ ...params, @@ -613,7 +1347,7 @@ export abstract class BasePaginator { async executeQuery({ direction = 'next', - queryShape: forcedQueryShape, // todo: remove it? + queryShape: forcedQueryShape, reset, retryCount = 0, }: PaginationQueryParams = {}) { @@ -623,7 +1357,6 @@ export abstract class BasePaginator { const isFirstPage = this.isFirstPageQuery({ queryShape, reset }); if (isFirstPage) { const state = this.getStateBeforeFirstQuery(); - // preload from the offline DB only if no successful HTTP request has been run previously let items: T[] | undefined = undefined; if (!this.isInitialized) { items = @@ -649,13 +1382,14 @@ export abstract class BasePaginator { this._lastQueryShape = this._nextQueryShape; this._nextQueryShape = undefined; - // if the request failed the value is null, loading finished if (!results) { this.state.partialNext({ isLoading: false }); return; } - const stateUpdate: Partial> = { lastQueryError: undefined }; + const stateUpdate: Partial> = { + lastQueryError: undefined, + }; const { items, next, prev } = results; if (isFirstPage && (next || prev)) { @@ -672,11 +1406,29 @@ export abstract class BasePaginator { } stateUpdate.items = await this.filterQueryResults(items); + + // ingest page into intervals if itemIndex is present + const interval = this.ingestPage({ + page: stateUpdate.items, + isHead: !stateUpdate.hasNext, + isTail: !stateUpdate.hasPrev, + targetIntervalId: this._activeIntervalId, + }); + // item index is available if an Interval is returned + if (interval) { + this._activeIntervalId = interval.id; + stateUpdate.items = this.intervalToItems(interval); + } + const state = this.getStateAfterQuery(stateUpdate, isFirstPage); this.state.next(state); this.populateOfflineDbAfterQuery({ items: state.items, queryShape }); } + // --------------------------------------------------------------------------- + // Public API: navigation + // --------------------------------------------------------------------------- + cancelScheduledQuery() { this._executeQueryDebounced.cancel(); } @@ -696,6 +1448,7 @@ export abstract class BasePaginator { ) => { this._executeQueryDebounced({ direction: 'next', ...params }); }; + prevDebounced = ( params: Omit, 'direction' | 'queryShape'> = {}, ) => { diff --git a/src/pagination/paginators/ChannelPaginator.ts b/src/pagination/paginators/ChannelPaginator.ts index 7a3a805a2d..9d9ca3ea71 100644 --- a/src/pagination/paginators/ChannelPaginator.ts +++ b/src/pagination/paginators/ChannelPaginator.ts @@ -4,6 +4,8 @@ import type { PaginationQueryShapeChangeIdentifier, PaginatorOptions, PaginatorState, + SetPaginatorItemsParams, + SortKey, } from './BasePaginator'; import { BasePaginator } from './BasePaginator'; import type { FilterBuilderOptions } from '../FilterBuilder'; @@ -20,7 +22,6 @@ import type { } from '../../types'; import type { FieldToDataResolver, PathResolver } from '../types.normalization'; import { resolveDotPathValue } from '../utility.normalization'; -import type { ValueOrPatch } from '../../store'; import { isEqual } from '../../utils/mergeWith/mergeWithCore'; const DEFAULT_BACKEND_SORT: ChannelSort = { last_message_at: -1, updated_at: -1 }; // {last_updated: -1} @@ -244,8 +245,8 @@ export class ChannelPaginator extends BasePaginator return this._staticFilters; } - get sort(): ChannelSort | undefined { - return this._sort; + get sort(): ChannelSort { + return this._sort ?? DEFAULT_BACKEND_SORT; } get options(): ChannelOptions | undefined { @@ -284,6 +285,14 @@ export class ChannelPaginator extends BasePaginator baseFilters: { ...this.staticFilters }, }); + computeSortKey(item: Channel): SortKey { + const generateSortKey = super.makeSortKeyGenerator({ + sort: this.sort, + resolvePathValue: channelSortPathResolver, + }); + return generateSortKey(item); + } + // invoked inside BasePaginator.executeQuery() to keep it as a query descriptor; protected getNextQueryShape(): ChannelQueryShape { const shape: ChannelQueryShape = { @@ -386,8 +395,8 @@ export class ChannelPaginator extends BasePaginator filterQueryResults = (items: Channel[]) => items; - setItems(valueOrFactory: ValueOrPatch) { - super.setItems(valueOrFactory); + setItems(params: SetPaginatorItemsParams) { + super.setItems(params); if (!this.client.offlineDb) return; diff --git a/src/pagination/sortCompiler.ts b/src/pagination/sortCompiler.ts index b56e9cd13f..15a6c8ccaa 100644 --- a/src/pagination/sortCompiler.ts +++ b/src/pagination/sortCompiler.ts @@ -9,31 +9,121 @@ import { normalizeQuerySort } from '../utils'; import type { AscDesc } from '../types'; import type { Comparator, PathResolver } from './types.normalization'; -export function binarySearchInsertIndex({ - compare, +export type ItemLocation = { + expected: number; + current: number; +}; + +/** + * Generic binary-search + plateau lookup over an abstract sorted array. + * + * The array is represented by: + * - its length + * - a getter `getItemAt(index)` that returns the item (or undefined) + * + * It returns: + * - current: actual index of the item in the array + * - expected: lower-bound position where the item belongs according to compare function + */ +export function binarySearch({ needle, - sortedArray, + length, + getItemAt, + itemIdentityEquals, + compare, + plateauScan, }: { - sortedArray: T[]; + /** Target item in the searched array */ needle: T; + length: number; + /** Retrieves the item from an array. The array could be just an array of reference by id to an index. + * Therefore, we do not access the array directly but allow to determine, how the item is constructed. + */ + getItemAt: (index: number) => T | undefined; + /** Used to determine identity, not equality based on sort / comparator rules */ + itemIdentityEquals: (item1: T, item2: T) => boolean; + /** Used to determine equality from the sort order point of view. */ compare: Comparator; -}): number { - let low = 0; - let high = sortedArray.length; + plateauScan?: boolean; +}): ItemLocation { + // empty array + if (length === 0) return { current: -1, expected: 0 }; + + // --- 1) Binary search to find lower bound (insertionIndex) --- + let lo = 0; + let hi = length; + + while (lo < hi) { + const mid = (lo + hi) >> 1; // fast floor((low+high)/2) + const midItem = getItemAt(mid); + if (!midItem) { + // Corruption: we have an ID but no backing item. + // Bail out with "not found". + return { current: -1, expected: -1 }; + } + + const cmp = compare(midItem, needle); + if (cmp < 0) { + // midItem < needle ⇒ go right + lo = mid + 1; + } else { + // midItem ≥ needle ⇒ go left + hi = mid; + } + } + + const expected = lo; - while (low < high) { - const middle = (low + high) >>> 1; // fast floor((low+high)/2) - const comparisonResult = compare(sortedArray[middle], needle); + // item is located where it is expected to be according to the sort + const itemAtExpectedIndex = getItemAt(expected); + if (itemAtExpectedIndex && itemIdentityEquals(itemAtExpectedIndex, needle)) { + return { current: expected, expected }; + } else if (!plateauScan) { + return { current: -1, expected }; + } + + // --- 2) Plateau scan around insertionIndex --- - // We want the first position where existing > needle to insert before it - if (comparisonResult > 0) { - high = middle; + const checkSide = (atIndex: number) => { + const result = { exhausted: false, found: false }; + const item = getItemAt(atIndex); + if (!item) { + result.exhausted = true; } else { - low = middle + 1; + const cmp = compare(item, needle); + if (cmp !== 0) { + result.exhausted = true; + } else { + if (itemIdentityEquals(item, needle)) { + result.found = true; + } + } + } + return result; + }; + + // Alternating left/right scan + let iLeft = expected - 1; + let iRight = expected + 1; // we've already checked insertionIndex + let leftDone = iLeft < 0; + let rightDone = iRight >= length; + + while (!leftDone || !rightDone) { + if (!leftDone) { + const result = checkSide(iLeft); + if (result.found) return { current: iLeft, expected }; + leftDone = result.exhausted || --iLeft < 0; + } + + if (!rightDone) { + const result = checkSide(iRight); + if (result.found) return { current: iRight, expected }; + rightDone = result.exhausted || ++iRight >= length; } } - return low; + // Not found in plateau; insertion index is still the correct lower bound. + return { current: -1, expected }; } /** diff --git a/src/pagination/types.normalization.ts b/src/pagination/types.normalization.ts index 1932a5bc73..f9ecc09542 100644 --- a/src/pagination/types.normalization.ts +++ b/src/pagination/types.normalization.ts @@ -1,5 +1,12 @@ export type PathResolver = (item: DataSource, field: string) => unknown; -export type Comparator = (left: T, right: T) => number; + +export enum ComparisonResult { + A_PRECEDES_B = -1, + A_IS_EQUAL_TO_B = 0, + A_COMES_AFTER_B = 1, +} + +export type Comparator = (left: T, right: T) => ComparisonResult; export type FieldToDataResolver = { matchesField: (field: string) => boolean; From 7aa4f9107a0a507500409408c9b5c42cf6d2dd1f Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 12 Jan 2026 08:14:40 +0100 Subject: [PATCH 10/48] feat: add MessagePaginator --- src/ChannelPaginatorsOrchestrator.ts | 3 +- src/channel.ts | 198 +- src/client.ts | 59 +- .../InstanceConfigurationService.ts | 73 + src/configuration/index.ts | 1 + src/configuration/types.ts | 81 + src/messageComposer/messageComposer.ts | 21 +- .../MessageOperationStatePolicy.ts | 85 + src/messageOperations/MessageOperations.ts | 107 + src/messageOperations/index.ts | 10 + src/messageOperations/types.ts | 57 + src/pagination/ItemIndex.ts | 17 +- .../createdAtAroundPaginationFlags.ts | 73 + .../idAroundPaginationFlags.ts | 53 + src/pagination/cursorDerivation/index.ts | 1 + .../cursorDerivation/linearPaginationFlags.ts | 83 + src/pagination/paginators/BasePaginator.ts | 1696 +++++--- src/pagination/paginators/ChannelPaginator.ts | 9 - src/pagination/paginators/MessagePaginator.ts | 520 +++ .../paginators/MessageReplyPaginator.ts | 301 ++ .../paginators/ReminderPaginator.ts | 6 +- src/pagination/paginators/index.ts | 2 + src/pagination/sortCompiler.ts | 47 +- src/reminders/ReminderManager.ts | 4 +- src/thread.ts | 113 +- .../ChannelPaginatorsOrchestrator.test.ts | 36 +- test/unit/EventHandlerPipeline.test.ts | 12 +- .../MessageComposer/messageComposer.test.ts | 59 +- .../MessageOperations.test.ts | 203 + test/unit/pagination/BasePaginator.test.ts | 1544 ------- test/unit/pagination/ItemIndex.test.ts | 175 + .../paginators/BasePaginator.test.ts | 3639 +++++++++++++++++ .../{ => paginators}/ChannelPaginator.test.ts | 24 +- .../paginators/MessagePaginator.test.ts | 493 +++ .../paginators/MessageReplyPaginator.test.ts | 114 + test/unit/pagination/sortCompiler.test.ts | 427 +- 36 files changed, 8081 insertions(+), 2265 deletions(-) create mode 100644 src/configuration/InstanceConfigurationService.ts create mode 100644 src/configuration/index.ts create mode 100644 src/configuration/types.ts create mode 100644 src/messageOperations/MessageOperationStatePolicy.ts create mode 100644 src/messageOperations/MessageOperations.ts create mode 100644 src/messageOperations/index.ts create mode 100644 src/messageOperations/types.ts create mode 100644 src/pagination/cursorDerivation/createdAtAroundPaginationFlags.ts create mode 100644 src/pagination/cursorDerivation/idAroundPaginationFlags.ts create mode 100644 src/pagination/cursorDerivation/index.ts create mode 100644 src/pagination/cursorDerivation/linearPaginationFlags.ts create mode 100644 src/pagination/paginators/MessagePaginator.ts create mode 100644 src/pagination/paginators/MessageReplyPaginator.ts create mode 100644 test/unit/messageOperations/MessageOperations.test.ts delete mode 100644 test/unit/pagination/BasePaginator.test.ts create mode 100644 test/unit/pagination/ItemIndex.test.ts create mode 100644 test/unit/pagination/paginators/BasePaginator.test.ts rename test/unit/pagination/{ => paginators}/ChannelPaginator.test.ts (97%) create mode 100644 test/unit/pagination/paginators/MessagePaginator.test.ts create mode 100644 test/unit/pagination/paginators/MessageReplyPaginator.test.ts diff --git a/src/ChannelPaginatorsOrchestrator.ts b/src/ChannelPaginatorsOrchestrator.ts index bbfc7f5bfe..33458fcb20 100644 --- a/src/ChannelPaginatorsOrchestrator.ts +++ b/src/ChannelPaginatorsOrchestrator.ts @@ -95,7 +95,8 @@ const reEmit: EventHandlerPipelineHandler = ({ if (!channel) return; orchestrator.paginators.forEach((paginator) => { const items = paginator.items; - if (paginator.findItem(channel) && items) { + const { state } = paginator.locateByItem(channel); + if ((state?.currentIndex ?? -1) > -1 && items) { paginator.state.partialNext({ items: [...items] }); } }); diff --git a/src/channel.ts b/src/channel.ts index 76cb7265ea..47856dc1c8 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1,6 +1,8 @@ import { ChannelState } from './channel_state'; import { MessageComposer } from './messageComposer'; import { MessageReceiptsTracker } from './messageDelivery'; +import { MessagePaginator } from './pagination/paginators'; +import { MessageOperations } from './messageOperations'; import { generateChannelTempCid, logChatPromiseExecution, @@ -72,10 +74,56 @@ import type { UpdateChannelAPIResponse, UpdateChannelOptions, UpdateLocationPayload, + UpdateMessageOptions, UserResponse, } from './types'; import type { Role } from './permissions'; import type { CustomChannelData } from './custom_types'; +import { StateStore } from './store'; + +// todo: move to dedicated file +export type SendMessageWithStateUpdateParams = { + localMessage: LocalMessage; + message?: Message; + options?: SendMessageOptions; + /** + * Per-call override for the send/retry request (advanced). + * If set, it takes precedence over channel instance configuration handlers. + */ + sendMessageRequestFn?: CustomSendMessageRequestFn; +}; + +export type RetrySendMessageWithLocalUpdateParams = Omit< + SendMessageWithStateUpdateParams, + 'message' +>; + +export type UpdateMessageWithStateUpdateParams = { + localMessage: LocalMessage; + options?: UpdateMessageOptions; + /** + * Per-call override for the update request (advanced). + * If set, it takes precedence over channel instance configuration handlers. + */ + updateMessageRequestFn?: CustomUpdateMessageRequestFn; +}; + +// Custom request function types for configuration +export type CustomSendMessageRequestFn = ( + params: Omit, +) => Promise<{ message: MessageResponse }>; + +export type CustomUpdateMessageRequestFn = ( + params: Omit, +) => Promise<{ message: MessageResponse }>; + +export type ChannelInstanceConfig = { + requestHandlers?: { + sendMessageRequest?: CustomSendMessageRequestFn; + retrySendMessageRequest?: CustomSendMessageRequestFn; + updateMessageRequest?: CustomUpdateMessageRequestFn; + }; +}; /** * Channel - The Channel class manages it's own state. @@ -110,8 +158,11 @@ export class Channel { isTyping: boolean; disconnected: boolean; push_preferences?: PushPreference; + public readonly configState = new StateStore({}); public readonly messageComposer: MessageComposer; public readonly messageReceiptsTracker: MessageReceiptsTracker; + public readonly messagePaginator: MessagePaginator; + public readonly messageOperations: MessageOperations; /** * constructor - Create a channel @@ -167,6 +218,54 @@ export class Channel { return msg && { timestampMs, msgId: msg.id }; }, }); + + this.messagePaginator = new MessagePaginator({ channel: this }); + + this.messageOperations = new MessageOperations({ + ingest: (m) => this.messagePaginator.ingestItem(m), + get: (id) => this.messagePaginator.getItem(id), + handlers: () => { + const { requestHandlers } = this.configState.getLatestValue(); + const sendMessageRequest = requestHandlers?.sendMessageRequest; + const retrySendMessageRequest = requestHandlers?.retrySendMessageRequest; + const updateMessageRequest = requestHandlers?.updateMessageRequest; + return { + send: sendMessageRequest + ? (p) => + sendMessageRequest({ + localMessage: p.localMessage, + message: p.message, + options: p.options, + }) + : undefined, + retry: retrySendMessageRequest + ? (p) => + retrySendMessageRequest({ + localMessage: p.localMessage, + message: p.message, + options: p.options, + }) + : undefined, + update: updateMessageRequest + ? (p) => + updateMessageRequest({ + localMessage: p.localMessage, + options: p.options, + }) + : undefined, + }; + }, + defaults: { + send: async (m, o) => { + const result = await this.sendMessage(m, o); + return { message: result.message }; + }, + update: async (m, o) => { + const result = await this.getClient().updateMessage(m, undefined, o); + return { message: result.message }; + }, + }, + }); } /** @@ -240,6 +339,51 @@ export class Channel { return await this._sendMessage(message, options); } + /** + * Sends a message with optimistic local state update. + */ + async sendMessageWithLocalUpdate( + params: SendMessageWithStateUpdateParams, + ): Promise { + await this.messageOperations.send( + { + localMessage: params.localMessage, + message: params.message, + options: params.options, + }, + params.sendMessageRequestFn, + ); + if (this.messageComposer.config.text.publishTypingEvents) await this.stopTyping(); + } + + /** + * Retry sending a failed message. + */ + async retrySendMessageWithLocalUpdate( + params: Omit, + ) { + await this.messageOperations.retry( + { + localMessage: { ...params.localMessage, type: 'regular' }, + options: params.options, + }, + params.sendMessageRequestFn, + ); + } + + /** + * Updates a message with optimistic local state update. + */ + async updateMessageWithLocalUpdate(params: UpdateMessageWithStateUpdateParams) { + await this.messageOperations.update( + { + localMessage: params.localMessage, + options: params.options, + }, + params.updateMessageRequestFn, + ); + } + sendFile( uri: string | NodeJS.ReadableStream | Buffer | File, name?: string, @@ -1399,7 +1543,7 @@ export class Channel { if (message.user?.id && this.getClient().userMuteStatus(message.user.id)) return false; - // Return false if channel doesn't allow read events. + // Return false if channel doesn't allow ad events. if ( Array.isArray(this.data?.own_capabilities) && !this.data?.own_capabilities.includes('read-events') @@ -1472,18 +1616,7 @@ export class Channel { return await this.query(defaultOptions, 'latest'); }; - /** - * query - Query the API, get messages, members or other channel fields - * - * @param {ChannelQueryOptions} options The query options - * @param {MessageSetType} messageSetToAddToIfDoesNotExist It's possible to load disjunct sets of a channel's messages into state, use `current` to load the initial channel state or if you want to extend the currently displayed messages, use `latest` if you want to load/extend the latest messages, `new` is used for loading a specific message and it's surroundings - * - * @return {Promise} Returns a query response - */ - async query( - options: ChannelQueryOptions = {}, - messageSetToAddToIfDoesNotExist: MessageSetType = 'current', - ) { + async _query(options: ChannelQueryOptions = {}) { // Make sure we wait for the connect promise if there is a pending one await this.getClient().wsPromise; @@ -1507,15 +1640,26 @@ export class Channel { queryURL += `/${encodeURIComponent(this.id)}`; } - const state = await this.getClient().post( - queryURL + '/query', - { - data: this._data, - state: true, - ...options, - }, - ); + return await this.getClient().post(queryURL + '/query', { + data: this._data, + state: true, + ...options, + }); + } + /** + * query - Query the API, get messages, members or other channel fields + * + * @param {ChannelQueryOptions} options The query options + * @param {MessageSetType} messageSetToAddToIfDoesNotExist It's possible to load disjunct sets of a channel's messages into state, use `current` to load the initial channel state or if you want to extend the currently displayed messages, use `latest` if you want to load/extend the latest messages, `new` is used for loading a specific message and it's surroundings + * + * @return {Promise} Returns a query response + */ + async query( + options: ChannelQueryOptions = {}, + messageSetToAddToIfDoesNotExist: MessageSetType = 'current', + ) { + const state = await this._query(options); // update the channel id if it was missing if (!this.id) { this.id = state.channel.id; @@ -2052,6 +2196,9 @@ export class Channel { if (this._countMessageAsUnread(event.message)) { channelState.unreadCount = channelState.unreadCount + 1; + this.messagePaginator.setUnreadSnapshot({ + unreadCount: channelState.unreadCount, + }); } client.syncDeliveredCandidates([this]); @@ -2101,6 +2248,8 @@ export class Channel { } } + this.messagePaginator.clearUnreadSnapshot(); + break; case 'member.added': case 'member.updated': { @@ -2168,6 +2317,13 @@ export class Channel { user: event.user, unread_messages: unreadCount, }; + this.messagePaginator.setUnreadSnapshot({ + firstUnreadMessageId: + channelState.read[event.user.id].first_unread_message_id ?? null, + lastReadAt: channelState.read[event.user.id].last_read, + lastReadMessageId: channelState.read[event.user.id].last_read_message_id, + unreadCount, + }); channelState.unreadCount = unreadCount; this.messageReceiptsTracker.onNotificationMarkUnread({ diff --git a/src/client.ts b/src/client.ts index c2cbe87036..150a73dad3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -19,6 +19,7 @@ import { addFileToFormData, axiosParamsSerializer, chatCodes, + formatMessage, generateChannelTempCid, isFunction, isOnline, @@ -244,34 +245,17 @@ import { ChannelManager } from './channel_manager'; import { MessageDeliveryReporter } from './messageDelivery'; import { NotificationManager } from './notifications'; import { ReminderManager } from './reminders'; -import { StateStore } from './store'; -import type { MessageComposer } from './messageComposer'; import type { AbstractOfflineDB } from './offline-support'; +import type { + MessageComposerSetupState, + SetInstanceConfigurationFunctions, +} from './configuration'; +import { InstanceConfigurationService } from './configuration/InstanceConfigurationService'; function isString(x: unknown): x is string { return typeof x === 'string' || x instanceof String; } -type MessageComposerTearDownFunction = () => void; - -type MessageComposerSetupFunction = ({ - composer, -}: { - composer: MessageComposer; -}) => void | MessageComposerTearDownFunction; - -export type MessageComposerSetupState = { - /** - * Each `MessageComposer` runs this function each time its signature changes or - * whenever you run `MessageComposer.registerSubscriptions`. Function returned - * from `applyModifications` will be used as a cleanup function - it will be stored - * and ran before new modification is applied. Cleaning up only the - * modified parts is the general way to go but if your setup gets a bit - * complicated, feel free to restore the whole composer with `MessageComposer.restore`. - */ - setupFunction: MessageComposerSetupFunction | null; -}; - export class StreamChat { private static _instance?: unknown | StreamChat; // type is undefined|StreamChat, unknown is due to TS limitations with statics messageDeliveryReporter: MessageDeliveryReporter; @@ -329,12 +313,7 @@ export class StreamChat { sdkIdentifier?: SdkIdentifier; deviceIdentifier?: DeviceIdentifier; private nextRequestAbortController: AbortController | null = null; - /** - * @private - */ - _messageComposerSetupState = new StateStore({ - setupFunction: null, - }); + instanceConfigurationService = new InstanceConfigurationService(); /** * Initialize a client @@ -581,7 +560,15 @@ export class StreamChat { public setMessageComposerSetupFunction = ( setupFunction: MessageComposerSetupState['setupFunction'], ) => { - this._messageComposerSetupState.partialNext({ setupFunction }); + this.instanceConfigurationService.setSetupFunctions({ + MessageComposer: setupFunction, + }); + }; + + public setInstanceConfigurationFunction = ( + setupFunctions: SetInstanceConfigurationFunctions, + ) => { + this.instanceConfigurationService.setSetupFunctions(setupFunctions); }; /** @@ -2008,7 +1995,19 @@ export class StreamChat { this.polls.hydratePollCache(channelState.messages, true); this.reminders.hydrateState(channelState.messages); } - + const requestedPageSize = + queryChannelsOptions?.message_limit ?? + DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE; + c.messagePaginator.postQueryReconcile({ + direction: 'tailward', + isFirstPage: true, + queryShape: { limit: requestedPageSize }, + requestedPageSize, + results: { + items: channelState.messages.map(formatMessage), + tailward: channelState.messages[0]?.id, + }, + }); c.messageComposer.initStateFromChannelResponse(channelState); channels.push(c); diff --git a/src/configuration/InstanceConfigurationService.ts b/src/configuration/InstanceConfigurationService.ts new file mode 100644 index 0000000000..c602ea4bb3 --- /dev/null +++ b/src/configuration/InstanceConfigurationService.ts @@ -0,0 +1,73 @@ +/** + * InstanceConfigurationService is a singleton class that is used to store the configuration for the instances of classes exposed by the SKD such as: + * - StreamChat + * - Channel + * - Thread + * - MessageComposer + * + * Every existing and future instance configuration of the above classes will be setup using the following pattern: + * - StreamChat: StreamChat.setClientSetupFunction(setupFunction) + * - Channel: StreamChat.setChannelSetupFunction(setupFunction) + * - Thread: StreamChat.setThreadSetupFunction(setupFunction) + * - MessageComposer: StreamChat.setMessageComposerSetupFunction(setupFunction) + * + * The setupFunction is a function that is used to set up the instance configuration. + */ + +import { StateStore } from '../store'; +import type { + ChannelSetupState, + MessageComposerSetupState, + SetInstanceConfigurationFunctions, + SetInstanceConfigurationServiceStates, + StreamChatSetupState, + ThreadSetupState, +} from './types'; + +type InstanceKey = keyof SetInstanceConfigurationServiceStates; + +export class InstanceConfigurationService { + private static instance: InstanceConfigurationService; + private setupStates: SetInstanceConfigurationServiceStates = { + Channel: new StateStore({ + setupFunction: null, + }), + MessageComposer: new StateStore({ + setupFunction: null, + }), + StreamChat: new StateStore({ + setupFunction: null, + }), + Thread: new StateStore({ + setupFunction: null, + }), + }; + + setSetupFunctions(setupFunctions: SetInstanceConfigurationFunctions) { + for (const [instance, setupFunction] of Object.entries(setupFunctions)) { + const setupState = + this.setupStates[instance as keyof SetInstanceConfigurationServiceStates]; + if (typeof setupState === 'undefined') return; // null is allowed + // todo: fix typing + (setupState as StateStore<{ setupFunction: unknown }>).partialNext({ + setupFunction: setupFunction as SetInstanceConfigurationFunctions[InstanceKey], + }); + } + } + + get Channel() { + return this.setupStates.Channel; + } + + get MessageComposer() { + return this.setupStates.MessageComposer; + } + + get StreamChat() { + return this.setupStates.StreamChat; + } + + get Thread() { + return this.setupStates.Thread; + } +} diff --git a/src/configuration/index.ts b/src/configuration/index.ts new file mode 100644 index 0000000000..fcb073fefc --- /dev/null +++ b/src/configuration/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/src/configuration/types.ts b/src/configuration/types.ts new file mode 100644 index 0000000000..1157e40709 --- /dev/null +++ b/src/configuration/types.ts @@ -0,0 +1,81 @@ +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'; + +export type MessageComposerTearDownFunction = () => void; + +export type MessageComposerSetupFunction = ({ + composer, +}: { + composer: MessageComposer; +}) => void | MessageComposerTearDownFunction; + +export type MessageComposerSetupState = { + /** + * Each `MessageComposer` runs this function each time its signature changes or + * whenever you run `MessageComposer.registerSubscriptions`. Function returned + * from `applyModifications` will be used as a cleanup function - it will be stored + * and ran before new modification is applied. Cleaning up only the + * modified parts is the general way to go but if your setup gets a bit + * complicated, feel free to restore the whole composer with `MessageComposer.restore`. + */ + setupFunction: MessageComposerSetupFunction | null; +}; + +export type StreamChatTearDownFunction = () => void; + +export type StreamChatSetupFunction = ({ + client, +}: { + client: StreamChat; +}) => void | StreamChatTearDownFunction; + +export type StreamChatSetupState = { + setupFunction: StreamChatSetupFunction | null; +}; + +export type ChannelTearDownFunction = () => void; + +export type ChannelSetupFunction = ({ + channel, +}: { + channel: Channel; +}) => void | ChannelTearDownFunction; + +export type ChannelSetupState = { + setupFunction: ChannelSetupFunction | null; +}; + +export type ThreadTearDownFunction = () => void; + +export type ThreadSetupFunction = ({ + thread, +}: { + thread: Thread; +}) => void | ThreadTearDownFunction; + +export type ThreadSetupState = { + setupFunction: ThreadSetupFunction | null; +}; + +export type SetInstanceConfigurationServiceStates = { + Channel: StateStore; + MessageComposer: StateStore; + StreamChat: StateStore; + Thread: StateStore; +}; + +export type SetupFnOf = + T extends StateStore + ? S extends { setupFunction?: infer F } + ? F + : never + : never; + +export type SetInstanceConfigurationFunctions = { + [K in keyof SetInstanceConfigurationServiceStates]?: SetupFnOf< + SetInstanceConfigurationServiceStates[K] + >; +}; diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index 08fd1b6890..5aaf56c648 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -41,6 +41,7 @@ export type EditingAuditState = { }; export type LocalMessageWithLegacyThreadId = LocalMessage & { legacyThreadId?: string }; +// todo: remove LocalMessageWithLegacyThreadId export type CompositionContext = Channel | Thread | LocalMessageWithLegacyThreadId; export type MessageComposerState = { @@ -475,15 +476,16 @@ export class MessageComposer extends WithSubscriptions { private subscribeMessageComposerSetupStateChange = () => { let tearDown: (() => void) | null = null; - const unsubscribe = this.client._messageComposerSetupState.subscribeWithSelector( - ({ setupFunction: setup }) => ({ - setup, - }), - ({ setup }) => { - tearDown?.(); - tearDown = setup?.({ composer: this }) ?? null; - }, - ); + const unsubscribe = + this.client.instanceConfigurationService.MessageComposer.subscribeWithSelector( + ({ setupFunction: setup }) => ({ + setup, + }), + ({ setup }) => { + tearDown?.(); + tearDown = setup?.({ composer: this }) ?? null; + }, + ); return () => { tearDown?.(); @@ -694,6 +696,7 @@ export class MessageComposer extends WithSubscriptions { }, localMessage: { attachments: [], + cid: this.channel.cid, // it is needed to match local paginator filters to be ingested into its state created_at, // only assigned to localMessage as this is used for optimistic update deleted_at: null, error: undefined, diff --git a/src/messageOperations/MessageOperationStatePolicy.ts b/src/messageOperations/MessageOperationStatePolicy.ts new file mode 100644 index 0000000000..82cc253356 --- /dev/null +++ b/src/messageOperations/MessageOperationStatePolicy.ts @@ -0,0 +1,85 @@ +import type { + APIErrorResponse, + ErrorFromResponse, + LocalMessage, + MessageResponse, +} from '../types'; +import { formatMessage } from '../utils'; + +export type MessageOperationStatePolicyContext = { + ingest: (m: LocalMessage) => void; + get: (id: string) => LocalMessage | undefined; +}; + +const parseError = (error: unknown): ErrorFromResponse => { + const stringError = JSON.stringify(error); + return ( + stringError ? JSON.parse(stringError) : {} + ) as ErrorFromResponse; +}; + +const isAlreadyExistsError = ( + error: unknown, + parsed: ErrorFromResponse, +) => + parsed.code === 4 && error instanceof Error && error.message.includes('already exists'); + +export class MessageOperationStatePolicy { + private ctx: MessageOperationStatePolicyContext; + + constructor(ctx: MessageOperationStatePolicyContext) { + this.ctx = ctx; + } + + optimistic(localMessage: LocalMessage) { + this.ctx.ingest({ + ...localMessage, + error: undefined, + status: + !localMessage.status || localMessage.status === 'failed' + ? 'sending' + : localMessage.status, + }); + } + + success({ + messageFromResponse, + messageId, + }: { + messageFromResponse: MessageResponse; + messageId: string; + }) { + const formatted = formatMessage({ ...messageFromResponse, status: 'received' }); + const existing = this.ctx.get(messageId); + + if ( + !existing || + existing.updated_at.getTime() < formatted.updated_at.getTime() || + existing.status === 'sending' + ) { + this.ctx.ingest(formatted); + } + } + + failure({ + error, + localMessage, + messageId, + }: { + error: unknown; + localMessage: LocalMessage; + messageId: string; + }) { + const parsed = parseError(error); + + if (isAlreadyExistsError(error, parsed)) { + const existing = this.ctx.get(messageId); + if (existing?.status === 'sending') { + this.ctx.ingest({ ...localMessage, status: 'received' }); + } + return; + } + + this.ctx.ingest({ ...localMessage, status: 'failed', error: parsed }); + } +} diff --git a/src/messageOperations/MessageOperations.ts b/src/messageOperations/MessageOperations.ts new file mode 100644 index 0000000000..b2833cb2b9 --- /dev/null +++ b/src/messageOperations/MessageOperations.ts @@ -0,0 +1,107 @@ +// todo: add tests +import type { Message, UpdateMessageOptions } from '../types'; +import { localMessageToNewMessagePayload } from '../utils'; +import { MessageOperationStatePolicy } from './MessageOperationStatePolicy'; +import type { + MessageOperationsContext, + OperationKind, + OperationParams, + OperationRequestFn, +} from './types'; + +export class MessageOperations { + private ctx: MessageOperationsContext; + private policy: MessageOperationStatePolicy; + + constructor(ctx: MessageOperationsContext) { + this.ctx = ctx; + this.policy = new MessageOperationStatePolicy({ ingest: ctx.ingest, get: ctx.get }); + } + + private normalizeMessage(message: Message): Message { + return this.ctx.normalizeOutgoingMessage + ? this.ctx.normalizeOutgoingMessage(message) + : message; + } + + private async run( + params: OperationParams, + doRequest: OperationRequestFn, + ): Promise { + const messageId = params.localMessage.id; + + this.policy.optimistic(params.localMessage); + + try { + const { message: messageFromResponse } = await doRequest(params); + this.policy.success({ messageFromResponse, messageId }); + } catch (e) { + this.policy.failure({ error: e, localMessage: params.localMessage, messageId }); + throw e; + } + } + + async send( + params: OperationParams<'send'>, + requestFn?: OperationRequestFn<'send'>, + ): Promise { + const handlers = this.ctx.handlers(); + const messageToSend = this.normalizeMessage( + params.message ?? localMessageToNewMessagePayload(params.localMessage), + ); + + return await this.run<'send'>( + { ...params, message: messageToSend }, + requestFn ?? + handlers.send ?? + (async (p) => + await this.ctx.defaults.send(p.message ?? messageToSend, p.options)), + ); + } + + async retry( + params: OperationParams<'retry'>, + requestFn?: OperationRequestFn<'retry'>, + ): Promise { + const handlers = this.ctx.handlers(); + const messageToSend = this.normalizeMessage( + params.message ?? localMessageToNewMessagePayload(params.localMessage), + ); + + const send = handlers.send; + const sendAsRetry: OperationRequestFn<'retry'> | undefined = send + ? (p) => send({ ...p } as OperationParams<'send'>) + : undefined; + + return await this.run<'retry'>( + { ...params, message: messageToSend }, + requestFn ?? + handlers.retry ?? + sendAsRetry ?? + (async (p) => + await this.ctx.defaults.send(p.message ?? messageToSend, p.options)), + ); + } + + async update( + params: OperationParams<'update'>, + requestFn?: OperationRequestFn<'update'>, + ): Promise { + const handlers = this.ctx.handlers(); + let updateOptions: UpdateMessageOptions | undefined; + if (params.options) { + updateOptions = {}; + if (typeof params.options.skip_enrich_url === 'boolean') + updateOptions.skip_enrich_url = params.options.skip_enrich_url; + if (typeof params.options.skip_push === 'boolean') + updateOptions.skip_push = params.options.skip_push; + } + + return await this.run<'update'>( + params, + requestFn ?? + handlers.update ?? + (async (p) => await this.ctx.defaults.update(p.localMessage, updateOptions)), + ); + } +} diff --git a/src/messageOperations/index.ts b/src/messageOperations/index.ts new file mode 100644 index 0000000000..c97374605c --- /dev/null +++ b/src/messageOperations/index.ts @@ -0,0 +1,10 @@ +export { MessageOperations } from './MessageOperations'; +export { MessageOperationStatePolicy } from './MessageOperationStatePolicy'; +export type { + MessageOperationsContext, + MessageOperationsHandlers, + OperationKind, + OperationParams, + OperationRequestFn, + OperationResponse, +} from './types'; diff --git a/src/messageOperations/types.ts b/src/messageOperations/types.ts new file mode 100644 index 0000000000..1403646f40 --- /dev/null +++ b/src/messageOperations/types.ts @@ -0,0 +1,57 @@ +import type { + LocalMessage, + Message, + MessageResponse, + SendMessageAPIResponse, + SendMessageOptions, + UpdateMessageAPIResponse, + UpdateMessageOptions, +} from '../types'; + +export type OperationKind = 'send' | 'retry' | 'update'; + +export type MessageOperationSpec = { + send: { + options: SendMessageOptions; + requestResult: SendMessageAPIResponse; + }; + retry: { + options: SendMessageOptions; + requestResult: SendMessageAPIResponse; + }; + update: { + options: UpdateMessageOptions; + requestResult: UpdateMessageAPIResponse; + }; +}; + +export type OperationParams = { + localMessage: LocalMessage; + options?: MessageOperationSpec[K]['options']; +} & (K extends 'update' ? {} : { message?: Message }); + +export type OperationResponse = { message: MessageResponse }; + +export type OperationRequestFn = ( + params: OperationParams, +) => Promise; + +export type MessageOperationsHandlers = { + send?: OperationRequestFn<'send'>; + retry?: OperationRequestFn<'retry'>; + update?: OperationRequestFn<'update'>; +}; + +export type MessageOperationsContext = { + ingest: (m: LocalMessage) => void; + get: (id: string) => LocalMessage | undefined; + + normalizeOutgoingMessage?: (m: Message) => Message; + + defaults: { + send: (m: Message, o?: SendMessageOptions) => Promise; + update: (m: LocalMessage, o?: UpdateMessageOptions) => Promise; + }; + + handlers: () => MessageOperationsHandlers; +}; diff --git a/src/pagination/ItemIndex.ts b/src/pagination/ItemIndex.ts index 18310c9e4a..fe9d2df266 100644 --- a/src/pagination/ItemIndex.ts +++ b/src/pagination/ItemIndex.ts @@ -1,3 +1,7 @@ +export type ItemIndexOptions = { + getId: (item: T) => string; +}; + /** * The ItemIndex is a canonical, ID-addressable storage layer for domain items. * @@ -66,8 +70,11 @@ */ export class ItemIndex { private byId = new Map(); + private readonly getId: (item: T) => string; - constructor(private getId: (item: T) => string) {} + constructor(options: ItemIndexOptions) { + this.getId = options.getId; + } setMany(items: T[]) { for (const item of items) { @@ -91,7 +98,15 @@ export class ItemIndex { this.byId.delete(id); } + clear() { + this.byId.clear(); + } + entries() { return [...this.byId.entries()]; } + + values() { + return [...this.byId.values()]; + } } diff --git a/src/pagination/cursorDerivation/createdAtAroundPaginationFlags.ts b/src/pagination/cursorDerivation/createdAtAroundPaginationFlags.ts new file mode 100644 index 0000000000..f581e807f2 --- /dev/null +++ b/src/pagination/cursorDerivation/createdAtAroundPaginationFlags.ts @@ -0,0 +1,73 @@ +import { binarySearch } from '../sortCompiler'; +import type { BasePaginator, CursorDeriveContext, PaginationFlags } from '../paginators'; +import { ComparisonResult } from '../types.normalization'; + +export const deriveCreatedAtAroundPaginationFlags = < + T extends { id: string; created_at: Date }, + Q extends { created_at_around?: Date | string }, + P extends BasePaginator, +>({ + hasMoreHead, + hasMoreTail, + interval, + page, + paginator, + queryShape, + requestedPageSize, +}: CursorDeriveContext & { paginator: P }): PaginationFlags => { + let flags: PaginationFlags = { hasMoreHead, hasMoreTail }; + if (!queryShape?.created_at_around) return flags; + const createdAtAroundDate = new Date(queryShape.created_at_around); + const [firstPageItem, lastPageItem] = [page[0], page.slice(-1)[0]]; + + // expect ASC order (from oldest to newest) + const isAboveHeadBound = + paginator.sortComparator({ created_at: createdAtAroundDate } as T, lastPageItem) === + ComparisonResult.A_PRECEDES_B; + const isBelowTailBound = + paginator.sortComparator(firstPageItem, { created_at: createdAtAroundDate } as T) === + ComparisonResult.A_PRECEDES_B; + + const requestedPageSizeNotMet = + requestedPageSize > interval.itemIds.length && requestedPageSize > page.length; + const noMoreMessages = + (requestedPageSize > interval.itemIds.length || + interval.itemIds.length >= page.length) && + requestedPageSize > page.length; + + if (isAboveHeadBound) { + flags.hasMoreHead = false; + if (requestedPageSizeNotMet) { + flags.hasMoreTail = false; + } + } else if (isBelowTailBound) { + flags.hasMoreTail = false; + if (requestedPageSizeNotMet) { + flags.hasMoreHead = false; + } + } else if (noMoreMessages) { + flags = { hasMoreHead: false, hasMoreTail: false }; + } else { + const [firstPageMsgIsFirstInSet, lastPageMsgIsLastInSet] = [ + firstPageItem?.id && firstPageItem.id === interval.itemIds[0], + lastPageItem?.id && lastPageItem.id === interval.itemIds.slice(-1)[0], + ]; + + const midPointByCount = Math.floor(page.length / 2); + const { insertionIndex } = binarySearch({ + needle: { created_at: createdAtAroundDate } as T, + length: page.length, + getItemAt: (index) => page[index], + compare: (a, b) => a.created_at?.getTime() - b.created_at.getTime(), + itemIdentityEquals: (a, b) => a.created_at?.getTime() === b.created_at?.getTime(), + plateauScan: false, + }); + + if (insertionIndex !== -1) { + if (firstPageMsgIsFirstInSet) flags.hasMoreTail = midPointByCount <= insertionIndex; + if (lastPageMsgIsLastInSet) flags.hasMoreHead = midPointByCount >= insertionIndex; + } + } + + return flags; +}; diff --git a/src/pagination/cursorDerivation/idAroundPaginationFlags.ts b/src/pagination/cursorDerivation/idAroundPaginationFlags.ts new file mode 100644 index 0000000000..55c8a08747 --- /dev/null +++ b/src/pagination/cursorDerivation/idAroundPaginationFlags.ts @@ -0,0 +1,53 @@ +import type { CursorDeriveContext, PaginationFlags } from '../paginators'; + +export const deriveIdAroundPaginationFlags = < + T extends { id: string }, + Q extends { id_around?: string }, +>({ + hasMoreHead, + hasMoreTail, + interval, + page, + queryShape, + requestedPageSize, +}: CursorDeriveContext): PaginationFlags => { + let flags: PaginationFlags = { hasMoreHead, hasMoreTail }; + if (!queryShape?.id_around) return flags; + const { id_around } = queryShape; + + const [firstPageMsg, lastPageMsg] = [page[0], page.slice(-1)[0]]; + const [firstPageMsgIsFirstInSet, lastPageMsgIsLastInSet] = [ + firstPageMsg?.id === interval.itemIds[0], + lastPageMsg?.id === interval.itemIds.slice(-1)[0], + ]; + + const midPoint = Math.floor(page.length / 2); + const noMoreMessages = + (requestedPageSize > interval.itemIds.length || + interval.itemIds.length >= page.length) && + requestedPageSize > page.length; + + if (noMoreMessages) { + flags = { hasMoreHead: false, hasMoreTail: false }; + } else if (!page[midPoint]) { + return flags; + } else if (page[midPoint].id === id_around) { + flags = { hasMoreHead: true, hasMoreTail: true }; + } else { + const halves = [page.slice(0, midPoint), page.slice(midPoint)]; + if (firstPageMsgIsFirstInSet) { + const targetMsg = halves[0].find((message) => message.id === id_around); + if (targetMsg) { + flags.hasMoreTail = false; + } + } + if (lastPageMsgIsLastInSet) { + const targetMsg = halves[1].find((message) => message.id === id_around); + if (targetMsg) { + flags.hasMoreHead = false; + } + } + } + + return flags; +}; diff --git a/src/pagination/cursorDerivation/index.ts b/src/pagination/cursorDerivation/index.ts new file mode 100644 index 0000000000..26c4176739 --- /dev/null +++ b/src/pagination/cursorDerivation/index.ts @@ -0,0 +1 @@ +export * from './createdAtAroundPaginationFlags'; diff --git a/src/pagination/cursorDerivation/linearPaginationFlags.ts b/src/pagination/cursorDerivation/linearPaginationFlags.ts new file mode 100644 index 0000000000..2f2c337b15 --- /dev/null +++ b/src/pagination/cursorDerivation/linearPaginationFlags.ts @@ -0,0 +1,83 @@ +import type { CursorDeriveContext, PaginationFlags } from '../paginators'; +import type { MessagePaginationOptions, PaginationOptions } from '../../types'; + +const TAILWARD_QUERY_PROPERTIES: Array = [ + 'created_at_before_or_equal', + 'created_at_before', + 'id_lt', + 'id_lte', + 'offset', +]; + +const HEADWARD_QUERY_PROPERTIES: Array = [ + 'created_at_after_or_equal', + 'created_at_after', + 'id_gt', + 'id_gte', +]; +export const deriveLinearPaginationFlags = < + T extends { id: string; created_at: Date }, + Q extends PaginationOptions, +>({ + direction, + hasMoreHead, + hasMoreTail, + interval, + page, + queryShape, + requestedPageSize, +}: CursorDeriveContext): PaginationFlags => { + const flags: PaginationFlags = { hasMoreHead, hasMoreTail }; + const [firstPageMsg, lastPageMsg] = [page[0], page.slice(-1)[0]]; + const [firstPageMsgIsFirstInSet, lastPageMsgIsLastInSet] = [ + firstPageMsg?.id && firstPageMsg.id === interval.itemIds[0], + lastPageMsg?.id && lastPageMsg.id === interval.itemIds.slice(-1)[0], + ]; + + const containsCursorPaginationProperties = + !!queryShape && + HEADWARD_QUERY_PROPERTIES.concat(TAILWARD_QUERY_PROPERTIES).some( + (p) => typeof queryShape[p] !== 'undefined', + ); + + const queriedMessagesTowardsHead = + direction === 'headward' || + (!!queryShape && + HEADWARD_QUERY_PROPERTIES.some((p) => typeof queryShape[p] !== 'undefined')); + + const queriedMessagesTowardsTail = + direction === 'tailward' || + typeof queryShape === 'undefined' || + TAILWARD_QUERY_PROPERTIES.some((p) => typeof queryShape[p] !== 'undefined'); + + const containsNonLinearPaginationProperties = + !!(queryShape as MessagePaginationOptions)?.id_around || + !!(queryShape as MessagePaginationOptions)?.created_at_around; + + const containsUnrecognizedOptionsOnly = + !queriedMessagesTowardsHead && + !queriedMessagesTowardsTail && + !containsNonLinearPaginationProperties; + + const isFirstPage = !containsCursorPaginationProperties; + + const hasMore = page.length >= requestedPageSize; + + if ( + typeof queriedMessagesTowardsTail !== 'undefined' || + containsUnrecognizedOptionsOnly + ) { + hasMoreTail = !hasMoreTail ? false : hasMore; + } + if (typeof queriedMessagesTowardsHead !== 'undefined') { + hasMoreHead = !hasMoreHead || isFirstPage ? false : hasMore; + } + const pageIsEmpty = page.length === 0; + + if ((firstPageMsgIsFirstInSet || pageIsEmpty) && typeof hasMoreTail !== 'undefined') + flags.hasMoreTail = hasMoreTail; + if ((lastPageMsgIsLastInSet || pageIsEmpty) && typeof hasMoreHead !== 'undefined') + flags.hasMoreHead = hasMoreHead; + + return flags; +}; diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 43fdfd2b10..8758226bef 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -2,203 +2,78 @@ import type { ItemLocation } from '../sortCompiler'; import { binarySearch } from '../sortCompiler'; import { itemMatchesFilter } from '../filterCompiler'; import { isPatch, StateStore, type ValueOrPatch } from '../../store'; -import { - debounce, - type DebouncedFunc, - generateUUIDv4, - normalizeQuerySort, - sleep, -} from '../../utils'; +import { debounce, type DebouncedFunc, generateUUIDv4, sleep } from '../../utils'; import type { FieldToDataResolver } from '../types.normalization'; import { ComparisonResult } from '../types.normalization'; -import type { ItemIndex } from '../ItemIndex'; +import { ItemIndex } from '../ItemIndex'; import { isEqual } from '../../utils/mergeWith/mergeWithCore'; import { DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES } from '../../constants'; -import type { AscDesc } from '../..'; -import { - normalizeStringAccentInsensitive, - toEpochMillis, - toNumberLike, -} from '../utility.normalization'; const noOrderChange = () => 0; -const LIVE_HEAD_INTERVAL_ID = '__live_head__'; -const LIVE_TAIL_INTERVAL_ID = '__live_tail__'; -const MISSING_LOW = Number.NEGATIVE_INFINITY; // "smaller than anything" -const MISSING_HIGH = Number.POSITIVE_INFINITY; // "bigger than anything" +export const LOGICAL_HEAD_INTERVAL_ID = '__logical_head__'; +export const LOGICAL_TAIL_INTERVAL_ID = '__logical_tail__'; -type SortKeyScalar = number | string | null; - -/** - * Normalize a raw field value into a comparable scalar. - * - * Rules: - * - Date / ISO / epoch-like → epoch millis (number) - * - numeric-like string → number - * - boolean → boolean (or 0/1, see below) - * - string → normalized string (case/accent insensitive) - * - everything else → stringified fallback - */ -function normalizeForSort(x: unknown): SortKeyScalar { - // 1) Date-like - const d = toEpochMillis(x); - if (d !== null) return d; - - // 2) numeric-like - const n = toNumberLike(x); - if (n !== null) return n; - - // 3) boolean - if (typeof x === 'boolean') return x ? 1 : 0; - - // 4) string (accent-insensitive) - if (typeof x === 'string') { - return normalizeStringAccentInsensitive(x); - } - - // 5) fallback - return x == null ? null : String(x); -} - -/** - * Sortable value that represents the item according to the paginator’s comparator. - * A comparable key that lets you determine: - * “Does this item fall inside the sort boundaries of any given interval?” - */ -export type SortKey = number[]; - -// Encodes a string into a numeric sequence suitable for lexicographic comparison. -// 0 as a terminal sentinel ensures shorter prefix strings sort before longer ones (e.g. "a" before "aa"). -const STRING_SENTINEL_ASC = 0; - -function encodeStringComponents(s: string, direction: 1 | -1): number[] { - // Ascending: [charCode+1, ..., charCode+1, 0] - const base: number[] = []; - for (let i = 0; i < s.length; i++) { - base.push(s.charCodeAt(i) + 1); // > 0 - } - base.push(STRING_SENTINEL_ASC); // 0 < any charCode+1 - - // Descending = element-wise sign flip of the ascending sequence - if (direction === 1) return base; - return base.map((v) => -v); -} - -/** Compare two SortKeys. */ -export function compareSortKeys(a: SortKey, b: SortKey): number { - if (typeof a !== 'object' && typeof b !== 'object') { - return a < b - ? ComparisonResult.A_PRECEDES_B - : a > b - ? ComparisonResult.A_COMES_AFTER_B - : ComparisonResult.A_IS_EQUAL_TO_B; - } - - const arrA = a as (number | string)[]; - const arrB = b as (number | string)[]; - - const len = Math.min(arrA.length, arrB.length); - for (let i = 0; i < len; i++) { - if (arrA[i] < arrB[i]) return ComparisonResult.A_PRECEDES_B; - if (arrA[i] > arrB[i]) return ComparisonResult.A_COMES_AFTER_B; - } - - return arrA.length - arrB.length; -} - -function minSortKey(a: SortKey, b: SortKey): SortKey { - return compareSortKeys(a, b) <= 0 ? a : b; -} - -function maxSortKey(a: SortKey, b: SortKey): SortKey { - return compareSortKeys(a, b) >= 0 ? a : b; -} - -function mergeUniqueStrings(a: string[], b: string[]): string[] { - const set = new Set(a); - for (const id of b) { - if (!set.has(id)) { - set.add(id); - a.push(id); - } - } - return a; -} - -type Sort = Record; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type PathResolver = (item: T, path: string) => any; +type IntervalSortBounds = { start: T; end: T }; +type IntervalPaginationEdges = { head: T; tail: T }; export type LogicalInterval = { itemIds: string[]; - id: typeof LIVE_HEAD_INTERVAL_ID | typeof LIVE_TAIL_INTERVAL_ID; - /** Key of the first item according to sorting. */ - startKey: SortKey; - /** Key of the last item according to sorting. */ - endKey: SortKey; + id: typeof LOGICAL_HEAD_INTERVAL_ID | typeof LOGICAL_TAIL_INTERVAL_ID; }; export type Interval = { + hasMoreHead: boolean; + hasMoreTail: boolean; itemIds: string[]; id: string; - /** Key of the first item according to sorting. */ - startKey: SortKey; - /** Key of the last item according to sorting. */ - endKey: SortKey; /** * True if this interval represents the global head of the dataset * under the current sortComparator. * * Cursor pagination: - * prev === null + * headward === null * * Offset pagination: * offset === 0 */ - isHead?: boolean; + isHead: boolean; /** * True if this interval represents the global tail of the dataset * under the current sortComparator. * * Cursor pagination: - * next === null + * tailward === null * * Offset pagination: * returnedItems.length < pageSize */ - isTail?: boolean; + isTail: boolean; }; export type AnyInterval = Interval | LogicalInterval; +export type IntervalMergePolicy = 'auto' | 'strict-overlap-only'; + +type ItemIntervalCoordinates = ItemLocation & { + interval: Interval | LogicalInterval; +}; + export type ItemCoordinates = { /** Location inside state.items (visible list) */ state?: ItemLocation; /** Location inside an interval (anchored or logical) */ - interval?: ItemLocation & { - interval: Interval | LogicalInterval; - }; + interval?: ItemIntervalCoordinates; }; -const isLiveHeadInterval = (interval: AnyInterval): interval is LogicalInterval => - interval.id === LIVE_HEAD_INTERVAL_ID; +export const isLiveHeadInterval = (interval: AnyInterval): interval is LogicalInterval => + interval.id === LOGICAL_HEAD_INTERVAL_ID; -const isLiveTailInterval = (interval: AnyInterval): interval is LogicalInterval => - interval.id === LIVE_TAIL_INTERVAL_ID; +export const isLiveTailInterval = (interval: AnyInterval): interval is LogicalInterval => + interval.id === LOGICAL_TAIL_INTERVAL_ID; -/** - * Returns true if intervals A and B overlap. - * - * Overlap condition: - * A.startKey ≤ B.endKey AND B.startKey ≤ A.endKey - */ -function intervalsOverlap(a: Interval, b: Interval): boolean { - return ( - compareSortKeys(a.startKey, b.endKey) <= 0 && - compareSortKeys(b.startKey, a.endKey) <= 0 - ); -} +export const isLogicalInterval = (interval: AnyInterval): interval is LogicalInterval => + isLiveHeadInterval(interval) || isLiveTailInterval(interval); function cloneInterval(interval: Interval): Interval { return { @@ -207,45 +82,6 @@ function cloneInterval(interval: Interval): Interval { }; } -function mergeTwoAnchoredIntervals(preceding: Interval, following: Interval): Interval { - return { - ...preceding, - itemIds: mergeUniqueStrings([...preceding.itemIds], following.itemIds), - startKey: minSortKey(preceding.startKey, following.startKey), - endKey: maxSortKey(preceding.endKey, following.endKey), - isHead: preceding.isHead || following.isHead, - isTail: preceding.isTail || following.isTail, - }; -} - -/** - * Merges anchored intervals. Returns null if there are no intervals to merge. - */ -function mergeAnchoredIntervals(intervals: Interval[]): Interval | null { - if (intervals.length === 0) return null; - - const intervalsCopy = [...intervals]; - intervalsCopy.sort((a, b) => compareSortKeys(a.startKey, b.startKey)); - - let acc = cloneInterval(intervalsCopy[0]); - for (let i = 1; i < intervalsCopy.length; i++) { - const next = intervalsCopy[i]; - acc = mergeTwoAnchoredIntervals(acc, next); - } - - return acc; -} - -/** - * Whether a SortKey belongs to an anchored interval. - */ -function belongsToInterval(itemSortKey: SortKey, interval: Interval): boolean { - return ( - compareSortKeys(itemSortKey, interval.startKey) >= 0 && - compareSortKeys(itemSortKey, interval.endKey) <= 0 - ); -} - export type MakeIntervalParams = { page: T[]; isHead?: boolean; @@ -255,7 +91,17 @@ export type MakeIntervalParams = { export type SetPaginatorItemsParams = { valueOrFactory: ValueOrPatch; cursor?: PaginatorCursor; + /** + * Relevant only is using item interval storage in the paginator. + * Indicates that the page would be the head of pagination intervals array. + * Items falling outside this intervals head bound will be merged into this interval. + */ isFirstPage?: boolean; + /** + * Relevant only is using item interval storage in the paginator. + * Indicates that the page would be the tail of pagination intervals array + * Items falling outside this intervals tail bound will be merged into this interval. + */ isLastPage?: boolean; }; @@ -265,13 +111,70 @@ type MergeIntervalsResult = { logicalTail: LogicalInterval | null; }; -type PaginationDirection = 'next' | 'prev'; -export type PaginatorCursor = { next: string | null; prev: string | null }; +/** + * headward - going from page X -> X-Y -> 0 + * tailward - goring from page 0 -> X -> X + Y ... + * + * Head is the place where new items are added - same as git. + * Tail is the place where retrieved pages are appended. + */ +export type PaginationDirection = 'headward' | 'tailward'; + +export type CursorDeriveContext = { + /** + * Current cursor to be merged with the newly derived cursor. + * Allows to preserve the direction we have not paginated with the given request. + */ + cursor: PaginatorCursor | undefined; + /** + * Direction we just paginated in. + * + * May be undefined for non-directional queries (e.g. jump-to / *_around). + */ + direction: PaginationDirection | undefined; + hasMoreTail: boolean; + hasMoreHead: boolean; + /** The parent interval the page was ingested into (if any) */ + interval: Interval; + /** The page we just received after filtering */ + page: T[]; + /** Last query shape (sometimes useful for bespoke logic) */ + queryShape: Q | undefined; + /** Number we asked for */ + requestedPageSize: number; +}; + +export type PaginationFlags = { + hasMoreHead: boolean; + hasMoreTail: boolean; +}; + +export type CursorDeriveResult = PaginationFlags & { + cursor: PaginatorCursor | undefined; +}; + +export type CursorDerivator = ( + ctx: CursorDeriveContext, +) => CursorDeriveResult; +/** + * string - there is a next page in the given direction + * null - pagination in the given direction has been exhausted + * undefined - no page has been requested in the given pagination direction + */ +export type PaginatorCursor = { + tailward: string | null | undefined; + headward: string | null | undefined; +}; +export const ZERO_PAGE_CURSOR: PaginatorCursor = { + tailward: undefined, + headward: undefined, +}; + type StateResetPolicy = 'auto' | 'yes' | 'no' | (string & {}); export type PaginationQueryShapeChangeIdentifier = ( - prevQueryShape?: S, - nextQueryShape?: S, + toHeadQueryShape?: S, + toTailQueryShape?: S, ) => boolean; export type PaginationQueryParams = { @@ -282,11 +185,32 @@ export type PaginationQueryParams = { reset?: StateResetPolicy; /** Should retry the failed request given number of times. Default is 0. */ retryCount?: number; + /** Determines, whether the page loaded with the query will be committed to the paginator state. Default: true. */ + updateState?: boolean; +}; + +export type PostQueryReconcileParams = Pick< + PaginationQueryParams, + 'direction' | 'queryShape' | 'updateState' +> & { + isFirstPage: boolean; + requestedPageSize: number; + results: PaginationQueryReturnValue | null; +}; + +export type ExecuteQueryReturnValue = { + /** + * State object resulting from the post query processing. + * The object is committed to the state if PaginationQueryParams['updateState'] === true. + */ + stateCandidate: Partial>; + /** In case the items are kept in intervals, the interval into which the page has been merged, will be returned. */ + targetInterval: AnyInterval | null; }; export type PaginationQueryReturnValue = { items: T[] } & { - next?: string; - prev?: string; + headward?: string; + tailward?: string; }; export type PaginatorDebounceOptions = { debounceMs: number; @@ -296,8 +220,8 @@ type DebouncedExecQueryFunction = DebouncedFunc< >; export type PaginatorState = { - hasNext: boolean; - hasPrev: boolean; + hasMoreHead: boolean; + hasMoreTail: boolean; isLoading: boolean; items: T[] | undefined; lastQueryError?: Error; @@ -305,6 +229,49 @@ export type PaginatorState = { offset?: number; }; +// todo: think whether plugins are necessary. Maybe we could just document how to add + +export type PaginatorItemsChangeProcessor = (params: { + nextItems: T[] | undefined; + previousItems: T[] | undefined; +}) => T[] | undefined; + +export interface PaginatorPlugin { + /** + * Optional plugin hook invoked immediately before the paginator emits a new + * `items` value to subscribers, but only when the `items` array has actually + * changed by reference. + * + * This hook allows plugins to post-process the visible items—such as + * deduplicating, normalizing, sorting, enriching, or otherwise transforming + * the array—at the final stage of state emission. The processed value becomes + * the `items` value delivered to subscribers. + * + * Return a new array to replace `nextState.items`, or return `undefined` + * to leave the items unchanged. + * + * Executed in the order plugins are registered. + */ + onBeforeItemsEmitted?: PaginatorItemsChangeProcessor; + + // future hooks (examples) + // onQueryStart?(ctx: { params: PaginationQueryParams; paginator: BasePaginator }): void | Promise; + // onQuerySuccess?(ctx: { state: PaginatorState; results: PaginationQueryReturnValue; paginator: BasePaginator }): void | Promise; + // onQueryError?(ctx: { error: unknown; paginator: BasePaginator }): void | Promise; +} + +/** + * Optional list of plugins that can hook into paginator lifecycle events. + * + * Plugins allow you to encapsulate cross-cutting behavior (such as items + * post-processing, analytics, offline caching, etc.) without modifying + * the core paginator logic. Each plugin can register handlers like + * `onItemsChange` that are invoked when relevant events occur. + * + * All registered plugins are executed in the order they appear in this array. + */ +// plugins?: PaginatorPlugin[]; + export type PaginatorOptions = { /** The number of milliseconds to debounce the search query. The default interval is 300ms. */ debounceMs?: number; @@ -314,15 +281,24 @@ export type PaginatorOptions = { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any hasPaginationQueryShapeChanged?: PaginationQueryShapeChangeIdentifier; + /** + * Optional hook to fully control cursor + hasMore logic in 'derived' mode. + * If not provided, BasePaginator uses its own default implementation. + */ + deriveCursor?: CursorDerivator; /** Custom function to retrieve items pages and optionally return a cursor in case of cursor pagination. */ doRequest?: (queryParams: Q) => Promise<{ items: T[]; cursor?: PaginatorCursor }>; /** In case of cursor pagination, specify the initial cursor value. */ initialCursor?: PaginatorCursor; /** In case of offset pagination, specify the initial offset value. */ initialOffset?: number; - /** If item index is provided, this index ensures updates in place and all consumers have access to a single source of data. */ + /** If item index is provided, this index ensures updates in a single place and all consumers have access to a single source of data. */ itemIndex?: ItemIndex; - /** Will prevent changing the index of existing items. */ + /** + * Will prevent changing the index of existing items in state. + * If true, an item that is already visible keeps its relative position in the current items array when updated. + * It does not guarantee global stability across interval changes or page jumps. + */ lockItemOrder?: boolean; /** The item page size to be requested from the server. */ pageSize?: number; @@ -331,6 +307,7 @@ export type PaginatorOptions = { }; type OptionalPaginatorConfigFields = + | 'deriveCursor' | 'doRequest' | 'initialCursor' | 'initialOffset' @@ -371,22 +348,66 @@ export abstract class BasePaginator { /** * ItemIndex is a canonical, ID-addressable storage layer for domain items. * It serves as a single source of truth for all those that need to access the items - * outside of the paginator. + * outside the paginator. + */ + protected _itemIndex: ItemIndex; + /** + * Whether the paginator should maintain interval storage. + * + * Intervals are populated only when a caller provides an `itemIndex` instance. + * Otherwise the paginator behaves as a classic list paginator and mutates + * only `state.items`. */ - protected _itemIndex: ItemIndex | undefined; + protected _usesItemIntervalStorage: boolean; protected _executeQueryDebounced!: DebouncedExecQueryFunction; - protected _isCursorPagination = false; /** Last effective query shape produced by subclass for the most recent request. */ protected _lastQueryShape?: Q; protected _nextQueryShape?: Q; + /** + * Stable, performs purely item data-driven (age, last_message_at, etc.) comparison. + * Used under the hood + * 1. as a fallback by effectiveComparator / boostComparator if boost comparison is not conclusive + * 2. interval comparator + * + * Intervals cannot be sorted using boostComparator, because boosting the interval boundary (top item) + * would lead to the boosting of the entire interval when sorting the intervals. + * + * Sorting within a single interval should be done using effectiveComparator, which by default uses boostComparator. + */ sortComparator: (a: T, b: T) => number; protected _filterFieldToDataResolvers: FieldToDataResolver[]; protected boosts = new Map(); protected _maxBoostSeq = 0; + /** + * Describes how `interval.itemIds` are oriented relative to pagination semantics. + * + * - `true` => `itemIds[0]` is the pagination head edge (default) + * - `false` => `itemIds[itemIds.length - 1]` is the pagination head edge + * + * NOTE: This does not affect the *sorting* of `itemIds` (they are always kept + * in `sortComparator` order). It only affects which side is considered + * "head" for interval ordering and live ingestion decisions. + */ + protected get intervalItemIdsAreHeadFirst(): boolean { + return true; + } + + /** + * Determines the ordering of intervals in the internal interval list. + * + * This controls only the ordering of intervals relative to each other (by comparing + * their head edges using `sortComparator`). It is intentionally decoupled from: + * - the ordering of itemIds inside an interval + * - the meaning of the head edge (controlled by `intervalItemIdsAreHeadFirst`) + */ + protected get intervalSortDirection(): 'asc' | 'desc' { + return 'asc'; + } + protected constructor({ initialCursor, initialOffset, @@ -408,7 +429,8 @@ export abstract class BasePaginator { this.setDebounceOptions({ debounceMs }); this.sortComparator = noOrderChange; this._filterFieldToDataResolvers = []; - this._itemIndex = itemIndex; + this._usesItemIntervalStorage = !!itemIndex; + this._itemIndex = itemIndex ?? new ItemIndex({ getId: this.getItemId.bind(this) }); } // --------------------------------------------------------------------------- @@ -419,12 +441,12 @@ export abstract class BasePaginator { return this.state.getLatestValue().lastQueryError; } - get hasNext() { - return this.state.getLatestValue().hasNext; + get hasMoreTail() { + return this.state.getLatestValue().hasMoreTail; } - get hasPrev() { - return this.state.getLatestValue().hasPrev; + get hasMoreHead() { + return this.state.getLatestValue().hasMoreHead; } get hasResults() { @@ -444,10 +466,14 @@ export abstract class BasePaginator { return false; } + get isCursorPagination() { + return !!this.cursor; + } + get initialState(): PaginatorState { return { - hasNext: true, - hasPrev: true, + hasMoreHead: true, + hasMoreTail: true, isLoading: false, items: undefined, lastQueryError: undefined, @@ -489,6 +515,17 @@ export abstract class BasePaginator { return this.boostComparator; } + get intervalComparator() { + return (a: AnyInterval, b: AnyInterval) => { + const aEdges = this.getIntervalPaginationEdges(a); + const bEdges = this.getIntervalPaginationEdges(b); + if (!aEdges || !bEdges) return 0; + if (!aEdges) return 1; // move interval without bounds to the end + if (!bEdges) return -1; // keep interval a preceding b + return this.compareIntervalHeadEdges(aEdges.head, bEdges.head); + }; + } + get maxBoostSeq() { return this._maxBoostSeq; } @@ -497,20 +534,20 @@ export abstract class BasePaginator { return Array.from(this._itemIntervals.values()); } + protected get usesItemIntervalStorage(): boolean { + return this._usesItemIntervalStorage; + } + protected get liveHeadLogical(): LogicalInterval | undefined { - const itv = this._itemIntervals.get(LIVE_HEAD_INTERVAL_ID); + const itv = this._itemIntervals.get(LOGICAL_HEAD_INTERVAL_ID); return itv && isLiveHeadInterval(itv) ? itv : undefined; } protected get liveTailLogical(): LogicalInterval | undefined { - const itv = this._itemIntervals.get(LIVE_TAIL_INTERVAL_ID); + const itv = this._itemIntervals.get(LOGICAL_TAIL_INTERVAL_ID); return itv && isLiveTailInterval(itv) ? itv : undefined; } - protected get usesItemIntervalStorage(): boolean { - return !!this._itemIndex; - } - // --------------------------------------------------------------------------- // Abstracts // --------------------------------------------------------------------------- @@ -521,14 +558,6 @@ export abstract class BasePaginator { abstract filterQueryResults(items: T[]): T[] | Promise; - /** - * Should be implemented in child classes from the specific sort requirements followed by the child classes. - * Should return a value according to which the given item can be correctly inserted into the target item interval - * based on the current sort rules. - * @param item - */ - abstract computeSortKey(item: T): SortKey; - /** * Subclasses must return the query shape. */ @@ -570,48 +599,6 @@ export abstract class BasePaginator { return typeof id === 'string' ? this._itemIndex?.get(id) : undefined; } - // --------------------------------------------------------------------------- - // Sort key generator (optional helper) - // --------------------------------------------------------------------------- - - /** - * Factory function to create a sort key generator. - * Sort key generation must be consistent with the comparator logic. - * - * The resulting SortKey is an array of numbers, e.g. - * [{last_updated_at}, {}] - */ - makeSortKeyGenerator({ - sort, - resolvePathValue, - }: { - sort: Sort | Sort[]; - resolvePathValue: PathResolver; - }): (item: T) => SortKey { - const normalizedSort = normalizeQuerySort(sort); // [{ field, direction }, ...] - - return (item: T): SortKey => { - const key: SortKey = []; - - for (const { field, direction } of normalizedSort) { - const raw = resolvePathValue(item, field); - const normalized = normalizeForSort(raw); - if (normalized === null) { - // No usable value → push a sentinel that depends on direction. - key.push(direction === 1 ? MISSING_LOW : MISSING_HIGH); - } else if (typeof normalized === 'number') { - key.push(direction === 1 ? normalized : -normalized); - } else { - // string - // If most of your sorts are numeric/date and string sorts are asc-only, - // you can just store the string as-is: - key.push(...encodeStringComponents(normalized, direction)); - } - } - return key; - }; - } - // --------------------------------------------------------------------------- // Boosts // --------------------------------------------------------------------------- @@ -653,6 +640,7 @@ export abstract class BasePaginator { /** * Increases the item's importance when sorting. + * Boost affects position inside an item interval (if used), but should not redefine interval boundaries. * @param itemId * @param opts */ @@ -686,58 +674,376 @@ export abstract class BasePaginator { } // --------------------------------------------------------------------------- - // Interval helpers + // Interval manipulation // --------------------------------------------------------------------------- // eslint-disable-next-line @typescript-eslint/no-unused-vars - generateIntervalId(page: T[]): string { + generateIntervalId(page: (T | string)[]): string { return `interval-${generateUUIDv4()}`; } intervalToItems(interval: Interval | LogicalInterval): T[] { - return interval.itemIds + const items = interval.itemIds .map((id) => this._itemIndex?.get(id)) .filter((item): item is T => !!item); + + // When lockItemOrder is true, we must *not* reflect boosts in state.items. + if (this.config.lockItemOrder) { + return items; + } + + // Visible ordering uses boost-aware comparator + return items.sort(this.effectiveComparator.bind(this)); } makeInterval({ page, isHead, isTail }: MakeIntervalParams): Interval { - const sorted = [...page].sort((a, b) => - compareSortKeys(this.computeSortKey(a), this.computeSortKey(b)), - ); + const sorted = [...page].sort((a, b) => this.sortComparator(a, b)); return { id: this.generateIntervalId(page), + // Default semantics: + // - if interval is known global head/tail, there is no more data in that direction + // - otherwise treat it as unknown => "has more" (until proven otherwise by a query) + hasMoreHead: isHead ? false : true, + hasMoreTail: isTail ? false : true, itemIds: sorted.map(this.getItemId.bind(this)), - startKey: this.computeSortKey(sorted[0]), - endKey: this.computeSortKey(sorted[sorted.length - 1]), - isHead, - isTail, + isHead: !!isHead, + isTail: !!isTail, + }; + } + + protected getCursorFromInterval(interval: Interval): PaginatorCursor { + // Prefer resolving edge items via sort bounds, because: + // - interval ordering can differ from interval sorting (intervalSortDirection) + // - "head" is a semantic concept (where new items appear), not necessarily `itemIds[0]` + // - itemIds are stored in sortComparator order, but we want the *pagination* edges + const edges = this.getIntervalPaginationEdges(interval); + + const fallbackFirstId = interval.itemIds[0] ?? null; + const fallbackLastId = interval.itemIds.slice(-1)[0] ?? null; + + const fallbackHeadId = this.intervalItemIdsAreHeadFirst + ? fallbackFirstId + : fallbackLastId; + const fallbackTailId = this.intervalItemIdsAreHeadFirst + ? fallbackLastId + : fallbackFirstId; + + const headId = edges?.head ? this.getItemId(edges.head) : fallbackHeadId; + const tailId = edges?.tail ? this.getItemId(edges.tail) : fallbackTailId; + + return { + headward: interval.hasMoreHead ? headId : null, + tailward: interval.hasMoreTail ? tailId : null, }; } - protected recomputeIntervalBoundaries(interval: AnyInterval): { - startKey: SortKey; - endKey: SortKey; - } { - // Recompute boundaries from the first and last items in the interval. - // Since ids are kept sorted by effectiveComparator, - // the first and last items define the correct startKey/endKey. + isActiveInterval(interval: AnyInterval): boolean { + return this._activeIntervalId === interval.id; + } + + setActiveInterval(interval: AnyInterval | undefined, opts?: { updateState?: boolean }) { + this._activeIntervalId = interval?.id; + + // Public API expectation: activating an anchored interval should immediately + // reflect its pagination ability in paginator state. + // + // Internal callers that are in the middle of a transactional `state.next()` + // update must pass `{ updateState: false }` and project these flags into the + // state object directly. + if (opts?.updateState === false) return; + if (!interval || isLogicalInterval(interval)) return; + + this.state.partialNext({ + items: this.intervalToItems(interval), + hasMoreHead: interval.hasMoreHead, + hasMoreTail: interval.hasMoreTail, + }); + } + + protected getIntervalSortBounds( + interval: Interval | LogicalInterval, + ): IntervalSortBounds | null { + if (!this.usesItemIntervalStorage) return null; const ids = interval.itemIds; - const first = this.getItem(ids[0]); - const last = this.getItem(ids[ids.length - 1]); + if (!this._itemIndex || ids.length === 0) return null; + const start = this._itemIndex?.get?.(ids[0]); + const end = this._itemIndex?.get?.(ids[ids.length - 1]); + return { start, end } as IntervalSortBounds; + } + + /** + * Returns pagination head/tail edges of an interval. + * + * IMPORTANT: + * - Edges are derived from the *sort bounds* of the interval (min/max under `sortComparator`). + * - Which bound is treated as the pagination "head" is controlled by `intervalItemIdsAreHeadFirst`. + * - This is a semantic notion of head/tail (where new items are expected to appear), + * not necessarily "min/max under sortComparator". + * New items are always expected to appear at the head of the interval. + */ + protected getIntervalPaginationEdges( + interval: Interval | LogicalInterval, + ): IntervalPaginationEdges | null { + if (!this.usesItemIntervalStorage) return null; + const bounds = this.getIntervalSortBounds(interval); + if (!bounds) return null; + return this.intervalItemIdsAreHeadFirst + ? { head: bounds.start, tail: bounds.end } + : { head: bounds.end, tail: bounds.start }; + } + + protected compareIntervalHeadEdges(a: T, b: T): number { + const cmp = this.sortComparator(a, b); + return this.intervalSortDirection === 'asc' ? cmp : -cmp; + } - if (!first || !last) { - throw new Error('Invalid interval to recompute boundaries: empty item array'); + protected aIsMoreHeadwardThanB(a: T, b: T): boolean { + return this.intervalItemIdsAreHeadFirst + ? this.sortComparator(a, b) === ComparisonResult.A_PRECEDES_B + : this.sortComparator(b, a) === ComparisonResult.A_PRECEDES_B; + } + + protected aIsMoreTailwardThanB(a: T, b: T): boolean { + return this.intervalItemIdsAreHeadFirst + ? this.sortComparator(b, a) === ComparisonResult.A_PRECEDES_B + : this.sortComparator(a, b) === ComparisonResult.A_PRECEDES_B; + } + + protected getHeadIntervalFromSortedIntervals( + intervals: AnyInterval[], + ): AnyInterval | undefined { + if (intervals.length === 0) return undefined; + if (intervals.length === 1) return intervals[0]; + + const headIsLowerSortValue = this.intervalItemIdsAreHeadFirst; + const intervalsSortedAsc = this.intervalSortDirection === 'asc'; + + const headIndex = + headIsLowerSortValue === intervalsSortedAsc ? 0 : intervals.length - 1; + return intervals[headIndex]; + } + + protected getTailIntervalFromSortedIntervals( + intervals: AnyInterval[], + ): AnyInterval | undefined { + if (intervals.length === 0) return undefined; + if (intervals.length === 1) return intervals[0]; + + const headIsLowerSortValue = this.intervalItemIdsAreHeadFirst; + const intervalsSortedAsc = this.intervalSortDirection === 'asc'; + + const tailIndex = + headIsLowerSortValue === intervalsSortedAsc ? intervals.length - 1 : 0; + return intervals[tailIndex]; + } + + protected sortIntervals(intervals: I[]): I[] { + const intervalsCopy = [...intervals]; + intervalsCopy.sort(this.intervalComparator.bind(this)); + return intervalsCopy; + } + + protected setIntervals(intervals: AnyInterval[]) { + this._itemIntervals = new Map(intervals.map((i) => [i.id, i])); + } + + protected intervalsStrictlyOverlap(a: AnyInterval, b: AnyInterval): boolean { + const aBounds = this.getIntervalSortBounds(a); + const bBounds = this.getIntervalSortBounds(b); + if (!aBounds || !bBounds) return false; + return ( + this.sortComparator(aBounds.start, bBounds.end) <= 0 && + this.sortComparator(bBounds.start, aBounds.end) <= 0 + ); + } + + /** + * Returns true if intervals A and B should be merged. + * + * 1) Strict overlap (range overlap in `sortComparator` order): + * A.min ≤ B.max AND B.min ≤ A.max + * + * 2) Forced merge (policy: 'auto' only): + * If one interval is marked as `isHead`/`isTail`, treat the other as mergeable + * when it extends beyond that interval's pagination head/tail edge + * (computed via `getIntervalPaginationEdges` + headward/tailward helpers). + * + * In 'strict-overlap-only' policy, only (1) applies. + */ + protected intervalsOverlap( + a: AnyInterval, + b: AnyInterval, + policy: IntervalMergePolicy = 'auto', + ): boolean { + const aBounds = this.getIntervalSortBounds(a); + const bBounds = this.getIntervalSortBounds(b); + if (!aBounds || !bBounds) return false; + + // Strict overlap if: + // a.first <= b.last && b.first <= a.last + if ( + this.sortComparator(aBounds.start, bBounds.end) <= 0 && + this.sortComparator(bBounds.start, aBounds.end) <= 0 + ) + return true; + + // If policy is strict-overlap-only, return false if the intervals do not strictly overlap. + if (policy === 'strict-overlap-only') return false; + + const aIsHead = (a as Interval).isHead; + const bIsHead = (b as Interval).isHead; + const aIsTail = (a as Interval).isTail; + const bIsTail = (b as Interval).isTail; + + const aEdges = this.getIntervalPaginationEdges(a); + const bEdges = this.getIntervalPaginationEdges(b); + if (!aEdges || !bEdges) return false; + + if (bIsHead && this.aIsMoreHeadwardThanB(aEdges.head, bEdges.head)) return true; + if (aIsHead && this.aIsMoreHeadwardThanB(bEdges.head, aEdges.head)) return true; + if (bIsTail && this.aIsMoreTailwardThanB(aEdges.tail, bEdges.tail)) return true; + if (aIsTail && this.aIsMoreTailwardThanB(bEdges.tail, aEdges.tail)) return true; + + return false; + } + + /** + * Whether an item belongs to an anchored interval. + */ + protected belongsToInterval(item: T, interval: AnyInterval): boolean { + const sortBounds = this.getIntervalSortBounds(interval); + if (!sortBounds) return false; + const { start, end } = sortBounds; + if (this.sortComparator(start, item) <= 0 && this.sortComparator(item, end) <= 0) + return true; + + const edges = this.getIntervalPaginationEdges(interval); + if (!edges) return false; + + // Items beyond head/tail edges are considered belonging to the head/tail pages. + if ((interval as Interval).isHead && this.aIsMoreHeadwardThanB(item, edges.head)) + return true; + + return (interval as Interval).isTail && this.aIsMoreTailwardThanB(item, edges.tail); + } + + protected mergeTwoAnchoredIntervals( + preceding: Interval, + following: Interval, + ): Interval { + const mergeIds = (a: string[], b: string[]): string[] => { + const itemIndex = this._itemIndex; + if (!itemIndex) return a; + + const seen = new Set(); + const merged: T[] = []; + const mergedIds: string[] = []; + + const pushId = (id: string) => { + if (seen.has(id)) return; + const item = itemIndex.get(id); + if (!item) return; + seen.add(id); + const { insertionIndex } = binarySearch({ + needle: item, + length: merged.length, + getItemAt: (index: number) => merged[index], + itemIdentityEquals: (item1, item2) => + this.getItemId(item1) === this.getItemId(item2), + // inter-interval operation sorts using the base comparator + compare: this.sortComparator.bind(this), + }); + if (insertionIndex > -1) { + merged.splice(insertionIndex, 0, item); + mergedIds.splice(insertionIndex, 0, this.getItemId(item)); + } + }; + + a.forEach(pushId); + b.forEach(pushId); + + return mergedIds; + }; + + const mergedItemIds = mergeIds(preceding.itemIds, following.itemIds); + + const precedingEdges = this.getIntervalPaginationEdges(preceding); + const followingEdges = this.getIntervalPaginationEdges(following); + + const isHead = preceding.isHead || following.isHead; + const isTail = preceding.isTail || following.isTail; + + // Default conservative merge: + // - if any contributor already concluded "no more" in a direction, keep that + let hasMoreHead = preceding.hasMoreHead && following.hasMoreHead; + let hasMoreTail = preceding.hasMoreTail && following.hasMoreTail; + + if (precedingEdges && followingEdges) { + const headMost = this.aIsMoreHeadwardThanB(precedingEdges.head, followingEdges.head) + ? preceding + : following; + const tailMost = this.aIsMoreTailwardThanB(precedingEdges.tail, followingEdges.tail) + ? preceding + : following; + + hasMoreHead = headMost.hasMoreHead; + hasMoreTail = tailMost.hasMoreTail; } - const startKey = this.computeSortKey(first); - const endKey = first === last ? startKey : this.computeSortKey(last); - return { startKey, endKey }; + return { + ...preceding, + itemIds: mergedItemIds, + // Boundary intervals stay boundaries even if their edge shifts due to forced merges. + hasMoreHead: isHead ? false : hasMoreHead, + hasMoreTail: isTail ? false : hasMoreTail, + isHead, + isTail, + }; + } + + /** + * Merges anchored intervals. Returns null if there are no intervals to merge. + */ + protected mergeAnchoredIntervals( + intervals: Interval[], + baseInterval?: Interval, + ): Interval | null { + if (intervals.length === 0) return null; + + const intervalsCopy = this.sortIntervals(intervals); + + let acc = cloneInterval(baseInterval ?? intervalsCopy[0]); + for (let i = baseInterval ? 0 : 1; i < intervalsCopy.length; i++) { + const next = intervalsCopy[i]; + acc = this.mergeTwoAnchoredIntervals(acc, next); + } + + return acc; } // --------------------------------------------------------------------------- - // Locate items + // Locate items and intervals // --------------------------------------------------------------------------- + protected locateIntervalIndex(interval: Interval): number { + const intervals = this.itemIntervals.filter( + (i) => !isLogicalInterval(i), + ) as Interval[]; + if (intervals.length === 0) return -1; + if (intervals.length === 1) return interval.id === intervals[0].id ? 0 : -1; + + return binarySearch({ + needle: interval, + length: intervals.length, + // eslint-disable-next-line + getItemAt: (index: number) => { + return intervals[index]; + }, + itemIdentityEquals: (item1, item2) => item1.id === item2.id, + compare: this.intervalComparator.bind(this), + plateauScan: true, + }).currentIndex; + } /** * Locate item inside a specific interval using the same logic as locateByItem, * but scoped to interval items. @@ -757,7 +1063,8 @@ export abstract class BasePaginator { getItemAt: (index: number) => this.getItem(ids[index]), itemIdentityEquals: (item1, item2) => this.getItemId(item1) === this.getItemId(item2), - compare: this.effectiveComparator.bind(this), + // items in intervals are not sorted by effectiveComparator + compare: this.sortComparator.bind(this), plateauScan: true, }); } @@ -765,10 +1072,8 @@ export abstract class BasePaginator { protected locateIntervalForItem(item: T): AnyInterval | undefined { if (this._itemIntervals.size === 0) return undefined; - const itemSortKey = this.computeSortKey(item); - for (const itv of this.itemIntervals) { - if (belongsToInterval(itemSortKey, itv)) { + if (this.belongsToInterval(item, itv)) { return itv; } } @@ -801,7 +1106,7 @@ export abstract class BasePaginator { }); } - protected locateByItem = (item: T): ItemCoordinates => { + locateByItem = (item: T): ItemCoordinates => { const result: ItemCoordinates = {}; // 1. Search in visible state.items @@ -811,77 +1116,80 @@ export abstract class BasePaginator { } // 2. Search in intervals if interval-mode is active - if (this.usesItemIntervalStorage) { - const intervalLoc = this.locateByItemInIntervals(item); - if (intervalLoc) { - result.interval = intervalLoc; - } + const intervalLoc = this.locateByItemInIntervals(item); + if (intervalLoc) { + result.interval = intervalLoc; } return result; }; - findItem(needle: T): T | undefined { - const { state, interval } = this.locateByItem(needle); - if (state && state.current > -1) { - return (this.items ?? [])[state.current]; - } else if (interval && interval.current > -1) { - const id = interval.interval.itemIds[interval.current]; - return this.getItem(id); - } - return undefined; - } - // --------------------------------------------------------------------------- // Item ingestion // --------------------------------------------------------------------------- - /** - * Inserts an item ID into the interval in the correct sorted position, - * preserving interval ordering and updating start/end keys. - * Returns unchaged interval if the correct insertion position could not be determined. - */ - protected insertItemIdIntoInterval( - interval: I, - item: T, - ): I { - const id = this.getItemId(item); - const itemLocation = this.locateByItemInInterval({ item, interval }); - - if (!itemLocation) return interval; - - // If already at the correct position, nothing to change - if (itemLocation.current >= 0 && itemLocation.current === itemLocation.expected) { - return interval; - } + protected removeItemIdFromInterval({ + interval, + ...itemLocation + }: ItemIntervalCoordinates): ItemIntervalCoordinates { + if ( + // If already at the correct position, nothing to change + itemLocation.currentIndex >= 0 && + itemLocation.currentIndex === itemLocation.insertionIndex + ) + return { interval, ...itemLocation }; - const ids = [...interval.itemIds]; + const itemIds = [...interval.itemIds]; // Adjust insertion index if we are removing the item before reinserting index. // locateByItemInInterval() computed insertionIndex with the item still in the array. - let insertionIndex = itemLocation.expected; - if (itemLocation.current >= 0 && itemLocation.expected > itemLocation.current) { + let insertionIndex = itemLocation.insertionIndex; + if ( + itemLocation.currentIndex >= 0 && + itemLocation.insertionIndex > itemLocation.currentIndex + ) { insertionIndex--; } // Remove existing occurrence if present - if (itemLocation.current >= 0) { - ids.splice(itemLocation.current, 1); + if (itemLocation.currentIndex >= 0) { + itemIds.splice(itemLocation.currentIndex, 1); } + return { + interval: { ...interval, itemIds }, + currentIndex: itemLocation.currentIndex, + insertionIndex, + }; + } - // Insert at the new position - ids.splice(insertionIndex, 0, id); + /** + * Inserts an item ID into the interval in the correct sorted position. + * Returns unchanged interval if the correct insertion position could not be determined. + */ + protected insertItemIdIntoInterval( + interval: I, + item: T, + ): I { + const itemLocation = this.locateByItemInInterval({ item, interval }); + let insertionIndex = itemLocation?.insertionIndex; + let itemIds = [...interval.itemIds]; - const intervalWithUpdatedIds = { - ...interval, - itemIds: ids, - }; + if (itemLocation && itemLocation.insertionIndex > -1) { + const removal = this.removeItemIdFromInterval({ interval, ...itemLocation }); + insertionIndex = removal.insertionIndex; + itemIds = removal.interval.itemIds; + } - const boundaries = this.recomputeIntervalBoundaries(intervalWithUpdatedIds); + const id = this.getItemId(item); + + // Insert at the new position + if (typeof insertionIndex !== 'undefined' && insertionIndex > -1) { + itemIds.splice(insertionIndex, 0, id); + } return { - ...intervalWithUpdatedIds, - ...boundaries, + ...interval, + itemIds, }; } @@ -904,9 +1212,7 @@ export abstract class BasePaginator { continue; } - const key = this.computeSortKey(item); - - if (belongsToInterval(key, anchored)) mergeIds.push(id); + if (this.belongsToInterval(item, anchored)) mergeIds.push(id); else keepIds.push(id); } @@ -917,26 +1223,28 @@ export abstract class BasePaginator { merged = this.insertItemIdIntoInterval(merged, item); } - const remainingLogical = keepIds.length > 0 ? { ...logical, itemIds: keepIds } : null; - return { mergedAnchored: merged, - remainingLogical: remainingLogical && { - ...remainingLogical, - ...this.recomputeIntervalBoundaries(remainingLogical), - }, + remainingLogical: keepIds.length > 0 ? { ...logical, itemIds: keepIds } : null, }; } /** * Merges all intervals (anchored + logical head/tail). * Returns: - * - merged anchored interval (or null if none) + * - merged anchored interval (or null if none merged) * - possibly reduced logical head / tail intervals */ - protected mergeIntervals(intervals: AnyInterval[]): MergeIntervalsResult { + protected mergeIntervals( + intervals: AnyInterval[], + baseInterval?: Interval, + ): MergeIntervalsResult { let logicalHead: LogicalInterval | null = null; let logicalTail: LogicalInterval | null = null; + + if (intervals.length <= 1 && !baseInterval) + return { logicalHead, merged: null, logicalTail }; + const anchored: Interval[] = []; // Separate logical vs anchored @@ -952,7 +1260,7 @@ export abstract class BasePaginator { } // Merge anchored intervals into one interval (if possible) - const mergedAnchored = mergeAnchoredIntervals(anchored); + const mergedAnchored = this.mergeAnchoredIntervals(anchored, baseInterval); // No anchored intervals → just return logical ones if (!mergedAnchored) { @@ -991,22 +1299,29 @@ export abstract class BasePaginator { /** * Ingests the whole page into intervals and returns the resulting anchored interval. */ - protected ingestPage({ + ingestPage({ page, + policy = 'auto', isHead, isTail, targetIntervalId, + setActive, }: { page: T[]; + /** + * Describes the policy for merging intervals. + * - 'auto' (default): Merge intervals if they overlap. + * - 'strict-overlap-only': Merge intervals only if they strictly overlap. Useful for jumping to a specific message. + * - This is useful for jumping to a specific message. + */ + policy?: IntervalMergePolicy; isHead?: boolean; isTail?: boolean; targetIntervalId?: string; + setActive?: boolean; }): Interval | null { - if (!this._itemIndex || !page?.length) return null; - - for (const item of page) { - this._itemIndex.setOne(item); - } + if (!this.usesItemIntervalStorage) return null; + if (!page?.length) return null; const pageInterval = this.makeInterval({ page, @@ -1014,47 +1329,128 @@ export abstract class BasePaginator { isTail, }); + for (const item of page) { + this._itemIndex.setOne(item); + } + const targetInterval = targetIntervalId ? this._itemIntervals.get(targetIntervalId) - : null; + : undefined; + + // Set the base interval in the following order of importance + // 1. if target interval + // a) is not logical interval and + // b) merge would not lead to corrupted interval sorting + // (pages: [a], [b,c], merging page [x] to [a] -> [a,x], [b,c] or pages: [b,c], [x] and merging [a] to [x] => [b,c], [a,x] ) + // 2. if one of the overlappingLogical is an active interval, use it as a base + // 3. if existing single anchored interval use it as a base + let baseInterval: Interval | undefined; // Find intervals that overlap with this page - const overlapping: Interval[] = []; + const overlappingAnchored: Interval[] = []; + const overlappingLogical: LogicalInterval[] = []; for (const itv of this.itemIntervals) { - // target will be appended separately + // target interval will be used as base if (targetInterval?.id === itv.id) continue; - if (intervalsOverlap(pageInterval, itv)) { - overlapping.push(itv); + if (this.intervalsOverlap(pageInterval, itv, policy)) { + if (this.isActiveInterval(itv) && !isLogicalInterval(itv)) { + baseInterval = itv; + } else { + if (!isLogicalInterval(itv)) overlappingAnchored.push(itv); + else overlappingLogical.push(itv); + } + } else if ( + (isHead && isLiveHeadInterval(itv)) || + (isTail && isLiveTailInterval(itv)) + ) { + overlappingLogical.push(itv); } } - const toMerge: AnyInterval[] = [...overlapping, pageInterval]; - if (targetInterval) { - toMerge.push(targetInterval); + // If caller specifies an anchored target interval, treat it as the merge anchor. + // The role of ingestPage method is to merge intervals that overlap + the target + // interval. Decision, whether target interval is a correct base interval is + // upon the ingestPage method caller, not ingestPage method, because the method + // does not know, in which context it has been invoked and cannot reliably tell, + // whether it is a valid move to merge into the target interval as when + // paginating linearly, the ingested page will never overlap with the previous page. + if (targetInterval && !isLogicalInterval(targetInterval)) { + baseInterval = targetInterval; + } else if (!baseInterval && overlappingAnchored.length === 1) { + baseInterval = overlappingAnchored[0]; + overlappingAnchored.length = 0; } - const { logicalHead, merged, logicalTail } = this.mergeIntervals(toMerge); + const toMerge: AnyInterval[] = [ + ...overlappingLogical, + ...overlappingAnchored, + pageInterval, + ]; + const { logicalHead, merged, logicalTail } = this.mergeIntervals( + toMerge, + baseInterval, + ); + + let resultingInterval = pageInterval; // Remove all intervals that participated - for (const itv of toMerge) { - this._itemIntervals.delete(itv.id); + if (merged) { + resultingInterval = merged; + for (const itv of toMerge) { + if (merged.id === itv.id) continue; + this._itemIntervals.delete(itv.id); + } } - // Decide which anchored interval we keep for this page: - const resultingInterval = merged ?? pageInterval; - this._itemIntervals.set(resultingInterval.id, resultingInterval); - // Store logical head/tail (if any) if (logicalHead) { - this._itemIntervals.set(LIVE_HEAD_INTERVAL_ID, logicalHead); - } else { - this._itemIntervals.delete(LIVE_HEAD_INTERVAL_ID); + // the leftovers that do not pertain to the first page should be migrated to a separate anchored interval + if (merged?.isHead) { + const convertedInterval = { + id: this.generateIntervalId(logicalHead.itemIds), + hasMoreHead: true, + hasMoreTail: true, + itemIds: logicalHead.itemIds, + isHead: false, + isTail: false, + }; + this._itemIntervals.set(convertedInterval.id, convertedInterval); + } else { + this._itemIntervals.set(LOGICAL_HEAD_INTERVAL_ID, logicalHead); + } } if (logicalTail) { - this._itemIntervals.set(LIVE_TAIL_INTERVAL_ID, logicalTail); - } else { - this._itemIntervals.delete(LIVE_TAIL_INTERVAL_ID); + // the leftovers that do not pertain to the last page should be migrated to a separate anchored interval + if (merged?.isTail) { + const convertedInterval = { + id: this.generateIntervalId(logicalTail.itemIds), + hasMoreHead: true, + hasMoreTail: true, + itemIds: logicalTail.itemIds, + isHead: false, + isTail: false, + }; + this._itemIntervals.set(convertedInterval.id, convertedInterval); + } else { + this._itemIntervals.set(LOGICAL_TAIL_INTERVAL_ID, logicalTail); + } + } + + this._itemIntervals.set(resultingInterval.id, resultingInterval); + // keep the intervals sorted + this.setIntervals(this.sortIntervals(this.itemIntervals)); + + if ( + resultingInterval && + setActive // || this.isActiveInterval(resultingInterval) + ) { + this.setActiveInterval(resultingInterval, { updateState: false }); + this.state.partialNext({ + items: this.intervalToItems(resultingInterval), + hasMoreHead: resultingInterval.hasMoreHead, + hasMoreTail: resultingInterval.hasMoreTail, + }); } return resultingInterval; @@ -1072,101 +1468,197 @@ export abstract class BasePaginator { * If no intervals or no itemIndex exist, falls back to the legacy list-based ingestion. */ ingestItem(ingestedItem: T): boolean { - // If we don't have itemIndex, manipulate only items array in paginator state and not intervals - // as intervals do not store the whole items and have to rely on _itemIndex if (!this.usesItemIntervalStorage) { const items = this.items ?? []; - const next = items.slice(); - const { current: existingIndex, expected: insertionIndex } = binarySearch({ - needle: ingestedItem, - length: items.length, - getItemAt: (index: number) => items[index], - itemIdentityEquals: (item1, item2) => - this.getItemId(item1) === this.getItemId(item2), - compare: this.effectiveComparator.bind(this), - plateauScan: true, - }); + const id = this.getItemId(ingestedItem); + const existingIndex = items.findIndex((i) => this.getItemId(i) === id); + const hadItem = existingIndex > -1; + + const nextItems = items.slice(); + if (hadItem) nextItems.splice(existingIndex, 1); + // If it no longer matches the filter, we only commit the removal (if any). if (!this.matchesFilter(ingestedItem)) { - if (existingIndex >= 0) { - next.splice(existingIndex, 1); - this.state.partialNext({ items: next }); - return true; - } - return false; + if (hadItem) this.state.partialNext({ items: nextItems }); + return hadItem; } - // override the existing item even though it already exists to make sure it is up-to-date - if (existingIndex >= 0) { - next.splice(existingIndex, 1); - } + // Determine insertion index against the list without the old snapshot. + const insertionIndex = + binarySearch({ + needle: ingestedItem, + length: nextItems.length, + getItemAt: (index: number) => nextItems[index], + itemIdentityEquals: (item1, item2) => + this.getItemId(item1) === this.getItemId(item2), + compare: this.effectiveComparator.bind(this), + plateauScan: true, + }).insertionIndex ?? -1; + + const keepOrderInState = this.config.lockItemOrder && hadItem; + const insertAt = keepOrderInState ? existingIndex : insertionIndex; + if (insertAt < 0) return false; + + nextItems.splice(insertAt, 0, ingestedItem); + this.state.partialNext({ items: nextItems }); + return true; + } - const insertAt = - this.config.lockItemOrder && existingIndex >= 0 ? existingIndex : insertionIndex; + const id = this.getItemId(ingestedItem); + const previousItem = this._itemIndex.get(id); - next.splice(insertAt, 0, ingestedItem); - this.state.partialNext({ items: next }); - return true; + // 0. PRE-ANALYSIS: capture previous coordinates BEFORE any mutations + const previousCoords = this.locateByItem(previousItem || ingestedItem); + + const originalIndexInState = previousCoords?.state?.currentIndex ?? -1; + const keepOrderInState = this.config.lockItemOrder && originalIndexInState >= 0; + + // 1. Remove the old snapshot from state & intervals. + let removedItemCoordinates: ItemCoordinates | undefined; + if (previousCoords) { + removedItemCoordinates = this.removeItemAtCoordinates(previousCoords); } + const itemHasBeenRemoved = + !!removedItemCoordinates?.state && removedItemCoordinates.state.currentIndex > -1; - // Always update the itemIndex if present - this._itemIndex?.setOne(ingestedItem); + // 2. Update canonical storage (ItemIndex) to the *new* snapshot, + // regardless of filters – this keeps the index authoritative. + this._itemIndex.setOne(ingestedItem); - // Ingestion into anchored intervals - let targetInterval = this.locateIntervalForItem(ingestedItem); + // 3. If it no longer matches the filter, we’re done (it has been removed above). + if (!this.matchesFilter(ingestedItem)) { + return itemHasBeenRemoved; + } - // if no page has been loaded yet or the anchored interval could not be found, - // because the relevant page has not been loaded yet, - // keep the incoming items in logical interval if falls outside of the head and tail boundaries - if (!targetInterval) { - let targetLogical: LogicalInterval | undefined; - // add to head or tail if item exceeds the total bounds - if (this._itemIntervals.size > 0) { - const intervalsArray = this.itemIntervals; - const [firstInterval, lastInterval] = [ - intervalsArray[0], - intervalsArray.slice(-1)[0], - ]; - const itemSortKey = this.computeSortKey(ingestedItem); - if ( - isLiveHeadInterval(firstInterval) && - compareSortKeys(itemSortKey, firstInterval.startKey) <= - ComparisonResult.A_PRECEDES_B - ) { - targetLogical = firstInterval; - } else if ( - isLiveTailInterval(lastInterval) && - compareSortKeys(itemSortKey, lastInterval.endKey) >= - ComparisonResult.A_COMES_AFTER_B - ) { - targetLogical = lastInterval; - } - // ingested item would fall somewhere inside the boundaries but relevant page has not been loaded yet - // and thus the interval is not identifiable - if (!targetLogical) return false; + // If we don't have itemIndex, manipulate only items array in paginator state and not intervals + // as intervals do not store the whole items and have to rely on _itemIndex + // if (!this.usesItemIntervalStorage) { + // const items = this.items ?? []; + // const newItems = items.slice(); + // + // // Recompute insertionIndex for the *new* snapshot against the updated list (original removed). + // const insertionIndex = this.locateItemInState(ingestedItem)?.insertionIndex ?? -1; + // + // const insertAt = keepOrderInState ? originalIndexInState : insertionIndex; + // + // if (insertAt < 0) return false; // corruption guard + // + // newItems.splice(insertAt, 0, ingestedItem); + // this.state.partialNext({ items: newItems }); + // return true; + // } + + const previousInterval = previousCoords?.interval?.interval; + + const onlyLogicalIntervals = + this.itemIntervals.length <= 2 && + this.itemIntervals.every((itv) => isLogicalInterval(itv)); + // IMPORTANT: decide if the new snapshot still belongs to the same anchored interval, + // using the OLD bounds. + const stillBelongsToPreviousAnchoredInterval = + previousInterval && + // 1) If we *only* have logical intervals and the item used to live in one of them, + // keep it there. This prevents items from disappearing on update. + ((onlyLogicalIntervals && isLogicalInterval(previousInterval)) || + // 2) Normal: for anchored intervals, only reuse if the new snapshot is still + // within that interval's sort bounds. + (!isLogicalInterval(previousInterval) && + this.belongsToInterval(ingestedItem, previousInterval))); + + let targetInterval = stillBelongsToPreviousAnchoredInterval + ? previousInterval + : this.locateIntervalForItem(ingestedItem); + const { liveHeadLogical, liveTailLogical } = this; - targetInterval = this.insertItemIdIntoInterval(targetLogical, ingestedItem); - } else { - // no page has been loaded yet + if (!targetInterval) { + // No anchored interval currently contains the new snapshot. + // Decide whether it belongs to logical head, logical tail, + // or to a brand-new anchored interval. + if (this._itemIntervals.size === 0) { + // No pages at all yet → keep in logical head. targetInterval = { - id: LIVE_HEAD_INTERVAL_ID, + id: LOGICAL_HEAD_INTERVAL_ID, itemIds: [this.getItemId(ingestedItem)], - startKey: this.computeSortKey(ingestedItem), - endKey: this.computeSortKey(ingestedItem), }; - if (!this._activeIntervalId) { - this._activeIntervalId = targetInterval.id; + this.setActiveInterval(targetInterval); + } + } else { + const intervals = this.itemIntervals; + const headInterval = this.getHeadIntervalFromSortedIntervals(intervals); + const tailInterval = this.getTailIntervalFromSortedIntervals(intervals); + const headEdges = headInterval && this.getIntervalPaginationEdges(headInterval); + const tailEdges = tailInterval && this.getIntervalPaginationEdges(tailInterval); + + if (headEdges && this.aIsMoreHeadwardThanB(ingestedItem, headEdges.head)) { + // Falls before the loaded head → logical head. + targetInterval = liveHeadLogical + ? this.insertItemIdIntoInterval(liveHeadLogical, ingestedItem) + : { + id: LOGICAL_HEAD_INTERVAL_ID, + itemIds: [this.getItemId(ingestedItem)], + }; + } else if (tailEdges && this.aIsMoreTailwardThanB(ingestedItem, tailEdges.tail)) { + // Falls after the loaded tail → logical tail. + targetInterval = liveTailLogical + ? this.insertItemIdIntoInterval(liveTailLogical, ingestedItem) + : { + id: LOGICAL_TAIL_INTERVAL_ID, + itemIds: [this.getItemId(ingestedItem)], + }; + } else { + // Falls somewhere *inside* the global bounds, but we don't have that page loaded. + // We’ve already removed any old occurrence, so from the paginator's perspective + // this item won't be visible again until the relevant page is fetched. + return itemHasBeenRemoved; } } } else { + // Found an anchored interval whose bounds contain the new snapshot. targetInterval = this.insertItemIdIntoInterval(targetInterval, ingestedItem); } + const addedNewInterval = !this._itemIntervals.has(targetInterval.id); this._itemIntervals.set(targetInterval.id, targetInterval); - if (this._activeIntervalId === targetInterval.id) { - this.state.partialNext({ items: this.intervalToItems(targetInterval) }); + if (addedNewInterval) { + this.setIntervals(this.sortIntervals(this.itemIntervals)); + } + + // emit new state if active interval impacted by ingestion + if ( + this._activeIntervalId && + [targetInterval.id, removedItemCoordinates?.interval?.interval.id].includes( + this._activeIntervalId, + ) + ) { + const items = this.items ?? []; + /** + * Having config.lockItemOrder enabled when working with intervals will lead to + * discrepancies once active intervals are switched: + * 1. state.items [a,b,c] intervals [a,b,c], [d] + * 2. a changed and is moved to another interval state.items is now [a,b,c], intervals [b,c,], [d, a] + * 3. jumping / changing active interval to [d,a] - state.items is now [d,a], intervals [b,c], [d,a] + */ + if (keepOrderInState) { + // Item was visible before → reinsert at its old index + const nextView = items.slice(); + const insertAt = Math.min(originalIndexInState, nextView.length); + nextView.splice(insertAt, 0, ingestedItem); + this.state.partialNext({ items: nextView }); + } else { + /** + * Select a correct interval from which the state.items array is derived + */ + this.state.partialNext({ + items: this.intervalToItems( + this._activeIntervalId === removedItemCoordinates?.interval?.interval.id && + this._activeIntervalId !== targetInterval.id + ? removedItemCoordinates.interval.interval + : targetInterval, + ), + }); + } } return true; @@ -1176,36 +1668,80 @@ export abstract class BasePaginator { // Remove / contains // --------------------------------------------------------------------------- - removeItem({ id, item: inputItem }: { id?: string; item?: T }): boolean { - if (!id && !inputItem) return false; - const item = inputItem ?? this.getItem(id); - // not in item index, and no item provided (cannot locate by item), so we will not check intervals, - // only state items and sequentially - if (!this._itemIndex || !item) { - const index = this.items?.findIndex((i) => this.getItemId(i) === id) ?? -1; - if (index === -1) return false; + protected removeItemAtCoordinates(coords: ItemCoordinates): ItemCoordinates { + const { state: stateLocation, interval: intervalLocation } = coords; + + const result: ItemCoordinates = { + state: { currentIndex: -1, insertionIndex: -1 }, + }; + + // 1) Remove from interval, if present + if (intervalLocation && intervalLocation.currentIndex > -1) { + const updatedInterval = this.removeItemIdFromInterval(intervalLocation); + const { interval } = updatedInterval; + if (interval.itemIds.length === 0) { + // Drop empty interval + this._itemIntervals.delete(interval.id); + + // If it was active -> clear active + if (this.isActiveInterval(interval)) { + this.setActiveInterval(undefined); + } + } else { + this._itemIntervals.set(updatedInterval.interval.id, updatedInterval.interval); + } + result.interval = updatedInterval; + } + + // 2) Remove from visible state.items, if present + if (stateLocation && stateLocation.currentIndex > -1) { const newItems = [...(this.items ?? [])]; - newItems.splice(index, 1); + newItems.splice(stateLocation.currentIndex, 1); this.state.partialNext({ items: newItems }); - return true; + + // keep insertionIndex consistent if someone uses it later + if (stateLocation.insertionIndex > stateLocation.currentIndex) { + stateLocation.insertionIndex--; + } + + result.state = stateLocation; } - const { state: stateLocation, interval: intervalLocation } = this.locateByItem(item); + return result; + } + + /** + * Meaning of location values + * - currentIndex === -1 could not be found + * - insertionIndex === -1 insertion index was no intended to be determined + * + * If we are removing the last item from the currently active interval, we do not search for a new active interval. + * If the number of items approach 0 in an active interval, we expect from the UI to load new pages to populate + * the active interval. + */ + removeItem({ id, item: inputItem }: { id?: string; item?: T }): ItemCoordinates { + const noAction = { state: { currentIndex: -1, insertionIndex: -1 } }; + if (!id && !inputItem) return noAction; - if (intervalLocation && intervalLocation.current > -1) { - const itemIds = [...intervalLocation.interval.itemIds]; - itemIds.splice(intervalLocation.current, 1); - const newInterval: AnyInterval = { ...intervalLocation.interval, itemIds }; - const boundaries = this.recomputeIntervalBoundaries(newInterval); - this._itemIntervals.set(newInterval.id, { ...newInterval, ...boundaries }); + const item = inputItem ?? this.getItem(id); + + if (item) { + const coords = this.locateByItem(item); + if (!coords.state && !coords.interval) return noAction; + return this.removeItemAtCoordinates(coords); } - if (stateLocation && stateLocation.current > -1) { + // Fallback for state-only mode (sequential scan in state.items) + if (!this.usesItemIntervalStorage) { + const index = this.items?.findIndex((i) => this.getItemId(i) === id) ?? -1; + if (index === -1) return noAction; const newItems = [...(this.items ?? [])]; - newItems.splice(stateLocation.current, 1); + newItems.splice(index, 1); this.state.partialNext({ items: newItems }); + return { state: { currentIndex: index, insertionIndex: -1 } }; } - return true; + + return noAction; } /** Sets the items in the state. If intervals are kept, the active interval will be updated */ @@ -1234,12 +1770,18 @@ export abstract class BasePaginator { newState.offset = newItems.length; } - const interval = this.ingestPage({ - page: newItems, - isHead: isFirstPage, - isTail: isLastPage, - }); - if (interval) this._activeIntervalId = interval.id; + if (this.usesItemIntervalStorage) { + const interval = this.ingestPage({ + page: newItems, + isHead: isFirstPage, + isTail: isLastPage, + }); + if (interval) { + this.setActiveInterval(interval, { updateState: false }); + newState.hasMoreHead = interval.hasMoreHead; + newState.hasMoreTail = interval.hasMoreTail; + } + } return newState; }); @@ -1266,11 +1808,13 @@ export abstract class BasePaginator { protected canExecuteQuery = ({ direction, reset, - }: { direction: PaginationDirection } & Pick, 'reset'>) => + }: { direction?: PaginationDirection } & Pick, 'reset'>) => !this.isLoading && (reset === 'yes' || - (direction === 'next' && this.hasNext) || - (direction === 'prev' && this.hasPrev)); + // If direction is undefined, we are jumping to a specific message. + typeof direction === 'undefined' || + (direction === 'tailward' && this.hasMoreTail) || + (direction === 'headward' && this.hasMoreHead)); isFirstPageQuery = ( params: { queryShape?: unknown } & Pick, 'reset'>, @@ -1289,8 +1833,14 @@ export abstract class BasePaginator { }; } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isJumpQueryShape(queryShape: Q): boolean { + return false; + } + protected getStateAfterQuery( stateUpdate: Partial>, + // eslint-disable-next-line @typescript-eslint/no-unused-vars isFirstPage: boolean, ): PaginatorState { const current = this.state.getLatestValue(); @@ -1299,9 +1849,7 @@ export abstract class BasePaginator { lastQueryError: undefined, ...stateUpdate, isLoading: false, - items: isFirstPage - ? stateUpdate.items - : [...(this.items ?? []), ...(stateUpdate.items || [])], + items: stateUpdate.items, }; } @@ -1345,12 +1893,21 @@ export abstract class BasePaginator { } } + /** + * Falsy return value means query was not successful. + * @param direction + * @param forcedQueryShape + * @param reset + * @param retryCount + * @param updateState + */ async executeQuery({ - direction = 'next', + direction, queryShape: forcedQueryShape, reset, retryCount = 0, - }: PaginationQueryParams = {}) { + updateState = true, + }: PaginationQueryParams = {}): Promise | void> { const queryShape = forcedQueryShape ?? this.getNextQueryShape({ direction }); if (!this.canExecuteQuery({ direction, reset })) return; @@ -1379,50 +1936,141 @@ export abstract class BasePaginator { reset, retryCount, }); - this._lastQueryShape = this._nextQueryShape; + + return await this.postQueryReconcile({ + direction, + isFirstPage, + queryShape, + requestedPageSize: this.pageSize, + results, + updateState, + }); + } + + async postQueryReconcile({ + direction, + isFirstPage, + queryShape, + requestedPageSize, + results, + updateState = true, + }: PostQueryReconcileParams): Promise> { + this._lastQueryShape = queryShape; this._nextQueryShape = undefined; + const stateUpdate: Partial> = { + isLoading: false, + }; + if (!results) { - this.state.partialNext({ isLoading: false }); - return; + this.state.partialNext(stateUpdate); + return { stateCandidate: stateUpdate, targetInterval: null }; } - const stateUpdate: Partial> = { - lastQueryError: undefined, - }; + const { items, headward, tailward } = results; + + stateUpdate.lastQueryError = undefined; + const filteredItems = await this.filterQueryResults(items); + stateUpdate.items = filteredItems; + + // State-only mode: merge pages into a single list. + if (!this.usesItemIntervalStorage) { + const currentItems = this.items ?? []; + if (!isFirstPage) { + // In state-only mode we treat pagination as a growing list. + // Both directions extend the same list (cursor semantics are expressed by the cursor, not by list "side"). + stateUpdate.items = [...currentItems, ...filteredItems]; + } + } - const { items, next, prev } = results; - if (isFirstPage && (next || prev)) { - this._isCursorPagination = true; + const isJumpQuery = !!queryShape && this.isJumpQueryShape(queryShape); + const interval = this.usesItemIntervalStorage + ? this.ingestPage({ + page: stateUpdate.items, + policy: isJumpQuery ? 'strict-overlap-only' : 'auto', + // the first page should be always marked as head + isHead: isJumpQuery + ? undefined //head/tail doesn't apply / is unknown for this ingestion + : isFirstPage || + (direction === 'headward' ? requestedPageSize > items.length : undefined), + // even though the page is first, we have to compare the requested vs returned page size + isTail: isJumpQuery + ? undefined //head/tail doesn't apply / is unknown for this ingestion + : isFirstPage || direction === 'tailward' + ? requestedPageSize > items.length + : undefined, + targetIntervalId: isJumpQuery ? undefined : this._activeIntervalId, + }) + : null; + if (interval && updateState) { + this.setActiveInterval(interval, { updateState: false }); + stateUpdate.items = this.intervalToItems(interval); } - if (this._isCursorPagination) { - stateUpdate.cursor = { next: next || null, prev: prev || null }; - stateUpdate.hasNext = !!next; - stateUpdate.hasPrev = !!prev; + /** + * Cursor can be calculated client-side or returned from the server. + * Therefore, the BasePaginator.cursorSource can be 'derived' | 'query' + * - derived - the BasePaginator applies the default client-side logic based on the pagination options (id_lt, id_gt, id_around...) + * - query - BasePaginator.query() resp. BasePaginator.config.doRequest (called inside query()) is expected to provide the cursor and abide by the rules that when the wall is hit in + * a given direction, the cursor will be set to null. + * + * The 'derived' calculation will perform the following steps: + * 1. After ingesting into the parent interval determine the cursor candidate values from the first and the last item in the interval. + * 2. Decide, whether the candidates can be set based on the requested vs real page size + * 3. If the page size from the response is smaller that the requested page size, then in the given direction + * the cursor will be set to null. + */ + if (this.isCursorPagination) { + if (this.config.deriveCursor && interval) { + const { cursor, hasMoreTail, hasMoreHead } = this.config.deriveCursor({ + direction, + interval, + queryShape, + page: results.items, + requestedPageSize, + cursor: this.cursor, + hasMoreHead: this.hasMoreHead, + hasMoreTail: this.hasMoreTail, + }); + stateUpdate.cursor = cursor; + stateUpdate.hasMoreTail = hasMoreTail; + stateUpdate.hasMoreHead = hasMoreHead; + } else { + stateUpdate.cursor = { tailward: tailward || null, headward: headward || null }; + stateUpdate.hasMoreTail = !!tailward; + stateUpdate.hasMoreHead = !!headward; + } } else { + // todo: we could keep the offset in two directions (initial tailward offset would be taken from config.initialOffset) stateUpdate.offset = (this.offset ?? 0) + items.length; - stateUpdate.hasNext = items.length === this.pageSize; + stateUpdate.hasMoreTail = items.length === this.pageSize; } - stateUpdate.items = await this.filterQueryResults(items); - - // ingest page into intervals if itemIndex is present - const interval = this.ingestPage({ - page: stateUpdate.items, - isHead: !stateUpdate.hasNext, - isTail: !stateUpdate.hasPrev, - targetIntervalId: this._activeIntervalId, - }); - // item index is available if an Interval is returned if (interval) { - this._activeIntervalId = interval.id; - stateUpdate.items = this.intervalToItems(interval); + const current = this.state.getLatestValue(); + const resolvedHasMoreHead = + typeof stateUpdate.hasMoreHead === 'boolean' + ? stateUpdate.hasMoreHead + : current.hasMoreHead; + const resolvedHasMoreTail = + typeof stateUpdate.hasMoreTail === 'boolean' + ? stateUpdate.hasMoreTail + : current.hasMoreTail; + + interval.hasMoreHead = resolvedHasMoreHead; + interval.hasMoreTail = resolvedHasMoreTail; + interval.isHead = resolvedHasMoreHead === false; + interval.isTail = resolvedHasMoreTail === false; } const state = this.getStateAfterQuery(stateUpdate, isFirstPage); - this.state.next(state); + if (updateState) this.state.next(state); this.populateOfflineDbAfterQuery({ items: state.items, queryShape }); + + return { + stateCandidate: state, + targetInterval: interval, + }; } // --------------------------------------------------------------------------- @@ -1435,27 +2083,29 @@ export abstract class BasePaginator { resetState() { this.state.next(this.initialState); + this.setIntervals([]); + this.setActiveInterval(undefined); } - next = (params: Omit, 'direction' | 'queryShape'> = {}) => - this.executeQuery({ direction: 'next', ...params }); + toTail = (params: Omit, 'direction' | 'queryShape'> = {}) => + this.executeQuery({ direction: 'tailward', ...params }); - prev = (params: Omit, 'direction' | 'queryShape'> = {}) => - this.executeQuery({ direction: 'prev', ...params }); + toHead = (params: Omit, 'direction' | 'queryShape'> = {}) => + this.executeQuery({ direction: 'headward', ...params }); - nextDebounced = ( + toTailDebounced = ( params: Omit, 'direction' | 'queryShape'> = {}, ) => { - this._executeQueryDebounced({ direction: 'next', ...params }); + this._executeQueryDebounced({ direction: 'tailward', ...params }); }; - prevDebounced = ( + toHeadDebounced = ( params: Omit, 'direction' | 'queryShape'> = {}, ) => { - this._executeQueryDebounced({ direction: 'prev', ...params }); + this._executeQueryDebounced({ direction: 'headward', ...params }); }; reload = async () => { - await this.next({ reset: 'yes' }); + await this.toTail({ reset: 'yes' }); }; } diff --git a/src/pagination/paginators/ChannelPaginator.ts b/src/pagination/paginators/ChannelPaginator.ts index 9d9ca3ea71..49f0a32f17 100644 --- a/src/pagination/paginators/ChannelPaginator.ts +++ b/src/pagination/paginators/ChannelPaginator.ts @@ -5,7 +5,6 @@ import type { PaginatorOptions, PaginatorState, SetPaginatorItemsParams, - SortKey, } from './BasePaginator'; import { BasePaginator } from './BasePaginator'; import type { FilterBuilderOptions } from '../FilterBuilder'; @@ -285,14 +284,6 @@ export class ChannelPaginator extends BasePaginator baseFilters: { ...this.staticFilters }, }); - computeSortKey(item: Channel): SortKey { - const generateSortKey = super.makeSortKeyGenerator({ - sort: this.sort, - resolvePathValue: channelSortPathResolver, - }); - return generateSortKey(item); - } - // invoked inside BasePaginator.executeQuery() to keep it as a query descriptor; protected getNextQueryShape(): ChannelQueryShape { const shape: ChannelQueryShape = { diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts new file mode 100644 index 0000000000..ea46d3f1f7 --- /dev/null +++ b/src/pagination/paginators/MessagePaginator.ts @@ -0,0 +1,520 @@ +import type { + AnyInterval, + CursorDerivator, + CursorDeriveResult, + ExecuteQueryReturnValue, + Interval, + PaginationDirection, + PaginationQueryParams, + PaginatorCursor, + PaginatorState, + PostQueryReconcileParams, +} from './BasePaginator'; +import { + BasePaginator, + isLogicalInterval, + type PaginationQueryReturnValue, + type PaginationQueryShapeChangeIdentifier, + type PaginatorOptions, + ZERO_PAGE_CURSOR, +} from './BasePaginator'; +import type { + AscDesc, + LocalMessage, + MessagePaginationOptions, + PinnedMessagePaginationOptions, +} from '../../types'; +import type { Channel } from '../../channel'; +import { StateStore } from '../../store'; +import { formatMessage, generateUUIDv4 } from '../../utils'; +import { makeComparator } from '../sortCompiler'; +import type { FieldToDataResolver } from '../types.normalization'; +import { resolveDotPathValue } from '../utility.normalization'; +import { ItemIndex } from '../ItemIndex'; +import { deriveCreatedAtAroundPaginationFlags } from '../cursorDerivation'; +import { deriveIdAroundPaginationFlags } from '../cursorDerivation/idAroundPaginationFlags'; +import { deriveLinearPaginationFlags } from '../cursorDerivation/linearPaginationFlags'; + +export type JumpToMessageOptions = { pageSize?: number }; + +export type MessagePaginatorSort = { created_at: AscDesc } | { created_at: AscDesc }[]; + +export type MessagePaginatorFilter = { + cid: string; +}; + +const DEFAULT_BACKEND_SORT: MessagePaginatorSort = { + created_at: 1, +}; + +// server's default size is 100 +const DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE = 100; + +export type MessagePaginatorState = PaginatorState; +export type MessageQueryShape = MessagePaginationOptions | PinnedMessagePaginationOptions; + +/** + * At the moment all the pagination parameters are just different types of cursors, e.g. + * id_lt, id_gt, ... + * But we always paginate within the same list without changing the sorting params. + * It is currently not possible to change the sorting params. + */ +const hasPaginationQueryShapeChanged: PaginationQueryShapeChangeIdentifier< + MessageQueryShape +> = () => false; + +const dataFieldFilterResolver: FieldToDataResolver = { + matchesField: () => true, + resolve: (message, path) => resolveDotPathValue(message, path), +}; + +export type MessagePaginatorOptions = { + channel: Channel; + id?: string; + itemIndex?: ItemIndex; + paginatorOptions?: PaginatorOptions; + /** + * Controls whether `jumpToTheFirstUnreadMessage()` should prefer the `unreadStateSnapshot` + * state over `channel.state.read[...]`. + * + * - 'snapshot' (default): retrieve the first unread message id from the unreadStateSnapshot state when jumping to the first unread message + * - 'read-state-only': retrieve the last read message id from the channel read state when jumping to the first unread message + */ + unreadReferencePolicy?: 'snapshot' | 'read-state-only'; +}; + +export type UnreadSnapshotState = { + lastReadAt: Date | null; + unreadCount: number; + /** + * Snapshot of the first unread message id for the user. + * This is intentionally decoupled from `channel.state.read[...]` because apps + * may mark the channel read immediately on open, while still wanting to render + * UI indicators that jump to the previously-unread location. + */ + firstUnreadMessageId: string | null; + /** + * Snapshot of the last read message id for the user (fallback when first unread + * is not known). + */ + lastReadMessageId: string | null; +}; + +/** + * MessagePaginator does not allow for sorting or filtering the items, because it is based on channe.query() and + * not client.search() calls. So the paginator just updates the cursor. + */ +export class MessagePaginator extends BasePaginator { + private readonly _id: string; + private channel: Channel; + private unreadReferencePolicy: 'snapshot' | 'read-state-only'; + /** + * Independent unread reference state (not tied to `channel.state.read`). + * Consumers may set this right before calling markRead / when opening a channel. + */ + readonly unreadStateSnapshot: StateStore; + protected _sort = DEFAULT_BACKEND_SORT; + protected _nextQueryShape: MessageQueryShape | undefined; + sortComparator: (a: LocalMessage, b: LocalMessage) => number; + /** + * Single source of truth for whether a message should be included in paginator intervals/state. + * Keep this consistent with `filterQueryResults` AND cursor flag derivation. + */ + shouldIncludeMessageInInterval(message: LocalMessage): boolean { + return !message.shadowed; + } + + protected get intervalItemIdsAreHeadFirst(): boolean { + // Messages are stored in chronological order (created_at asc) within an interval. + // Pagination "head" (newest side) is therefore at the END of the `itemIds` array. + return false; + } + + protected get intervalSortDirection(): 'asc' | 'desc' { + // Head edge is newest, but sortComparator is created_at asc => newer head edges + // should come first => reverse interval ordering. + return 'desc'; + } + + constructor({ + channel, + id, + itemIndex = new ItemIndex({ getId: (item) => item.id }), + paginatorOptions, + unreadReferencePolicy = 'snapshot', + }: MessagePaginatorOptions) { + super({ + hasPaginationQueryShapeChanged, + initialCursor: ZERO_PAGE_CURSOR, + itemIndex, + ...paginatorOptions, + pageSize: paginatorOptions?.pageSize ?? DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE, + }); + this.config.deriveCursor = makeDeriveCursor(this); + this.channel = channel; + this._id = id ?? `message-paginator-${generateUUIDv4()}`; + this._sort = DEFAULT_BACKEND_SORT; + this.unreadReferencePolicy = unreadReferencePolicy; + this.unreadStateSnapshot = new StateStore({ + lastReadAt: null, + firstUnreadMessageId: null, + lastReadMessageId: null, + unreadCount: 0, + }); + this.sortComparator = makeComparator({ + sort: this._sort, + resolvePathValue: resolveDotPathValue, + tiebreaker: (l, r) => { + const leftId = this.getItemId(l); + const rightId = this.getItemId(r); + return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; + }, + }); + this.setFilterResolvers([dataFieldFilterResolver]); + } + + get id() { + return this._id; + } + + get sort() { + return this._sort ?? DEFAULT_BACKEND_SORT; + } + + /** + * Even though we do not send filters object to the server, we need to have filters for client-side item ingestion logic. + */ + buildFilters = (): MessagePaginatorFilter => ({ + cid: this.channel.cid, + }); + + // invoked inside BasePaginator.executeQuery() to keep it as a query descriptor; + protected getNextQueryShape({ + direction, + }: Omit< + PaginationQueryParams, + 'isFirstPageQuery' + >): MessageQueryShape { + return { + limit: this.pageSize, + [direction === 'tailward' ? 'id_lt' : 'id_gt']: + direction && this.cursor?.[direction], + }; + } + + getCursorFromQueryResults = ({ + direction, + items, + }: { + direction?: PaginationDirection; + items: LocalMessage[]; + }) => { + if (!items.length) { + return { + tailward: undefined, + headward: undefined, + }; + } + + const start = items[0]; + const end = items[items.length - 1]; + + // Newer side is the pagination head for messages. Which bound is considered "head" + // is determined by intervalItemIdsAreHeadFirst (see BasePaginator.getIntervalPaginationEdges). + const head = this.intervalItemIdsAreHeadFirst ? start : end; + const tail = this.intervalItemIdsAreHeadFirst ? end : start; + + // if there is no direction, then we are jumping, and we want to set both directions in the cursor + return { + tailward: !direction || direction === 'tailward' ? this.getItemId(tail) : undefined, + headward: !direction || direction === 'headward' ? this.getItemId(head) : undefined, + }; + }; + + query = async ({ + direction, + }: PaginationQueryParams): Promise< + PaginationQueryReturnValue + > => { + // get the params only if they were not generated previously + if (!this._nextQueryShape) { + this._nextQueryShape = this.getNextQueryShape({ direction }); + } + + const options = this._nextQueryShape; + let items: LocalMessage[]; + let tailward: string | undefined; + let headward: string | undefined; + if (this.config.doRequest) { + const result = await this.config.doRequest(options); + items = result?.items ?? []; + // if there is no direction, then we are jumping, and we want to set both directions in the cursor + tailward = + !direction || direction === 'tailward' + ? (result.cursor?.tailward ?? undefined) + : undefined; + headward = + !direction || direction === 'headward' + ? (result.cursor?.headward ?? undefined) + : undefined; + } else { + const { messages } = await this.channel.query({ + messages: options, + // todo: why do we query for watchers? + // watchers: { limit: this.pageSize }, + }); + items = messages.map(formatMessage); + const cursor = this.getCursorFromQueryResults({ direction, items }); + tailward = cursor.tailward; + headward = cursor.headward; + } + + return { items, headward, tailward }; + }; + + /** + * Invokes the super.postQueryReconcile() and takes unread state snapshot on the first page query. + * The snapshot has to be taken immediately after the query as the viewed channel is marked read immediately after opening it. + * The snapshot can be used to display unread UI indicators. + */ + async postQueryReconcile( + params: PostQueryReconcileParams, + ): Promise> { + const result = await super.postQueryReconcile(params); + + // Take unread state snapshot + const ownUserId = this.channel.getClient().user?.id; + const ownReadState = ownUserId ? this.channel.state.read[ownUserId] : undefined; + if (ownReadState && params.isFirstPage) { + this.setUnreadSnapshot({ + firstUnreadMessageId: null, + lastReadAt: ownReadState.last_read, + lastReadMessageId: ownReadState.last_read_message_id, + unreadCount: ownReadState.unread_messages, + }); + } + return result; + } + + isJumpQueryShape(queryShape: MessageQueryShape): boolean { + return ( + !!queryShape?.id_around || + !!(queryShape as MessagePaginationOptions)?.created_at_around + ); + } + + jumpToMessage = async ( + messageId: string, + { pageSize }: JumpToMessageOptions = {}, + ): Promise => { + let localMessage = this.getItem(messageId); + let interval: AnyInterval | undefined; + let state: Partial> | undefined; + if (localMessage) { + interval = this.locateIntervalForItem(localMessage); + } + + if (localMessage && interval && !isLogicalInterval(interval)) { + state = { + hasMoreHead: interval.hasMoreHead, + hasMoreTail: interval.hasMoreTail, + cursor: this.getCursorFromInterval(interval), + items: this.intervalToItems(interval), + }; + } else if (!localMessage || !interval || isLogicalInterval(interval)) { + const result = await this.executeQuery({ + queryShape: { id_around: messageId, limit: pageSize }, + updateState: false, + }); + localMessage = this.getItem(messageId); + if (!localMessage || !result || !result.targetInterval) { + this.channel.getClient().notifications.addError({ + message: 'Jump to message unsuccessful', + origin: { emitter: 'MessagePaginator.jumpToMessage', context: { messageId } }, + options: { type: 'api:messages:query:failed' }, + }); + return false; + } + interval = result.targetInterval; + state = isLogicalInterval(interval) + ? result.stateCandidate + : { + ...result.stateCandidate, + hasMoreHead: interval.hasMoreHead, + hasMoreTail: interval.hasMoreTail, + // Prefer the cursor derived during postQueryReconcile, but fall back to + // interval-derived cursor to keep jumps consistent if the stateCandidate + // is partial. + cursor: result.stateCandidate.cursor ?? this.getCursorFromInterval(interval), + items: this.intervalToItems(interval), + }; + } + + if (!this.isActiveInterval(interval)) { + this.setActiveInterval(interval, { updateState: false }); + if (state) this.state.partialNext(state); + } + return true; + }; + + jumpToTheLatestMessage = async (options?: JumpToMessageOptions): Promise => { + let latestMessageId: string | undefined; + const intervals = this.itemIntervals; + if (!(intervals[0] as Interval)?.isHead) { + // get the first page (in case the pagination has not started at the head) + await this.executeQuery({ direction: 'headward', updateState: false }); + } + + const headInterval = intervals[0]; + if ((intervals[0] as Interval)?.isHead) { + latestMessageId = headInterval.itemIds.slice(-1)[0]; + } + + if (!latestMessageId) { + this.channel.getClient().notifications.addError({ + message: 'Jump to latest message unsuccessful', + origin: { emitter: 'MessagePaginator.jumpToTheLatestMessage' }, + options: { type: 'api:message:query:failed' }, + }); + return false; + } + + return await this.jumpToMessage(latestMessageId, options); + }; + + /** + * Jumps to the unread reference message. + * + * IMPORTANT: This intentionally does *not* rely on `channel.state.read[ownUserId]` only, + * because apps may mark a channel read immediately after opening it, while still + * wanting to keep "jump to unread" UI indicators alive (based on a snapshot). + */ + jumpToTheFirstUnreadMessage = async (options?: JumpToMessageOptions) => { + const ownUserId = this.channel.getClient().user?.id; + if (!ownUserId) return false; + + const unreadSnapshot = + this.unreadReferencePolicy === 'snapshot' + ? this.unreadStateSnapshot.getLatestValue() + : { firstUnreadMessageId: null, lastReadMessageId: null }; + const firstUnreadFromSnapshot = unreadSnapshot.firstUnreadMessageId; + const lastReadFromSnapshot = unreadSnapshot.lastReadMessageId; + + const firstUnreadFromReadState = + this.channel.state.read[ownUserId]?.first_unread_message_id ?? null; + const lastReadFromReadState = + this.channel.state.read[ownUserId]?.last_read_message_id ?? null; + + const firstUnreadMessageId = firstUnreadFromSnapshot ?? firstUnreadFromReadState; + if (firstUnreadMessageId) { + return await this.jumpToMessage(firstUnreadMessageId, options); + } + + const lastReadMessageId = lastReadFromSnapshot ?? lastReadFromReadState; + if (!lastReadMessageId) return false; + return await this.jumpToMessage(lastReadMessageId, options); + }; + + setUnreadSnapshot = (next: Partial): UnreadSnapshotState => { + this.unreadStateSnapshot.partialNext(next); + return this.unreadStateSnapshot.getLatestValue(); + }; + + clearUnreadSnapshot = () => { + this.unreadStateSnapshot.next({ + firstUnreadMessageId: null, + lastReadMessageId: null, + lastReadAt: null, + unreadCount: 0, + }); + }; + + filterQueryResults = (items: LocalMessage[]) => + items.filter(this.shouldIncludeMessageInInterval.bind(this)); +} + +const makeDeriveCursor = + (paginator: MessagePaginator): CursorDerivator => + (ctx) => { + // Not included in the interval (filtered out by MessagePaginator.filterQueryResults). + // + // IMPORTANT: We must keep cursor derivation consistent with the ingested interval. + // The interval is built from the filtered page, but ctx.page contains the raw response. + // Around/linear derivators compare page edges and lengths against interval.itemIds. If we + // pass a page that includes locally filtered messages (e.g. shadowed), those comparisons + // can incorrectly conclude that the page is not at the dataset bounds. + const pageWithPermittedMessages: LocalMessage[] = []; + let filteredLocallyCount = 0; + for (const message of ctx.page) { + if (!paginator.shouldIncludeMessageInInterval(message)) { + filteredLocallyCount++; + } else { + pageWithPermittedMessages.push(message); + } + } + + const requestedPageSizeAfterAdjustment = Math.max( + 0, + ctx.requestedPageSize - filteredLocallyCount, + ); + + if ( + ctx.interval && + ctx.interval.itemIds.length + filteredLocallyCount < ctx.page.length + ) { + console.error( + 'error', + 'Corrupted message set state: parent set size < returned page size', + ); + return { + cursor: ctx.cursor, + hasMoreHead: ctx.hasMoreHead, + hasMoreTail: ctx.hasMoreTail, + }; + } + + const injectCursor = ({ + hasMoreHead, + hasMoreTail, + }: { + hasMoreHead: boolean; + hasMoreTail: boolean; + }): CursorDeriveResult => { + const cursor: PaginatorCursor = { + headward: !hasMoreHead ? null : (ctx.interval?.itemIds.slice(-1)[0] ?? null), + tailward: !hasMoreTail ? null : (ctx.interval?.itemIds[0] ?? null), + }; + return { cursor, hasMoreHead, hasMoreTail }; + }; + + if ((ctx.queryShape as MessagePaginationOptions)?.created_at_around) { + return injectCursor( + deriveCreatedAtAroundPaginationFlags< + LocalMessage, + MessagePaginationOptions, + MessagePaginator + >({ + ...ctx, + paginator, + page: pageWithPermittedMessages, + requestedPageSize: requestedPageSizeAfterAdjustment, + }), + ); + } else if (ctx.queryShape?.id_around) { + return injectCursor( + deriveIdAroundPaginationFlags({ + ...ctx, + page: pageWithPermittedMessages, + requestedPageSize: requestedPageSizeAfterAdjustment, + }), + ); + } else { + return injectCursor( + deriveLinearPaginationFlags({ + ...ctx, + page: pageWithPermittedMessages, + requestedPageSize: requestedPageSizeAfterAdjustment, + }), + ); + } + }; diff --git a/src/pagination/paginators/MessageReplyPaginator.ts b/src/pagination/paginators/MessageReplyPaginator.ts new file mode 100644 index 0000000000..415be87d53 --- /dev/null +++ b/src/pagination/paginators/MessageReplyPaginator.ts @@ -0,0 +1,301 @@ +import type { + AnyInterval, + Interval, + PaginationQueryParams, + PaginatorState, +} from './BasePaginator'; +import { isLogicalInterval, ZERO_PAGE_CURSOR } from './BasePaginator'; +import { + BasePaginator, + type PaginationQueryReturnValue, + type PaginationQueryShapeChangeIdentifier, + type PaginatorOptions, +} from './BasePaginator'; +import type { + LocalMessage, + MessagePaginationOptions, + PinnedMessagePaginationOptions, +} from '../../types'; +import type { Channel } from '../../channel'; +import { formatMessage, generateUUIDv4 } from '../../utils'; +import { makeComparator } from '../sortCompiler'; +import { isEqual } from '../../utils/mergeWith/mergeWithCore'; +import type { FieldToDataResolver } from '../types.normalization'; +import { resolveDotPathValue } from '../utility.normalization'; +import type { + JumpToMessageOptions, + MessagePaginatorOptions, + MessagePaginatorSort, +} from './MessagePaginator'; +import { ItemIndex } from '../ItemIndex'; + +export type MessageReplyPaginatorFilter = { + cid: string; + parent_id: string; +}; + +const DEFAULT_PAGE_SIZE = 50; + +const DEFAULT_BACKEND_SORT: MessagePaginatorSort = { + created_at: 1, +}; + +export type MessageReplyQueryShape = { + options: MessagePaginationOptions | PinnedMessagePaginationOptions; + sort: MessagePaginatorSort; +}; + +const getQueryShapeRelevantMessageOptions = ( + options: MessagePaginationOptions, +): Omit => { + const { + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + limit: _, + ...relevantOptions + } = options; + return relevantOptions; +}; + +const hasPaginationQueryShapeChanged: PaginationQueryShapeChangeIdentifier< + MessageReplyQueryShape +> = (prevQueryShape, nextQueryShape) => + !isEqual( + { + ...prevQueryShape, + options: getQueryShapeRelevantMessageOptions(prevQueryShape?.options ?? {}), + }, + { + ...nextQueryShape, + options: getQueryShapeRelevantMessageOptions(nextQueryShape?.options ?? {}), + }, + ); + +const dataFieldFilterResolver: FieldToDataResolver = { + matchesField: () => true, + resolve: (message, path) => resolveDotPathValue(message, path), +}; + +export type MessageReplyPaginatorOptions = Omit< + MessagePaginatorOptions, + 'paginatorOptions' +> & { + parentMessageId: string; + paginatorOptions?: PaginatorOptions; +}; + +export class MessageReplyPaginator extends BasePaginator< + LocalMessage, + MessageReplyQueryShape +> { + private readonly _id: string; + private channel: Channel; + protected _parentMessageId: string; + protected _sort = DEFAULT_BACKEND_SORT; + protected _nextQueryShape: MessageReplyQueryShape | undefined; + sortComparator: (a: LocalMessage, b: LocalMessage) => number; + + protected get intervalItemIdsAreHeadFirst(): boolean { + // Replies are stored in chronological order (created_at asc) within an interval. + // Pagination "head" (newest side) is therefore at the END of the `itemIds` array. + return false; + } + + protected get intervalSortDirection(): 'asc' | 'desc' { + // Head edge is newest, but sortComparator is created_at asc => newer head edges + // should come first => reverse interval ordering. + return 'desc'; + } + + constructor({ + channel, + id, + itemIndex = new ItemIndex({ getId: (item) => item.id }), + paginatorOptions, + parentMessageId, + }: MessageReplyPaginatorOptions) { + super({ + hasPaginationQueryShapeChanged, + initialCursor: ZERO_PAGE_CURSOR, + itemIndex, + ...paginatorOptions, + pageSize: paginatorOptions?.pageSize ?? DEFAULT_PAGE_SIZE, + }); + const definedSort = DEFAULT_BACKEND_SORT; + this.channel = channel; + this._parentMessageId = parentMessageId; + this._id = id ?? `message-reply-paginator-${generateUUIDv4()}`; + this._sort = definedSort; + this.sortComparator = makeComparator({ + sort: this._sort, + resolvePathValue: resolveDotPathValue, + tiebreaker: (l, r) => { + const leftId = this.getItemId(l); + const rightId = this.getItemId(r); + return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; + }, + }); + this.setFilterResolvers([dataFieldFilterResolver]); + } + + get id() { + return this._id; + } + + get sort() { + return this._sort ?? DEFAULT_BACKEND_SORT; + } + + /** + * Even though we do not send filters object to the server, we need to have filters for client-side item ingestion logic. + */ + buildFilters = (): MessageReplyPaginatorFilter => ({ + cid: this.channel.cid, + parent_id: this._parentMessageId, + }); + + // invoked inside BasePaginator.executeQuery() to keep it as a query descriptor; + protected getNextQueryShape({ + direction, + }: PaginationQueryParams): MessageReplyQueryShape { + return { + options: { + limit: this.pageSize, + [direction === 'tailward' ? 'id_lt' : 'id_gt']: + direction && this.cursor?.[direction], + }, + sort: this._sort, + }; + } + + query = async ({ + direction, + queryShape, + }: PaginationQueryParams): Promise< + PaginationQueryReturnValue + > => { + if (!queryShape) { + queryShape = this.getNextQueryShape({ direction }); + } + const { sort, options } = queryShape; + let items: LocalMessage[]; + let tailward: string | undefined; + let headward: string | undefined; + if (this.config.doRequest) { + const result = await this.config.doRequest({ + options, + sort: Array.isArray(sort) ? sort : [sort], + }); + items = result?.items ?? []; + // if there is no direction, then we are jumping, and we want to set both directions in the cursor + tailward = + !direction || direction === 'tailward' + ? (result.cursor?.tailward ?? undefined) + : undefined; + headward = + !direction || direction === 'headward' + ? (result.cursor?.headward ?? undefined) + : undefined; + } else { + const { messages } = await this.channel.getReplies( + this._parentMessageId, + options, + Array.isArray(sort) ? sort : [sort], + ); + items = messages.map(formatMessage); + // if there is no direction, then we are jumping, and we want to set both directions in the cursor + tailward = !direction || direction === 'tailward' ? messages[0].id : undefined; + headward = + !direction || direction === 'headward' ? messages.slice(-1)[0].id : undefined; + } + + return { items, headward, tailward }; + }; + + isJumpQueryShape(queryShape: MessageReplyQueryShape): boolean { + return ( + !!queryShape?.options?.id_around || + !!(queryShape.options as MessagePaginationOptions)?.created_at_around + ); + } + + /** + * Jump to a message inside thread replies. + * + * Mirrors `MessagePaginator.jumpToMessage` behavior: + * - If the message is already present in the item index and belongs to an existing interval, + * it activates that interval without querying. + * - Otherwise, performs an `id_around` query and ensures the item is present. + */ + jumpToMessage = async ( + messageId: string, + { pageSize }: JumpToMessageOptions = {}, + ): Promise => { + let localMessage = this.getItem(messageId); + let interval: AnyInterval | undefined; + let state: Partial> | undefined; + + if (localMessage) { + interval = this.locateIntervalForItem(localMessage); + } + + if (!localMessage || !interval || isLogicalInterval(interval)) { + const result = await this.executeQuery({ + queryShape: { + options: { id_around: messageId, limit: pageSize }, + sort: this.sort, + }, + updateState: false, + }); + + localMessage = this.getItem(messageId); + if (!localMessage || !result || !result.targetInterval) { + this.channel.getClient().notifications.addError({ + message: 'Jump to message unsuccessful', + origin: { + emitter: 'MessageReplyPaginator.jumpToMessage', + context: { messageId, parentMessageId: this._parentMessageId }, + }, + options: { type: 'api:replies:query:failed' }, + }); + return false; + } + interval = result.targetInterval; + state = result.stateCandidate; + } + + if (!this.isActiveInterval(interval)) { + this.setActiveInterval(interval); + if (state) this.state.partialNext(state); + } + + return true; + }; + + jumpToTheLatestMessage = async (options?: JumpToMessageOptions): Promise => { + let latestMessageId: string | undefined; + const intervals = this.itemIntervals; + + if (!(intervals[0] as Interval)?.isHead) { + // get the first page (in case the pagination has not started at the head) + await this.executeQuery({ updateState: false }); + } + + const headInterval = intervals[0]; + if ((intervals[0] as Interval)?.isHead) { + latestMessageId = headInterval.itemIds.slice(-1)[0]; + } + + if (!latestMessageId) { + this.channel.getClient().notifications.addError({ + message: 'Jump to latest message unsuccessful', + origin: { emitter: 'MessageReplyPaginator.jumpToTheLatestMessage' }, + options: { type: 'api:message:replies:query:failed' }, + }); + return false; + } + + return await this.jumpToMessage(latestMessageId, options); + }; + + filterQueryResults = (items: LocalMessage[]) => items; +} diff --git a/src/pagination/paginators/ReminderPaginator.ts b/src/pagination/paginators/ReminderPaginator.ts index 8cf23b914d..8c50224523 100644 --- a/src/pagination/paginators/ReminderPaginator.ts +++ b/src/pagination/paginators/ReminderPaginator.ts @@ -1,4 +1,4 @@ -import { BasePaginator } from './BasePaginator'; +import { BasePaginator, ZERO_PAGE_CURSOR } from './BasePaginator'; import type { PaginationQueryParams, PaginationQueryReturnValue, @@ -42,7 +42,7 @@ export class ReminderPaginator extends BasePaginator< client: StreamChat, options?: PaginatorOptions, ) { - super(options); + super({ initialCursor: ZERO_PAGE_CURSOR, ...options }); this.client = client; } @@ -66,7 +66,7 @@ export class ReminderPaginator extends BasePaginator< PaginationQueryReturnValue > => { const { reminders: items, next, prev } = await this.client.queryReminders(queryShape); - return { items, next, prev }; + return { items, headward: prev, tailward: next }; }; filterQueryResults = (items: ReminderResponse[]) => items; diff --git a/src/pagination/paginators/index.ts b/src/pagination/paginators/index.ts index 1c5fbb4d44..03cd6bae39 100644 --- a/src/pagination/paginators/index.ts +++ b/src/pagination/paginators/index.ts @@ -1,3 +1,5 @@ export * from './BasePaginator'; export * from './ChannelPaginator'; +export * from './MessagePaginator'; +export * from './MessageReplyPaginator'; export * from './ReminderPaginator'; diff --git a/src/pagination/sortCompiler.ts b/src/pagination/sortCompiler.ts index 15a6c8ccaa..b4b05aaf92 100644 --- a/src/pagination/sortCompiler.ts +++ b/src/pagination/sortCompiler.ts @@ -10,8 +10,8 @@ import type { AscDesc } from '../types'; import type { Comparator, PathResolver } from './types.normalization'; export type ItemLocation = { - expected: number; - current: number; + currentIndex: number; + insertionIndex: number; }; /** @@ -47,7 +47,7 @@ export function binarySearch({ plateauScan?: boolean; }): ItemLocation { // empty array - if (length === 0) return { current: -1, expected: 0 }; + if (length === 0) return { currentIndex: -1, insertionIndex: 0 }; // --- 1) Binary search to find lower bound (insertionIndex) --- let lo = 0; @@ -59,11 +59,10 @@ export function binarySearch({ if (!midItem) { // Corruption: we have an ID but no backing item. // Bail out with "not found". - return { current: -1, expected: -1 }; + return { currentIndex: -1, insertionIndex: -1 }; } - const cmp = compare(midItem, needle); - if (cmp < 0) { + if (compare(midItem, needle) <= 0) { // midItem < needle ⇒ go right lo = mid + 1; } else { @@ -72,14 +71,16 @@ export function binarySearch({ } } - const expected = lo; + const insertionIndex = lo; // item is located where it is expected to be according to the sort - const itemAtExpectedIndex = getItemAt(expected); + const itemAtExpectedIndex = getItemAt(insertionIndex); if (itemAtExpectedIndex && itemIdentityEquals(itemAtExpectedIndex, needle)) { - return { current: expected, expected }; - } else if (!plateauScan) { - return { current: -1, expected }; + return { currentIndex: insertionIndex, insertionIndex }; + } + + if (!plateauScan) { + return { currentIndex: -1, insertionIndex }; } // --- 2) Plateau scan around insertionIndex --- @@ -87,43 +88,33 @@ export function binarySearch({ const checkSide = (atIndex: number) => { const result = { exhausted: false, found: false }; const item = getItemAt(atIndex); - if (!item) { - result.exhausted = true; - } else { - const cmp = compare(item, needle); - if (cmp !== 0) { - result.exhausted = true; - } else { - if (itemIdentityEquals(item, needle)) { - result.found = true; - } - } - } + if (!item) result.exhausted = true; + else if (itemIdentityEquals(item, needle)) result.found = true; return result; }; // Alternating left/right scan - let iLeft = expected - 1; - let iRight = expected + 1; // we've already checked insertionIndex + let iLeft = insertionIndex - 1; + let iRight = insertionIndex + 1; // we've already checked insertionIndex let leftDone = iLeft < 0; let rightDone = iRight >= length; while (!leftDone || !rightDone) { if (!leftDone) { const result = checkSide(iLeft); - if (result.found) return { current: iLeft, expected }; + if (result.found) return { currentIndex: iLeft, insertionIndex }; leftDone = result.exhausted || --iLeft < 0; } if (!rightDone) { const result = checkSide(iRight); - if (result.found) return { current: iRight, expected }; + if (result.found) return { currentIndex: iRight, insertionIndex }; rightDone = result.exhausted || ++iRight >= length; } } // Not found in plateau; insertion index is still the correct lower bound. - return { current: -1, expected }; + return { currentIndex: -1, insertionIndex }; } /** diff --git a/src/reminders/ReminderManager.ts b/src/reminders/ReminderManager.ts index 8c4dac1b5d..95b12fefa5 100644 --- a/src/reminders/ReminderManager.ts +++ b/src/reminders/ReminderManager.ts @@ -287,11 +287,11 @@ export class ReminderManager extends WithSubscriptions { }; queryNextReminders = async () => { - await this.paginator.next(); + await this.paginator.toTail(); }; queryPreviousReminders = async () => { - await this.paginator.prev(); + await this.paginator.toHead(); }; // API calls END // diff --git a/src/thread.ts b/src/thread.ts index bf6f778121..9c0cdcf627 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -15,11 +15,17 @@ import type { ThreadResponse, UserResponse, } from './types'; -import type { Channel } from './channel'; +import type { + Channel, + SendMessageWithStateUpdateParams, + UpdateMessageWithStateUpdateParams, +} from './channel'; import type { StreamChat } from './client'; import type { CustomThreadData } from './custom_types'; import { MessageComposer } from './messageComposer'; +import { MessageOperations } from './messageOperations'; import { WithSubscriptions } from './utils/WithSubscriptions'; +import { MessagePaginator } from './pagination'; type QueryRepliesOptions = { sort?: { created_at: AscDesc }[]; @@ -113,6 +119,8 @@ export class Thread extends WithSubscriptions { public readonly state: StateStore; public readonly id: string; public readonly messageComposer: MessageComposer; + public readonly messagePaginator: MessagePaginator; + public readonly messageOperations: MessageOperations; private client: StreamChat; private failedRepliesMap: Map = new Map(); @@ -175,11 +183,62 @@ export class Thread extends WithSubscriptions { this.id = threadData.parent_message_id; this.client = client; + this.messagePaginator = new MessagePaginator({ channel: this.channel }); // todo: pass Thread instance this.messageComposer = new MessageComposer({ client, composition: threadData.draft, compositionContext: this, }); + + this.messageOperations = new MessageOperations({ + ingest: (m) => this.messagePaginator.ingestItem(m), + get: (id) => this.messagePaginator.getItem(id), + normalizeOutgoingMessage: (m) => ({ + ...m, + parent_id: this.id, + }), + handlers: () => { + const { requestHandlers } = this.channel.configState.getLatestValue(); + const sendMessageRequest = requestHandlers?.sendMessageRequest; + const retrySendMessageRequest = requestHandlers?.retrySendMessageRequest; + const updateMessageRequest = requestHandlers?.updateMessageRequest; + return { + send: sendMessageRequest + ? (p) => + sendMessageRequest({ + localMessage: p.localMessage, + message: p.message, + options: p.options, + }) + : undefined, + retry: retrySendMessageRequest + ? (p) => + retrySendMessageRequest({ + localMessage: p.localMessage, + message: p.message, + options: p.options, + }) + : undefined, + update: updateMessageRequest + ? (p) => + updateMessageRequest({ + localMessage: p.localMessage, + options: p.options, + }) + : undefined, + }; + }, + defaults: { + send: async (m, o) => { + const result = await this.channel.sendMessage(m, o); + return { message: result.message }; + }, + update: async (m, o) => { + const result = await this.channel.getClient().updateMessage(m, undefined, o); + return { message: result.message }; + }, + }, + }); } get channel() { @@ -489,6 +548,7 @@ export class Thread extends WithSubscriptions { const formattedMessage = formatMessage(message); + // todo: do we really need to keep the failedRepliesMap? if (message.status === 'failed') { // store failed reply so that it's not lost when reloading or hydrating this.failedRepliesMap.set(formattedMessage.id, formattedMessage); @@ -529,6 +589,57 @@ export class Thread extends WithSubscriptions { } }; + /** + * Sends a message with optimistic local state update. + */ + async sendMessageWithLocalUpdate({ + localMessage, + message, + options, + sendMessageRequestFn, + }: SendMessageWithStateUpdateParams): Promise { + await this.messageOperations.send( + { + localMessage, + message, + options, + }, + sendMessageRequestFn, + ); + } + + /** + * Retry sending a failed message. + */ + async retrySendMessageWithLocalUpdate( + params: Omit, + ) { + await this.messageOperations.retry( + { + localMessage: { ...params.localMessage, type: 'regular' }, + options: params.options, + }, + params.sendMessageRequestFn, + ); + } + + /** + * Updates a message with optimistic local state update. + * + * NOTE: This updates message state via `messagePaginator` only. If you still rely on + * `Thread.state.replies` as UI source of truth, make sure it is wired to paginator updates + * (or keep upserting separately until migration is complete). + */ + async updateMessageWithLocalUpdate(params: UpdateMessageWithStateUpdateParams) { + await this.messageOperations.update( + { + localMessage: params.localMessage, + options: params.options, + }, + params.updateMessageRequestFn, + ); + } + public markAsRead = async ({ force = false }: { force?: boolean } = {}) => { if (this.ownUnreadCount === 0 && !force) { return null; diff --git a/test/unit/ChannelPaginatorsOrchestrator.test.ts b/test/unit/ChannelPaginatorsOrchestrator.test.ts index 28bde42cd1..dc50dc8cd9 100644 --- a/test/unit/ChannelPaginatorsOrchestrator.test.ts +++ b/test/unit/ChannelPaginatorsOrchestrator.test.ts @@ -188,26 +188,26 @@ describe('ChannelPaginatorsOrchestrator', () => { ownershipResolver: [p2.id], }); - await Promise.all([p1, p2].map((p) => p.next())); + await Promise.all([p1, p2].map((p) => p.toTail())); await vi.waitFor(() => { expect(p1.items).toHaveLength(0); // even though ownership claimed by p2, it is still possible to request next page. - expect(p1.hasNext).toBe(true); + expect(p1.hasMoreTail).toBe(true); expect(p2.items).toHaveLength(1); expect(p2.items).toStrictEqual([ch1]); - expect(p2.hasNext).toBe(true); + expect(p2.hasMoreTail).toBe(true); }); queryChannelSpy.mockResolvedValue([ch2]); - await Promise.all([p1, p2].map((p) => p.next())); + await Promise.all([p1, p2].map((p) => p.toTail())); await vi.waitFor(() => { expect(p1.items).toHaveLength(0); - expect(p1.hasNext).toBe(true); + expect(p1.hasMoreTail).toBe(true); expect(p2.items).toHaveLength(2); expect(p2.items).toStrictEqual([ch1, ch2]); - expect(p2.hasNext).toBe(true); + expect(p2.hasMoreTail).toBe(true); }); }); }); @@ -702,8 +702,12 @@ describe('ChannelPaginatorsOrchestrator', () => { const p1 = new ChannelPaginator({ client }); const p2 = new ChannelPaginator({ client }); p1.state.partialNext({ items: [ch] }); - vi.spyOn(p1, 'findItem').mockReturnValue(ch); - vi.spyOn(p2, 'findItem').mockReturnValue(undefined); + vi.spyOn(p1, 'locateByItem').mockReturnValue({ + state: { currentIndex: 0, insertionIndex: 1 }, + }); + vi.spyOn(p2, 'locateByItem').mockReturnValue({ + state: { currentIndex: -1, insertionIndex: 1 }, + }); const partialNextSpy1 = vi.spyOn(p1.state, 'partialNext'); const partialNextSpy2 = vi.spyOn(p2.state, 'partialNext'); @@ -737,7 +741,9 @@ describe('ChannelPaginatorsOrchestrator', () => { const p = new ChannelPaginator({ client }); const matchesFilterSpy = vi.spyOn(p, 'matchesFilter').mockReturnValue(true); const ingestItemSpy = vi.spyOn(p, 'ingestItem').mockReturnValue(true); - const removeItemSpy = vi.spyOn(p, 'removeItem').mockReturnValue(true); + const removeItemSpy = vi + .spyOn(p, 'removeItem') + .mockReturnValue({ state: { currentIndex: 0, insertionIndex: 1 } }); orchestrator.insertPaginator({ paginator: p }); orchestrator.registerSubscriptions(); @@ -762,7 +768,9 @@ describe('ChannelPaginatorsOrchestrator', () => { const orchestrator = new ChannelPaginatorsOrchestrator({ client }); const p = new ChannelPaginator({ client }); - const removeItemSpy = vi.spyOn(p, 'removeItem').mockReturnValue(true); + const removeItemSpy = vi + .spyOn(p, 'removeItem') + .mockReturnValue({ state: { currentIndex: 0, insertionIndex: -1 } }); const ingestItemSpy = vi.spyOn(p, 'ingestItem').mockReturnValue(true); vi.spyOn(p, 'matchesFilter').mockReturnValue(true); orchestrator.insertPaginator({ paginator: p }); @@ -793,7 +801,9 @@ describe('ChannelPaginatorsOrchestrator', () => { const p = new ChannelPaginator({ client }); - const removeItemSpy = vi.spyOn(p, 'removeItem').mockReturnValue(true); + const removeItemSpy = vi + .spyOn(p, 'removeItem') + .mockReturnValue({ state: { currentIndex: 0, insertionIndex: -1 } }); const ingestItemSpy = vi.spyOn(p, 'ingestItem').mockReturnValue(true); vi.spyOn(p, 'matchesFilter').mockReturnValue(true); @@ -817,7 +827,9 @@ describe('ChannelPaginatorsOrchestrator', () => { const p = new ChannelPaginator({ client }); - const removeItemSpy = vi.spyOn(p, 'removeItem').mockReturnValue(true); + const removeItemSpy = vi + .spyOn(p, 'removeItem') + .mockReturnValue({ state: { currentIndex: 0, insertionIndex: -1 } }); const ingestItemSpy = vi.spyOn(p, 'ingestItem').mockReturnValue(true); vi.spyOn(p, 'matchesFilter').mockReturnValue(false); diff --git a/test/unit/EventHandlerPipeline.test.ts b/test/unit/EventHandlerPipeline.test.ts index de47aaf6f3..8ac43ee7cf 100644 --- a/test/unit/EventHandlerPipeline.test.ts +++ b/test/unit/EventHandlerPipeline.test.ts @@ -547,9 +547,9 @@ describe('EventHandlerPipeline', () => { }, }; const head = { - id: 'head', + id: 'isHead', handle: () => { - order.push('head'); + order.push('isHead'); }, }; const inserter = { @@ -561,9 +561,9 @@ describe('EventHandlerPipeline', () => { }, }; const tail = { - id: 'tail', + id: 'isTail', handle: () => { - order.push('tail'); + order.push('isTail'); }, }; @@ -574,14 +574,14 @@ describe('EventHandlerPipeline', () => { // @ts-expect-error passing custom event type await pipeline.run(makeEvt('e1'), ctx); // 'late' must NOT run for e1 - expect(order).toEqual(['head', 'inserter', 'tail']); + expect(order).toEqual(['isHead', 'inserter', 'isTail']); order.length = 0; // @ts-expect-error passing custom event type await pipeline.run(makeEvt('e2'), ctx); // For the next event, late is present - expect(order).toEqual(['head', 'inserter', 'tail', 'late']); + expect(order).toEqual(['isHead', 'inserter', 'isTail', 'late']); }); }); }); diff --git a/test/unit/MessageComposer/messageComposer.test.ts b/test/unit/MessageComposer/messageComposer.test.ts index dbf03325d7..724b968e2c 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -5,6 +5,7 @@ import { ChannelAPIResponse, ChannelConfigWithInfo, ChannelResponse, + DEFAULT_COMPOSER_CONFIG, LocalMessage, MessageComposerConfig, StaticLocationPayload, @@ -15,6 +16,7 @@ import { DeepPartial } from '../../../src/types.utility'; import { MessageComposer } from '../../../src/messageComposer/messageComposer'; import { DraftResponse, MessageResponse } from '../../../src/types'; import { MockOfflineDB } from '../offline-support/MockOfflineDB'; +import { generateMsg } from '../test-utils/generateMessage'; const generateUuidV4Output = 'test-uuid'; // Mock dependencies @@ -168,7 +170,7 @@ describe('MessageComposer', () => { const { messageComposer, mockChannel } = setup(); expect(messageComposer).toBeDefined(); expect(messageComposer.channel).toBe(mockChannel); - expect(messageComposer.config).toBeDefined(); + expect(messageComposer.config).toStrictEqual(DEFAULT_COMPOSER_CONFIG); expect(messageComposer.attachmentManager).toBeDefined(); expect(messageComposer.linkPreviewsManager).toBeDefined(); expect(messageComposer.textComposer).toBeDefined(); @@ -178,16 +180,45 @@ describe('MessageComposer', () => { it('should initialize with custom config', () => { const customConfig: DeepPartial = { + attachments: { + maxNumberOfFilesPerMessage: 1, + }, + drafts: { enabled: true }, + linkPreviews: { debounceURLEnrichmentMs: 20 }, + location: { enabled: false }, text: { maxLengthOnEdit: 1000, publishTypingEvents: false, }, + sendMessageRequestFn: () => Promise.resolve({ message: generateMsg() }), }; const { messageComposer } = setup({ config: customConfig }); - expect(messageComposer.config.text.publishTypingEvents).toBe(false); - expect(messageComposer.config.text?.maxLengthOnEdit).toBe(1000); + expect(messageComposer.config).toStrictEqual({ + attachments: { + acceptedFiles: DEFAULT_COMPOSER_CONFIG.attachments.acceptedFiles, + fileUploadFilter: DEFAULT_COMPOSER_CONFIG.attachments.fileUploadFilter, + maxNumberOfFilesPerMessage: + customConfig.attachments!.maxNumberOfFilesPerMessage, + }, + drafts: customConfig.drafts, + linkPreviews: { + debounceURLEnrichmentMs: customConfig.linkPreviews!.debounceURLEnrichmentMs, + enabled: DEFAULT_COMPOSER_CONFIG.linkPreviews.enabled, + findURLFn: DEFAULT_COMPOSER_CONFIG.linkPreviews.findURLFn, + }, + location: { + enabled: customConfig.location!.enabled, + getDeviceId: DEFAULT_COMPOSER_CONFIG.location!.getDeviceId, + }, + sendMessageRequestFn: customConfig.sendMessageRequestFn, + text: { + enabled: DEFAULT_COMPOSER_CONFIG.text.enabled, + maxLengthOnEdit: customConfig.text!.maxLengthOnEdit, + publishTypingEvents: customConfig.text!.publishTypingEvents, + }, + }); }); it('should initialize with custom config overridden with back-end configuration', () => { @@ -1024,6 +1055,28 @@ describe('MessageComposer', () => { expect(result).toBeUndefined(); }); + describe('sendMessage', () => { + it.fails('performs optimistic update before sending the message'); + it.fails( + 'updates the message in state after successful response if message has not arrived over WS', + ); + it.fails( + 'does not update the message in state after successful response if message has arrived over WS and the update timestamp is <= existing message timestamp', + ); + it.fails( + 'does not update the message in state if it already exists on the server and in the local state as not delivered', + ); + it.fails( + 'does not update the message in state if it already exists on the server and in the local state as not failed', + ); + it.fails( + 'updates the message in state if it already exists on the server and in the local state with status sending', + ); + it.fails( + 'updates the message in state if it does not exist on the server and the send request failed', + ); + }); + it('should compose draft', async () => { const { messageComposer } = setup(); const mockResult = { diff --git a/test/unit/messageOperations/MessageOperations.test.ts b/test/unit/messageOperations/MessageOperations.test.ts new file mode 100644 index 0000000000..2f588bcd7c --- /dev/null +++ b/test/unit/messageOperations/MessageOperations.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'vitest'; +import { MessageOperations } from '../../../src/messageOperations/MessageOperations'; +import type { LocalMessage, Message, MessageResponse } from '../../../src/types'; + +type Store = Map; + +const makeLocalMessage = (overrides?: Partial): LocalMessage => + ({ + attachments: [], + created_at: new Date(), + deleted_at: null, + id: 'm1', + mentioned_users: [], + pinned_at: null, + reaction_groups: null, + status: 'failed', + text: 'hi', + type: 'regular', + updated_at: new Date(), + ...overrides, + }) as LocalMessage; + +const makeMessageResponse = (overrides?: Partial): MessageResponse => + ({ + id: 'm1', + text: 'hi', + type: 'regular', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }) as MessageResponse; + +describe('MessageOperations', () => { + it('marks optimistic message as sending, then ingests received response', async () => { + const store: Store = new Map(); + + const ops = new MessageOperations({ + ingest: (m) => store.set(m.id, m), + get: (id) => store.get(id), + handlers: () => ({}), + defaults: { + send: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + update: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + }, + }); + + const localMessage = makeLocalMessage({ id: 'm1', status: 'failed' }); + await ops.send({ localMessage }); + + expect(store.get('m1')?.status).toBe('received'); + }); + + it('uses per-call requestFn override for send', async () => { + const store: Store = new Map(); + + const ops = new MessageOperations({ + ingest: (m) => store.set(m.id, m), + get: (id) => store.get(id), + handlers: () => ({}), + defaults: { + send: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + update: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + }, + }); + + const localMessage = makeLocalMessage({ id: 'm1' }); + + await ops.send({ localMessage }, async () => ({ + message: makeMessageResponse({ id: 'm1', text: 'override' }), + })); + + expect(store.get('m1')?.text).toBe('override'); + }); + + it('marks as received on duplicate send error (already exists)', async () => { + const store: Store = new Map(); + + const ops = new MessageOperations({ + ingest: (m) => store.set(m.id, m), + get: (id) => store.get(id), + handlers: () => ({}), + defaults: { + send: async () => { + throw Object.assign(new Error('message already exists'), { code: 4 }); + }, + update: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + }, + }); + + const localMessage = makeLocalMessage({ id: 'm1', status: 'failed' }); + + await expect(ops.send({ localMessage })).rejects.toThrow(); + expect(store.get('m1')?.status).toBe('received'); + }); + + it('marks as failed on non-duplicate error', async () => { + const store: Store = new Map(); + + const ops = new MessageOperations({ + ingest: (m) => store.set(m.id, m), + get: (id) => store.get(id), + handlers: () => ({}), + defaults: { + send: async () => { + throw new Error('nope'); + }, + update: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + }, + }); + + const localMessage = makeLocalMessage({ id: 'm1', status: 'failed' }); + + await expect(ops.send({ localMessage })).rejects.toThrow('nope'); + expect(store.get('m1')?.status).toBe('failed'); + }); + + it('normalizes outgoing message for send', async () => { + const store: Store = new Map(); + + const ops = new MessageOperations({ + ingest: (m) => store.set(m.id, m), + get: (id) => store.get(id), + normalizeOutgoingMessage: (m) => ({ ...m, parent_id: 't1' }), + handlers: () => ({ + send: async (p) => { + expect(p.message?.parent_id).toBe('t1'); + return { message: makeMessageResponse({ id: p.localMessage.id }) }; + }, + }), + defaults: { + send: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + update: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + }, + }); + + const localMessage = makeLocalMessage({ id: 'm1' }); + const message = { id: 'm1', text: 'hi' } as unknown as Message; + + await ops.send({ localMessage, message }); + expect(store.get('m1')?.status).toBe('received'); + }); + + it('update passes only supported options (skip_enrich_url / skip_push) to defaults.update', async () => { + const store: Store = new Map(); + + let seenOptions: unknown = 'unset'; + + const ops = new MessageOperations({ + ingest: (m) => store.set(m.id, m), + get: (id) => store.get(id), + handlers: () => ({}), + defaults: { + send: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + update: async (_m, options) => { + seenOptions = options; + return { message: makeMessageResponse({ id: 'm1' }) }; + }, + }, + }); + + const localMessage = makeLocalMessage({ id: 'm1', status: 'received' }); + + await ops.update({ + localMessage, + options: { + // known fields + skip_enrich_url: true, + skip_push: false, + // @ts-expect-error extra fields should be dropped by MessageOperations.update + force_moderation: true, + }, + }); + + expect(seenOptions).toEqual({ + skip_enrich_url: true, + skip_push: false, + }); + }); + + it('update passes undefined options to defaults.update when params.options is undefined', async () => { + const store: Store = new Map(); + + let seenOptions: unknown = 'unset'; + + const ops = new MessageOperations({ + ingest: (m) => store.set(m.id, m), + get: (id) => store.get(id), + handlers: () => ({}), + defaults: { + send: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + update: async (_m, options) => { + seenOptions = options; + return { message: makeMessageResponse({ id: 'm1' }) }; + }, + }, + }); + + const localMessage = makeLocalMessage({ id: 'm1', status: 'received' }); + + await ops.update({ localMessage }); + expect(seenOptions).toBeUndefined(); + }); +}); diff --git a/test/unit/pagination/BasePaginator.test.ts b/test/unit/pagination/BasePaginator.test.ts deleted file mode 100644 index f82516e31e..0000000000 --- a/test/unit/pagination/BasePaginator.test.ts +++ /dev/null @@ -1,1544 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { - AscDesc, - BasePaginator, - DEFAULT_PAGINATION_OPTIONS, - PaginationQueryParams, - PaginationQueryReturnValue, - PaginatorCursor, - type PaginatorOptions, - PaginatorState, - PrimitiveFilter, - QueryFilter, - QueryFilters, - RequireOnlyOne, -} from '../../../src'; -import { sleep } from '../../../src/utils'; -import { makeComparator } from '../../../src/pagination/sortCompiler'; -import { DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES } from '../../../src/constants'; - -const toNextTick = async () => { - const sleepPromise = sleep(0); - vi.advanceTimersByTime(0); - await sleepPromise; -}; - -type TestItem = { - id: string; - name?: string; - teams?: string[]; - blocked?: boolean; - createdAt?: string; // date string - age?: number; -}; - -type QueryShape = { - filters: { - [Key in keyof TestItem]: - | RequireOnlyOne> - | PrimitiveFilter; - }; - sort: { [Key in keyof TestItem]?: AscDesc }; -}; - -class IncompletePaginator extends BasePaginator { - sort: QueryFilters | undefined; - sortComparator: (a: TestItem, b: TestItem) => number = vi.fn(); - queryResolve: Function = vi.fn(); - queryReject: Function = vi.fn(); - queryPromise: Promise> | null = null; - mockClientQuery = vi.fn(); - - constructor(options: PaginatorOptions = {}) { - super(options); - } - - query( - params: PaginationQueryParams, - ): Promise> { - const promise = new Promise>( - (queryResolve, queryReject) => { - this.queryResolve = queryResolve; - this.queryReject = queryReject; - }, - ); - this.mockClientQuery(params); - this.queryPromise = promise; - return promise; - } - - filterQueryResults(items: TestItem[]): TestItem[] | Promise { - return items; - } -} - -const defaultNextQueryShape: QueryShape = { filters: { id: 'test-id' }, sort: { id: 1 } }; - -class Paginator extends IncompletePaginator { - constructor(options: PaginatorOptions = {}) { - super(options); - } - - getNextQueryShape = vi.fn().mockReturnValue(defaultNextQueryShape); -} - -describe('BasePaginator', () => { - describe('constructor', () => { - it('initiates with the defaults', () => { - const paginator = new Paginator(); - expect(paginator.state.getLatestValue()).toEqual({ - hasNext: true, - hasPrev: true, - isLoading: false, - items: undefined, - lastQueryError: undefined, - cursor: undefined, - offset: 0, - }); - expect(paginator.isInitialized).toBe(false); - // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toHaveLength(0); - expect(paginator.config.initialCursor).toBeUndefined(); - expect(paginator.config.initialOffset).toBeUndefined(); - expect(paginator.config.throwErrors).toBe(false); - expect(paginator.pageSize).toBe(DEFAULT_PAGINATION_OPTIONS.pageSize); - expect(paginator.config.debounceMs).toBe(DEFAULT_PAGINATION_OPTIONS.debounceMs); - expect(paginator.config.lockItemOrder).toBe( - DEFAULT_PAGINATION_OPTIONS.lockItemOrder, - ); - expect(paginator.config.hasPaginationQueryShapeChanged).toBe( - DEFAULT_PAGINATION_OPTIONS.hasPaginationQueryShapeChanged, - ); - }); - - it('initiates with custom options', () => { - const options: PaginatorOptions = { - debounceMs: DEFAULT_PAGINATION_OPTIONS.debounceMs - 100, - doRequest: () => Promise.resolve({ items: [{ id: 'test-id' }] }), - hasPaginationQueryShapeChanged: () => true, - initialCursor: { next: 'next', prev: 'prev' }, - initialOffset: 10, - lockItemOrder: !DEFAULT_PAGINATION_OPTIONS.lockItemOrder, - pageSize: DEFAULT_PAGINATION_OPTIONS.pageSize - 1, - throwErrors: true, - }; - const paginator = new Paginator(options); - expect(paginator.state.getLatestValue()).toEqual({ - hasNext: true, - hasPrev: true, - isLoading: false, - items: undefined, - lastQueryError: undefined, - cursor: options.initialCursor, - offset: options.initialOffset, - }); - expect(paginator.isInitialized).toBe(false); - // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toHaveLength(0); - expect(paginator.config.initialCursor).toStrictEqual(options.initialCursor); - expect(paginator.config.initialOffset).toStrictEqual(options.initialOffset); - expect(paginator.config.throwErrors).toBe(options.throwErrors); - expect(paginator.pageSize).toBe(options.pageSize); - expect(paginator.config.hasPaginationQueryShapeChanged).toStrictEqual( - options.hasPaginationQueryShapeChanged, - ); - expect(paginator.config.debounceMs).toBe(options.debounceMs); - expect(paginator.config.lockItemOrder).toBe(options.lockItemOrder); - }); - }); - - describe('pagination API', () => { - it('throws is the paginator does implement own getNextQueryShape', () => { - const paginator = new IncompletePaginator(); - // @ts-expect-error accessing protected property - expect(paginator.getNextQueryShape).toThrow( - 'Paginator.getNextQueryShape() is not implemented', - ); - }); - - describe('shouldResetStateBeforeQuery', () => { - const stateBeforeQuery: PaginatorState = { - hasNext: true, - hasPrev: true, - isLoading: false, - items: [{ id: 'test-item' }], - lastQueryError: undefined, - cursor: { next: 'next', prev: 'prev' }, - offset: 10, - }; - - const prevQueryShape: QueryShape = { filters: { id: 'a' }, sort: { id: 1 } }; - const nextQueryShape: QueryShape = { filters: { id: 'b' }, sort: { id: 1 } }; - - it('resets the state before a query when querying the first page', () => { - const paginator = new Paginator(); - const initialState = { ...stateBeforeQuery, items: undefined }; - paginator.state.next(initialState); - expect(paginator.state.getLatestValue()).toEqual(initialState); - // @ts-expect-error accessing protected property - expect(paginator.shouldResetStateBeforeQuery()).toBe(true); - }); - - it('resets the state before a query when query shape changed', () => { - const prevQueryShape: QueryShape = { filters: { id: 'a' }, sort: { id: 1 } }; - const nextQueryShape: QueryShape = { filters: { id: 'b' }, sort: { id: 1 } }; - const paginator = new Paginator(); - expect( - // @ts-expect-error accessing protected property - paginator.shouldResetStateBeforeQuery(prevQueryShape, nextQueryShape), - ).toBe(true); - expect( - // @ts-expect-error accessing protected property - paginator.shouldResetStateBeforeQuery(prevQueryShape, prevQueryShape), - ).toBe(false); - }); - - it('determines whether pagination state should be reset before a query using custom logic', () => { - const options = { - hasPaginationQueryShapeChanged: vi.fn().mockReturnValue(true), - }; - const paginator = new Paginator(options); - expect( - // @ts-expect-error accessing protected property - paginator.shouldResetStateBeforeQuery(prevQueryShape, nextQueryShape), - ).toBe(true); - expect( - // @ts-expect-error accessing protected property - paginator.shouldResetStateBeforeQuery(prevQueryShape, prevQueryShape), - ).toBe(true); - expect(options.hasPaginationQueryShapeChanged).toHaveBeenCalledTimes(2); - }); - }); - - it('paginates to next pages (cursor)', async () => { - const paginator = new Paginator(); - let nextPromise = paginator.next(); - // wait for the DB data first page load - await sleep(0); - expect(paginator.isLoading).toBe(true); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - - paginator.queryResolve({ items: [{ id: 'id1' }], next: 'next1', prev: 'prev1' }); - await nextPromise; - expect(paginator.isLoading).toBe(false); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - expect(paginator.items).toEqual([{ id: 'id1' }]); - expect(paginator.cursor).toEqual({ next: 'next1', prev: 'prev1' }); - expect(paginator.mockClientQuery).toHaveBeenCalledWith({ - direction: 'next', - queryShape: defaultNextQueryShape, - reset: undefined, - retryCount: 0, - }); - - nextPromise = paginator.next(); - expect(paginator.isLoading).toBe(true); - paginator.queryResolve({ items: [{ id: 'id2' }], next: 'next2', prev: 'prev2' }); - await nextPromise; - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); - expect(paginator.cursor).toEqual({ next: 'next2', prev: 'prev2' }); - - nextPromise = paginator.next(); - paginator.queryResolve({ items: [] }); - await nextPromise; - expect(paginator.hasNext).toBe(false); - expect(paginator.hasPrev).toBe(false); - expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); - expect(paginator.cursor).toEqual({ next: null, prev: null }); - - paginator.next(); - expect(paginator.isLoading).toBe(false); - expect(paginator.mockClientQuery).toHaveBeenCalledTimes(3); - }); - - it('paginates to next pages (offset)', async () => { - const paginator = new Paginator({ pageSize: 1 }); - let nextPromise = paginator.next(); - // wait for the DB data first page load - await sleep(0); - expect(paginator.isLoading).toBe(true); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - - paginator.queryResolve({ items: [{ id: 'id1' }] }); - await nextPromise; - expect(paginator.isLoading).toBe(false); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - expect(paginator.items).toEqual([{ id: 'id1' }]); - expect(paginator.cursor).toBeUndefined(); - expect(paginator.offset).toBe(1); - expect(paginator.mockClientQuery).toHaveBeenCalledWith({ - direction: 'next', - queryShape: defaultNextQueryShape, - reset: undefined, - retryCount: 0, - }); - - nextPromise = paginator.next(); - expect(paginator.isLoading).toBe(true); - paginator.queryResolve({ items: [{ id: 'id2' }] }); - await nextPromise; - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); - expect(paginator.cursor).toBeUndefined(); - expect(paginator.offset).toBe(2); - - nextPromise = paginator.next(); - paginator.queryResolve({ items: [] }); - await nextPromise; - expect(paginator.hasNext).toBe(false); - expect(paginator.hasPrev).toBe(true); - expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); - expect(paginator.cursor).toBeUndefined(); - expect(paginator.offset).toBe(2); - - paginator.next(); - expect(paginator.isLoading).toBe(false); - expect(paginator.mockClientQuery).toHaveBeenCalledTimes(3); - }); - - it('paginates to next pages debounced', async () => { - vi.useFakeTimers(); - const paginator = new Paginator({ debounceMs: 2000 }); - - paginator.nextDebounced(); - expect(paginator.isLoading).toBe(false); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - vi.advanceTimersByTime(2000); - // await first page load from the DB - await toNextTick(); - expect(paginator.isLoading).toBe(true); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - - paginator.queryResolve({ items: [{ id: 'id1' }], next: 'next1', prev: 'prev1' }); - await paginator.queryPromise; - await toNextTick(); - expect(paginator.isLoading).toBe(false); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - expect(paginator.items).toEqual([{ id: 'id1' }]); - expect(paginator.cursor).toEqual({ next: 'next1', prev: 'prev1' }); - expect(paginator.mockClientQuery).toHaveBeenCalledWith({ - direction: 'next', - queryShape: defaultNextQueryShape, - reset: undefined, - retryCount: 0, - }); - - vi.useRealTimers(); - }); - - it('paginates to a previous page', async () => { - const paginator = new Paginator(); - let nextPromise = paginator.prev(); - await sleep(0); - expect(paginator.isLoading).toBe(true); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - - paginator.queryResolve({ items: [{ id: 'id1' }], next: 'next1', prev: 'prev1' }); - await nextPromise; - expect(paginator.isLoading).toBe(false); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - expect(paginator.items).toEqual([{ id: 'id1' }]); - expect(paginator.cursor).toEqual({ next: 'next1', prev: 'prev1' }); - expect(paginator.mockClientQuery).toHaveBeenCalledWith({ - direction: 'prev', - queryShape: defaultNextQueryShape, - reset: undefined, - retryCount: 0, - }); - - nextPromise = paginator.prev(); - expect(paginator.isLoading).toBe(true); - paginator.queryResolve({ items: [{ id: 'id2' }], next: 'next2', prev: 'prev2' }); - await nextPromise; - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); - expect(paginator.cursor).toEqual({ next: 'next2', prev: 'prev2' }); - - nextPromise = paginator.prev(); - paginator.queryResolve({ items: [] }); - await nextPromise; - expect(paginator.hasNext).toBe(false); - expect(paginator.hasPrev).toBe(false); - expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); - expect(paginator.cursor).toEqual({ next: null, prev: null }); - - paginator.prev(); - expect(paginator.isLoading).toBe(false); - }); - - it('debounces the pagination to a previous page', async () => { - vi.useFakeTimers(); - const paginator = new Paginator({ debounceMs: 2000 }); - - paginator.prevDebounced(); - expect(paginator.isLoading).toBe(false); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - vi.advanceTimersByTime(2000); - await toNextTick(); - expect(paginator.isLoading).toBe(true); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - - paginator.queryResolve({ items: [{ id: 'id1' }], next: 'next1', prev: 'prev1' }); - await paginator.queryPromise; - await toNextTick(); - expect(paginator.isLoading).toBe(false); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - expect(paginator.items).toEqual([{ id: 'id1' }]); - expect(paginator.cursor).toEqual({ next: 'next1', prev: 'prev1' }); - expect(paginator.mockClientQuery).toHaveBeenCalledWith({ - direction: 'prev', - queryShape: defaultNextQueryShape, - reset: undefined, - retryCount: 0, - }); - vi.useRealTimers(); - }); - - it('prevents pagination if another query is in progress', async () => { - const paginator = new Paginator(); - const nextPromise1 = paginator.next(); - // wait for the first page load from the DB - await sleep(0); - expect(paginator.isLoading).toBe(true); - expect(paginator.mockClientQuery).toHaveBeenCalledTimes(1); - const nextPromise2 = paginator.next(); - paginator.queryResolve({ items: [{ id: 'id1' }], next: 'next1', prev: 'prev1' }); - await Promise.all([nextPromise1, nextPromise2]); - expect(paginator.mockClientQuery).toHaveBeenCalledTimes(1); - }); - - it('resets the state if the query shape changed', async () => { - const paginator = new Paginator({ pageSize: 1 }); - let nextPromise = paginator.next(); - await sleep(0); - paginator.queryResolve({ items: [{ id: 'id1' }] }); - await nextPromise; - expect(paginator.isLoading).toBe(false); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - expect(paginator.items).toEqual([{ id: 'id1' }]); - expect(paginator.cursor).toBeUndefined(); - expect(paginator.offset).toBe(1); - - paginator.getNextQueryShape.mockReturnValueOnce({ - filters: { id: 'test' }, - sort: { id: -1 }, - }); - nextPromise = paginator.next(); - await sleep(0); - expect(paginator.isLoading).toBe(true); - expect(paginator.items).toBeUndefined(); - expect(paginator.offset).toBe(0); - paginator.queryResolve({ items: [{ id: 'id2' }] }); - await nextPromise; - expect(paginator.isLoading).toBe(false); - expect(paginator.items).toEqual([{ id: 'id2' }]); - expect(paginator.offset).toBe(1); - }); - - it('resets the state if forced', async () => { - const paginator = new Paginator({ pageSize: 1 }); - let nextPromise = paginator.next(); - await sleep(0); - paginator.queryResolve({ items: [{ id: 'id1' }] }); - await nextPromise; - expect(paginator.isLoading).toBe(false); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - expect(paginator.items).toEqual([{ id: 'id1' }]); - expect(paginator.cursor).toBeUndefined(); - expect(paginator.offset).toBe(1); - - nextPromise = paginator.next({ reset: 'yes' }); - await sleep(0); - expect(paginator.isLoading).toBe(true); - expect(paginator.items).toBeUndefined(); - expect(paginator.offset).toBe(0); - paginator.queryResolve({ items: [{ id: 'id2' }] }); - await nextPromise; - expect(paginator.isLoading).toBe(false); - expect(paginator.items).toEqual([{ id: 'id2' }]); - expect(paginator.offset).toBe(1); - }); - - it('does not reset the state if forced', async () => { - const paginator = new Paginator({ pageSize: 1 }); - let nextPromise = paginator.next(); - await sleep(0); - paginator.queryResolve({ items: [{ id: 'id1' }] }); - await nextPromise; - expect(paginator.isLoading).toBe(false); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - expect(paginator.items).toEqual([{ id: 'id1' }]); - expect(paginator.cursor).toBeUndefined(); - expect(paginator.offset).toBe(1); - - paginator.getNextQueryShape.mockReturnValueOnce({ - filters: { id: 'test' }, - sort: { id: -1 }, - }); - nextPromise = paginator.next({ reset: 'no' }); - await sleep(0); - expect(paginator.items).toStrictEqual([{ id: 'id1' }]); - expect(paginator.offset).toBe(1); - paginator.queryResolve({ items: [{ id: 'id2' }] }); - await nextPromise; - expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); - expect(paginator.offset).toBe(2); - }); - - it('stores lastQueryError and clears it with the next successful query', async () => { - const paginator = new Paginator(); - let nextPromise = paginator.next(); - // wait for the first page load from DB - await sleep(0); - const error = new Error('Failed'); - paginator.queryReject(error); - // hand over to finish the cleanup and state update after the query execution - await sleep(0); - expect(paginator.lastQueryError).toEqual(error); - expect(paginator.isLoading).toEqual(false); - - nextPromise = paginator.next(); - paginator.queryResolve({ items: [{ id: 'id1' }], next: 'next1', prev: 'prev1' }); - await nextPromise; - expect(paginator.lastQueryError).toBeUndefined(); - expect(paginator.items).toEqual([{ id: 'id1' }]); - expect(paginator.cursor).toEqual({ next: 'next1', prev: 'prev1' }); - }); - - it('throws error if enabled', async () => { - const paginator = new Paginator({ throwErrors: true }); - let nextPromise = paginator.next(); - // wait for the first page load from DB - await sleep(0); - const error = new Error('Failed'); - paginator.queryReject(error); - await expect(nextPromise).rejects.toThrowError(error); - // hand over to finish the cleanup and state update after the query execution - await sleep(0); - expect(paginator.lastQueryError).toEqual(error); - expect(paginator.isLoading).toEqual(false); - - nextPromise = paginator.next(); - // wait for the first page load from DB - await sleep(0); - paginator.queryResolve({ items: [{ id: 'id1' }], next: 'next1', prev: 'prev1' }); - await nextPromise; - expect(paginator.lastQueryError).toBeUndefined(); - expect(paginator.items).toEqual([{ id: 'id1' }]); - expect(paginator.cursor).toEqual({ next: 'next1', prev: 'prev1' }); - }); - - it('retries the query', async () => { - vi.useFakeTimers(); - const paginator = new Paginator(); - let nextPromise = paginator.next({ retryCount: 2 }); - // wait for the first page load from DB - await toNextTick(); - const error = new Error('Failed'); - paginator.queryReject(error); - // hand over to finish the cleanup and state update after the query execution - await toNextTick(); - expect(paginator.lastQueryError).toEqual(error); - vi.advanceTimersByTime(DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES); - await toNextTick(); - - paginator.queryResolve({ items: [{ id: 'id1' }], next: 'next1', prev: 'prev1' }); - await nextPromise; - expect(paginator.lastQueryError).toBeUndefined(); - expect(paginator.items).toEqual([{ id: 'id1' }]); - expect(paginator.cursor).toEqual({ next: 'next1', prev: 'prev1' }); - vi.useRealTimers(); - }); - }); - - describe('item management', () => { - const item: TestItem = { - id: 'id1', - name: 'test', - age: 100, - teams: ['abc', 'efg'], - }; - - const item2 = { - ...item, - id: 'id2', - name: 'test2', - age: 101, - }; - - const item3 = { - ...item, - id: 'id3', - name: 'test3', - age: 102, - }; - - describe('matchesFilter', () => { - it('returns true if no filter is provided', async () => { - const paginator = new Paginator(); - expect(paginator.matchesFilter(item)).toBeTruthy(); - }); - it('returns false if does not match the filter', async () => { - const paginator = new Paginator(); - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - name: { $eq: 'test1' }, - }); - expect(paginator.matchesFilter(item)).toBeFalsy(); - }); - it('returns true if item matches the filter', async () => { - const paginator = new Paginator(); - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - $or: [{ name: { $eq: 'test1' } }, { teams: { $contains: 'abc' } }], - }); - expect(paginator.matchesFilter(item)).toBeTruthy(); - }); - }); - - describe('ingestItem', () => { - it.each([ - ['on lockItemOrder: false', false], - ['on lockItemOrder: true', true], - ])( - 'exists but does not match the filter anymore removes the item %s', - (_, lockItemOrder) => { - const paginator = new Paginator({ lockItemOrder }); - paginator.state.partialNext({ - items: [item3, item2, item], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - teams: { $eq: ['abc', 'efg'] }, // required membership in these two teams - }); - - const adjustedItem = { - ...item, - teams: ['efg'], // removed from the team abc - }; - - expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item removed - expect(paginator.items).toHaveLength(2); - }, - ); - - it.each([ - [' adjusts the order on lockItemOrder: false', false], - [' does not adjust the order on lockItemOrder: true', true], - ])('exists and matches the filter updates the item and %s', (_, lockItemOrder) => { - const paginator = new Paginator({ lockItemOrder }); - paginator.state.partialNext({ - items: [item, item2, item3], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - age: { $gt: 100 }, - }); - - paginator.sort = { age: 1 }; - - const adjustedItem = { - ...item, - age: 103, - }; - - expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item updated - expect(paginator.items).toHaveLength(3); - - if (lockItemOrder) { - expect(paginator.items).toStrictEqual([adjustedItem, item2, item3]); - } else { - expect(paginator.items).toStrictEqual([item2, item3, adjustedItem]); - } - }); - - it.each([ - ['on lockItemOrder: false', false], - ['on lockItemOrder: true', true], - ])( - 'does not exist and does not match the filter results in no action %s', - (_, lockItemOrder) => { - const paginator = new Paginator({ lockItemOrder }); - paginator.state.partialNext({ - items: [item], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - age: { $gt: 100 }, - }); - - const adjustedItem = { - ...item, - id: 'id2', - name: 'test2', - }; - - expect(paginator.ingestItem(adjustedItem)).toBeFalsy(); // no action - expect(paginator.items).toStrictEqual([item]); - }, - ); - - it.each([ - ['on lockItemOrder: false', false], - ['on lockItemOrder: true', true], - ])( - 'does not exist and matches the filter inserts according to default sort order (append) %s', - (_, lockItemOrder) => { - const paginator = new Paginator({ lockItemOrder }); - paginator.state.partialNext({ - items: [item3, item], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - teams: { $contains: 'abc' }, - }); - - expect(paginator.ingestItem(item2)).toBeTruthy(); - expect(paginator.items).toStrictEqual([item3, item, item2]); - }, - ); - - it.each([ - ['on lockItemOrder: false', false], - ['on lockItemOrder: true', true], - ])( - 'does not exist and matches the filter inserts according to sort order %s', - (_, lockItemOrder) => { - const paginator = new Paginator({ lockItemOrder }); - paginator.state.partialNext({ - items: [item3, item], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - teams: { $contains: 'abc' }, - }); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ sort: { age: -1 } }); - - expect(paginator.ingestItem(item2)).toBeTruthy(); - expect(paginator.items).toHaveLength(3); - expect(paginator.items![0]).toStrictEqual(item3); - expect(paginator.items![1]).toStrictEqual(item2); - expect(paginator.items![2]).toStrictEqual(item); - }, - ); - - it('reflects the boost priority on lockItemOrder: false for newly ingested items', () => { - const paginator = new Paginator(); - paginator.state.partialNext({ - items: [item3, item], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - teams: { $contains: 'abc' }, - }); - - paginator.boost(item2.id); - expect(paginator.ingestItem(item2)).toBeTruthy(); - expect(paginator.items).toStrictEqual([item2, item3, item]); - }); - - it('reflects the boost priority on lockItemOrder: false for existing items recently boosted', () => { - const paginator = new Paginator(); - paginator.state.partialNext({ - items: [item, item2, item3], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - age: { $gt: 100 }, - }); - - paginator.sort = { age: 1 }; - - const adjustedItem = { - ...item2, - age: 103, - }; - paginator.boost(item2.id); - expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item updated - expect(paginator.items).toHaveLength(3); - - expect(paginator.items).toStrictEqual([adjustedItem, item, item3]); - }); - - it('does not reflect the boost priority on lockItemOrder: true', () => { - const paginator = new Paginator({ lockItemOrder: true }); - paginator.state.partialNext({ - items: [item, item2, item3], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - age: { $gt: 100 }, - }); - - paginator.sort = { age: 1 }; - - const adjustedItem = { - ...item2, - age: 103, - }; - paginator.boost(item2.id); - expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item updated - expect(paginator.items).toHaveLength(3); - - expect(paginator.items).toStrictEqual([item, adjustedItem, item3]); - }); - - it('reflects the boost priority on lockItemOrder: true when ingesting a new item', () => { - const paginator = new Paginator({ lockItemOrder: true }); - paginator.state.partialNext({ - items: [item3, item], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - teams: { $contains: 'abc' }, - }); - - paginator.boost(item2.id); - expect(paginator.ingestItem(item2)).toBeTruthy(); - expect(paginator.items).toStrictEqual([item2, item3, item]); - }); - }); - - describe('removeItem', () => { - it('removes existing item', () => { - const paginator = new Paginator(); - paginator.state.partialNext({ - items: [item3, item2, item], - }); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ - sort: { age: -1 }, - }); - expect(paginator.removeItem({ item: item3 })).toBeTruthy(); - expect(paginator.items).toHaveLength(2); - expect(paginator.items![0]).toStrictEqual(item2); - expect(paginator.items![1]).toStrictEqual(item); - }); - - it('results in no action for non-existent item', () => { - const paginator = new Paginator(); - paginator.state.partialNext({ - items: [item2, item], - }); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ - sort: { age: -1 }, - }); - expect(paginator.removeItem({ item: item3 })).toBeFalsy(); - expect(paginator.items).toHaveLength(2); - expect(paginator.items![0]).toStrictEqual(item2); - expect(paginator.items![1]).toStrictEqual(item); - }); - }); - - describe('setItems', () => { - it('overrides all the items in the state with provided value', () => { - const paginator = new Paginator(); - const items1 = [{ id: 'test-item1' }]; - const items2 = [{ id: 'test-item2' }]; - paginator.setItems(items1); - expect(paginator.items).toStrictEqual(items1); - paginator.setItems(items2); - expect(paginator.items).toStrictEqual(items2); - }); - - const items = [{ id: 'test-item1' }]; - const expectedStateEmissions = [ - { - cursor: undefined, - hasNext: true, - hasPrev: true, - isLoading: false, - items: undefined, - lastQueryError: undefined, - offset: 0, - }, - { - cursor: undefined, - hasNext: true, - hasPrev: true, - isLoading: false, - items, - lastQueryError: undefined, - offset: 1, - }, - ]; - - it('emits state change as long as the items are not the same', () => { - const paginator = new Paginator(); - const subscriptionHandler = vi.fn(); - const unsubscribe = paginator.state.subscribe(subscriptionHandler); - expect(subscriptionHandler).toHaveBeenCalledTimes(1); - expect(subscriptionHandler).toHaveBeenCalledWith( - expectedStateEmissions[0], - undefined, - ); - - paginator.setItems(items); - expect(paginator.items).toStrictEqual(items); - expect(subscriptionHandler).toHaveBeenCalledTimes(2); - expect(subscriptionHandler).toHaveBeenCalledWith( - expectedStateEmissions[1], - expectedStateEmissions[0], - ); - - // setting an object with the same reference - paginator.setItems(items); - expect(paginator.items).toStrictEqual(items); - expect(subscriptionHandler).toHaveBeenCalledTimes(2); - expect(subscriptionHandler).toHaveBeenCalledWith( - expectedStateEmissions[1], - expectedStateEmissions[0], - ); - - unsubscribe(); - }); - - it('emits state change as long as the state factory returns objects with different reference', () => { - const paginator = new Paginator(); - const subscriptionHandler = vi.fn(); - const unsubscribe = paginator.state.subscribe(subscriptionHandler); - - paginator.setItems(() => items); - expect(paginator.items).toStrictEqual(items); - // first call is on subscribe - expect(subscriptionHandler).toHaveBeenCalledTimes(2); - expect(subscriptionHandler).toHaveBeenCalledWith( - expectedStateEmissions[1], - expectedStateEmissions[0], - ); - - // setting an object with the same reference - paginator.setItems(() => items); - expect(paginator.items).toStrictEqual(items); - expect(subscriptionHandler).toHaveBeenCalledTimes(2); - expect(subscriptionHandler).toHaveBeenCalledWith( - expectedStateEmissions[1], - expectedStateEmissions[0], - ); - - unsubscribe(); - }); - - it('updates the cursor if provided', () => { - const paginator = new Paginator(); - const cursors: PaginatorCursor[] = [ - { next: 'next1', prev: 'prev1' }, - { next: 'next2', prev: 'prev1' }, - ]; - const subscriptionHandler = vi.fn(); - const unsubscribe = paginator.state.subscribe(subscriptionHandler); - - paginator.setItems(items, cursors[0]); - expect(subscriptionHandler).toHaveBeenCalledTimes(2); - expect(subscriptionHandler).toHaveBeenCalledWith( - { ...expectedStateEmissions[1], cursor: cursors[0], offset: 0 }, - { ...expectedStateEmissions[0], cursor: undefined, offset: 0 }, - ); - - unsubscribe(); - }); - }); - - describe('reload', () => { - it('starts the ended pagination from the beginning', async () => { - const paginator = new Paginator({ pageSize: 2 }); - paginator.state.next({ - hasNext: false, - hasPrev: false, - isLoading: false, - items: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }], - offset: 4, - }); - let reloadPromise = paginator.reload(); - // wait for the DB data first page load - await sleep(0); - expect(paginator.isLoading).toBe(true); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - - paginator.queryResolve({ items: [{ id: 'id1' }] }); - await reloadPromise; - expect(paginator.isLoading).toBe(false); - expect(paginator.hasNext).toBe(false); - expect(paginator.hasPrev).toBe(true); - expect(paginator.items).toEqual([{ id: 'id1' }]); - expect(paginator.cursor).toBeUndefined(); - expect(paginator.offset).toBe(1); - expect(paginator.mockClientQuery).toHaveBeenCalledWith({ - direction: 'next', - queryShape: defaultNextQueryShape, - reset: 'yes', - retryCount: 0, - }); - - reloadPromise = paginator.reload(); - // wait for the DB data first page load - await sleep(0); - expect(paginator.isLoading).toBe(true); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - - paginator.queryResolve({ items: [{ id: 'id2' }], next: 'next2' }); - await reloadPromise; - expect(paginator.isLoading).toBe(false); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(false); - expect(paginator.items).toEqual([{ id: 'id2' }]); - expect(paginator.cursor).toStrictEqual({ next: 'next2', prev: null }); - expect(paginator.offset).toBe(0); - expect(paginator.mockClientQuery).toHaveBeenCalledWith({ - direction: 'next', - queryShape: defaultNextQueryShape, - reset: 'yes', - retryCount: 0, - }); - - // reset in another direction - reloadPromise = paginator.reload(); - // wait for the DB data first page load - await sleep(0); - expect(paginator.isLoading).toBe(true); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(true); - expect(paginator.items).toBe(undefined); - - paginator.queryResolve({ items: [{ id: 'id2' }], next: 'next2' }); - await reloadPromise; - expect(paginator.isLoading).toBe(false); - expect(paginator.hasNext).toBe(true); - expect(paginator.hasPrev).toBe(false); - expect(paginator.items).toEqual([{ id: 'id2' }]); - expect(paginator.cursor).toStrictEqual({ next: 'next2', prev: null }); - expect(paginator.offset).toBe(0); - }); - }); - - describe('contains', () => { - it('returns true if the item exists', () => { - const paginator = new Paginator(); - paginator.state.partialNext({ - items: [item3, item2, item], - }); - expect(paginator.contains(item3)).toBeTruthy(); - }); - - it('returns false if the items does not exist', () => { - const paginator = new Paginator(); - paginator.state.partialNext({ - items: [item2, item], - }); - expect(paginator.contains(item3)).toBeFalsy(); - }); - }); - - describe('locateByItem', () => { - const a: TestItem = { id: 'a', age: 30, name: 'A' }; - const b: TestItem = { id: 'b', age: 25, name: 'B' }; - const c: TestItem = { id: 'c', age: 25, name: 'C' }; - const d: TestItem = { id: 'd', age: 20, name: 'D' }; - - const tieBreakerById = (l: TestItem, r: TestItem) => - l.id < r.id ? -1 : l.id > r.id ? 1 : 0; - - it('returns {index:-1, insertionIndex:0} for empty list', () => { - const paginator = new Paginator(); - const res = paginator.locateByItem(a); - expect(res).toEqual({ index: -1, insertionIndex: 0 }); - }); - - it('finds an existing item on a tie plateau (no ID tiebreaker)', () => { - const paginator = new Paginator(); - // comparator: age desc only (ties produce a plateau) - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ - sort: { age: -1 }, - }); - // items are already sorted by age desc - paginator.state.partialNext({ items: [a, b, c, d] }); - - const res = paginator.locateByItem(c); - expect(res.index).toBe(2); // c is at index 2 in [a, b, c, d] - // insertionIndex for identical key (age 25) is after the plateau - expect(res.insertionIndex).toBe(3); - }); - - it('returns insertion index when not found on a tie plateau (no ID tiebreaker)', () => { - const paginator = new Paginator(); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ - sort: { age: -1 }, - }); - paginator.state.partialNext({ items: [a, b, c, d] }); - - // same sort keys as b/c but different id; not present - const x: TestItem = { id: 'x', age: 25, name: 'X' }; - const res = paginator.locateByItem(x); - // insertion point should be after the 25-plateau (after c at index 2) - expect(res.index).toBe(-1); - expect(res.insertionIndex).toBe(3); - }); - - it('finds exact index with ID tiebreaker in comparator (pure O(log n))', () => { - const paginator = new Paginator(); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ - sort: { age: -1 }, - // tie-breaker on id asc guarantees a total order - tiebreaker: tieBreakerById, - }); - - // With tiebreaker, the order within age==25 is by id asc: b (id 'b'), then c (id 'c') - paginator.state.partialNext({ items: [a, b, c, d] }); - - const res = paginator.locateByItem(c); - expect(res.index).toBe(2); - // In this setting the insertionIndex is deterministic but not strictly needed when found - expect(res.insertionIndex).toBeGreaterThanOrEqual(2); - }); - - it('computes insertion at the beginning when needle sorts before all items', () => { - const paginator = new Paginator(); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ - sort: { age: -1 }, - tiebreaker: tieBreakerById, - }); - paginator.state.partialNext({ items: [a, b, c, d] }); - - const z: TestItem = { id: 'z', age: 40, name: 'Z' }; // highest age → goes to front - const res = paginator.locateByItem(z); - expect(res.index).toBe(-1); - expect(res.insertionIndex).toBe(0); - }); - - it('computes insertion at the end when needle sorts after all items', () => { - const paginator = new Paginator(); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ - sort: { age: -1 }, - tiebreaker: tieBreakerById, - }); - paginator.state.partialNext({ items: [a, b, c, d] }); - - const z: TestItem = { id: 'z', age: 10, name: 'Z' }; // lowest age → goes to end - const res = paginator.locateByItem(z); - expect(res.index).toBe(-1); - expect(res.insertionIndex).toBe(4); - }); - - it('checks both immediate neighbors before plateau scan (fast path)', () => { - const paginator = new Paginator(); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ - sort: { age: -1 }, - }); - paginator.state.partialNext({ items: [a, b, c, d] }); - - // needle equal to left neighbor of insertionIndex - const resLeftNeighbor = paginator.locateByItem(c); - expect(resLeftNeighbor.index).toBe(2); - - // needle equal to right neighbor (craft by duplicating c’s sort but different id not present) - const y: TestItem = { id: 'y', age: 25, name: 'Y' }; - const resRightNeighbor = paginator.locateByItem(y); - expect(resRightNeighbor.index).toBe(-1); - expect(resRightNeighbor.insertionIndex).toBe(3); - }); - }); - - describe('findItem', () => { - const a: TestItem = { id: 'a', age: 30 }; - const b: TestItem = { id: 'b', age: 25 }; - const c: TestItem = { id: 'c', age: 25 }; - const d: TestItem = { id: 'd', age: 20 }; - - it('returns the exact item instance when present', () => { - const paginator = new Paginator(); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ - sort: { age: -1 }, - }); - paginator.state.partialNext({ items: [a, b, c, d] }); - - // Same identity object: - expect(paginator.findItem(c)).toBe(c); - - // Same identity by id but different object reference still matches by locateByItem: - const cClone = { ...c }; - expect(paginator.findItem(cClone)).toBe(c); - }); - - it('returns undefined when not present', () => { - const paginator = new Paginator(); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ - sort: { age: -1 }, - }); - paginator.state.partialNext({ items: [a, b, d] }); - - const needle: TestItem = { id: 'x', age: 25 }; - expect(paginator.findItem(needle)).toBeUndefined(); - }); - - it('works with an ID tie-breaker comparator as well', () => { - const paginator = new Paginator(); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ - sort: { age: -1 }, - tiebreaker: (l: TestItem, r: TestItem) => - l.id < r.id ? -1 : l.id > r.id ? 1 : 0, - }); - paginator.state.partialNext({ items: [a, b, c, d] }); - - expect(paginator.findItem(c)).toBe(c); - const x: TestItem = { id: 'x', age: 25 }; - expect(paginator.findItem(x)).toBeUndefined(); - }); - - it('handles empty list', () => { - const paginator = new Paginator(); - expect(paginator.findItem({ id: 'z' })).toBeUndefined(); - }); - }); - - describe('filter resolvers', () => { - const resolvers1 = [{ matchesField: () => true, resolve: () => 'abc' }]; - const resolvers2 = [ - { matchesField: () => false, resolve: () => 'efg' }, - { matchesField: () => true, resolve: () => 'hij' }, - ]; - it('get overridden with setFilterResolvers', () => { - const paginator = new Paginator(); - // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toHaveLength(0); - - paginator.setFilterResolvers(resolvers1); - - // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toHaveLength(resolvers1.length); - // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toStrictEqual(resolvers1); - - paginator.setFilterResolvers(resolvers2); - - // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toHaveLength(resolvers2.length); - // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toStrictEqual(resolvers2); - - paginator.setFilterResolvers([]); - // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toHaveLength(0); - }); - - it('get expanded with addFilterResolvers', () => { - const paginator = new Paginator(); - paginator.addFilterResolvers(resolvers1); - - // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toStrictEqual(resolvers1); - - paginator.addFilterResolvers(resolvers2); - - // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toStrictEqual([ - ...resolvers1, - ...resolvers2, - ]); - - paginator.addFilterResolvers([]); - // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toStrictEqual([ - ...resolvers1, - ...resolvers2, - ]); - }); - }); - - describe('item boosting', () => { - const a = { id: 'a', age: 10, name: 'A' } as TestItem; - const b = { id: 'b', age: 20, name: 'B' } as TestItem; - const c = { id: 'c', age: 30, name: 'C' } as TestItem; - - const byIdAsc = (l: TestItem, r: TestItem) => - l.id < r.id ? -1 : l.id > r.id ? 1 : 0; - - describe('clearExpiredBoosts', () => { - it('removes expired boosts and updates maxBoostSeq', () => { - const paginator = new Paginator(); - // @ts-expect-error accessing protected property - paginator.boosts.clear(); - const now = 1000000; - - paginator.boost('fresh', { until: now + 1000, seq: 1 }); - paginator.boost('stale', { until: now - 1, seq: 5 }); - - // @ts-expect-error accessing protected method - paginator.clearExpiredBoosts(now); - - // @ts-expect-error accessing protected property - expect(Array.from(paginator.boosts.keys())).toEqual(['fresh']); - expect(paginator.maxBoostSeq).toBe(1); - }); - - it('sets maxBoostSeq to 0 when no boosts remain', () => { - const paginator = new Paginator(); - // two expired boosts at "now" - paginator.boost('x', { until: 1000, seq: 1 }); - paginator.boost('y', { until: 1500, seq: 3 }); - - // @ts-expect-error accessing protected method - paginator.clearExpiredBoosts(10000); - - // @ts-expect-error accessing protected property - expect(paginator.boosts.size).toBe(0); - expect(paginator.maxBoostSeq).toBe(0); - }); - }); - - describe('boostComparator', () => { - it('prioritizes boosted over non-boosted', () => { - vi.useFakeTimers(); - const now = new Date('2025-01-01T00:00:00Z'); - vi.setSystemTime(now); - - const paginator = new Paginator(); - paginator.sortComparator = byIdAsc; - - // Boost only "a" - paginator.boost('b', { ttlMs: 10000, seq: 0 }); - - // @ts-expect-error: protected method - expect(paginator.boostComparator(a, b)).toBe(1); // a after b - // @ts-expect-error - expect(paginator.boostComparator(b, a)).toBe(-1); // b stays before a - - // Let boost expire - vi.setSystemTime(new Date(now.getTime() + 11000)); - // @ts-expect-error - expect(paginator.boostComparator(a, b)).toBe(-1); // fallback to byIdAsc - vi.useRealTimers(); - }); - - it('when both boosted, higher seq comes first; ties fall back to sortComparator', () => { - vi.useFakeTimers(); - const now = new Date('2025-01-01T00:00:00Z'); - vi.setSystemTime(now); - - const paginator = new Paginator(); - // Fallback comparator id asc - paginator.sortComparator = byIdAsc; - - paginator.boost('a', { ttlMs: 60000, seq: 1 }); - paginator.boost('b', { ttlMs: 60000, seq: 3 }); - - // b has higher seq → should come first → comparator(a,b) > 0 - // @ts-expect-error - expect(paginator.boostComparator(a, b)).toBe(1); - // reverse check - // @ts-expect-error - expect(paginator.boostComparator(b, a)).toBe(-1); - - // Equal seq → fall back to sortComparator (id asc => a before b) - paginator.boost('a', { ttlMs: 60000, seq: 2 }); - paginator.boost('b', { ttlMs: 60000, seq: 2 }); - // @ts-expect-error - expect(paginator.boostComparator(a, b)).toBe(-1); - - vi.useRealTimers(); - }); - - it('ignores expired boosts automatically during comparison', () => { - vi.useFakeTimers(); - const now = new Date('2025-01-01T00:00:00Z'); - vi.setSystemTime(now); - - const paginator = new Paginator(); - paginator.sortComparator = byIdAsc; - - paginator.boost('b', { ttlMs: 5000, seq: 10 }); - // Initially boosted - // @ts-expect-error - expect(paginator.boostComparator(a, b)).toBe(1); - - // Advance beyond TTL so boost is expired; comparator should fall back - vi.setSystemTime(new Date(now.getTime() + 6000)); - // @ts-expect-error - expect(paginator.boostComparator(a, b)).toBe(-1); // byIdAsc, not boost - vi.useRealTimers(); - }); - }); - - describe('boost', () => { - it('assigns default TTL (15s) and default seq=0; updates maxBoostSeq only upward', () => { - vi.useFakeTimers(); - const now = new Date('2025-01-01T00:00:00Z'); - vi.setSystemTime(now); - - const paginator = new Paginator(); - - paginator.boost('k'); // default 15s, seq 0 - const b1 = paginator.getBoost('k')!; - expect(b1.seq).toBe(0); - expect(b1.until).toBe(now.getTime() + 15000); - expect(paginator.maxBoostSeq).toBe(0); - - // Raise max seq - paginator.boost('m', { ttlMs: 1000, seq: 5 }); - expect(paginator.maxBoostSeq).toBe(5); - - // Lower seq should NOT decrease maxBoostSeq - paginator.boost('n', { ttlMs: 1000, seq: 2 }); - expect(paginator.maxBoostSeq).toBe(5); - - vi.useRealTimers(); - }); - - it('accepts explicit until and seq', () => { - const paginator = new Paginator(); - paginator.boost('z', { until: 42, seq: 7 }); - const b = paginator.getBoost('z')!; - expect(b.until).toBe(42); - expect(b.seq).toBe(7); - expect(paginator.maxBoostSeq).toBe(7); - }); - }); - - describe('getBoost', () => { - it('returns the boost record when present; otherwise undefined', () => { - const paginator = new Paginator(); - expect(paginator.getBoost('missing')).toBeUndefined(); - paginator.boost('a', { ttlMs: 1000, seq: 1 }); - const b = paginator.getBoost('a'); - expect(b).toBeDefined(); - expect(b!.seq).toBe(1); - }); - }); - - describe('removeBoost', () => { - it('removes a boost and recalculates maxBoostSeq', () => { - const paginator = new Paginator(); - paginator.boost('a', { ttlMs: 60000, seq: 1 }); - paginator.boost('b', { ttlMs: 60000, seq: 5 }); - paginator.boost('c', { ttlMs: 60000, seq: 2 }); - expect(paginator.maxBoostSeq).toBe(5); - - paginator.removeBoost('b'); // remove current max - expect(paginator.getBoost('b')).toBeUndefined(); - expect(paginator.maxBoostSeq).toBe(2); - - paginator.removeBoost('c'); - expect(paginator.getBoost('c')).toBeUndefined(); - expect(paginator.maxBoostSeq).toBe(1); - - paginator.removeBoost('a'); - expect(paginator.getBoost('a')).toBeUndefined(); - expect(paginator.maxBoostSeq).toBe(0); - }); - }); - - describe('isBoosted', () => { - it('returns true when boost exists and now <= until; false otherwise', () => { - vi.useFakeTimers(); - const now = new Date('2025-01-01T00:00:00Z'); - vi.setSystemTime(now); - - const paginator = new Paginator(); - expect(paginator.isBoosted('x')).toBe(false); - - paginator.boost('x', { ttlMs: 5000, seq: 0 }); - expect(paginator.isBoosted('x')).toBe(true); - - // Exactly at until is still considered boosted per <= check - vi.setSystemTime(new Date(now.getTime() + 5000)); - expect(paginator.isBoosted('x')).toBe(true); - - // After until → false - vi.setSystemTime(new Date(now.getTime() + 5001)); - expect(paginator.isBoosted('x')).toBe(false); - - vi.useRealTimers(); - }); - }); - - describe('integration: ingestion respects boostComparator implicitly', () => { - it('newly ingested boosted items float above non-boosted regardless of fallback sort', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2025-01-01T00:00:00Z')); - - const paginator = new Paginator(); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ - sort: { age: 1 }, // ascending age (so normally a < b < c by age) - }); - paginator.state.partialNext({ items: [a, b] }); - - // Boost "c" before ingest → it should be placed ahead of non-boosted even though age is highest - paginator.boost('c', { ttlMs: 60000, seq: 1 }); - expect(paginator.ingestItem(c)).toBeTruthy(); - - // c should be first due to boost, then a, then b (fallback sort would place c last otherwise) - expect(paginator.items!.map((i) => i.id)).toEqual(['c', 'a', 'b']); - - vi.useRealTimers(); - }); - }); - }); - }); -}); diff --git a/test/unit/pagination/ItemIndex.test.ts b/test/unit/pagination/ItemIndex.test.ts new file mode 100644 index 0000000000..1c57f3deec --- /dev/null +++ b/test/unit/pagination/ItemIndex.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { ItemIndex } from '../../../src/pagination/ItemIndex'; + +interface TestItem { + id: string; + value: number; +} + +describe('ItemIndex', () => { + let itemIndex: ItemIndex; + const getId = (item: TestItem) => item.id; + + beforeEach(() => { + itemIndex = new ItemIndex({ getId }); + }); + + describe('constructor', () => { + it('should initialize with an empty index', () => { + expect(itemIndex.entries()).toEqual([]); + }); + + it('should accept a custom getId function', () => { + const customIndex = new ItemIndex<{ key: string }>({ getId: (item) => item.key }); + const item = { key: '123' }; + customIndex.setOne(item); + expect(customIndex.get('123')).toBe(item); + }); + }); + + describe('setOne', () => { + it('should add a single item', () => { + const item: TestItem = { id: '1', value: 10 }; + itemIndex.setOne(item); + expect(itemIndex.get('1')).toBe(item); + expect(itemIndex.has('1')).toBe(true); + }); + + it('should overwrite an existing item with the same ID', () => { + const item1: TestItem = { id: '1', value: 10 }; + const item2: TestItem = { id: '1', value: 20 }; + + itemIndex.setOne(item1); + expect(itemIndex.get('1')).toBe(item1); + + itemIndex.setOne(item2); + expect(itemIndex.get('1')).toBe(item2); + expect(itemIndex.get('1')?.value).toBe(20); + }); + }); + + describe('setMany', () => { + it('should add multiple items', () => { + const items: TestItem[] = [ + { id: '1', value: 10 }, + { id: '2', value: 20 }, + { id: '3', value: 30 }, + ]; + + itemIndex.setMany(items); + + expect(itemIndex.get('1')).toBe(items[0]); + expect(itemIndex.get('2')).toBe(items[1]); + expect(itemIndex.get('3')).toBe(items[2]); + expect(itemIndex.entries().length).toBe(3); + }); + + it('should handle empty array', () => { + itemIndex.setMany([]); + expect(itemIndex.entries().length).toBe(0); + }); + + it('should overwrite existing items when setting many', () => { + const item1: TestItem = { id: '1', value: 10 }; + itemIndex.setOne(item1); + + const newItems: TestItem[] = [ + { id: '1', value: 99 }, + { id: '2', value: 20 }, + ]; + + itemIndex.setMany(newItems); + + expect(itemIndex.get('1')?.value).toBe(99); + expect(itemIndex.get('2')?.value).toBe(20); + }); + }); + + describe('get', () => { + it('should return undefined for non-existent item', () => { + expect(itemIndex.get('non-existent')).toBeUndefined(); + }); + + it('should return the correct item for existing ID', () => { + const item: TestItem = { id: 'abc', value: 123 }; + itemIndex.setOne(item); + expect(itemIndex.get('abc')).toBe(item); + }); + }); + + describe('has', () => { + it('should return false for non-existent item', () => { + expect(itemIndex.has('non-existent')).toBe(false); + }); + + it('should return true for existing item', () => { + const item: TestItem = { id: 'abc', value: 123 }; + itemIndex.setOne(item); + expect(itemIndex.has('abc')).toBe(true); + }); + }); + + describe('remove', () => { + it('should remove an existing item', () => { + const item: TestItem = { id: '1', value: 10 }; + itemIndex.setOne(item); + expect(itemIndex.has('1')).toBe(true); + + itemIndex.remove('1'); + expect(itemIndex.has('1')).toBe(false); + expect(itemIndex.get('1')).toBeUndefined(); + }); + + it('should do nothing when removing non-existent item', () => { + // Should not throw + itemIndex.remove('non-existent'); + expect(itemIndex.entries().length).toBe(0); + }); + }); + + describe('entries', () => { + it('should return all entries as an array of [id, item] tuples', () => { + const items: TestItem[] = [ + { id: '1', value: 10 }, + { id: '2', value: 20 }, + ]; + itemIndex.setMany(items); + + const entries = itemIndex.entries(); + expect(entries).toHaveLength(2); + expect(entries).toEqual( + expect.arrayContaining([ + ['1', items[0]], + ['2', items[1]], + ]), + ); + }); + + it('should return empty array for empty index', () => { + expect(itemIndex.entries()).toEqual([]); + }); + }); + + describe('values', () => { + it('should return all values as an array of items', () => { + const items: TestItem[] = [ + { id: '1', value: 10 }, + { id: '2', value: 20 }, + ]; + itemIndex.setMany(items); + + const entries = itemIndex.values(); + expect(entries).toHaveLength(2); + expect(entries).toEqual( + expect.arrayContaining([ + { id: '1', value: 10 }, + { id: '2', value: 20 }, + ]), + ); + }); + + it('should return empty array for empty index', () => { + expect(itemIndex.values()).toEqual([]); + }); + }); +}); diff --git a/test/unit/pagination/paginators/BasePaginator.test.ts b/test/unit/pagination/paginators/BasePaginator.test.ts new file mode 100644 index 0000000000..4e05c82052 --- /dev/null +++ b/test/unit/pagination/paginators/BasePaginator.test.ts @@ -0,0 +1,3639 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + AscDesc, + BasePaginator, + DEFAULT_PAGINATION_OPTIONS, + ItemCoordinates, + LOGICAL_HEAD_INTERVAL_ID, + LOGICAL_TAIL_INTERVAL_ID, + PaginationQueryParams, + PaginationQueryReturnValue, + PaginatorCursor, + type PaginatorOptions, + PaginatorState, + PrimitiveFilter, + QueryFilter, + QueryFilters, + RequireOnlyOne, + ZERO_PAGE_CURSOR, +} from '../../../../src'; +import { sleep } 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'; + +const toNextTick = async () => { + const sleepPromise = sleep(0); + vi.advanceTimersByTime(0); + await sleepPromise; +}; + +type TestItem = { + id: string; + name?: string; + teams?: string[]; + blocked?: boolean; + createdAt?: string; // date string + age?: number; +}; + +type QueryShape = { + filters: { + [Key in keyof TestItem]: + | RequireOnlyOne> + | PrimitiveFilter; + }; + sort: { [Key in keyof TestItem]?: AscDesc }; +}; + +class IncompletePaginator extends BasePaginator { + sort: QueryFilters | undefined; + // @ts-ignore + sortComparator: (a: TestItem, b: TestItem) => number = vi.fn().mockReturnValue(0); // BasePaginator implementation + queryResolve: Function = vi.fn(); + queryReject: Function = vi.fn(); + queryPromise: Promise> | null = null; + mockClientQuery = vi.fn(); + + constructor(options: PaginatorOptions = {}) { + super(options); + } + + query( + params: PaginationQueryParams, + ): Promise> { + const promise = new Promise>( + (queryResolve, queryReject) => { + this.queryResolve = queryResolve; + this.queryReject = queryReject; + }, + ); + this.mockClientQuery(params); + this.queryPromise = promise; + return promise; + } + + filterQueryResults(items: TestItem[]): TestItem[] | Promise { + return items; + } +} + +const defaultNextQueryShape: QueryShape = { filters: { id: 'test-id' }, sort: { id: 1 } }; + +class Paginator extends IncompletePaginator { + constructor(options: PaginatorOptions = {}) { + super(options); + } + + getNextQueryShape = vi.fn().mockReturnValue(defaultNextQueryShape); +} + +const itemIndex = new ItemIndex({ getId: ({ id }) => id }); +const a: TestItem = { id: 'a', age: 30, name: 'A' }; +const b: TestItem = { id: 'b', age: 25, name: 'B' }; +const c: TestItem = { id: 'c', age: 25, name: 'C' }; +const d: TestItem = { id: 'd', age: 20, name: 'D' }; + +const v: TestItem = { id: 'v', age: 10, name: 'V' }; +const x: TestItem = { id: 'x', age: 5, name: 'x' }; +const y: TestItem = { id: 'y', age: 4, name: 'Y' }; +const z: TestItem = { id: 'z', age: 1, name: 'Z' }; + +describe('BasePaginator', () => { + describe('constructor', () => { + it('initiates with the defaults', () => { + const paginator = new Paginator(); + expect(paginator.state.getLatestValue()).toEqual({ + hasMoreTail: true, + hasMoreHead: true, + isLoading: false, + items: undefined, + lastQueryError: undefined, + cursor: undefined, + offset: 0, + }); + expect(paginator.isInitialized).toBe(false); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(0); + expect(paginator.config.initialCursor).toBeUndefined(); + expect(paginator.config.initialOffset).toBeUndefined(); + expect(paginator.config.throwErrors).toBe(false); + expect(paginator.pageSize).toBe(DEFAULT_PAGINATION_OPTIONS.pageSize); + expect(paginator.config.debounceMs).toBe(DEFAULT_PAGINATION_OPTIONS.debounceMs); + expect(paginator.config.lockItemOrder).toBe( + DEFAULT_PAGINATION_OPTIONS.lockItemOrder, + ); + expect(paginator.config.hasPaginationQueryShapeChanged).toBe( + DEFAULT_PAGINATION_OPTIONS.hasPaginationQueryShapeChanged, + ); + }); + + it('initiates with custom options', () => { + const options: PaginatorOptions = { + debounceMs: DEFAULT_PAGINATION_OPTIONS.debounceMs - 100, + doRequest: () => Promise.resolve({ items: [{ id: 'test-id' }] }), + hasPaginationQueryShapeChanged: () => true, + initialCursor: { tailward: 'tailward', headward: 'headward' }, + initialOffset: 10, + lockItemOrder: !DEFAULT_PAGINATION_OPTIONS.lockItemOrder, + pageSize: DEFAULT_PAGINATION_OPTIONS.pageSize - 1, + throwErrors: true, + }; + const paginator = new Paginator(options); + expect(paginator.state.getLatestValue()).toEqual({ + hasMoreTail: true, + hasMoreHead: true, + isLoading: false, + items: undefined, + lastQueryError: undefined, + cursor: options.initialCursor, + offset: options.initialOffset, + }); + expect(paginator.isInitialized).toBe(false); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(0); + expect(paginator.config.initialCursor).toStrictEqual(options.initialCursor); + expect(paginator.config.initialOffset).toStrictEqual(options.initialOffset); + expect(paginator.config.throwErrors).toBe(options.throwErrors); + expect(paginator.pageSize).toBe(options.pageSize); + expect(paginator.config.hasPaginationQueryShapeChanged).toStrictEqual( + options.hasPaginationQueryShapeChanged, + ); + expect(paginator.config.debounceMs).toBe(options.debounceMs); + expect(paginator.config.lockItemOrder).toBe(options.lockItemOrder); + }); + }); + + describe('pagination API', () => { + it('throws is the paginator does implement own getNextQueryShape', () => { + const paginator = new IncompletePaginator(); + // @ts-expect-error accessing protected property + expect(paginator.getNextQueryShape).toThrow( + 'Paginator.getNextQueryShape() is not implemented', + ); + }); + + describe('shouldResetStateBeforeQuery', () => { + const stateBeforeQuery: PaginatorState = { + hasMoreTail: true, + hasMoreHead: true, + isLoading: false, + items: [{ id: 'test-item' }], + lastQueryError: undefined, + cursor: { tailward: 'tailward', headward: 'headward' }, + offset: 10, + }; + + const prevQueryShape: QueryShape = { filters: { id: 'a' }, sort: { id: 1 } }; + const nextQueryShape: QueryShape = { filters: { id: 'b' }, sort: { id: 1 } }; + + it('resets the state before a query when querying the first page', () => { + const paginator = new Paginator(); + const initialState = { ...stateBeforeQuery, items: undefined }; + paginator.state.next(initialState); + expect(paginator.state.getLatestValue()).toEqual(initialState); + // @ts-expect-error accessing protected property + expect(paginator.shouldResetStateBeforeQuery()).toBe(true); + }); + + it('resets the state before a query when query shape changed', () => { + const prevQueryShape: QueryShape = { filters: { id: 'a' }, sort: { id: 1 } }; + const nextQueryShape: QueryShape = { filters: { id: 'b' }, sort: { id: 1 } }; + const paginator = new Paginator(); + expect( + // @ts-expect-error accessing protected property + paginator.shouldResetStateBeforeQuery(prevQueryShape, nextQueryShape), + ).toBe(true); + expect( + // @ts-expect-error accessing protected property + paginator.shouldResetStateBeforeQuery(prevQueryShape, prevQueryShape), + ).toBe(false); + }); + + it('determines whether pagination state should be reset before a query using custom logic', () => { + const options = { + hasPaginationQueryShapeChanged: vi.fn().mockReturnValue(true), + }; + const paginator = new Paginator(options); + expect( + // @ts-expect-error accessing protected property + paginator.shouldResetStateBeforeQuery(prevQueryShape, nextQueryShape), + ).toBe(true); + expect( + // @ts-expect-error accessing protected property + paginator.shouldResetStateBeforeQuery(prevQueryShape, prevQueryShape), + ).toBe(true); + expect(options.hasPaginationQueryShapeChanged).toHaveBeenCalledTimes(2); + }); + }); + + it('paginates to next pages (cursor)', async () => { + const paginator = new Paginator({ initialCursor: ZERO_PAGE_CURSOR }); + let nextPromise = paginator.toTail(); + // wait for the DB data first page load + await sleep(0); + expect(paginator.isLoading).toBe(true); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + + paginator.queryResolve({ + items: [{ id: 'id1' }], + tailward: 'next1', + headward: 'prev1', + }); + await nextPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toEqual({ tailward: 'next1', headward: 'prev1' }); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'tailward', + queryShape: defaultNextQueryShape, + reset: undefined, + retryCount: 0, + }); + + nextPromise = paginator.toTail(); + expect(paginator.isLoading).toBe(true); + paginator.queryResolve({ + items: [{ id: 'id2' }], + tailward: 'next2', + headward: 'prev2', + }); + await nextPromise; + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); + expect(paginator.cursor).toEqual({ tailward: 'next2', headward: 'prev2' }); + + nextPromise = paginator.toTail(); + paginator.queryResolve({ items: [] }); + await nextPromise; + expect(paginator.hasMoreTail).toBe(false); + expect(paginator.hasMoreHead).toBe(false); + expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); + expect(paginator.cursor).toEqual({ tailward: null, headward: null }); + + paginator.toTail(); + expect(paginator.isLoading).toBe(false); + expect(paginator.mockClientQuery).toHaveBeenCalledTimes(3); + }); + + it('paginates to next pages (offset)', async () => { + const paginator = new Paginator({ pageSize: 1 }); + let nextPromise = paginator.toTail(); + // wait for the DB data first page load + await sleep(0); + expect(paginator.isLoading).toBe(true); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await nextPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toBeUndefined(); + expect(paginator.offset).toBe(1); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'tailward', + queryShape: defaultNextQueryShape, + reset: undefined, + retryCount: 0, + }); + + nextPromise = paginator.toTail(); + expect(paginator.isLoading).toBe(true); + paginator.queryResolve({ items: [{ id: 'id2' }] }); + await nextPromise; + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); + expect(paginator.cursor).toBeUndefined(); + expect(paginator.offset).toBe(2); + + nextPromise = paginator.toTail(); + paginator.queryResolve({ items: [] }); + await nextPromise; + expect(paginator.hasMoreTail).toBe(false); + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); + expect(paginator.cursor).toBeUndefined(); + expect(paginator.offset).toBe(2); + + paginator.toTail(); + expect(paginator.isLoading).toBe(false); + expect(paginator.mockClientQuery).toHaveBeenCalledTimes(3); + }); + + it('paginates to next pages debounced (cursor)', async () => { + vi.useFakeTimers(); + const paginator = new Paginator({ + debounceMs: 2000, + initialCursor: ZERO_PAGE_CURSOR, + pageSize: 1, + }); + + paginator.toTailDebounced(); + expect(paginator.isLoading).toBe(false); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + vi.advanceTimersByTime(2000); + // await first page load from the DB + await toNextTick(); + expect(paginator.isLoading).toBe(true); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + + paginator.queryResolve({ + items: [{ id: 'id1' }], + tailward: 'next1', + headward: 'prev1', + }); + await paginator.queryPromise; + await toNextTick(); + expect(paginator.isLoading).toBe(false); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toEqual({ tailward: 'next1', headward: 'prev1' }); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'tailward', + queryShape: defaultNextQueryShape, + reset: undefined, + retryCount: 0, + }); + + vi.useRealTimers(); + }); + + it('paginates to next pages debounced (offset)', async () => { + vi.useFakeTimers(); + const paginator = new Paginator({ debounceMs: 2000, pageSize: 1 }); + + paginator.toTailDebounced(); + expect(paginator.isLoading).toBe(false); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + vi.advanceTimersByTime(2000); + // await first page load from the DB + await toNextTick(); + expect(paginator.isLoading).toBe(true); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + + paginator.queryResolve({ + items: [{ id: 'id1' }], + }); + await paginator.queryPromise; + await toNextTick(); + expect(paginator.isLoading).toBe(false); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toBeUndefined(); + expect(paginator.offset).toBe(1); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'tailward', + queryShape: defaultNextQueryShape, + reset: undefined, + retryCount: 0, + }); + + vi.useRealTimers(); + }); + + it('paginates to a previous page (cursor only)', async () => { + const paginator = new Paginator({ initialCursor: ZERO_PAGE_CURSOR }); + let nextPromise = paginator.toHead(); + await sleep(0); + expect(paginator.isLoading).toBe(true); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + + paginator.queryResolve({ + items: [{ id: 'id1' }], + tailward: 'next1', + headward: 'prev1', + }); + await nextPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toEqual({ tailward: 'next1', headward: 'prev1' }); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'headward', + queryShape: defaultNextQueryShape, + reset: undefined, + retryCount: 0, + }); + + nextPromise = paginator.toHead(); + expect(paginator.isLoading).toBe(true); + paginator.queryResolve({ + items: [{ id: 'id2' }], + tailward: 'next2', + headward: 'prev2', + }); + await nextPromise; + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); + expect(paginator.cursor).toEqual({ tailward: 'next2', headward: 'prev2' }); + + nextPromise = paginator.toHead(); + paginator.queryResolve({ items: [] }); + await nextPromise; + expect(paginator.hasMoreTail).toBe(false); + expect(paginator.hasMoreHead).toBe(false); + expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); + expect(paginator.cursor).toEqual({ tailward: null, headward: null }); + + paginator.toHead(); + expect(paginator.isLoading).toBe(false); + }); + + it('debounces the pagination to a previous page (cursor only)', async () => { + vi.useFakeTimers(); + const paginator = new Paginator({ + debounceMs: 2000, + initialCursor: ZERO_PAGE_CURSOR, + }); + + paginator.toHeadDebounced(); + expect(paginator.isLoading).toBe(false); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + vi.advanceTimersByTime(2000); + await toNextTick(); + expect(paginator.isLoading).toBe(true); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + + paginator.queryResolve({ + items: [{ id: 'id1' }], + tailward: 'next1', + headward: 'prev1', + }); + await paginator.queryPromise; + await toNextTick(); + expect(paginator.isLoading).toBe(false); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toEqual({ tailward: 'next1', headward: 'prev1' }); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'headward', + queryShape: defaultNextQueryShape, + reset: undefined, + retryCount: 0, + }); + vi.useRealTimers(); + }); + + it('cancelScheduledQuery cancels a pending debounced query', async () => { + vi.useFakeTimers(); + const paginator = new Paginator({ debounceMs: 2000 }); + + paginator.toTailDebounced(); + paginator.cancelScheduledQuery(); + + vi.advanceTimersByTime(2000); + await toNextTick(); + + expect(paginator.isLoading).toBe(false); + expect(paginator.mockClientQuery).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('prevents pagination if another query is in progress', async () => { + const paginator = new Paginator(); + const nextPromise1 = paginator.toTail(); + // wait for the first page load from the DB + await sleep(0); + expect(paginator.isLoading).toBe(true); + expect(paginator.mockClientQuery).toHaveBeenCalledTimes(1); + const nextPromise2 = paginator.toTail(); + paginator.queryResolve({ + items: [{ id: 'id1' }], + tailward: 'next1', + headward: 'prev1', + }); + await Promise.all([nextPromise1, nextPromise2]); + expect(paginator.mockClientQuery).toHaveBeenCalledTimes(1); + }); + + it('resets the state if the query shape changed', async () => { + const paginator = new Paginator({ pageSize: 1 }); + let nextPromise = paginator.toTail(); + await sleep(0); + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await nextPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toBeUndefined(); + expect(paginator.offset).toBe(1); + + paginator.getNextQueryShape.mockReturnValueOnce({ + filters: { id: 'test' }, + sort: { id: -1 }, + }); + nextPromise = paginator.toTail(); + await sleep(0); + expect(paginator.isLoading).toBe(true); + expect(paginator.items).toBeUndefined(); + expect(paginator.offset).toBe(0); + paginator.queryResolve({ items: [{ id: 'id2' }] }); + await nextPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.items).toEqual([{ id: 'id2' }]); + expect(paginator.offset).toBe(1); + }); + + it('resets the state if forced', async () => { + const paginator = new Paginator({ pageSize: 1 }); + let nextPromise = paginator.toTail(); + await sleep(0); + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await nextPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toBeUndefined(); + expect(paginator.offset).toBe(1); + + nextPromise = paginator.toTail({ reset: 'yes' }); + await sleep(0); + expect(paginator.isLoading).toBe(true); + expect(paginator.items).toBeUndefined(); + expect(paginator.offset).toBe(0); + paginator.queryResolve({ items: [{ id: 'id2' }] }); + await nextPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.items).toEqual([{ id: 'id2' }]); + expect(paginator.offset).toBe(1); + }); + + it('does not reset the state if forced', async () => { + const paginator = new Paginator({ pageSize: 1 }); + let nextPromise = paginator.toTail(); + await sleep(0); + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await nextPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toBeUndefined(); + expect(paginator.offset).toBe(1); + + paginator.getNextQueryShape.mockReturnValueOnce({ + filters: { id: 'test' }, + sort: { id: -1 }, + }); + nextPromise = paginator.toTail({ reset: 'no' }); + await sleep(0); + expect(paginator.items).toStrictEqual([{ id: 'id1' }]); + expect(paginator.offset).toBe(1); + paginator.queryResolve({ items: [{ id: 'id2' }] }); + await nextPromise; + expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); + expect(paginator.offset).toBe(2); + }); + + it('stores lastQueryError and clears it with the next successful query', async () => { + const paginator = new Paginator({ initialCursor: ZERO_PAGE_CURSOR }); + let nextPromise = paginator.toTail(); + // wait for the first page load from DB + await sleep(0); + const error = new Error('Failed'); + paginator.queryReject(error); + // hand over to finish the cleanup and state update after the query execution + await sleep(0); + expect(paginator.lastQueryError).toEqual(error); + expect(paginator.isLoading).toEqual(false); + + nextPromise = paginator.toTail(); + paginator.queryResolve({ + items: [{ id: 'id1' }], + tailward: 'next1', + headward: 'prev1', + }); + await nextPromise; + expect(paginator.lastQueryError).toBeUndefined(); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toEqual({ tailward: 'next1', headward: 'prev1' }); + }); + + it('throws error if enabled', async () => { + const paginator = new Paginator({ + initialCursor: ZERO_PAGE_CURSOR, + throwErrors: true, + }); + let nextPromise = paginator.toTail(); + // wait for the first page load from DB + await sleep(0); + const error = new Error('Failed'); + paginator.queryReject(error); + await expect(nextPromise).rejects.toThrowError(error); + // hand over to finish the cleanup and state update after the query execution + await sleep(0); + expect(paginator.lastQueryError).toEqual(error); + expect(paginator.isLoading).toEqual(false); + + nextPromise = paginator.toTail(); + // wait for the first page load from DB + await sleep(0); + paginator.queryResolve({ + items: [{ id: 'id1' }], + tailward: 'next1', + headward: 'prev1', + }); + await nextPromise; + expect(paginator.lastQueryError).toBeUndefined(); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toEqual({ tailward: 'next1', headward: 'prev1' }); + }); + + it('retries the query', async () => { + vi.useFakeTimers(); + const paginator = new Paginator({ initialCursor: ZERO_PAGE_CURSOR }); + let nextPromise = paginator.toTail({ retryCount: 2 }); + // wait for the first page load from DB + await toNextTick(); + const error = new Error('Failed'); + paginator.queryReject(error); + // hand over to finish the cleanup and state update after the query execution + await toNextTick(); + expect(paginator.lastQueryError).toEqual(error); + vi.advanceTimersByTime(DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES); + await toNextTick(); + + paginator.queryResolve({ + items: [{ id: 'id1' }], + tailward: 'next1', + headward: 'prev1', + }); + await nextPromise; + expect(paginator.lastQueryError).toBeUndefined(); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toEqual({ tailward: 'next1', headward: 'prev1' }); + vi.useRealTimers(); + }); + + it('executeQuery uses explicit queryShape and does not call getNextQueryShape', async () => { + const paginator = new Paginator(); + const forcedShape: QueryShape = { + filters: { id: 'forced' }, + sort: { id: -1 }, + }; + + const promise = paginator.executeQuery({ + direction: 'tailward', + queryShape: forcedShape, + }); + + await sleep(0); + paginator.queryResolve({ items: [] }); + await promise; + + expect(paginator.getNextQueryShape).not.toHaveBeenCalled(); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'tailward', + queryShape: forcedShape, + reset: undefined, + retryCount: 0, + }); + }); + + it.todo( + 'prevents setting active interval and emitting new state whe updateState === false', + () => {}, + ); + }); + + describe('item management', () => { + const item1: TestItem = { + id: 'id1', + name: 'test', + age: 100, + teams: ['abc', 'efg'], + }; + + const item2 = { + ...item1, + id: 'id2', + name: 'test2', + age: 101, + }; + + const item3 = { + ...item1, + id: 'id3', + name: 'test3', + age: 102, + }; + + it('hasResults reflects whether items have been set', () => { + const paginator = new Paginator(); + expect(paginator.hasResults).toBe(false); + + paginator.state.partialNext({ items: [] }); + expect(paginator.hasResults).toBe(true); + + paginator.resetState(); + expect(paginator.hasResults).toBe(false); + }); + + describe('matchesFilter', () => { + it('returns true if no filter is provided', async () => { + const paginator = new Paginator(); + expect(paginator.matchesFilter(item1)).toBeTruthy(); + }); + it('returns false if does not match the filter', async () => { + const paginator = new Paginator(); + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + name: { $eq: 'test1' }, + }); + expect(paginator.matchesFilter(item1)).toBeFalsy(); + }); + it('returns true if item matches the filter', async () => { + const paginator = new Paginator(); + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + $or: [{ name: { $eq: 'test1' } }, { teams: { $contains: 'abc' } }], + }); + expect(paginator.matchesFilter(item1)).toBeTruthy(); + }); + }); + + describe('locateByItem', () => { + afterEach(() => itemIndex.clear()); + + const tieBreakerById = (l: TestItem, r: TestItem) => + l.id < r.id ? -1 : l.id > r.id ? 1 : 0; + + it('returns -1 for empty list', () => { + const paginator = new Paginator(); + const res = paginator.locateByItem(a); + expect(res).toEqual({ + state: { currentIndex: -1, insertionIndex: 0 }, + } as ItemCoordinates); + }); + + it('finds an existing item on a tie plateau (no ID tiebreaker)', () => { + const paginator = new Paginator(); + // comparator: age desc only (ties produce a plateau) + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + // items are already sorted by age desc + paginator.state.partialNext({ items: [a, b, c, d] }); + + const location = paginator.locateByItem(c); + // c is at index 2 in [a, b, c, d] + // insertionIndex for identical key (age 25) is after the plateau + expect(location).toStrictEqual({ + state: { currentIndex: 2, insertionIndex: 3 }, + }); + }); + + it('finds an existing item on a tie plateau (no ID tiebreaker) with itemIndex', () => { + const paginator = new Paginator({ itemIndex }); + // comparator: age desc only (ties produce a plateau) + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + // items are already sorted by age desc + paginator.ingestPage({ page: [a, b, c, d], setActive: true }); + + const location = paginator.locateByItem(c); + expect(location).toStrictEqual({ + state: { currentIndex: 2, insertionIndex: 3 }, + interval: { + currentIndex: 2, + insertionIndex: 3, + interval: { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['a', 'b', 'c', 'd'], + }, + }, + }); + }); + + it('returns insertion index when not found on a tie plateau (no ID tiebreaker)', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + paginator.state.partialNext({ items: [a, b, c, d] }); + + // same sort keys as b/c but different id; not present + const x: TestItem = { id: 'x', age: 25, name: 'X' }; + const { state } = paginator.locateByItem(x); + // insertion point should be after the 25-plateau (after c at index 2) + expect(state?.currentIndex).toBe(-1); + expect(state?.insertionIndex).toBe(3); + }); + + it('returns insertion index when not found on a tie plateau (no ID tiebreaker) with itemIndex', () => { + const paginator = new Paginator({ itemIndex }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + paginator.ingestPage({ page: [a, b, c, d], setActive: true }); + + // same sort keys as b/c but different id; not present + const x: TestItem = { id: 'x', age: 25, name: 'X' }; + const location = paginator.locateByItem(x); + // insertion point should be after the 25-plateau (after c at index 2) + expect(location).toStrictEqual({ + state: { currentIndex: -1, insertionIndex: 3 }, + interval: { + currentIndex: -1, + insertionIndex: 3, + interval: { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['a', 'b', 'c', 'd'], + }, + }, + }); + }); + + it('finds exact index with ID tiebreaker in comparator (pure O(log n))', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + // tie-breaker on id asc guarantees a total order + tiebreaker: tieBreakerById, + }); + + // With tiebreaker, the order within age==25 is by id asc: b (id 'b'), then c (id 'c') + paginator.state.partialNext({ items: [a, b, c, d] }); + + const { state } = paginator.locateByItem(c); + expect(state?.currentIndex).toBe(2); + // In this setting the insertionIndex is deterministic but not strictly needed when found + expect(state?.insertionIndex).toBe(3); + }); + + it('finds exact index with ID tiebreaker in comparator (pure O(log n)) with itemIndex', () => { + const paginator = new Paginator({ itemIndex }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + // tie-breaker on id asc guarantees a total order + tiebreaker: tieBreakerById, + }); + + // With tiebreaker, the order within age==25 is by id asc: b (id 'b'), then c (id 'c') + paginator.ingestPage({ page: [a, b, c, d], setActive: true }); + const location = paginator.locateByItem(c); + expect(location).toStrictEqual({ + state: { currentIndex: 2, insertionIndex: 3 }, + interval: { + currentIndex: 2, + insertionIndex: 3, + interval: { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['a', 'b', 'c', 'd'], + }, + }, + }); + }); + + it('computes insertion for state at the beginning when needle sorts before all items', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + tiebreaker: tieBreakerById, + }); + paginator.state.partialNext({ items: [a, b, c, d] }); + + const z: TestItem = { id: 'z', age: 40, name: 'Z' }; // highest age → goes to front + const { state } = paginator.locateByItem(z); + expect(state?.currentIndex).toBe(-1); + expect(state?.insertionIndex).toBe(0); + }); + + it('computes insertion for state at the beginning when needle sorts before all items with itemIndex', () => { + const paginator = new Paginator({ itemIndex }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + tiebreaker: tieBreakerById, + }); + paginator.ingestPage({ page: [a, b, c, d], setActive: true }); + + const z: TestItem = { id: 'z', age: 40, name: 'Z' }; // highest age → goes to front + const location = paginator.locateByItem(z); + // interval does not exist so it is not included in the search result + expect(location).toStrictEqual({ + state: { currentIndex: -1, insertionIndex: 0 }, + }); + }); + + it('computes insertion for state at the end when needle sorts after all items', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + tiebreaker: tieBreakerById, + }); + paginator.state.partialNext({ items: [a, b, c, d] }); + + const z: TestItem = { id: 'z', age: 10, name: 'Z' }; // lowest age → goes to end + const { state } = paginator.locateByItem(z); + expect(state?.currentIndex).toBe(-1); + expect(state?.insertionIndex).toBe(4); + }); + + it('computes insertion for state at the end when needle sorts after all items with item index', () => { + const paginator = new Paginator({ itemIndex }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + tiebreaker: tieBreakerById, + }); + paginator.ingestPage({ page: [a, b, c, d], setActive: true }); + + const z: TestItem = { id: 'z', age: 10, name: 'Z' }; // lowest age → goes to end + const location = paginator.locateByItem(z); + // interval does not exist so it is not included in the search result + expect(location).toStrictEqual({ + state: { currentIndex: -1, insertionIndex: 4 }, + }); + }); + + it('locates the correct interval when multiple intervals exist', () => { + const paginator = new Paginator({ itemIndex }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + tiebreaker: tieBreakerById, + }); + paginator.ingestPage({ page: [a, b, c, d], setActive: true }); + paginator.ingestPage({ page: [v, x, y, z], setActive: true }); + + const location = paginator.locateByItem(z); + // interval does not exist so it is not included in the search result + expect(location).toStrictEqual({ + state: { currentIndex: 3, insertionIndex: 4 }, + interval: { + interval: { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['v', 'x', 'y', 'z'], + }, + currentIndex: 3, + insertionIndex: 4, + }, + }); + }); + }); + + describe('ingestPage', () => { + let paginator: Paginator; + beforeEach(() => { + paginator = new Paginator({ itemIndex }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + }); + + it('postQueryReconcile treats jump query as non-directional (direction undefined)', async () => { + class JumpAwarePaginator extends Paginator { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isJumpQueryShape(_queryShape: QueryShape): boolean { + return true; + } + } + + const jumpPaginator = new JumpAwarePaginator({ itemIndex }); + jumpPaginator.sortComparator = paginator.sortComparator; + + const ingestSpy = vi.spyOn(jumpPaginator, 'ingestPage'); + + await jumpPaginator.postQueryReconcile({ + direction: undefined, + isFirstPage: true, + queryShape: defaultNextQueryShape, + requestedPageSize: 10, + results: { items: [a] }, + updateState: false, + }); + + expect(ingestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + policy: 'strict-overlap-only', + isHead: undefined, + isTail: undefined, + targetIntervalId: undefined, + }), + ); + }); + + it('sorts items according to effectiveSortComparator', () => { + paginator.ingestPage({ page: [c, a, b, d, b, c, a], setActive: true }); + // sorts by age, not id + expect(paginator.items).toStrictEqual([a, a, c, b, b, c, d]); + // @ts-expect-error accessing protected property _itemIntervals + expect(paginator._itemIntervals.size).toBe(1); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['a', 'a', 'c', 'b', 'b', 'c', 'd'], + }, + ]); + }); + + it('sets items in intervals only', () => { + paginator.ingestPage({ page: [c, a, b, d, b, c, a] }); + expect(paginator.items).toBeUndefined(); + // @ts-expect-error accessing protected property _itemIntervals + expect(paginator._itemIntervals.size).toBe(1); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['a', 'a', 'c', 'b', 'b', 'c', 'd'], + }, + ]); + }); + + it('ingests into the anchored head interval', () => { + paginator.ingestPage({ page: [c, d], isHead: true, setActive: true }); + expect(paginator.items).toStrictEqual([c, d]); + // @ts-expect-error accessing protected property _itemIntervals + expect(paginator._itemIntervals.size).toBe(1); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: false, + hasMoreTail: true, + isHead: true, + isTail: false, + itemIds: ['c', 'd'], + }, + ]); + + paginator.ingestPage({ page: [a] }); + // ingestPage without setActive does not emit state.items + expect(paginator.items).toStrictEqual([c, d]); + // @ts-expect-error accessing protected property _itemIntervals + expect(paginator._itemIntervals.size).toBe(1); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: false, + hasMoreTail: true, + isHead: true, + isTail: false, + itemIds: ['a', 'c', 'd'], + }, + ]); + }); + + it('does not force-merge into head interval under strict-overlap-only policy', () => { + paginator.ingestPage({ page: [c, d], isHead: true, setActive: true }); + + // Under default ('auto') policy, ingesting [a] would be merged into the head interval + // even though the sort bounds do not overlap. + paginator.ingestPage({ page: [a], policy: 'strict-overlap-only' }); + + // @ts-expect-error accessing protected property _itemIntervals + const intervals = Array.from(paginator._itemIntervals.values()); + expect(intervals).toHaveLength(2); + + const headInterval = intervals.find( + (itv) => 'isHead' in itv && (itv as { isHead: boolean }).isHead, + ); + expect(headInterval).toBeTruthy(); + expect(headInterval).toMatchObject({ + isHead: true, + isTail: false, + itemIds: ['c', 'd'], + }); + + const otherInterval = intervals.find( + (itv) => !('isHead' in itv) || !(itv as { isHead: boolean }).isHead, + ); + expect(otherInterval).toBeTruthy(); + expect(otherInterval).toMatchObject({ + isHead: false, + isTail: false, + itemIds: ['a'], + }); + }); + + it('merges intervals when they strictly overlap under strict-overlap-only policy', () => { + paginator.ingestPage({ page: [b, c], setActive: true }); + paginator.ingestPage({ page: [c, d], policy: 'strict-overlap-only' }); + + // @ts-expect-error accessing protected property _itemIntervals + expect(paginator._itemIntervals.size).toBe(1); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['b', 'c', 'd'], + }, + ]); + }); + + it('prepends and appends a page', () => { + paginator.ingestPage({ page: [b, c], setActive: true }); + expect(paginator.items).toStrictEqual([b, c]); + // @ts-expect-error accessing protected property _itemIntervals + expect(paginator._itemIntervals.size).toBe(1); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['b', 'c'], + }, + ]); + + paginator.ingestPage({ page: [a] }); + expect(paginator.items).toStrictEqual([b, c]); + // @ts-expect-error accessing protected property _itemIntervals + expect(paginator._itemIntervals.size).toBe(2); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['a'], + }, + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['b', 'c'], + }, + ]); + + paginator.ingestPage({ page: [d] }); + expect(paginator.items).toStrictEqual([b, c]); + // @ts-expect-error accessing protected property _itemIntervals + expect(paginator._itemIntervals.size).toBe(3); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['a'], + }, + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['b', 'c'], + }, + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['d'], + }, + ]); + }); + + it('ingests into the anchored tail interval', () => { + paginator.ingestPage({ page: [b, c], isTail: true, setActive: true }); + expect(paginator.items).toStrictEqual([b, c]); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: false, + isHead: false, + isTail: true, + itemIds: ['b', 'c'], + }, + ]); + + paginator.ingestPage({ page: [d] }); + // ingestPage without setActive does not emit state.items + expect(paginator.items).toStrictEqual([b, c]); + // @ts-expect-error accessing protected property _itemIntervals + expect(paginator._itemIntervals.size).toBe(1); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: false, + isHead: false, + isTail: true, + itemIds: ['b', 'c', 'd'], + }, + ]); + }); + + it('merges all the overlapping anchored intervals, parts of logical intervals with target interval', () => { + let keys: string[] = []; + paginator.ingestPage({ page: [c, d], setActive: true }); + paginator.ingestPage({ page: [b] }); + // @ts-expect-error accessing protected property _itemIntervals + expect(paginator._itemIntervals.size).toBe(1); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['c', 'b', 'd'], // b.age === c.age => merged + }, + ]); + // @ts-expect-error accessing protected property _itemIntervals + keys = Array.from(paginator._itemIntervals.keys()); + + paginator.ingestItem(a); // leads to creation of logical head + // ingestItem does not emit into state.items if active interval isn't affected + expect(paginator.items).toStrictEqual([c, d]); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: LOGICAL_HEAD_INTERVAL_ID, + itemIds: ['a'], + }, + { + id: keys[0], + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['c', 'b', 'd'], + }, + ]); + + // @ts-expect-error accessing protected property _itemIntervals + keys = Array.from(paginator._itemIntervals.keys()); + + paginator.ingestItem(z); // leads to creation of logical tail + expect(paginator.items).toStrictEqual([c, d]); + // @ts-expect-error accessing protected property _itemIntervals + expect(paginator._itemIntervals.size).toBe(3); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: LOGICAL_HEAD_INTERVAL_ID, + itemIds: ['a'], + }, + { + id: keys[1], + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['c', 'b', 'd'], + }, + { + id: LOGICAL_TAIL_INTERVAL_ID, + itemIds: ['z'], + }, + ]); + + // @ts-expect-error accessing protected property _itemIntervals + keys = Array.from(paginator._itemIntervals.keys()); + + paginator.ingestPage({ page: [x] }); + expect(paginator.items).toStrictEqual([c, d]); + // @ts-expect-error accessing protected property _itemIntervals + expect(paginator._itemIntervals.size).toBe(4); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: LOGICAL_HEAD_INTERVAL_ID, + itemIds: ['a'], + }, + { + id: keys[1], + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['c', 'b', 'd'], + }, + { + id: expect.any(String), // new interval with new id + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['x'], + }, + { + id: LOGICAL_TAIL_INTERVAL_ID, + itemIds: ['z'], + }, + ]); + // @ts-expect-error accessing protected property _itemIntervals + keys = Array.from(paginator._itemIntervals.keys()); + + paginator.ingestPage({ page: [y], targetIntervalId: keys[2] }); + expect(paginator.items).toStrictEqual([c, d]); + // @ts-expect-error accessing protected property _itemIntervals + expect(paginator._itemIntervals.size).toBe(4); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: LOGICAL_HEAD_INTERVAL_ID, + itemIds: ['a'], + }, + { + id: keys[1], + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['c', 'b', 'd'], + }, + { + id: keys[2], + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['x', 'y'], + }, + { + id: LOGICAL_TAIL_INTERVAL_ID, + itemIds: ['z'], + }, + ]); + + // @ts-expect-error accessing protected property _itemIntervals + keys = Array.from(paginator._itemIntervals.keys()); + const previousAnchoredPageId = keys[1]; + + paginator.ingestPage({ page: [a, b, z] }); + // @ts-expect-error accessing protected property _itemIntervals + expect(paginator._itemIntervals.size).toBe(1); + // @ts-expect-error accessing protected property _itemIntervals + const currentAnchoredPageId = Array.from(paginator._itemIntervals.keys())[0]; + expect(previousAnchoredPageId).toBe(currentAnchoredPageId); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: currentAnchoredPageId, + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + // original interval (containing 'c') served as a base, therefore 'b' is merged after 'c' + itemIds: ['a', 'c', 'b', 'd', 'x', 'y', 'z'], + }, + ]); + }); + + it('marks head and tail anchored intervals and removes existing logical intervals', () => { + paginator.ingestItem(b); + paginator.ingestPage({ page: [d] }); + paginator.ingestItem(y); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: LOGICAL_HEAD_INTERVAL_ID, + itemIds: ['b'], + }, + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['d'], + }, + { + id: LOGICAL_TAIL_INTERVAL_ID, + itemIds: ['y'], + }, + ]); + + paginator.ingestPage({ page: [a], isHead: true }); + paginator.ingestPage({ page: [z], isTail: true }); + + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: false, + hasMoreTail: true, + isHead: true, + isTail: false, + itemIds: ['a'], + }, + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['b'], + }, + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['d'], + }, + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['y'], + }, + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: false, + isHead: false, + isTail: true, + itemIds: ['z'], + }, + ]); + }); + + it('merges incomplete head intervals with existing logical intervals and sorts their items', () => { + paginator.ingestItem(a); // logical head + paginator.ingestPage({ page: [d] }); // anchored interval + paginator.ingestItem(y); // logical tail + + paginator.ingestPage({ page: [c], isHead: true }); + paginator.ingestPage({ page: [x], isTail: true }); + + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: false, + hasMoreTail: true, + isHead: true, + isTail: false, + itemIds: ['a', 'c'], + }, + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['d'], + }, + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: false, + isHead: false, + isTail: true, + itemIds: ['x', 'y'], + }, + ]); + }); + + it('ignores targetInterval if it is a logical interval', () => { + paginator.ingestItem(a); // logical head + paginator.ingestPage({ page: [c, d] }); // anchored interval + paginator.ingestPage({ page: [b], targetIntervalId: LOGICAL_HEAD_INTERVAL_ID }); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: LOGICAL_HEAD_INTERVAL_ID, + itemIds: ['a'], + }, + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['c', 'b', 'd'], // according to sort c === b and thus b is inserted at the next free slot + }, + ]); + + paginator.ingestPage({ page: [x], targetIntervalId: LOGICAL_HEAD_INTERVAL_ID }); + + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: LOGICAL_HEAD_INTERVAL_ID, + itemIds: ['a'], + }, + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['c', 'b', 'd'], + }, + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['x'], + }, + ]); + }); + + it('merges to target interval within the neighbour interval bounds (does not overlap with neighbours) - paginator.toTail()', () => { + paginator.ingestPage({ page: [a, b] }); + paginator.ingestPage({ page: [x, y] }); + // @ts-expect-error accessing protected property _itemIntervals + const keys = Array.from(paginator._itemIntervals.keys()); + paginator.ingestPage({ page: [c, d], targetIntervalId: keys[0] }); + + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: keys[0], + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['a', 'b', 'c', 'd'], + }, + { + id: keys[1], + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['x', 'y'], + }, + ]); + }); + + it('uses anchored target interval as base even if non-overlapping', () => { + paginator.ingestPage({ page: [a] }); + paginator.ingestPage({ page: [b, d] }); + // @ts-expect-error accessing protected property _itemIntervals + const keys = Array.from(paginator._itemIntervals.keys()); + paginator.ingestPage({ page: [c], targetIntervalId: keys[0] }); + + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: keys[0], + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['a', 'b', 'c', 'd'], + }, + ]); + }); + + it('merges page into anchored target interval even if disjoint', () => { + paginator.ingestPage({ page: [a] }); + paginator.ingestPage({ page: [b, d] }); + // @ts-expect-error accessing protected property _itemIntervals + let keys = Array.from(paginator._itemIntervals.keys()); + paginator.ingestPage({ page: [x], targetIntervalId: keys[0] }); + + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: keys[0], + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['a', 'x'], + }, + { + id: keys[1], + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['b', 'd'], + }, + ]); + + paginator.resetState(); + paginator.ingestPage({ page: [a] }); + paginator.ingestPage({ page: [d] }); + paginator.ingestPage({ page: [x] }); + // @ts-expect-error accessing protected property _itemIntervals + expect(paginator._itemIntervals.size).toBe(3); + + // @ts-expect-error accessing protected property _itemIntervals + keys = Array.from(paginator._itemIntervals.keys()); + // ingesting into the interval with x will merge b into x + paginator.ingestPage({ page: [b], targetIntervalId: keys[2] }); + + // @ts-expect-error accessing protected property _itemIntervals + expect(paginator._itemIntervals.size).toBe(3); + // @ts-expect-error accessing protected property _itemIntervals + const values = Array.from(paginator._itemIntervals.values()); + expect(values).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: keys[0], + isHead: false, + isTail: false, + itemIds: ['a'], + }), + expect.objectContaining({ + isHead: false, + isTail: false, + itemIds: ['d'], + }), + expect.objectContaining({ + id: keys[2], + isHead: false, + isTail: false, + itemIds: ['b', 'x'], + }), + ]), + ); + }); + + it('does not ingest if itemIndex is not available', () => { + paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + expect(paginator.items).toBeUndefined(); + paginator.ingestPage({ page: [a] }); + expect(paginator.items).toBeUndefined(); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([]); + }); + + it('does not ingest if page has no items', () => { + paginator.ingestPage({ page: [] }); + expect(paginator.items).toBeUndefined(); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([]); + }); + }); + + describe('ingestItem to state only', () => { + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'item exists but does not match the filter anymore removes the item %s', + (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder }); + + paginator.state.partialNext({ + items: [item3, item2, item1], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $eq: ['abc', 'efg'] }, // required membership in these two teams + }); + + const adjustedItem = { + ...item1, + teams: ['efg'], // removed from the team abc + }; + + expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item removed + expect(paginator.items).toStrictEqual([item3, item2]); + }, + ); + + it.each([ + [' adjusts the order on lockItemOrder: false', false], + [' does not adjust the order on lockItemOrder: true', true], + ])('exists and matches the filter updates the item and %s', (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder }); + paginator.state.partialNext({ + items: [item1, item2, item3], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + age: { $gt: 100 }, + }); + + const adjustedItem1 = { + ...item1, + age: 103, + }; + + expect(paginator.ingestItem(adjustedItem1)).toBeTruthy(); // item updated + + if (lockItemOrder) { + expect(paginator.items).toStrictEqual([adjustedItem1, item2, item3]); + } else { + expect(paginator.items).toStrictEqual([item2, item3, adjustedItem1]); + } + }); + + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'does not exist and does not match the filter results in no action %s', + (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder }); + paginator.state.partialNext({ + items: [item1], // age: 100 + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + age: { $gt: 100 }, + }); + + const adjustedItem = { + ...item1, + id: 'id2', + name: 'test2', + }; + + expect(paginator.ingestItem(adjustedItem)).toBeFalsy(); // no action + expect(paginator.items).toStrictEqual([item1]); + }, + ); + + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'does not exist and matches the filter inserts according to default sort order (append) %s', + (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder }); + paginator.state.partialNext({ + items: [item3, item1], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + + expect(paginator.ingestItem(item2)).toBeTruthy(); + expect(paginator.items).toStrictEqual([item3, item1, item2]); + }, + ); + + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'does not exist and matches the filter inserts according to sort order %s', + (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder }); + paginator.state.partialNext({ + items: [item3, item1], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + expect(paginator.ingestItem(item2)).toBeTruthy(); + expect(paginator.items).toStrictEqual([item3, item2, item1]); + }, + ); + + it('reflects the boost priority on lockItemOrder: false for newly ingested items', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item3, item1], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + + paginator.boost(item2.id); + expect(paginator.ingestItem(item2)).toBeTruthy(); + expect(paginator.items).toStrictEqual([item2, item3, item1]); + }); + + it('reflects the boost priority on lockItemOrder: false for existing items recently boosted', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item1, item2, item3], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + age: { $gt: 100 }, + }); + + const adjustedItem2 = { + ...item2, + age: 103, + }; + paginator.boost(item2.id); + expect(paginator.ingestItem(adjustedItem2)).toBeTruthy(); // item updated + expect(paginator.items).toStrictEqual([adjustedItem2, item1, item3]); + }); + + it('does not reflect the boost priority on lockItemOrder: true', () => { + const paginator = new Paginator({ lockItemOrder: true }); + paginator.state.partialNext({ + items: [item1, item2, item3], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + age: { $gt: 100 }, + }); + + paginator.boost(item2.id); + expect(paginator.ingestItem(item2)).toBeTruthy(); // item updated + expect(paginator.items).toStrictEqual([item1, item2, item3]); + }); + + it('reflects the boost priority on lockItemOrder: true when ingesting a new item', () => { + const paginator = new Paginator({ lockItemOrder: true }); + paginator.state.partialNext({ + items: [item3, item1], + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + + paginator.boost(item2.id); + expect(paginator.ingestItem(item2)).toBeTruthy(); + expect(paginator.items).toStrictEqual([item2, item3, item1]); + }); + }); + + describe('ingestItem with itemIndex', () => { + beforeEach(() => { + itemIndex.clear(); + }); + + it('updates an item that lives only in the logical head interval re-inserts the item back to logical interval', () => { + const paginator = new Paginator({ itemIndex }); + + // Sort by age desc so we can create "head" and "tail" logically + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + // First ingestion: item2 (age 101) → logical head + expect(paginator.ingestItem(item2)).toBe(true); + + // Second ingestion: item1 (age 100, younger than item2) → logical tail + expect(paginator.ingestItem(item1)).toBe(true); + + // We should now have only logical intervals: head + tail + // @ts-expect-error accessing protected property + let intervals = Array.from(paginator._itemIntervals.values()); + expect(intervals).toStrictEqual([ + { + id: LOGICAL_HEAD_INTERVAL_ID, + itemIds: [item2.id], + }, + { + id: LOGICAL_TAIL_INTERVAL_ID, + itemIds: [item1.id], + }, + ]); + + // Update the tail item snapshot (change sort-relevant field) + const updatedHead: TestItem = { + ...item2, + age: 150, // arbitrary change + }; + + expect(paginator.ingestItem(updatedHead)).toBe(true); + + // ItemIndex snapshot for id1 is updated + // @ts-expect-error accessing protected property + expect(paginator._itemIndex!.get(item2.id)).toStrictEqual(updatedHead); + + // We still have exactly the same logical head + tail intervals by ID and membership + // (the "still belongs to previous logical interval when only logical intervals exist" rule) + // @ts-expect-error accessing protected property + intervals = Array.from(paginator._itemIntervals.values()); + expect(intervals).toStrictEqual([ + { + id: LOGICAL_HEAD_INTERVAL_ID, + itemIds: [item2.id], + }, + { + id: LOGICAL_TAIL_INTERVAL_ID, + itemIds: [item1.id], + }, + ]); + }); + + it('keeps updated existing item in logical tail when only logical intervals exist', () => { + const paginator = new Paginator({ itemIndex }); + + // Sort by age desc so we can create "head" and "tail" logically + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + // First ingestion: item2 (age 101) → logical head + expect(paginator.ingestItem(item2)).toBe(true); + + // Second ingestion: item1 (age 100, younger than item2) → logical tail + expect(paginator.ingestItem(item1)).toBe(true); + + // We should now have only logical intervals: head + tail + // @ts-expect-error accessing protected property + let intervals = Array.from(paginator._itemIntervals.values()); + expect(intervals).toStrictEqual([ + { + id: LOGICAL_HEAD_INTERVAL_ID, + itemIds: [item2.id], + }, + { + id: LOGICAL_TAIL_INTERVAL_ID, + itemIds: [item1.id], + }, + ]); + + // Update the tail item snapshot (change sort-relevant field) + const updatedTail: TestItem = { + ...item1, + age: 50, // arbitrary change + }; + + expect(paginator.ingestItem(updatedTail)).toBe(true); + + // ItemIndex snapshot for id1 is updated + // @ts-expect-error accessing protected property + expect(paginator._itemIndex!.get(item1.id)).toStrictEqual(updatedTail); + + // We still have exactly the same logical head + tail intervals by ID and membership + // (the "still belongs to previous logical interval when only logical intervals exist" rule) + // @ts-expect-error accessing protected property + intervals = Array.from(paginator._itemIntervals.values()); + expect(intervals).toStrictEqual([ + { + id: LOGICAL_HEAD_INTERVAL_ID, + itemIds: [item2.id], + }, + { + id: LOGICAL_TAIL_INTERVAL_ID, + itemIds: [item1.id], + }, + ]); + }); + + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'item exists but does not match the filter anymore removes the item %s', + (_, lockItemOrder) => { + const paginator = new Paginator({ itemIndex, lockItemOrder }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $eq: ['abc', 'efg'] }, // required membership in these two teams + }); + + paginator.ingestPage({ + page: [item3, item2, item1], + setActive: true, + }); + + // @ts-expect-error accessing protected property _itemIndex + expect(Array.from(paginator._itemIndex!.values())).toStrictEqual([ + item3, + item2, + item1, + ]); + + const adjustedItem1 = { + ...item1, + teams: ['efg'], // removed from the team abc + }; + + expect(paginator.ingestItem(adjustedItem1)).toBeTruthy(); // item removed + expect(paginator.items).toStrictEqual([item3, item2]); + + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id3', 'id2'], + }, + ]); + + // item index keeps the reference + // @ts-expect-error accessing protected property _itemIndex + expect(Array.from(paginator._itemIndex!.values())).toStrictEqual([ + item3, + item2, + adjustedItem1, + ]); + }, + ); + + it.each([ + [' does not adjust the order on lockItemOrder: true', true], + [' adjusts the order on lockItemOrder: false', false], + ])('exists and matches the filter updates the item and %s', (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder, itemIndex }); + paginator.ingestPage({ + page: [item1, item2, item3], + setActive: true, + }); + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + age: { $gt: 100 }, + }); + + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: 1 } }); + + const adjustedItem1 = { + ...item1, + age: 103, + }; + + expect(paginator.ingestItem(adjustedItem1)).toBeTruthy(); // item updated + + if (lockItemOrder) { + expect(paginator.items).toStrictEqual([adjustedItem1, item2, item3]); + } else { + // moved to next page that may be disjoint and would be retrieved by pagination + expect(paginator.items).toStrictEqual([item2, item3]); + } + + // intervals are independent of the UI layer in state.items + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id2', 'id3'], + }, + { + id: LOGICAL_TAIL_INTERVAL_ID, + itemIds: ['id1'], + }, + ]); + + // item index keeps the reference + // @ts-expect-error accessing protected property _itemIndex + expect(Array.from(paginator._itemIndex!.values())).toStrictEqual([ + adjustedItem1, + item2, + item3, + ]); + }); + + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'does not exist and does not match the filter results in no action %s', + (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder, itemIndex }); + paginator.ingestPage({ + page: [item1], // age: 100 + setActive: true, + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + age: { $gt: 100 }, + }); + + const adjustedItem = { + ...item1, + id: 'id2', + name: 'test2', + }; + + expect(paginator.ingestItem(adjustedItem)).toBeFalsy(); // no action + expect(paginator.items).toStrictEqual([item1]); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id1'], + }, + ]); + }, + ); + + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'does not exist and matches the filter inserts according to default sort order (append) %s', + (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder, itemIndex }); + paginator.ingestPage({ + page: [item3, item1], + setActive: true, + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + + expect(paginator.ingestItem(item2)).toBeTruthy(); + expect(paginator.items).toStrictEqual([item3, item1, item2]); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id3', 'id1', 'id2'], + }, + ]); + }, + ); + + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'does not exist and matches the filter inserts according to sort order %s', + (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder, itemIndex }); + paginator.ingestPage({ + page: [item3, item1], + setActive: true, + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + expect(paginator.ingestItem(item2)).toBeTruthy(); + expect(paginator.items).toHaveLength(3); + expect(paginator.items).toStrictEqual([item3, item2, item1]); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id3', 'id2', 'id1'], + }, + ]); + }, + ); + + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'does not exist, matches the filter, is out of the current interval bounds, inserts according to sort order %s to a new interval', + (_, lockItemOrder) => { + const paginator = new Paginator({ lockItemOrder, itemIndex }); + paginator.ingestPage({ + page: [item3, item1], + setActive: true, + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + const item4 = { + id: 'id4', + name: 'test', + age: 99, + teams: ['abc'], + }; + expect(paginator.ingestItem(item4)).toBeTruthy(); + expect(paginator.items).toStrictEqual([item3, item1]); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id3', 'id1'], + }, + { + id: LOGICAL_TAIL_INTERVAL_ID, + itemIds: ['id4'], + }, + ]); + }, + ); + + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + '%s is not reflected in a previously non-active interval we jump to', + (_, lockItemOrder) => { + const paginator = new Paginator({ itemIndex, lockItemOrder }); + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + const firstPage = paginator.ingestPage({ + page: [item3, item1], + setActive: true, + }); + + const item4 = { + id: 'id4', + name: 'test', + age: 96, + teams: ['abc'], + }; + const item5 = { + id: 'id5', + name: 'test', + age: 97, + teams: ['abc'], + }; + const item6 = { + id: 'id6', + name: 'test', + age: 98, + teams: ['abc'], + }; + const secondPage = paginator.ingestPage({ + page: [item6, item5, item4], + setActive: true, + }); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: firstPage!.id, + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id3', 'id1'], + }, + { + id: secondPage!.id, + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id6', 'id5', 'id4'], + }, + ]); + + const adjustedItem4 = { + ...item4, + age: 98, + }; + expect(paginator.ingestItem(adjustedItem4)).toBeTruthy(); + + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: firstPage!.id, + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id3', 'id1'], + }, + { + id: secondPage!.id, + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id6', 'id4', 'id5'], + }, + ]); + expect( + // @ts-expect-error accessing protected property _itemIntervals + paginator.intervalToItems(paginator._itemIntervals.get(secondPage!.id)!), + ).toStrictEqual([item6, adjustedItem4, item5]); + }, + ); + + it.each([ + ['on lockItemOrder: false', false], + ['on lockItemOrder: true', true], + ])( + 'existing item with changed sort-relevant properties is removed altogether if falls between existing intervals', + (_, lockItemOrder) => { + const paginator = new Paginator({ itemIndex, lockItemOrder }); + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + const firstPage = paginator.ingestPage({ + page: [item3, item1], + setActive: true, + }); + + const item4 = { + id: 'id4', + name: 'test', + age: 96, + teams: ['abc'], + }; + const item5 = { + id: 'id5', + name: 'test', + age: 97, + teams: ['abc'], + }; + const item6 = { + id: 'id6', + name: 'test', + age: 98, + teams: ['abc'], + }; + const secondPage = paginator.ingestPage({ + page: [item6, item5, item4], + setActive: true, + }); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: firstPage!.id, + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id3', 'id1'], + }, + { + id: secondPage!.id, + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id6', 'id5', 'id4'], + }, + ]); + + const adjustedItem5 = { + ...item5, + age: 99, + }; + expect(paginator.ingestItem(adjustedItem5)).toBeTruthy(); + + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: firstPage!.id, + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id3', 'id1'], + }, + { + id: secondPage!.id, + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id6', 'id4'], + }, + ]); + expect( + // @ts-expect-error accessing protected property _itemIntervals + paginator.intervalToItems(paginator._itemIntervals.get(secondPage!.id)!), + ).toStrictEqual([item6, item4]); + }, + ); + + it.each([ + ['on lockItemOrder: false', 'is', false], + ['on lockItemOrder: true', 'is not', true], + ])( + '%s boost %s reflected in a previously non-active interval we jump to', + (_, __, lockItemOrder) => { + const paginator = new Paginator({ itemIndex, lockItemOrder }); + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + const firstPage = paginator.ingestPage({ + page: [item3, item1], + setActive: true, + }); + + const item4 = { + id: 'id4', + name: 'test', + age: 97, + teams: ['abc'], + }; + const item5 = { + id: 'id5', + name: 'test', + age: 98, + teams: ['abc'], + }; + const item6 = { + id: 'id6', + name: 'test', + age: 99, + teams: ['abc'], + }; + const secondPage = paginator.ingestPage({ + page: [item6, item5, item4], + setActive: true, + }); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: firstPage!.id, + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id3', 'id1'], + }, + { + id: secondPage!.id, + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id6', 'id5', 'id4'], + }, + ]); + + paginator.boost(item5.id, { until: 9999999999999999 }); + expect(paginator.ingestItem(item5)).toBeTruthy(); + + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: firstPage!.id, + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id3', 'id1'], + }, + { + id: secondPage!.id, + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id6', 'id5', 'id4'], + }, + ]); + if (lockItemOrder) { + expect( + // @ts-expect-error accessing protected property _itemIntervals + paginator.intervalToItems(paginator._itemIntervals.get(secondPage!.id)!), + ).toStrictEqual([item6, item5, item4]); + } else { + expect( + // @ts-expect-error accessing protected property _itemIntervals + paginator.intervalToItems(paginator._itemIntervals.get(secondPage!.id)!), + ).toStrictEqual([item5, item6, item4]); + } + }, + ); + + it('reflects the boost priority on lockItemOrder: false for newly ingested items in state.items only', () => { + const paginator = new Paginator({ itemIndex }); + paginator.ingestPage({ + page: [item3, item1], + setActive: true, + }); + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + paginator.boost(item2.id); + expect(paginator.ingestItem(item2)).toBeTruthy(); + expect(paginator.items).toStrictEqual([item2, item3, item1]); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id3', 'id2', 'id1'], + }, + ]); + }); + + it('reflects the boost priority on lockItemOrder: false for newly ingested items ingested outside the existing interval only in state.items', () => { + const paginator = new Paginator({ itemIndex }); + paginator.ingestPage({ + page: [item3, item1], + setActive: true, + }); + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + const item4 = { + id: 'id4', + name: 'test', + age: 99, + teams: ['abc'], + }; + paginator.boost(item4.id, { until: 9999999999999999 }); + expect(paginator.ingestItem(item4)).toBeTruthy(); + expect(paginator.items).toStrictEqual([item3, item1]); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id3', 'id1'], + }, + { + id: LOGICAL_TAIL_INTERVAL_ID, + itemIds: ['id4'], + }, + ]); + + const item5 = { + id: 'id5', + name: 'test', + age: 98, + teams: ['abc'], + }; + paginator.boost(item5.id, { until: 9999999999999999, seq: 1 }); + expect(paginator.ingestItem(item5)).toBeTruthy(); + expect(paginator.items).toStrictEqual([item3, item1]); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id3', 'id1'], + }, + { + id: LOGICAL_TAIL_INTERVAL_ID, + itemIds: ['id4', 'id5'], + }, + ]); + expect( + paginator.intervalToItems( + // @ts-expect-error accessing protected property _itemIntervals + paginator._itemIntervals.get(LOGICAL_TAIL_INTERVAL_ID)!, + ), + ).toStrictEqual([item5, item4]); + }); + + it('boosted existing item in an anchored interval moves ahead of non-boosted items (lockItemOrder: false) only in state.items', () => { + const paginator = new Paginator({ itemIndex }); + paginator.ingestPage({ + page: [item1, item2, item3], + setActive: true, + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + age: { $gt: 100 }, + }); + + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: 1 }, + }); + + paginator.boost(item2.id); + expect(paginator.ingestItem(item2)).toBeTruthy(); // item updated + expect(paginator.items).toStrictEqual([item2, item1, item3]); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id1', 'id2', 'id3'], + }, + ]); + }); + + it('does not reflect the boost priority of existing on lockItemOrder: true', () => { + const paginator = new Paginator({ itemIndex, lockItemOrder: true }); + paginator.ingestPage({ + page: [item1, item2, item3], + setActive: true, + }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + age: { $gt: 100 }, + }); + + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: 1 }, + }); + + paginator.boost(item2.id); + expect(paginator.ingestItem(item2)).toBeTruthy(); // item updated + expect(paginator.items).toStrictEqual([item1, item2, item3]); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id1', 'id2', 'id3'], + }, + ]); + }); + + it('does not reflect the boost priority on lockItemOrder: true when ingesting a new item only in state.items', () => { + const paginator = new Paginator({ itemIndex, lockItemOrder: true }); + paginator.ingestPage({ page: [item3, item1], setActive: true }); + + // @ts-expect-error accessing protected property + paginator.buildFilters = () => ({ + teams: { $contains: 'abc' }, + }); + + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + + paginator.boost(item2.id); + expect(paginator.ingestItem(item2)).toBeTruthy(); + expect(paginator.items).toStrictEqual([item3, item2, item1]); + // @ts-expect-error accessing protected property _itemIntervals + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: true, + hasMoreTail: true, + isHead: false, + isTail: false, + itemIds: ['id3', 'id2', 'id1'], + }, + ]); + }); + }); + + describe('removeItem', () => { + it('removes existing item', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item3, item2, item1], + }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + expect(paginator.removeItem({ item: item3 })).toStrictEqual({ + state: { currentIndex: 0, insertionIndex: 0 }, + }); + expect(paginator.items).toHaveLength(2); + expect(paginator.items![0]).toStrictEqual(item2); + expect(paginator.items![1]).toStrictEqual(item1); + }); + + it('results in no action for non-existent item', () => { + const paginator = new Paginator(); + paginator.state.partialNext({ + items: [item2, item1], + }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: -1 }, + }); + expect(paginator.removeItem({ item: item3 })).toStrictEqual({ + state: { currentIndex: -1, insertionIndex: -1 }, + }); + expect(paginator.items).toHaveLength(2); + expect(paginator.items![0]).toStrictEqual(item2); + expect(paginator.items![1]).toStrictEqual(item1); + }); + + it('removes item from both state and anchored intervals when itemIndex is present', () => { + const paginator = new Paginator({ itemIndex }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + paginator.ingestPage({ page: [item3, item2, item1], setActive: true }); + + const result = paginator.removeItem({ id: item2.id }); + + expect(result.state?.currentIndex).toBe(1); + // Interval no longer contains id2 + // @ts-expect-error accessing protected property + const intervals = Array.from(paginator._itemIntervals.values()); + expect(intervals).toHaveLength(1); + expect(intervals[0].itemIds).toEqual(['id3', 'id1']); + + expect(paginator.items!.map((i) => i.id)).toEqual(['id3', 'id1']); + }); + + it('falls back to linear scan by id when no itemIndex is provided', () => { + const paginator = new Paginator(); // no itemIndex + paginator.state.partialNext({ items: [item3, item2, item1] }); + + const res = paginator.removeItem({ id: item2.id }); + + expect(res).toEqual({ state: { currentIndex: 1, insertionIndex: -1 } }); + expect(paginator.items!.map((i) => i.id)).toEqual(['id3', 'id1']); + }); + + it('removeItem is a no-op when itemIndex exists but does not have the interval for the given id', () => { + const paginator = new Paginator({ itemIndex }); + paginator.state.partialNext({ items: [item1] }); + + const res = paginator.removeItem({ id: 'missing' }); + + expect(res).toEqual({ state: { currentIndex: -1, insertionIndex: -1 } }); + expect(paginator.items).toEqual([item1]); + // @ts-expect-error accessing protected property + expect(paginator._itemIntervals.size).toBe(0); + }); + + it('removeItem is a no-op when itemIndex exists and has the interval but id is unknown', () => { + const paginator = new Paginator({ itemIndex }); + paginator.ingestPage({ page: [item1], setActive: true }); + + const res = paginator.removeItem({ id: 'missing' }); + + expect(res).toEqual({ state: { currentIndex: -1, insertionIndex: -1 } }); + expect(paginator.items).toEqual([item1]); + // @ts-expect-error accessing protected property + expect(paginator._itemIntervals.size).toBe(1); + }); + + it('removes last item and removes the parent interval', () => { + const paginator = new Paginator({ itemIndex }); + paginator.ingestPage({ page: [item1], setActive: true }); + + const res = paginator.removeItem({ id: item1.id }); + + expect(res).toEqual({ + state: { currentIndex: 0, insertionIndex: 0 }, + interval: { + interval: res.interval!.interval, + currentIndex: 0, + insertionIndex: 0, + }, + }); + // we are not returning to undefined as a sign that we have not reset the pagination + expect(paginator.items).toStrictEqual([]); + // @ts-expect-error accessing protected property + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([]); + }); + + it('removes last item and removes the parent interval from a non-active page', () => { + const paginator = new Paginator({ itemIndex }); + paginator.ingestPage({ page: [item1] }); + + const res = paginator.removeItem({ id: item1.id }); + + expect(res).toEqual({ + // the state has no data so we get -1 for indices + state: { currentIndex: -1, insertionIndex: -1 }, + interval: { + interval: res.interval!.interval, + currentIndex: 0, + insertionIndex: 0, + }, + }); + expect(paginator.items).toBeUndefined(); + // @ts-expect-error accessing protected property + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([]); + }); + }); + + describe('setItems', () => { + it('overrides all the items in the state with provided value', () => { + const paginator = new Paginator(); + const items1 = [{ id: 'test-item1' }]; + const items2 = [{ id: 'test-item2' }]; + paginator.setItems({ valueOrFactory: items1 }); + expect(paginator.items).toStrictEqual(items1); + paginator.setItems({ valueOrFactory: items2 }); + expect(paginator.items).toStrictEqual(items2); + }); + + const items = [{ id: 'test-item1' }]; + const expectedStateEmissions = [ + { + cursor: undefined, + hasMoreTail: true, + hasMoreHead: true, + isLoading: false, + items: undefined, + lastQueryError: undefined, + offset: 0, + }, + { + cursor: undefined, + hasMoreTail: true, + hasMoreHead: true, + isLoading: false, + items, + lastQueryError: undefined, + offset: 1, + }, + ]; + + it('emits state change as long as the items are not the same', () => { + const paginator = new Paginator(); + const subscriptionHandler = vi.fn(); + const unsubscribe = paginator.state.subscribe(subscriptionHandler); + expect(subscriptionHandler).toHaveBeenCalledTimes(1); + expect(subscriptionHandler).toHaveBeenCalledWith( + expectedStateEmissions[0], + undefined, + ); + + paginator.setItems({ valueOrFactory: items }); + expect(paginator.items).toStrictEqual(items); + expect(subscriptionHandler).toHaveBeenCalledTimes(2); + expect(subscriptionHandler).toHaveBeenCalledWith( + expectedStateEmissions[1], + expectedStateEmissions[0], + ); + + // setting an object with the same reference + paginator.setItems({ valueOrFactory: items }); + expect(paginator.items).toStrictEqual(items); + expect(subscriptionHandler).toHaveBeenCalledTimes(2); + expect(subscriptionHandler).toHaveBeenCalledWith( + expectedStateEmissions[1], + expectedStateEmissions[0], + ); + + unsubscribe(); + }); + + it('emits state change as long as the state factory returns objects with different reference', () => { + const paginator = new Paginator(); + const subscriptionHandler = vi.fn(); + const unsubscribe = paginator.state.subscribe(subscriptionHandler); + + paginator.setItems({ valueOrFactory: () => items }); + expect(paginator.items).toStrictEqual(items); + // first call is on subscribe + expect(subscriptionHandler).toHaveBeenCalledTimes(2); + expect(subscriptionHandler).toHaveBeenCalledWith( + expectedStateEmissions[1], + expectedStateEmissions[0], + ); + + // setting an object with the same reference + paginator.setItems({ valueOrFactory: () => items }); + expect(paginator.items).toStrictEqual(items); + expect(subscriptionHandler).toHaveBeenCalledTimes(2); + expect(subscriptionHandler).toHaveBeenCalledWith( + expectedStateEmissions[1], + expectedStateEmissions[0], + ); + + unsubscribe(); + }); + + it('updates the cursor if provided', () => { + const paginator = new Paginator(); + const cursors: PaginatorCursor[] = [ + { tailward: 'next1', headward: 'prev1' }, + { tailward: 'next2', headward: 'prev1' }, + ]; + const subscriptionHandler = vi.fn(); + const unsubscribe = paginator.state.subscribe(subscriptionHandler); + + paginator.setItems({ valueOrFactory: items, cursor: cursors[0] }); + expect(subscriptionHandler).toHaveBeenCalledTimes(2); + expect(subscriptionHandler).toHaveBeenCalledWith( + { ...expectedStateEmissions[1], cursor: cursors[0], offset: 0 }, + { ...expectedStateEmissions[0], cursor: undefined, offset: 0 }, + ); + + unsubscribe(); + }); + + it('prioritizes isFirstPage: true and isLastPage: true', () => { + const paginator = new Paginator({ itemIndex }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + const page = [item2, item1]; + + paginator.setItems({ + valueOrFactory: page, + isFirstPage: true, + isLastPage: true, + }); + + // @ts-expect-error accessing protected property + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: false, + hasMoreTail: false, + isHead: true, + isTail: true, + itemIds: ['id2', 'id1'], + }, + ]); + + paginator.setItems({ + valueOrFactory: [item3], + isFirstPage: false, + isLastPage: false, + }); + + // @ts-expect-error accessing protected property + expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([ + { + id: expect.any(String), + hasMoreHead: false, + hasMoreTail: false, + isHead: true, + isTail: true, + itemIds: ['id3', 'id2', 'id1'], + }, + ]); + }); + + it('does not reflect on isFirstPage and isLastPage when item interval storage is disabled', () => { + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + const page = [item2, item1]; + + paginator.setItems({ + valueOrFactory: page, + isFirstPage: true, + isLastPage: true, + }); + + // @ts-expect-error accessing protected property + expect(paginator._itemIntervals.size).toBe(0); + expect(paginator.items).toStrictEqual([item2, item1]); + + paginator.setItems({ + valueOrFactory: [item3], + isFirstPage: false, + isLastPage: false, + }); + + // @ts-expect-error accessing protected property + expect(paginator._itemIntervals.size).toBe(0); + expect(paginator.items).toStrictEqual([item3]); + }); + + it('with itemIndex creates an anchored interval and sets it active', () => { + const paginator = new Paginator({ itemIndex }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + const page = [item3, item1]; + + paginator.setItems({ + valueOrFactory: page, + isFirstPage: true, + isLastPage: false, + }); + + expect(paginator.items).toEqual(page); + expect(paginator.offset).toBe(page.length); + + // @ts-expect-error accessing protected property + const intervals = Array.from(paginator._itemIntervals.values()); + expect(intervals).toHaveLength(1); + expect(intervals[0]).toMatchObject({ + isHead: true, + isTail: false, + itemIds: ['id3', 'id1'], + }); + + // @ts-expect-error accessing protected property + expect(paginator._activeIntervalId).toBe(intervals[0].id); + }); + }); + + describe('reload', () => { + it('starts the ended pagination from the beginning [offset pagination]', async () => { + const paginator = new Paginator({ pageSize: 2 }); + paginator.state.next({ + hasMoreTail: false, + hasMoreHead: false, + isLoading: false, + items: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }], + offset: 4, + }); + let reloadPromise = paginator.reload(); + // wait for the DB data first page load + await sleep(0); + expect(paginator.isLoading).toBe(true); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await reloadPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasMoreTail).toBe(false); + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toBeUndefined(); + expect(paginator.offset).toBe(1); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'tailward', + queryShape: defaultNextQueryShape, + reset: 'yes', + retryCount: 0, + }); + + reloadPromise = paginator.reload(); + // wait for the DB data first page load + await sleep(0); + expect(paginator.isLoading).toBe(true); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + + paginator.queryResolve({ items: [{ id: 'id2' }], tailward: 'next2' }); + await reloadPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasMoreTail).toBe(false); + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.items).toEqual([{ id: 'id2' }]); + expect(paginator.cursor).toBeUndefined(); + expect(paginator.offset).toBe(1); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'tailward', + queryShape: defaultNextQueryShape, + reset: 'yes', + retryCount: 0, + }); + }); + it('starts the ended pagination from the beginning [cursor pagination]', async () => { + const paginator = new Paginator({ initialCursor: ZERO_PAGE_CURSOR, pageSize: 2 }); + paginator.state.next({ + hasMoreTail: false, + hasMoreHead: false, + isLoading: false, + items: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }], + cursor: { tailward: 'tailward1', headward: 'headward1' }, + }); + let reloadPromise = paginator.reload(); + // wait for the DB data first page load + await sleep(0); + expect(paginator.isLoading).toBe(true); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await reloadPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasMoreTail).toBe(false); + expect(paginator.hasMoreHead).toBe(false); + expect(paginator.items).toEqual([{ id: 'id1' }]); + expect(paginator.cursor).toStrictEqual({ tailward: null, headward: null }); + expect(paginator.offset).toBe(0); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'tailward', + queryShape: defaultNextQueryShape, + reset: 'yes', + retryCount: 0, + }); + + reloadPromise = paginator.reload(); + // wait for the DB data first page load + await sleep(0); + expect(paginator.isLoading).toBe(true); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + + paginator.queryResolve({ items: [{ id: 'id2' }], tailward: 'tailward2' }); + await reloadPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(false); + expect(paginator.items).toEqual([{ id: 'id2' }]); + expect(paginator.cursor).toStrictEqual({ tailward: 'tailward2', headward: null }); + expect(paginator.offset).toBe(0); + expect(paginator.mockClientQuery).toHaveBeenCalledWith({ + direction: 'tailward', + queryShape: defaultNextQueryShape, + reset: 'yes', + retryCount: 0, + }); + + // reset in another direction + reloadPromise = paginator.reload(); + // wait for the DB data first page load + await sleep(0); + expect(paginator.isLoading).toBe(true); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.items).toBe(undefined); + + paginator.queryResolve({ items: [{ id: 'id2' }], headward: 'headward2' }); + await reloadPromise; + expect(paginator.isLoading).toBe(false); + expect(paginator.hasMoreTail).toBe(false); + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.items).toEqual([{ id: 'id2' }]); + expect(paginator.cursor).toStrictEqual({ headward: 'headward2', tailward: null }); + expect(paginator.offset).toBe(0); + }); + }); + + describe('resetState', () => { + it('restores initial state and clears intervals', () => { + const paginator = new Paginator({ itemIndex }); + paginator.ingestPage({ page: [item3, item2], setActive: true }); + + // Sanity: mutated state + intervals + expect(paginator.items).toEqual([item3, item2]); + // @ts-expect-error + expect(paginator._itemIntervals.size).toBe(1); + + paginator.resetState(); + + expect(paginator.state.getLatestValue()).toEqual(paginator.initialState); + // @ts-expect-error + expect(paginator._itemIntervals.size).toBe(0); + }); + }); + + describe('filter resolvers', () => { + const resolvers1 = [{ matchesField: () => true, resolve: () => 'abc' }]; + const resolvers2 = [ + { matchesField: () => false, resolve: () => 'efg' }, + { matchesField: () => true, resolve: () => 'hij' }, + ]; + it('get overridden with setFilterResolvers', () => { + const paginator = new Paginator(); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(0); + + paginator.setFilterResolvers(resolvers1); + + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(resolvers1.length); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toStrictEqual(resolvers1); + + paginator.setFilterResolvers(resolvers2); + + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(resolvers2.length); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toStrictEqual(resolvers2); + + paginator.setFilterResolvers([]); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(0); + }); + + it('get expanded with addFilterResolvers', () => { + const paginator = new Paginator(); + paginator.addFilterResolvers(resolvers1); + + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toStrictEqual(resolvers1); + + paginator.addFilterResolvers(resolvers2); + + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toStrictEqual([ + ...resolvers1, + ...resolvers2, + ]); + + paginator.addFilterResolvers([]); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toStrictEqual([ + ...resolvers1, + ...resolvers2, + ]); + }); + }); + + describe('item boosting', () => { + const a = { id: 'a', age: 10, name: 'A' } as TestItem; + const b = { id: 'b', age: 20, name: 'B' } as TestItem; + const c = { id: 'c', age: 30, name: 'C' } as TestItem; + + const byIdAsc = (l: TestItem, r: TestItem) => + l.id < r.id ? -1 : l.id > r.id ? 1 : 0; + + describe('clearExpiredBoosts', () => { + it('removes expired boosts and updates maxBoostSeq', () => { + const paginator = new Paginator(); + // @ts-expect-error accessing protected property + paginator.boosts.clear(); + const now = 1000000; + + paginator.boost('fresh', { until: now + 1000, seq: 1 }); + paginator.boost('stale', { until: now - 1, seq: 5 }); + + // @ts-expect-error accessing protected method + paginator.clearExpiredBoosts(now); + + // @ts-expect-error accessing protected property + expect(Array.from(paginator.boosts.keys())).toEqual(['fresh']); + expect(paginator.maxBoostSeq).toBe(1); + }); + + it('sets maxBoostSeq to 0 when no boosts remain', () => { + const paginator = new Paginator(); + // two expired boosts at "now" + paginator.boost('x', { until: 1000, seq: 1 }); + paginator.boost('y', { until: 1500, seq: 3 }); + + // @ts-expect-error accessing protected method + paginator.clearExpiredBoosts(10000); + + // @ts-expect-error accessing protected property + expect(paginator.boosts.size).toBe(0); + expect(paginator.maxBoostSeq).toBe(0); + }); + }); + + describe('boostComparator', () => { + it('prioritizes boosted over non-boosted', () => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + + const paginator = new Paginator(); + paginator.sortComparator = byIdAsc; + + // Boost only "a" + paginator.boost('b', { ttlMs: 10000, seq: 0 }); + + // @ts-expect-error: protected method + expect(paginator.boostComparator(a, b)).toBe(1); // a after b + // @ts-expect-error + expect(paginator.boostComparator(b, a)).toBe(-1); // b stays before a + + // Let boost expire + vi.setSystemTime(new Date(now.getTime() + 11000)); + // @ts-expect-error + expect(paginator.boostComparator(a, b)).toBe(-1); // fallback to byIdAsc + vi.useRealTimers(); + }); + + it('when both boosted, higher seq comes first; ties fall back to sortComparator', () => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + + const paginator = new Paginator(); + // Fallback comparator id asc + paginator.sortComparator = byIdAsc; + + paginator.boost('a', { ttlMs: 60000, seq: 1 }); + paginator.boost('b', { ttlMs: 60000, seq: 3 }); + + // b has higher seq → should come first → comparator(a,b) > 0 + // @ts-expect-error + expect(paginator.boostComparator(a, b)).toBe(1); + // reverse check + // @ts-expect-error + expect(paginator.boostComparator(b, a)).toBe(-1); + + // Equal seq → fall back to sortComparator (id asc => a before b) + paginator.boost('a', { ttlMs: 60000, seq: 2 }); + paginator.boost('b', { ttlMs: 60000, seq: 2 }); + // @ts-expect-error + expect(paginator.boostComparator(a, b)).toBe(-1); + + vi.useRealTimers(); + }); + + it('ignores expired boosts automatically during comparison', () => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + + const paginator = new Paginator(); + paginator.sortComparator = byIdAsc; + + paginator.boost('b', { ttlMs: 5000, seq: 10 }); + // Initially boosted + // @ts-expect-error + expect(paginator.boostComparator(a, b)).toBe(1); + + // Advance beyond TTL so boost is expired; comparator should fall back + vi.setSystemTime(new Date(now.getTime() + 6000)); + // @ts-expect-error + expect(paginator.boostComparator(a, b)).toBe(-1); // byIdAsc, not boost + vi.useRealTimers(); + }); + }); + + describe('boost', () => { + it('assigns default TTL (15s) and default seq=0; updates maxBoostSeq only upward', () => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + + const paginator = new Paginator(); + + paginator.boost('k'); // default 15s, seq 0 + const b1 = paginator.getBoost('k')!; + expect(b1.seq).toBe(0); + expect(b1.until).toBe(now.getTime() + 15000); + expect(paginator.maxBoostSeq).toBe(0); + + // Raise max seq + paginator.boost('m', { ttlMs: 1000, seq: 5 }); + expect(paginator.maxBoostSeq).toBe(5); + + // Lower seq should NOT decrease maxBoostSeq + paginator.boost('n', { ttlMs: 1000, seq: 2 }); + expect(paginator.maxBoostSeq).toBe(5); + + vi.useRealTimers(); + }); + + it('accepts explicit until and seq', () => { + const paginator = new Paginator(); + paginator.boost('z', { until: 42, seq: 7 }); + const b = paginator.getBoost('z')!; + expect(b.until).toBe(42); + expect(b.seq).toBe(7); + expect(paginator.maxBoostSeq).toBe(7); + }); + }); + + describe('getBoost', () => { + it('returns the boost record when present; otherwise undefined', () => { + const paginator = new Paginator(); + expect(paginator.getBoost('missing')).toBeUndefined(); + paginator.boost('a', { ttlMs: 1000, seq: 1 }); + const b = paginator.getBoost('a'); + expect(b).toBeDefined(); + expect(b!.seq).toBe(1); + }); + }); + + describe('removeBoost', () => { + it('removes a boost and recalculates maxBoostSeq', () => { + const paginator = new Paginator(); + paginator.boost('a', { ttlMs: 60000, seq: 1 }); + paginator.boost('b', { ttlMs: 60000, seq: 5 }); + paginator.boost('c', { ttlMs: 60000, seq: 2 }); + expect(paginator.maxBoostSeq).toBe(5); + + paginator.removeBoost('b'); // remove current max + expect(paginator.getBoost('b')).toBeUndefined(); + expect(paginator.maxBoostSeq).toBe(2); + + paginator.removeBoost('c'); + expect(paginator.getBoost('c')).toBeUndefined(); + expect(paginator.maxBoostSeq).toBe(1); + + paginator.removeBoost('a'); + expect(paginator.getBoost('a')).toBeUndefined(); + expect(paginator.maxBoostSeq).toBe(0); + }); + }); + + describe('isBoosted', () => { + it('returns true when boost exists and now <= until; false otherwise', () => { + vi.useFakeTimers(); + const now = new Date('2025-01-01T00:00:00Z'); + vi.setSystemTime(now); + + const paginator = new Paginator(); + expect(paginator.isBoosted('x')).toBe(false); + + paginator.boost('x', { ttlMs: 5000, seq: 0 }); + expect(paginator.isBoosted('x')).toBe(true); + + // Exactly at until is still considered boosted per <= check + vi.setSystemTime(new Date(now.getTime() + 5000)); + expect(paginator.isBoosted('x')).toBe(true); + + // After until → false + vi.setSystemTime(new Date(now.getTime() + 5001)); + expect(paginator.isBoosted('x')).toBe(false); + + vi.useRealTimers(); + }); + }); + + describe('integration: ingestion respects boostComparator implicitly', () => { + it('newly ingested boosted items float above non-boosted regardless of fallback sort', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-01T00:00:00Z')); + + const paginator = new Paginator(); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ + sort: { age: 1 }, // ascending age (so normally a < b < c by age) + }); + paginator.state.partialNext({ items: [a, b] }); + + // Boost "c" before ingest → it should be placed ahead of non-boosted even though age is highest + paginator.boost('c', { ttlMs: 60000, seq: 1 }); + expect(paginator.ingestItem(c)).toBeTruthy(); + + // c should be first due to boost, then a, then b (fallback sort would place c last otherwise) + expect(paginator.items!.map((i) => i.id)).toEqual(['c', 'a', 'b']); + + vi.useRealTimers(); + }); + }); + }); + }); +}); diff --git a/test/unit/pagination/ChannelPaginator.test.ts b/test/unit/pagination/paginators/ChannelPaginator.test.ts similarity index 97% rename from test/unit/pagination/ChannelPaginator.test.ts rename to test/unit/pagination/paginators/ChannelPaginator.test.ts index 9ce1c0564a..bd5cd225e4 100644 --- a/test/unit/pagination/ChannelPaginator.test.ts +++ b/test/unit/pagination/paginators/ChannelPaginator.test.ts @@ -9,10 +9,10 @@ import { type FilterBuilderGenerators, PaginatorCursor, type StreamChat, -} from '../../../src'; -import { getClientWithUser } from '../test-utils/getClient'; -import type { FieldToDataResolver } from '../../../src/pagination/types.normalization'; -import { MockOfflineDB } from '../offline-support/MockOfflineDB'; +} from '../../../../src'; +import { getClientWithUser } from '../../test-utils/getClient'; +import type { FieldToDataResolver } from '../../../../src/pagination/types.normalization'; +import { MockOfflineDB } from '../../offline-support/MockOfflineDB'; const user = { id: 'custom-id' }; @@ -38,8 +38,8 @@ describe('ChannelPaginator', () => { const paginator = new ChannelPaginator({ client }); expect(paginator.pageSize).toBe(DEFAULT_PAGINATION_OPTIONS.pageSize); expect(paginator.state.getLatestValue()).toEqual({ - hasNext: true, - hasPrev: true, + hasMoreTail: true, + hasMoreHead: true, isLoading: false, items: undefined, lastQueryError: undefined, @@ -83,7 +83,7 @@ describe('ChannelPaginator', () => { debounceMs: 45000, doRequest, hasPaginationQueryShapeChanged, - initialCursor: { prev: 'prev', next: '' }, + initialCursor: { headward: 'headward', tailward: '' }, initialOffset: 10, lockItemOrder: true, pageSize: 2, @@ -104,8 +104,8 @@ describe('ChannelPaginator', () => { }); expect(paginator.pageSize).toBe(2); expect(paginator.state.getLatestValue()).toEqual({ - hasNext: true, - hasPrev: true, + hasMoreTail: true, + hasMoreHead: true, isLoading: false, items: undefined, lastQueryError: undefined, @@ -556,8 +556,8 @@ describe('ChannelPaginator', () => { describe('setters', () => { const stateAfterQuery = { items: [channel1, channel2], - hasNext: false, - hasPrev: false, + hasMoreTail: false, + hasMoreHead: false, offset: 10, isLoading: false, lastQueryError: undefined, @@ -620,7 +620,7 @@ describe('ChannelPaginator', () => { paginator.staticFilters = filters; paginator.sort = sort; - paginator.setItems(items1); + paginator.setItems({ valueOrFactory: items1 }); expect(paginator.items).toStrictEqual(items1); expect( client.offlineDb?.upsertCidsForQuery as unknown as MockInstance, diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts new file mode 100644 index 0000000000..c86f218549 --- /dev/null +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -0,0 +1,493 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ZERO_PAGE_CURSOR } from '../../../../src/pagination/paginators/BasePaginator'; +import type { Interval } from '../../../../src/pagination/paginators/BasePaginator'; +import { MessagePaginator } from '../../../../src/pagination/paginators/MessagePaginator'; +import { ItemIndex } from '../../../../src/pagination/ItemIndex'; +import type { Channel } from '../../../../src/channel'; +import type { + LocalMessage, + MessagePaginationOptions, + MessageResponse, +} from '../../../../src/types'; +import { generateMessageDraft } from '../../test-utils/generateMessageDraft'; +import { generateMsg } from '../../test-utils/generateMessage'; +import { formatMessage } from '../../../../src'; +import { DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE } from '../../../../src/constants'; + +const createMessage = (overrides: Partial): LocalMessage => + formatMessage( + generateMsg({ + id: 'message-id', + ...overrides, + }), + ); + +describe('MessagePaginator', () => { + let channel: Channel; + let itemIndex: ItemIndex; + + beforeEach(() => { + channel = { cid: 'channel-id', query: vi.fn() } as unknown as Channel; + itemIndex = new ItemIndex({ getId: (message) => message.id }); + }); + + describe('constructor()', () => { + it('applies defaults and builds comparator', () => { + const paginator = new MessagePaginator({ channel }); + + expect(paginator.pageSize).toBe(100); + expect(paginator.id.startsWith('message-paginator-')).toBe(true); + expect(paginator.state.getLatestValue()).toEqual({ + cursor: ZERO_PAGE_CURSOR, + hasMoreHead: true, + hasMoreTail: true, + isLoading: false, + items: undefined, + lastQueryError: undefined, + offset: 0, + }); + // @ts-expect-error accessing protected property + expect(paginator._filterFieldToDataResolvers).toHaveLength(1); + + const newer = createMessage({ id: 'b', created_at: '2021-01-01T00:00:00.000Z' }); + const older = createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }); + expect(paginator.sortComparator(older, newer)).toBeLessThan(0); + expect(paginator.sortComparator(newer, older)).toBeGreaterThan(0); + + const sameDateA = createMessage({ + id: 'a', + created_at: '2021-01-01T00:00:00.000Z', + }); + const sameDateB = createMessage({ + id: 'b', + created_at: '2021-01-01T00:00:00.000Z', + }); + expect(paginator.sortComparator(sameDateA, sameDateB)).toBeLessThan(0); // because of the same date, the tiebreaker kicks in + }); + + it('respects provided paginator options', () => { + const doRequest = vi.fn(); + const paginator = new MessagePaginator({ + channel, + id: 'custom-id', + itemIndex, + paginatorOptions: { doRequest, pageSize: 5 }, + }); + + expect(paginator.pageSize).toBe(5); + expect(paginator.id).toBe('custom-id'); + expect(paginator.sort).toEqual({ created_at: 1 }); + expect(paginator.config.doRequest).toBe(doRequest); + }); + }); + + describe('query shape handling', () => { + it('returns always false for hasPaginationQueryShapeChanged', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + const prev: MessagePaginationOptions = { id_gt: 'a', limit: 10 }; + const nextSameShape: MessagePaginationOptions = { id_gt: 'a', limit: 30 }; + const nextDifferent: MessagePaginationOptions = { id_gt: 'b', limit: 10 }; + + expect(paginator.config.hasPaginationQueryShapeChanged(prev, nextSameShape)).toBe( + false, + ); + expect(paginator.config.hasPaginationQueryShapeChanged(prev, nextDifferent)).toBe( + false, + ); + }); + + it('builds filters using the channel cid', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + expect(paginator.buildFilters()).toEqual({ cid: 'channel-id' }); + }); + + it('computes next query shape from cursor and direction', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + const currentState = paginator.state.getLatestValue(); + paginator.state.next({ + ...currentState, + cursor: { headward: 'head-cursor', tailward: 'tail-cursor' }, + }); + + // @ts-expect-error accessing protected method + expect(paginator.getNextQueryShape({ direction: 'tailward' })).toEqual({ + id_lt: 'tail-cursor', + limit: 100, + }); + + // @ts-expect-error accessing protected method + expect(paginator.getNextQueryShape({ direction: 'headward' })).toEqual({ + id_gt: 'head-cursor', + limit: 100, + }); + }); + }); + + describe('query()', () => { + it('uses an existing query shape when provided and respects doRequest path', async () => { + const paginator = new MessagePaginator({ + channel, + itemIndex, + paginatorOptions: { + doRequest: vi.fn().mockResolvedValue({ + cursor: { headward: 'head', tailward: 'tail' }, + items: [generateMsg({ id: '1' })], + }), + }, + }); + // @ts-expect-error setting protected field for test coverage + paginator._nextQueryShape = { + custom: 'shape', + } as unknown as MessagePaginationOptions; + // @ts-expect-error spying on protected method + const getNextQueryShapeSpy = vi.spyOn(paginator, 'getNextQueryShape'); + + const result = await paginator.query({ direction: 'headward' }); + + expect(paginator.config.doRequest).toHaveBeenCalledWith({ custom: 'shape' }); + expect(result.headward).toBe('head'); + expect(result.tailward).toBeUndefined(); + expect(getNextQueryShapeSpy).not.toHaveBeenCalled(); + }); + + it('formats channel query results and sets cursors based on direction', async () => { + const messages = [ + { id: 'first', created_at: '2022-01-01T00:00:00.000Z' }, + { id: 'last', created_at: '2022-01-02T00:00:00.000Z' }, + ]; + (channel.query as unknown as ReturnType).mockResolvedValue({ + messages, + }); + const paginator = new MessagePaginator({ channel, itemIndex }); + // @ts-expect-error setting protected field for test coverage + paginator._nextQueryShape = { id_gt: 'from-cursor', limit: 30 }; + + const result = await paginator.query({}); + + expect(channel.query).toHaveBeenCalledWith({ + messages: { id_gt: 'from-cursor', limit: 30 }, + }); + expect(result.tailward).toBe('first'); + expect(result.headward).toBe('last'); + expect(result.items[0].created_at).toBeInstanceOf(Date); + expect(result.items[1].created_at).toBeInstanceOf(Date); + }); + }); + + describe('jumpToMessage()', () => { + it('delegates to executeQuery with id_around payload', async () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + itemIndex.setOne( + createMessage({ id: 'target-message', created_at: '2020-01-01T00:00:00.000Z' }), + ); + const targetInterval: Interval = { + id: 'interval-1', + hasMoreHead: true, + hasMoreTail: true, + itemIds: ['target-message'], + isHead: false, + isTail: false, + }; + const executeQuerySpy = vi + .spyOn(paginator, 'executeQuery') + .mockResolvedValue({ stateCandidate: {}, targetInterval }); + + const result = await paginator.jumpToMessage('target-message', { pageSize: 13 }); + + expect(executeQuerySpy).toHaveBeenCalledWith({ + queryShape: { id_around: 'target-message', limit: 13 }, + updateState: false, + }); + expect(result).toBe(true); + }); + + it('updates cursor when jumping between already loaded intervals', async () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + + const m4 = createMessage({ + cid: 'channel-id', + id: 'm4', + created_at: '2020-01-04T00:00:00.000Z', + }); + const m5 = createMessage({ + cid: 'channel-id', + id: 'm5', + created_at: '2020-01-05T00:00:00.000Z', + }); + const m8 = createMessage({ + cid: 'channel-id', + id: 'm8', + created_at: '2020-01-08T00:00:00.000Z', + }); + const m9 = createMessage({ + cid: 'channel-id', + id: 'm9', + created_at: '2020-01-09T00:00:00.000Z', + }); + + // two disjoint anchored intervals + paginator.ingestPage({ page: [m8, m9], isHead: true, setActive: true }); + paginator.ingestPage({ page: [m4, m5] }); + + await paginator.jumpToMessage('m4'); + expect(paginator.cursor?.tailward).toBe('m4'); + + await paginator.jumpToMessage('m9'); + // jumping back to the head interval should restore its tailward cursor + expect(paginator.cursor?.tailward).toBe('m8'); + }); + }); + + describe.todo('jumpToTheLatestMessage', () => {}); + + describe('jumpToTheFirstUnreadMessage()', () => { + it('uses unreadState snapshot even if channel read state is already "read"', async () => { + const channelWithReadState = { + cid: 'channel-id', + query: vi.fn(), + state: { + read: { + user1: { + first_unread_message_id: null, + last_read_message_id: null, + }, + }, + }, + getClient: () => ({ + user: { id: 'user1' }, + }), + } as unknown as Channel; + + const paginator = new MessagePaginator({ + channel: channelWithReadState, + itemIndex, + }); + paginator.setUnreadSnapshot({ + firstUnreadMessageId: 'm-unread', + lastReadMessageId: 'm-read', + }); + + const jumpSpy = vi.spyOn(paginator, 'jumpToMessage').mockResolvedValue(true); + + const ok = await paginator.jumpToTheFirstUnreadMessage(); + + expect(ok).toBe(true); + expect(jumpSpy).toHaveBeenCalledWith('m-unread', undefined); + }); + + it('can ignore snapshot and rely on channel read state only', async () => { + const channelWithReadState = { + cid: 'channel-id', + query: vi.fn(), + state: { + read: { + user1: { + first_unread_message_id: null, + last_read_message_id: null, + }, + }, + }, + getClient: () => ({ + user: { id: 'user1' }, + }), + } as unknown as Channel; + + const paginator = new MessagePaginator({ + channel: channelWithReadState, + itemIndex, + unreadReferencePolicy: 'read-state-only', + }); + paginator.setUnreadSnapshot({ + firstUnreadMessageId: 'm-unread', + lastReadMessageId: 'm-read', + }); + + const jumpSpy = vi.spyOn(paginator, 'jumpToMessage').mockResolvedValue(true); + + const ok = await paginator.jumpToTheFirstUnreadMessage(); + + expect(ok).toBe(false); + expect(jumpSpy).not.toHaveBeenCalled(); + }); + }); + + describe('filterQueryResults()', () => { + it('removes shadowed messages', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + let items = [createMessage({ id: 'only' })]; + expect(paginator.filterQueryResults(items)).toEqual(items); + + items = [createMessage({ id: 'only', shadowed: true })]; + expect(paginator.filterQueryResults(items)).toEqual([]); + }); + }); + + describe.todo('postQueryReconcile and deriveCursor for', () => {}); + describe('linear pagination', () => { + describe('updates the hasMoreTail flag only if the first message on page is the first message in interval', () => { + it('no query shape is given', () => { + // const paginator = new MessagePaginator({ channel, itemIndex }); + // paginator.postQueryReconcile({ + // isFirstPage: true, + // requestedPageSize: + // queryChannelsOptions?.message_limit || + // DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE, + // results: { + // items: channelState.messages.map(formatMessage), + // }, + // }); + }); + it('and direction is "tailward"', () => { + // const paginator = new MessagePaginator({ channel, itemIndex }); + // paginator.config.deriveCursor({ + // direction: 'tailward', + // isFirstPage: true, + // requestedPageSize: + // queryChannelsOptions?.message_limit || + // DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE, + // results: { + // items: channelState.messages.map(formatMessage), + // }, + // }); + }); + it('query shape contains "created_at_before_or_equal"', () => {}); + it('query shape contains "created_at_before"', () => {}); + it('query shape contains "id_lt"', () => {}); + it('query shape contains "id_lte"', () => {}); + it('query shape contains "offset"', () => {}); + it('contains unrecognized query shape properties only', () => {}); + }); + it('updates the hasMoreTail flag if the page is empty', () => {}); + + describe('updates the hasMoreHead flag only if the last message on page is the last message in interval', () => { + it('and direction is "headward"', () => {}); + it('query shape contains "created_at_after_or_equal"', () => {}); + it('query shape contains "created_at_after"', () => {}); + it('query shape contains "id_gt"', () => {}); + it('query shape contains "id_gte"', () => {}); + it('query shape contains "offset"', () => {}); + it('contains unrecognized query shape properties only', () => {}); + }); + it('updates the hasMoreHead flag if the page is empty', () => {}); + }); + + describe('interval head/tail semantics', () => { + it('treats interval head as the newest edge (head is last itemId)', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + + const older = createMessage({ + cid: 'channel-id', + id: 'm1', + created_at: '2020-01-01T00:00:00.000Z', + }); + const newer = createMessage({ + cid: 'channel-id', + id: 'm2', + created_at: '2020-01-02T00:00:00.000Z', + }); + itemIndex.setMany([older, newer]); + + const intervalA = paginator.makeInterval({ page: [older] }); + const intervalB = paginator.makeInterval({ page: [newer] }); + + // @ts-expect-error accessing protected method + const sorted = paginator.sortIntervals([intervalA, intervalB]); + expect(sorted[0].id).toBe(intervalB.id); + expect(sorted[1].id).toBe(intervalA.id); + }); + + it('ingests a newer live message into the head interval (not logical tail)', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + + const m1 = createMessage({ + cid: 'channel-id', + id: 'm1', + created_at: '2020-01-01T00:00:00.000Z', + }); + const m2 = createMessage({ + cid: 'channel-id', + id: 'm2', + created_at: '2020-01-02T00:00:00.000Z', + }); + paginator.setItems({ + valueOrFactory: [m1, m2], + isFirstPage: true, + isLastPage: true, + }); + + const m3 = createMessage({ + cid: 'channel-id', + id: 'm3', + created_at: '2020-01-03T00:00:00.000Z', + }); + paginator.ingestItem(m3); + + expect(paginator.items?.map((m) => m.id)).toEqual(['m1', 'm2', 'm3']); + + // @ts-expect-error accessing protected storage + expect(paginator._itemIntervals.has('__logical_tail__')).toBe(false); + // @ts-expect-error accessing protected storage + expect(paginator._itemIntervals.has('__logical_head__')).toBe(false); + }); + }); + + describe('jump pagination + local filtering', () => { + it('marks jump interval as head when the newest message in the raw page is shadowed', async () => { + // postQueryReconcile override reads `channel.getClient().user.id` + (channel as unknown as { getClient: () => { user: { id: string } } }).getClient = + () => ({ + user: { id: 'user1' }, + }); + // also needs read state access for first page snapshot side effects + (channel as unknown as { state?: { read?: Record } }).state = { + read: {}, + }; + + const paginator = new MessagePaginator({ channel, itemIndex }); + + const m1 = createMessage({ + cid: 'channel-id', + id: 'm1', + created_at: '2020-01-01T00:00:00.000Z', + }); + const m2 = createMessage({ + cid: 'channel-id', + id: 'm2', + created_at: '2020-01-02T00:00:00.000Z', + }); + const m3 = createMessage({ + cid: 'channel-id', + id: 'm3', + created_at: '2020-01-03T00:00:00.000Z', + }); + const around = createMessage({ + cid: 'channel-id', + id: 'm4', + created_at: '2020-01-04T00:00:00.000Z', + }); + // newest message is shadowed -> filtered out before interval ingestion + const newestShadowed = createMessage({ + cid: 'channel-id', + id: 'm5', + created_at: '2020-01-05T00:00:00.000Z', + shadowed: true, + }); + + const { targetInterval } = await paginator.postQueryReconcile({ + isFirstPage: true, + queryShape: { id_around: around.id, limit: 5 }, + requestedPageSize: 5, + results: { items: [m1, m2, m3, around, newestShadowed] }, + updateState: false, + }); + + expect(targetInterval).toBeTruthy(); + expect((targetInterval as unknown as { isHead: boolean }).isHead).toBe(true); + expect((targetInterval as unknown as { isTail: boolean }).isTail).toBe(false); + }); + }); + + it('cannot be customized', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + }); +}); diff --git a/test/unit/pagination/paginators/MessageReplyPaginator.test.ts b/test/unit/pagination/paginators/MessageReplyPaginator.test.ts new file mode 100644 index 0000000000..43f73495cc --- /dev/null +++ b/test/unit/pagination/paginators/MessageReplyPaginator.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest'; +import { MessageReplyPaginator } from '../../../../src/pagination/paginators/MessageReplyPaginator'; +import type { + LocalMessage, + MessagePaginationOptions, + MessageResponse, +} from '../../../../src/types'; + +const makeLocalMessage = (id: string, createdAtMs: number): LocalMessage => + ({ + attachments: [], + created_at: new Date(createdAtMs), + deleted_at: null, + id, + mentioned_users: [], + pinned_at: null, + reaction_groups: null, + status: 'received', + text: id, + type: 'regular', + updated_at: new Date(createdAtMs), + }) as LocalMessage; + +const makeChannel = () => + ({ + cid: 'messaging:cid', + getClient: () => ({ + notifications: { addError: vi.fn() }, + }), + // Not used when config.doRequest is provided + getReplies: vi.fn(), + }) as unknown as import('../../../../src/channel').Channel; + +describe('MessageReplyPaginator', () => { + it('jumpToMessage does not query if message already in an interval', async () => { + const channel = makeChannel(); + const paginator = new MessageReplyPaginator({ + channel, + parentMessageId: 'parent-1', + }); + + const doRequest = vi.fn(async (query) => { + const options = query.options as MessagePaginationOptions; + const ids = options.id_around ? ['m1'] : ['m1']; + return { + items: ids.map((id) => makeLocalMessage(id, 1)), + }; + }); + + paginator.config.doRequest = doRequest; + + // Seed intervals + index + await paginator.executeQuery({ + queryShape: { options: { limit: 1 }, sort: paginator.sort }, + }); + expect(doRequest).toHaveBeenCalledTimes(1); + + const executeSpy = vi.spyOn(paginator, 'executeQuery'); + const ok = await paginator.jumpToMessage('m1'); + expect(ok).toBe(true); + expect(executeSpy).not.toHaveBeenCalled(); + }); + + it('jumpToMessage queries id_around when message not present', async () => { + const channel = makeChannel(); + const paginator = new MessageReplyPaginator({ + channel, + parentMessageId: 'parent-1', + }); + + const doRequest = vi.fn(async () => { + return { + items: [makeLocalMessage('m2', 2)], + }; + }); + paginator.config.doRequest = doRequest; + + const ok = await paginator.jumpToMessage('m2', { pageSize: 10 }); + expect(ok).toBe(true); + + expect(doRequest).toHaveBeenCalledTimes(1); + expect(doRequest).toHaveBeenCalledWith({ + options: { id_around: 'm2', limit: 10 }, + sort: [{ created_at: 1 }], + }); + }); + + it('jumpToTheLatestMessage calls jumpToMessage with latest id from head interval', async () => { + const channel = makeChannel(); + const paginator = new MessageReplyPaginator({ + channel, + parentMessageId: 'parent-1', + }); + + const doRequest = vi.fn(async () => { + return { + items: [makeLocalMessage('m1', 1), makeLocalMessage('m2', 2)], + }; + }); + paginator.config.doRequest = doRequest; + + // Ensure intervals are populated + await paginator.executeQuery({ + queryShape: { options: { limit: 2 }, sort: paginator.sort }, + }); + + const jumpSpy = vi.spyOn(paginator, 'jumpToMessage'); + await paginator.jumpToTheLatestMessage(); + + // We don't hard assert the id here because interval "head" semantics are internal, + // but we ensure it uses jumpToMessage as the final step. + expect(jumpSpy).toHaveBeenCalled(); + }); +}); diff --git a/test/unit/pagination/sortCompiler.test.ts b/test/unit/pagination/sortCompiler.test.ts index 500ab3eafd..ccc03a21bd 100644 --- a/test/unit/pagination/sortCompiler.test.ts +++ b/test/unit/pagination/sortCompiler.test.ts @@ -1,9 +1,6 @@ // sortCompiler.spec.ts import { describe, it, expect } from 'vitest'; -import { - binarySearchInsertIndex, - makeComparator, -} from '../../../src/pagination/sortCompiler'; +import { binarySearch, makeComparator } from '../../../src/pagination/sortCompiler'; import { resolveDotPathValue as defaultResolvePathValue } from '../../../src/pagination/utility.normalization'; import type { AscDesc } from '../../../src'; @@ -191,77 +188,387 @@ describe('makeComparator', () => { }); }); -describe('binarySearchInsertIndex', () => { - it('inserts at beginning, middle, and end as expected', () => { - const items: Item[] = [ - { cid: 'a', v: 10 }, - { cid: 'b', v: 20 }, - { cid: 'c', v: 30 }, - { cid: 'd', v: 40 }, - ]; - const cmp = toComparator({ v: 1 }); +const numberCompare = (a: number, b: number) => a - b; +const numberIdentityEquals = (a: number, b: number) => a === b; + +describe('binarySearch (generic cursor-based)', () => { + describe('empty array', () => { + it('returns not found and insertionIndex 0 for empty array', () => { + const result = binarySearch({ + needle: 42, + length: 0, + getItemAt: () => undefined, + itemIdentityEquals: numberIdentityEquals, + compare: numberCompare, + }); + + expect(result).toEqual({ currentIndex: -1, insertionIndex: 0 }); + }); + }); + + describe('single-element array', () => { + it('finds the element with plateauScan enabled', () => { + const arr = [10]; + + const result = binarySearch({ + needle: arr[0], + length: arr.length, + getItemAt: (i) => arr[i], + itemIdentityEquals: numberIdentityEquals, + compare: numberCompare, + plateauScan: true, + }); + + // insertionIndex is after the last <= needle (upper bound) + expect(result).toEqual({ currentIndex: 0, insertionIndex: 1 }); + }); + + it('does not find the element when plateauScan is disabled', () => { + const arr = [10]; - // Insert before all - let index = binarySearchInsertIndex({ - sortedArray: items, - needle: { cid: 'x', v: 5 }, - compare: cmp, + const result = binarySearch({ + needle: arr[0], + length: arr.length, + getItemAt: (i) => arr[i], + itemIdentityEquals: numberIdentityEquals, + compare: numberCompare, + plateauScan: false, + }); + + // insertionIndex is upper bound; currentIndex is -1 when plateauScan is false + expect(result).toEqual({ currentIndex: -1, insertionIndex: 1 }); }); - expect(index).toBe(0); - // Insert in the middle - index = binarySearchInsertIndex({ - sortedArray: items, - needle: { cid: 'y', v: 25 }, - compare: cmp, + it('inserts before the element when needle is smaller', () => { + const arr = [10]; + + const result = binarySearch({ + needle: 5, + length: arr.length, + getItemAt: (i) => arr[i], + itemIdentityEquals: numberIdentityEquals, + compare: numberCompare, + plateauScan: true, + }); + + expect(result).toEqual({ currentIndex: -1, insertionIndex: 0 }); }); - expect(index).toBe(2); // between 20 and 30 - // Insert after all - index = binarySearchInsertIndex({ - sortedArray: items, - needle: { cid: 'z', v: 50 }, - compare: cmp, + it('inserts after the element when needle is larger', () => { + const arr = [10]; + + const result = binarySearch({ + needle: 20, + length: arr.length, + getItemAt: (i) => arr[i], + itemIdentityEquals: numberIdentityEquals, + compare: numberCompare, + plateauScan: true, + }); + + expect(result).toEqual({ currentIndex: -1, insertionIndex: 1 }); }); - expect(index).toBe(4); }); - it('inserts after equal values block (stable position after equals)', () => { - const items: Item[] = [ - { cid: 'a', v: 10 }, - { cid: 'b', v: 10 }, - { cid: 'c', v: 10 }, - ]; - const cmp = toComparator({ v: 1 }); + describe('unique ascending numbers', () => { + const arr = [1, 3, 5, 7, 9]; + + it('computes correct insertionIndex when item not present (various positions)', () => { + const baseArgs = { + length: arr.length, + getItemAt: (i: number) => arr[i], + itemIdentityEquals: numberIdentityEquals, + compare: numberCompare, + }; + + // before all elements + expect( + binarySearch({ + ...baseArgs, + needle: 0, + plateauScan: true, + }), + ).toEqual({ + currentIndex: -1, + insertionIndex: 0, + }); + + // between 1 and 3 + expect( + binarySearch({ + ...baseArgs, + needle: 2, + plateauScan: true, + }), + ).toEqual({ + currentIndex: -1, + insertionIndex: 1, + }); + + // between 3 and 5 + expect( + binarySearch({ + ...baseArgs, + needle: 4, + plateauScan: true, + }), + ).toEqual({ + currentIndex: -1, + insertionIndex: 2, + }); + + // after all elements + expect( + binarySearch({ + ...baseArgs, + needle: 10, + plateauScan: true, + }), + ).toEqual({ + currentIndex: -1, + insertionIndex: arr.length, + }); + }); - const index = binarySearchInsertIndex({ - sortedArray: items, - needle: { cid: 'x', v: 10 }, - compare: cmp, + it('finds existing elements only when plateauScan is enabled', () => { + const baseArgs = { + length: arr.length, + getItemAt: (i: number) => arr[i], + itemIdentityEquals: numberIdentityEquals, + compare: numberCompare, + }; + + for (let idx = 0; idx < arr.length; idx++) { + const needle = arr[idx]; + + const found = binarySearch({ + ...baseArgs, + needle, + plateauScan: true, + }); + + // insertionIndex is upper bound (after the element) + expect(found.currentIndex).toBe(idx); + expect(found.insertionIndex).toBe(idx + 1); + + const notFound = binarySearch({ + ...baseArgs, + needle, + plateauScan: false, + }); + + // Without plateauScan, currentIndex is always -1 even for existing element + expect(notFound.currentIndex).toBe(-1); + expect(notFound.insertionIndex).toBe(idx + 1); + } }); - // By design, our binary search returns the first position where existing > needle. - // For equals, it advances to the right of the equal block. - expect(index).toBe(3); + it('treats omitted plateauScan the same as plateauScan=false', () => { + const needleIndex = 2; + const needle = arr[needleIndex]; // 5 + + const result = binarySearch({ + needle, + length: arr.length, + getItemAt: (i) => arr[i], + itemIdentityEquals: numberIdentityEquals, + compare: numberCompare, + }); + + // by default, plateauScan is falsy + expect(result.currentIndex).toBe(-1); + // insertionIndex is upper bound + expect(result.insertionIndex).toBe(needleIndex + 1); + }); }); - it('respects multi-field comparator (e.g., secondary key decides insertion point)', () => { - const items: Item[] = [ - { cid: '2', v: 1, nested: { x: 5 } }, - { cid: '1', v: 1, nested: { x: 10 } }, // comes earlier due to nested.x desc - { cid: '3', v: 2, nested: { x: 0 } }, - ]; - const cmp = toComparator([{ v: 1 }, { 'nested.x': -1 }]); + describe('duplicates (plateaus) with object identity', () => { + type Obj = { id: number; label: string }; + + it('returns end-of-plateau insertionIndex and correct currentIndex for identity', () => { + // Plateau of 3's in the middle + const arr: Obj[] = [ + { id: 1, label: 'a' }, // 0 + { id: 3, label: 'b' }, // 1 + { id: 3, label: 'c' }, // 2 + { id: 3, label: 'd' }, // 3 + { id: 5, label: 'e' }, // 4 + ]; + + const compare = (a: Obj, b: Obj) => a.id - b.id; + const identityEquals = (a: Obj, b: Obj) => a === b; + + const baseArgs = { + length: arr.length, + getItemAt: (i: number) => arr[i], + itemIdentityEquals: identityEquals, + compare, + plateauScan: true, + }; + + // insertionIndex for id=3 value is after all 3's → index 4 + const insertionIndexFor3 = 4; + + const needleMiddle = arr[2]; + const resMiddle = binarySearch({ + ...baseArgs, + needle: needleMiddle, + }); + expect(resMiddle).toEqual({ + currentIndex: 2, + insertionIndex: insertionIndexFor3, + }); + + const needleLeft = arr[1]; + const resLeft = binarySearch({ ...baseArgs, needle: needleLeft }); + expect(resLeft).toEqual({ + currentIndex: 1, + insertionIndex: insertionIndexFor3, + }); + + const needleRight = arr[3]; + const resRight = binarySearch({ ...baseArgs, needle: needleRight }); + expect(resRight).toEqual({ + currentIndex: 3, + insertionIndex: insertionIndexFor3, + }); + }); + + it('plateau at the start of the array', () => { + const arr: Obj[] = [ + { id: 3, label: 'a' }, // 0 + { id: 3, label: 'b' }, // 1 + { id: 3, label: 'c' }, // 2 + { id: 5, label: 'd' }, // 3 + { id: 8, label: 'e' }, // 4 + ]; + const compare = (a: Obj, b: Obj) => a.id - b.id; + const identityEquals = (a: Obj, b: Obj) => a === b; + + const insertionIndexFor3 = 3; // first element with id > 3 is index 3 + + const result = binarySearch({ + needle: arr[0], + length: arr.length, + getItemAt: (i) => arr[i], + itemIdentityEquals: identityEquals, + compare, + plateauScan: true, + }); + + expect(result).toEqual({ + currentIndex: 0, + insertionIndex: insertionIndexFor3, + }); + }); + + it('plateau at the end of the array', () => { + const arr: Obj[] = [ + { id: 1, label: 'a' }, // 0 + { id: 2, label: 'b' }, // 1 + { id: 5, label: 'c' }, // 2 + { id: 5, label: 'd' }, // 3 + { id: 5, label: 'e' }, // 4 + ]; + const compare = (a: Obj, b: Obj) => a.id - b.id; + const identityEquals = (a: Obj, b: Obj) => a === b; + + const insertionIndexFor5 = arr.length; // no element > 5 + + const result = binarySearch({ + needle: arr[4], + length: arr.length, + getItemAt: (i) => arr[i], + itemIdentityEquals: identityEquals, + compare, + plateauScan: true, + }); + + expect(result).toEqual({ + currentIndex: 4, + insertionIndex: insertionIndexFor5, + }); + }); + + it('does not match by value when identity differs', () => { + const arr: Obj[] = [ + { id: 1, label: 'a' }, + { id: 2, label: 'b' }, + { id: 3, label: 'c' }, + ]; + + const compare = (a: Obj, b: Obj) => a.id - b.id; + const identityEquals = (a: Obj, b: Obj) => a === b; + + // same id as arr[1] but different object => not identical + const needle: Obj = { id: 2, label: 'other' }; + + const result = binarySearch({ + needle, + length: arr.length, + getItemAt: (i) => arr[i], + itemIdentityEquals: identityEquals, + compare, + plateauScan: true, + }); + + // upper bound for id=2 is after index 1 → index 2 + expect(result).toEqual({ + currentIndex: -1, + insertionIndex: 2, + }); + }); + }); - // Needle with same v=1 but nested.x=7 should go between cid=1 (x=10) and cid=2 (x=5) - const index = binarySearchInsertIndex({ - sortedArray: orderByComparator(items, cmp).map( - (cid) => items.find((i) => i.cid === cid)!, - ) as Item[], - needle: { cid: 'x', v: 1, nested: { x: 7 } }, - compare: cmp, + describe('corruption handling (getItemAt returns undefined during binary search)', () => { + it('returns -1/-1 when mid item is undefined', () => { + // length = 4 → first mid = 2 + const length = 4; + + const getItemAt = (index: number): number | undefined => { + if (index === 2) return undefined; // corruption at mid + return index; // arbitrary non-undefined value for others + }; + + const result = binarySearch({ + needle: 10, + length, + getItemAt, + itemIdentityEquals: numberIdentityEquals, + compare: numberCompare, + plateauScan: true, + }); + + expect(result).toEqual({ currentIndex: -1, insertionIndex: -1 }); }); + }); - expect(index).toBe(1); // after the 10, before the 5 + describe('plateauScan scanning behavior around insertionIndex', () => { + it('treats undefined during plateau scan as exhaustion of that side only', () => { + // We make one index undefined, but ensure binary search never hits it. + // length = 5 => first mid = 2. We'll set arr[2] so that compare(midItem, needle) > 0, + // forcing hi = 2 and thus never touching index 4 in binary search. + const backing: Array = [10, 20, 30, 40, undefined]; + + const getItemAt = (i: number) => backing[i]; + + const needle = 5; // smaller than 30, so hi will move left on the first step + + const result = binarySearch({ + needle, + length: backing.length, + getItemAt, + itemIdentityEquals: numberIdentityEquals, + compare: numberCompare, + plateauScan: true, + }); + + // insertionIndex is correct for the sorted values [10,20,30,40] + // first > 5 is 10 at index 0 + expect(result).toEqual({ + currentIndex: -1, + insertionIndex: 0, + }); + }); }); }); From 98c4d1b3821a6b2b5a34df8f60aa6f8d9a766514 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 27 Feb 2026 11:02:41 +0100 Subject: [PATCH 11/48] chore(Thread): add initial spec for Thread Constructor Minimal Init --- .../decisions.md | 39 +++ specs/thread-constructor-minimal-init/plan.md | 182 +++++++++++++ .../state.json | 21 ++ .../thread-constructor-minimal-init.spec.md | 246 ++++++++++++++++++ 4 files changed, 488 insertions(+) create mode 100644 specs/thread-constructor-minimal-init/decisions.md create mode 100644 specs/thread-constructor-minimal-init/plan.md create mode 100644 specs/thread-constructor-minimal-init/state.json create mode 100644 specs/thread-constructor-minimal-init/thread-constructor-minimal-init.spec.md diff --git a/specs/thread-constructor-minimal-init/decisions.md b/specs/thread-constructor-minimal-init/decisions.md new file mode 100644 index 0000000000..e49c42c37a --- /dev/null +++ b/specs/thread-constructor-minimal-init/decisions.md @@ -0,0 +1,39 @@ +## Decision: Thread instance as the single runtime source for Thread.tsx + +**Date:** 2026-02-27 +**Context:** +The ChatView layoutController flow requires rendering `Thread.tsx` as a sibling of `Channel.tsx` and removing Thread runtime coupling to `ChannelActionContext`-based thread behavior. + +**Decision:** +`Thread.tsx` in the target flow will rely only on `Thread` instance API/state (`state`, `reload`, `loadPrevPage`, `loadNextPage`, lifecycle methods). `ThreadProvider` will provide thread context only and will not render `Channel`. + +**Reasoning:** +This makes sibling rendering possible without prefetching full thread payload and aligns thread lifecycle ownership with `stream-chat-js` `Thread` class. + +**Alternatives considered:** + +- Keep `ChannelActionContext` integration and preload full thread before mount — rejected because it blocks the target layout and increases coupling. +- Keep `ThreadProvider` rendering `Channel` while partially migrating internals — rejected because it preserves the same context coupling that the layoutController direction removes. + +**Tradeoffs / Consequences:** +`Thread` instance API/state must be complete enough for first render and post-mount hydration. React thread flow tests need to shift from channel-context assumptions to thread-instance assumptions. + +## Decision: Single constructor signature with optional threadData + +**Date:** 2026-02-27 +**Context:** +The implementation should support minimal initialization while keeping constructor ergonomics simple. + +**Decision:** +Use one constructor params object with optional `threadData`; when present initialize from `threadData`, otherwise initialize from `client + channel + parentMessage` (with optional `draft`). + +**Reasoning:** +This satisfies both minimal and payload-backed creation without constructor overload complexity and matches requested API direction. + +**Alternatives considered:** + +- Constructor overloads/discriminated unions — rejected because not required and adds typing complexity. +- Separate factory methods (`fromThreadData`, `fromParent`) — rejected to avoid API expansion at this stage. + +**Tradeoffs / Consequences:** +Runtime validation must be explicit for missing minimal inputs (especially `parentMessage.id`) to avoid ambiguous failures. diff --git a/specs/thread-constructor-minimal-init/plan.md b/specs/thread-constructor-minimal-init/plan.md new file mode 100644 index 0000000000..a613b51782 --- /dev/null +++ b/specs/thread-constructor-minimal-init/plan.md @@ -0,0 +1,182 @@ +# Plan: Thread Constructor Minimal Init + +## Worktree + +- **Path:** `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init` +- **Branch:** `feat/init-empty-thread` +- **Base branch:** `master` + +## Task Overview + +Tasks are self-contained and parallelizable where possible; tasks touching the same file have explicit dependencies and must run sequentially. + +## Task 1: Add Optional `threadData` Constructor Branch in `Thread` + +**File(s) to create/modify:** `src/thread.ts` + +**Dependencies:** None + +**Status:** in-progress + +**Owner:** codex + +**Scope:** + +- Keep a single constructor params object and make `threadData` optional. +- Add minimal-init branch (`client + channel + parentMessage`, optional `draft`). +- Validate required minimal identity fields (especially `parentMessage.id`). +- Initialize complete minimal `ThreadState` defaults. + +**Acceptance Criteria:** + +- [ ] `Thread` can be constructed without `threadData`. +- [ ] Constructor still accepts `threadData` when provided. +- [ ] Minimal init produces a valid `ThreadState` shape with no undefined required fields. + +## Task 2: Complete Hydration + Pagination Bootstrap for Minimal Threads + +**File(s) to create/modify:** `src/thread.ts` + +**Dependencies:** Task 1 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Ensure `hydrateState(...)` copies pagination state needed for thread-instance pagination. +- Ensure minimal-init threads can become paginable after hydration/reload. +- Keep read-state and message composer behavior consistent with thread-instance flow. + +**Acceptance Criteria:** + +- [ ] `hydrateState(...)` updates pagination fields required by `loadPrevPage/loadNextPage`. +- [ ] Minimal thread does not get stuck with unusable pagination after reload. + +## Task 3: Decouple `ThreadProvider` from `Channel` Rendering + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-react/src/components/Threads/ThreadContext.tsx` + +**Dependencies:** None + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Remove `Channel` wrapper from `ThreadProvider`. +- Keep provider focused on thread context only. +- Preserve type safety for thread context consumers. + +**Acceptance Criteria:** + +- [ ] `ThreadProvider` no longer renders ``. +- [ ] Thread context remains available to downstream components. + +## Task 4: Make `Thread.tsx` Thread-Instance-Driven (No `ChannelActionContext` Thread Actions) + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-react/src/components/Thread/Thread.tsx` + +**Dependencies:** Task 3 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- In thread-instance mode, use `Thread` instance API (`reload`, `loadPrevPage`, `loadNextPage`, state selectors). +- Trigger self-hydration on mount when thread state is stale. +- Remove reliance on `ChannelActionContext` thread actions for this flow. + +**Acceptance Criteria:** + +- [ ] `Thread.tsx` renders with a minimal thread instance and self-hydrates. +- [ ] Pagination in thread-instance mode uses `threadInstance` methods. +- [ ] Thread-instance flow does not require `ChannelActionContext.loadMoreThread/closeThread`. + +## Task 5: Add `stream-chat-js` Unit Coverage for Minimal Constructor + Hydration + +**File(s) to create/modify:** `test/unit/threads.test.ts` + +**Dependencies:** Task 2 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Add tests for minimal constructor path and validation behavior. +- Add tests for hydration/pagination behavior after minimal initialization. +- Confirm thread identity/read defaults for minimal mode. + +**Acceptance Criteria:** + +- [ ] Tests cover minimal construction, missing id validation, and reload hydration path. +- [ ] Tests verify pagination becomes usable after hydration. + +## Task 6: Add `stream-chat-react` Coverage for Thread-Instance-Only Flow + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-react/src/components/Thread/__tests__/Thread.test.js`, `/Users/martincupela/Projects/stream/chat/stream-chat-react/src/components/Threads/__tests__/ThreadContext.test.tsx` + +**Dependencies:** Task 4 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Add tests for minimal thread instance render before hydration completes. +- Verify mount-time reload in thread-instance mode. +- Verify `ThreadProvider` works without `Channel` wrapper. + +**Acceptance Criteria:** + +- [ ] Tests fail if thread-instance flow regresses to `ChannelActionContext` dependency. +- [ ] Tests validate self-hydration and thread-instance pagination hooks. + +## Task 7: Integration Verification and Final Checks + +**File(s) to create/modify:** `src/thread.ts`, `test/unit/threads.test.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-react/src/components/Thread/Thread.tsx`, `/Users/martincupela/Projects/stream/chat/stream-chat-react/src/components/Threads/ThreadContext.tsx`, `/Users/martincupela/Projects/stream/chat/stream-chat-react/src/components/Thread/__tests__/Thread.test.js`, `/Users/martincupela/Projects/stream/chat/stream-chat-react/src/components/Threads/__tests__/ThreadContext.test.tsx` + +**Dependencies:** Task 5, Task 6 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Run type checks and targeted tests across both repos. +- Fix any integration breakages caused by decoupling. +- Confirm spec acceptance criteria are met end-to-end. + +**Acceptance Criteria:** + +- [ ] Required type checks and tests pass for touched areas. +- [ ] No remaining `Thread.tsx` dependency on `ChannelActionContext` thread actions in target flow. + +## Execution Order + +- **Phase 1 (parallel):** Task 1, Task 3 +- **Phase 2 (sequential branches):** +- `src/thread.ts` branch: Task 2 (after Task 1) +- `stream-chat-react` branch: Task 4 (after Task 3) +- **Phase 3 (parallel):** Task 5 (after Task 2), Task 6 (after Task 4) +- **Phase 4:** Task 7 (after Task 5 and Task 6) + +## File Ownership Summary + +| Task | Creates/Modifies | +| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Task 1 | `src/thread.ts` | +| Task 2 | `src/thread.ts` | +| Task 3 | `/Users/martincupela/Projects/stream/chat/stream-chat-react/src/components/Threads/ThreadContext.tsx` | +| Task 4 | `/Users/martincupela/Projects/stream/chat/stream-chat-react/src/components/Thread/Thread.tsx` | +| Task 5 | `test/unit/threads.test.ts` | +| Task 6 | `/Users/martincupela/Projects/stream/chat/stream-chat-react/src/components/Thread/__tests__/Thread.test.js`, `/Users/martincupela/Projects/stream/chat/stream-chat-react/src/components/Threads/__tests__/ThreadContext.test.tsx` | +| Task 7 | Integration verification across touched files | diff --git a/specs/thread-constructor-minimal-init/state.json b/specs/thread-constructor-minimal-init/state.json new file mode 100644 index 0000000000..927d96719a --- /dev/null +++ b/specs/thread-constructor-minimal-init/state.json @@ -0,0 +1,21 @@ +{ + "tasks": { + "thread-minimal-constructor": "pending", + "thread-hydration-pagination": "pending", + "react-thread-instance-flow": "pending", + "thread-provider-decoupling": "pending", + "js-tests": "pending", + "react-tests": "pending", + "verification": "pending" + }, + "flags": { + "blocked": false, + "needs-review": false, + "awaiting-human-input": false + }, + "meta": { + "last_updated": "2026-02-27", + "worktree": "/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init", + "branch": "feat/init-empty-thread" + } +} diff --git a/specs/thread-constructor-minimal-init/thread-constructor-minimal-init.spec.md b/specs/thread-constructor-minimal-init/thread-constructor-minimal-init.spec.md new file mode 100644 index 0000000000..b130c0e74f --- /dev/null +++ b/specs/thread-constructor-minimal-init/thread-constructor-minimal-init.spec.md @@ -0,0 +1,246 @@ +# Thread Constructor Minimal Initialization Spec + +## Problem Statement + +Today, `Thread` in `stream-chat-js` can only be created from full `threadData` (`ThreadResponse`). + +This spec exists to support `stream-chat-react/src/components/ChatView/layoutController/spec.md`. + +That is a blocker for the target UI composition in `stream-chat-react`: + +- render `` and `` as siblings +- hand a `Thread` instance to `` immediately (from known parent message + channel) +- fetch/hydrate full thread data after `` mounts + +Without constructor support for this, `` in thread-instance mode expects an already hydrated instance, which defeats the sibling/lazy-hydration flow. + +## Desired UX/Data Flow + +1. User opens a thread from a parent message already available in channel state. +2. App creates a minimal `Thread` instance using known references (`client`, `channel`, `parentMessage`). +3. `` renders immediately using minimal state (at least parent message context). +4. `` (or the thread instance) triggers fetch (`reload`) to hydrate replies/read/participants/pagination. +5. UI updates seamlessly once data arrives. + +Why this matters: + +- faster perceived response (no blocking on `getThread` before render) +- enables clean sibling layout architecture +- keeps SDK-level thread lifecycle encapsulated in `Thread` + +## Current Cross-Codebase Constraints + +### `stream-chat-js` assumptions that must remain valid + +- `ThreadManager` expects stable `thread.id`, `thread.channel.cid`, `hasStaleState`, `hydrateState`. +- `MessageComposer` uses `compositionContext instanceof Thread` and requires `thread.channel`. +- `MessageDeliveryReporter` reads thread `id`, `channel`, `state.replies`, and `state.read`. + +### `stream-chat-react` target architecture constraints (per ChatView layoutController) + +- `ThreadProvider` should provide thread context only; it should not render/wrap a `Channel` component. +- `Thread.tsx` in the new ChatView flow should not depend on `ChannelActionContext` thread functions (`loadMoreThread`, `closeThread`, etc.). +- `Thread.tsx` should operate from `Thread` instance state/methods in thread-instance mode (`state`, `reload`, `loadPrevPage`, `loadNextPage`, `activate`, `deactivate`). +- Legacy Channel-centric thread behavior is out of scope for this change. + +## Goals + +- Add a safe minimal constructor path for `Thread`. +- Support sibling `` + `` rendering with post-mount hydration. +- Make `Thread.tsx` rely only on `Thread` instance API in the ChatView layoutController flow. +- Keep thread identity and behavior predictable across SDK and React consumers that use `Thread` instances. + +## Non-Goals + +- No API contract changes for backend thread endpoints. +- No support for `Thread.tsx` behavior that depends on `ChannelActionContext` thread actions. +- No removals/renames of public `Thread` methods. + +## Proposed API + +Constructor should use a single params object where `threadData` is optional: + +- `{ client, channel, parentMessage, draft?, threadData? }` + +Initialization behavior: + +- if `threadData` is provided, initialize from `threadData` +- if `threadData` is not provided, initialize from `channel + parentMessage` + +Rationale: + +- `channel` and `parentMessage` are exactly what the sibling-render flow already has. +- optional `draft` enables initializing thread message composition state in instance-only flow. +- optional `threadData` allows callers that already have server payload to initialize directly. + +## Required Changes in `src/thread.ts` + +### 1) Constructor typing and branching + +Change: + +- Keep a single constructor signature and make `threadData` optional. +- When `threadData` is present, initialize from it. +- When `threadData` is absent, require minimal input: `client + channel + parentMessage` (with optional `draft`). + +Why: + +- keeps API explicit and type-safe +- prevents ambiguous partially-hydrated constructor inputs + +### 2) Add minimal initialization path + +Change: + +- Build a valid `ThreadState` from minimal input (without server thread payload). + +Why: + +- `Thread.tsx` and other consumers can safely subscribe to `thread.state` immediately. + +### 3) Validate identity-critical fields + +Change: + +- In minimal mode, require `parentMessage.id`; throw early if missing. +- Set `this.id = parentMessage.id`. + +Why: + +- `id` is used everywhere (React keys, thread selection, mark-read targets, manager maps). +- silent `undefined` ids would create hard-to-debug downstream failures. + +### 4) Keep `channel` as provided in minimal mode + +Change: + +- Do not synthesize channel from thread payload in minimal mode; use provided instance. + +Why: + +- sibling rendering already operates in a concrete channel context. +- `ThreadProvider` no longer wrapping `Channel` means `Thread` instance must be the source of channel linkage for thread operations. + +### 5) Share read-state placeholder logic + +Change: + +- Extract current placeholder read behavior and reuse for both constructor modes. + +Why: + +- unread/read logic currently depends on read-state shape being initialized. +- avoids drift between modes. + +### 6) Hydration completeness + +Change: + +- Update `hydrateState(...)` to also copy/hydrate pagination state, not only replies/read/etc. + +Why: + +- minimal thread starts without useful cursors. +- pagination must become operational after hydration. + +### 7) Pagination bootstrap behavior + +Change: + +- Ensure minimal thread does not get stuck with both cursors `null`. +- Either: + - guarantee `reload()` is run before paginating, and hydration sets pagination correctly, or + - allow first pagination query to bootstrap when stale/minimal. + +Why: + +- in thread-instance mode, `Thread.tsx` uses `thread.loadPrevPage/loadNextPage`. +- if cursors never initialize, load-more becomes inert. + +### 8) Composer initialization parity + +Change: + +- Initialize `messageComposer` in minimal mode with optional `draft`. + +Why: + +- support draft-first UIs in the instance-only flow. + +## Minimal `ThreadState` Defaults (with rationale) + +- `active: false` (not yet focused) +- `isLoading: false` (no request in flight initially) +- `isStateStale: true` (explicit signal that server hydration is needed) +- `channel: provided channel` (required by React and composer) +- `parentMessage: formatMessage(parentMessage)` (enables immediate header/parent render) +- `createdAt: parent message created time or now` (non-null contract) +- `deletedAt: null` +- `participants: []` (unknown until hydration) +- `read: placeholder per current user strategy` (stable unread logic) +- `replies: []` (unknown until hydration) +- `replyCount: 0` (unknown default) +- `pagination: { isLoadingNext: false, isLoadingPrev: false, nextCursor: null, prevCursor: null }` +- `updatedAt: null` +- `title: ''` +- `custom: {}` + +## Required `stream-chat-react` Integration Behavior + +For `Thread.tsx` thread-instance mode (from `ThreadProvider`) in ChatView layoutController flow: + +1. On mount, if `threadInstance.hasStaleState` is true, call `threadInstance.reload()`. +2. Keep immediate render using `parentMessage` from minimal state while loading. +3. Avoid duplicate fetches by relying on `Thread.reload()` in-flight guards. +4. Use `threadInstance.loadPrevPage/loadNextPage` for pagination; do not call `ChannelActionContext.loadMoreThread`. +5. Use thread-instance close/navigation callbacks provided by ChatView/layout controller wiring; do not require `ChannelActionContext.closeThread`. +6. `ThreadProvider` must not render ``; it should provide only thread context. + +Why: + +- this is the core mechanism that makes sibling rendering practical without prefetching. +- it removes the old Channel-coupled dependency chain that blocks the new layout controller architecture. + +## Testing Plan + +### `stream-chat-js` tests + +Add to `test/unit/threads.test.ts`: + +- constructs thread in minimal mode with valid default shape +- throws when minimal `parentMessage.id` is missing +- `id` derives from parent message id in minimal mode +- `reload()` hydrates stale minimal thread +- `hydrateState()` updates pagination too (not only replies/read/parent) +- pagination methods become usable after hydration + +### `stream-chat-react` verification tests + +Add/extend thread-instance tests: + +- `` renders with minimal thread instance (before hydration completes) +- mount triggers hydration path in thread-instance mode +- hydrated data appears in message list +- pagination uses thread-instance methods, not `ChannelActionContext` callbacks +- `ThreadProvider` can provide thread context without rendering `Channel` +- `activate/deactivate` lifecycle remains stable + +## Risks and Mitigations + +- Risk: minimal threads never hydrate in UI. + Mitigation: explicit mount-time stale check + reload in `Thread.tsx`. + +- Risk: pagination remains unusable after hydration. + Mitigation: include pagination in `hydrateState` and add dedicated tests. + +- Risk: hidden couplings to old Channel-context assumptions. + Mitigation: remove ChannelActionContext dependencies from `Thread.tsx` path and enforce thread-instance tests. + +## Acceptance Criteria + +- Minimal constructor mode compiles with strict types. +- `Thread.tsx` can be mounted as a sibling of `Channel.tsx` with a minimal thread instance. +- The mounted `Thread.tsx` self-hydrates thread data and updates UI without manual prefetch. +- ChatView layoutController path works with `Thread.tsx` not relying on `ChannelActionContext` thread actions. +- `ThreadProvider` no longer needs to render `Channel` to make thread-instance rendering functional. +- All existing tests pass and new minimal-flow tests pass. From 1ff227174395d949c374171efd54ef20725e9cb4 Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 27 Feb 2026 11:33:10 +0100 Subject: [PATCH 12/48] feat(Thread): implement Add Optional `threadData` Constructor Branch in `Thread` --- .../decisions.md | 20 +++ specs/thread-constructor-minimal-init/plan.md | 14 +- .../state.json | 6 +- src/thread.ts | 142 ++++++++++++------ 4 files changed, 126 insertions(+), 56 deletions(-) diff --git a/specs/thread-constructor-minimal-init/decisions.md b/specs/thread-constructor-minimal-init/decisions.md index e49c42c37a..4949653016 100644 --- a/specs/thread-constructor-minimal-init/decisions.md +++ b/specs/thread-constructor-minimal-init/decisions.md @@ -37,3 +37,23 @@ This satisfies both minimal and payload-backed creation without constructor over **Tradeoffs / Consequences:** Runtime validation must be explicit for missing minimal inputs (especially `parentMessage.id`) to avoid ambiguous failures. + +## Decision: Minimal constructor branch requires explicit parent identity and initializes deterministic defaults + +**Date:** 2026-02-27 +**Context:** +Task 1 implementation needed to support creating `Thread` without API `threadData` while preserving runtime guarantees expected by existing thread methods. + +**Decision:** +When `threadData` is absent, constructor requires `channel` and `parentMessage.id`; it initializes full `ThreadState` with deterministic defaults (`replies: []`, empty participants/custom/title, placeholder read state for current user when available, and pagination cursors set to `null`). + +**Reasoning:** +This keeps `Thread` usable immediately after construction with no undefined required fields and provides a stable baseline for later hydration/reload to populate server-backed state. + +**Alternatives considered:** + +- Allow missing `parentMessage.id` and derive later — rejected because thread identity and thread-scoped operations depend on a stable id at construction time. +- Leave read/pagination fields partially undefined in minimal mode — rejected because it introduces conditional handling across runtime selectors and pagination codepaths. + +**Tradeoffs / Consequences:** +Minimal instances start non-paginable until hydrated by server data; Task 2 is responsible for carrying hydrated pagination into existing instances. diff --git a/specs/thread-constructor-minimal-init/plan.md b/specs/thread-constructor-minimal-init/plan.md index a613b51782..4e937accc7 100644 --- a/specs/thread-constructor-minimal-init/plan.md +++ b/specs/thread-constructor-minimal-init/plan.md @@ -3,7 +3,7 @@ ## Worktree - **Path:** `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init` -- **Branch:** `feat/init-empty-thread` +- **Branch:** `agent/feat/init-empty-thread` - **Base branch:** `master` ## Task Overview @@ -16,7 +16,7 @@ Tasks are self-contained and parallelizable where possible; tasks touching the s **Dependencies:** None -**Status:** in-progress +**Status:** done **Owner:** codex @@ -29,9 +29,9 @@ Tasks are self-contained and parallelizable where possible; tasks touching the s **Acceptance Criteria:** -- [ ] `Thread` can be constructed without `threadData`. -- [ ] Constructor still accepts `threadData` when provided. -- [ ] Minimal init produces a valid `ThreadState` shape with no undefined required fields. +- [x] `Thread` can be constructed without `threadData`. +- [x] Constructor still accepts `threadData` when provided. +- [x] Minimal init produces a valid `ThreadState` shape with no undefined required fields. ## Task 2: Complete Hydration + Pagination Bootstrap for Minimal Threads @@ -39,9 +39,9 @@ Tasks are self-contained and parallelizable where possible; tasks touching the s **Dependencies:** Task 1 -**Status:** pending +**Status:** in-progress -**Owner:** unassigned +**Owner:** codex **Scope:** diff --git a/specs/thread-constructor-minimal-init/state.json b/specs/thread-constructor-minimal-init/state.json index 927d96719a..494edf780e 100644 --- a/specs/thread-constructor-minimal-init/state.json +++ b/specs/thread-constructor-minimal-init/state.json @@ -1,7 +1,7 @@ { "tasks": { - "thread-minimal-constructor": "pending", - "thread-hydration-pagination": "pending", + "thread-minimal-constructor": "done", + "thread-hydration-pagination": "in-progress", "react-thread-instance-flow": "pending", "thread-provider-decoupling": "pending", "js-tests": "pending", @@ -16,6 +16,6 @@ "meta": { "last_updated": "2026-02-27", "worktree": "/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init", - "branch": "feat/init-empty-thread" + "branch": "agent/feat/init-empty-thread" } } diff --git a/src/thread.ts b/src/thread.ts index bf6f778121..1f1c7715e2 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -7,6 +7,7 @@ import { } from './utils'; import type { AscDesc, + DraftResponse, EventTypes, LocalMessage, MessagePaginationOptions, @@ -120,64 +121,102 @@ export class Thread extends WithSubscriptions { constructor({ client, threadData, + channel, + parentMessage, + draft, }: { client: StreamChat; - threadData: ThreadResponse; + threadData?: ThreadResponse; + channel?: Channel; + parentMessage?: MessageResponse | LocalMessage; + draft?: DraftResponse; }) { super(); + if (threadData) { + const threadChannel = client.channel(threadData.channel.type, threadData.channel.id, { + // @ts-expect-error name is a "custom" property + name: threadData.channel.name, + }); + threadChannel._hydrateMembers({ + members: threadData.channel.members ?? [], + overrideCurrentState: false, + }); - const channel = client.channel(threadData.channel.type, threadData.channel.id, { - // @ts-expect-error name is a "custom" property - name: threadData.channel.name, - }); - channel._hydrateMembers({ - members: threadData.channel.members ?? [], - overrideCurrentState: false, - }); + this.state = new StateStore({ + // local only + active: false, + isLoading: false, + isStateStale: false, + // 99.9% should never change + channel: threadChannel, + createdAt: new Date(threadData.created_at), + // rest + deletedAt: threadData.deleted_at ? new Date(threadData.deleted_at) : null, + pagination: repliesPaginationFromInitialThread(threadData), + parentMessage: formatMessage(threadData.parent_message), + participants: threadData.thread_participants, + read: formatReadState( + !threadData.read || threadData.read.length === 0 + ? getPlaceholderReadResponse(client.userID) + : threadData.read, + ), + replies: threadData.latest_replies.map(formatMessage), + replyCount: threadData.reply_count ?? 0, + updatedAt: threadData.updated_at ? new Date(threadData.updated_at) : null, + title: threadData.title, + custom: constructCustomDataObject(threadData), + }); - // For when read object is undefined and due to that unreadMessageCount for - // the current user isn't being incremented on message.new - const placeholderReadResponse: ReadResponse[] = client.userID - ? [ - { - user: { id: client.userID }, - unread_messages: 0, - last_read: new Date().toISOString(), - }, - ] - : []; + this.id = threadData.parent_message_id; + } else { + if (!channel) { + throw new Error('Channel is required when threadData is not provided'); + } - this.state = new StateStore({ - // local only - active: false, - isLoading: false, - isStateStale: false, - // 99.9% should never change - channel, - createdAt: new Date(threadData.created_at), - // rest - deletedAt: threadData.deleted_at ? new Date(threadData.deleted_at) : null, - pagination: repliesPaginationFromInitialThread(threadData), - parentMessage: formatMessage(threadData.parent_message), - participants: threadData.thread_participants, - read: formatReadState( - !threadData.read || threadData.read.length === 0 - ? placeholderReadResponse - : threadData.read, - ), - replies: threadData.latest_replies.map(formatMessage), - replyCount: threadData.reply_count ?? 0, - updatedAt: threadData.updated_at ? new Date(threadData.updated_at) : null, - title: threadData.title, - custom: constructCustomDataObject(threadData), - }); + if (!parentMessage || !parentMessage.id) { + throw new Error( + 'Parent message with a valid id is required when threadData is not provided', + ); + } + + const formattedParentMessage = formatMessage(parentMessage); + const createdAt = parentMessage.created_at + ? new Date(parentMessage.created_at) + : new Date(); + + this.state = new StateStore({ + active: false, + channel, + createdAt, + custom: {}, + deletedAt: formattedParentMessage.deleted_at, + isLoading: false, + isStateStale: false, + pagination: { + isLoadingNext: false, + isLoadingPrev: false, + nextCursor: null, + prevCursor: null, + }, + parentMessage: formattedParentMessage, + participants: [], + read: formatReadState(getPlaceholderReadResponse(client.userID)), + replies: [], + replyCount: parentMessage.reply_count ?? 0, + title: '', + updatedAt: parentMessage.updated_at + ? new Date(parentMessage.updated_at) + : null, + }); + + this.id = parentMessage.id; + } - this.id = threadData.parent_message_id; this.client = client; this.messageComposer = new MessageComposer({ client, - composition: threadData.draft, + composition: threadData?.draft ?? draft, compositionContext: this, }); } @@ -618,6 +657,17 @@ const formatReadState = (read: ReadResponse[]): ThreadReadState => return state; }, {}); +const getPlaceholderReadResponse = (currentUserId?: string): ReadResponse[] => + currentUserId + ? [ + { + user: { id: currentUserId }, + unread_messages: 0, + last_read: new Date().toISOString(), + }, + ] + : []; + const repliesPaginationFromInitialThread = ( thread: ThreadResponse, ): ThreadRepliesPagination => { From 9650862c11802c10410644115315b88f61457e8e Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 27 Feb 2026 11:44:41 +0100 Subject: [PATCH 13/48] feat(Thread): implement Complete Hydration + Pagination Bootstrap for Minimal Threads --- .../decisions.md | 20 ++++ specs/thread-constructor-minimal-init/plan.md | 6 +- .../state.json | 2 +- src/thread.ts | 2 + test/unit/threads.test.ts | 112 ++++++++++++++++++ 5 files changed, 138 insertions(+), 4 deletions(-) diff --git a/specs/thread-constructor-minimal-init/decisions.md b/specs/thread-constructor-minimal-init/decisions.md index 4949653016..1525bec088 100644 --- a/specs/thread-constructor-minimal-init/decisions.md +++ b/specs/thread-constructor-minimal-init/decisions.md @@ -57,3 +57,23 @@ This keeps `Thread` usable immediately after construction with no undefined requ **Tradeoffs / Consequences:** Minimal instances start non-paginable until hydrated by server data; Task 2 is responsible for carrying hydrated pagination into existing instances. + +## Decision: Hydration must overwrite pagination from the fetched thread instance + +**Date:** 2026-02-27 +**Context:** +Minimal constructor threads initialize with null pagination cursors, so pagination methods remain inert until server-backed thread state is applied. + +**Decision:** +`Thread.hydrateState(...)` now copies `pagination` from the hydrated source thread alongside replies/read/metadata. + +**Reasoning:** +`loadPrevPage/loadNextPage` depend on `prevCursor/nextCursor`; without hydration of pagination, minimal threads stay permanently non-paginable after `reload()`. + +**Alternatives considered:** + +- Recompute pagination from current local replies during hydration — rejected because local replies may include optimistic/pending items and may not reflect server window boundaries. +- Keep pagination untouched and rely on later events — rejected because pagination remains blocked with null cursors. + +**Tradeoffs / Consequences:** +Hydration treats fetched thread pagination as source-of-truth and replaces local pagination state at once. diff --git a/specs/thread-constructor-minimal-init/plan.md b/specs/thread-constructor-minimal-init/plan.md index 4e937accc7..c85d5247e1 100644 --- a/specs/thread-constructor-minimal-init/plan.md +++ b/specs/thread-constructor-minimal-init/plan.md @@ -39,7 +39,7 @@ Tasks are self-contained and parallelizable where possible; tasks touching the s **Dependencies:** Task 1 -**Status:** in-progress +**Status:** done **Owner:** codex @@ -51,8 +51,8 @@ Tasks are self-contained and parallelizable where possible; tasks touching the s **Acceptance Criteria:** -- [ ] `hydrateState(...)` updates pagination fields required by `loadPrevPage/loadNextPage`. -- [ ] Minimal thread does not get stuck with unusable pagination after reload. +- [x] `hydrateState(...)` updates pagination fields required by `loadPrevPage/loadNextPage`. +- [x] Minimal thread does not get stuck with unusable pagination after reload. ## Task 3: Decouple `ThreadProvider` from `Channel` Rendering diff --git a/specs/thread-constructor-minimal-init/state.json b/specs/thread-constructor-minimal-init/state.json index 494edf780e..fecfcabe5f 100644 --- a/specs/thread-constructor-minimal-init/state.json +++ b/specs/thread-constructor-minimal-init/state.json @@ -1,7 +1,7 @@ { "tasks": { "thread-minimal-constructor": "done", - "thread-hydration-pagination": "in-progress", + "thread-hydration-pagination": "done", "react-thread-instance-flow": "pending", "thread-provider-decoupling": "pending", "js-tests": "pending", diff --git a/src/thread.ts b/src/thread.ts index 1f1c7715e2..146363e0af 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -273,6 +273,7 @@ export class Thread extends WithSubscriptions { custom, title, deletedAt, + pagination, parentMessage, participants, read, @@ -293,6 +294,7 @@ export class Thread extends WithSubscriptions { participants, read, replyCount, + pagination, replies: pendingReplies.length ? replies.concat(pendingReplies) : replies, updatedAt, isStateStale: false, diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index 121cfc40f5..482ec90ab9 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -48,6 +48,26 @@ describe('Threads 2.0', () => { }); } + function createMinimalThread({ + parentMessageOverrides = {}, + draft, + }: { + parentMessageOverrides?: Partial; + draft?: { + channel_cid: string; + created_at: string; + message: { id: string; text: string; parent_id?: string }; + parent_id?: string; + }; + } = {}) { + return new Thread({ + client, + channel, + parentMessage: { ...parentMessageResponse, ...parentMessageOverrides }, + draft, + }); + } + beforeEach(() => { client = new StreamChat('apiKey'); client._setUser({ id: TEST_USER_ID }); @@ -81,6 +101,43 @@ describe('Threads 2.0', () => { expect(thread.channel.data?.name).to.equal(channelResponse.name); }); + it('initializes properly without threadData', () => { + const thread = createMinimalThread(); + const state = thread.state.getLatestValue(); + + expect(thread.id).to.equal(parentMessageResponse.id); + expect(thread.channel.cid).to.equal(channel.cid); + expect(state.parentMessage.id).to.equal(parentMessageResponse.id); + expect(state.replies).to.deep.equal([]); + expect(state.participants).to.deep.equal([]); + expect(state.custom).to.deep.equal({}); + expect(state.pagination.prevCursor).to.be.null; + expect(state.pagination.nextCursor).to.be.null; + expect(state.read).to.have.keys([TEST_USER_ID]); + }); + + it('throws if minimal init parent message id is missing', () => { + expect(() => + createMinimalThread({ + parentMessageOverrides: { id: '' }, + }), + ).to.throw(); + }); + + it('accepts draft in minimal init path', () => { + const draftId = uuidv4(); + const thread = createMinimalThread({ + draft: { + channel_cid: channel.cid, + created_at: new Date().toISOString(), + message: { id: draftId, text: 'draft text', parent_id: parentMessageResponse.id }, + parent_id: parentMessageResponse.id, + }, + }); + + expect(thread.messageComposer.draftId).to.equal(draftId); + }); + describe('Methods', () => { describe('upsertReplyLocally', () => { it('prevents inserting a new message that does not belong to the associated thread', () => { @@ -265,6 +322,30 @@ describe('Threads 2.0', () => { expect(stateAfter.participants).to.equal(hydrationState.participants); }); + it('copies pagination state during hydration', () => { + const thread = createMinimalThread(); + const hydrationThread = createTestThread({ + latest_replies: [ + generateMsg({ parent_id: parentMessageResponse.id }) as MessageResponse, + ], + reply_count: 3, + }); + + hydrationThread.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + nextCursor: 'next-cursor', + }, + })); + + thread.hydrateState(hydrationThread); + + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.pagination.prevCursor).to.not.be.null; + expect(stateAfter.pagination.nextCursor).to.equal('next-cursor'); + }); + it('retains failed replies after hydration', () => { const thread = createTestThread(); const hydrationThread = createTestThread({ @@ -287,6 +368,37 @@ describe('Threads 2.0', () => { }); }); + describe('reload', () => { + it('bootstraps pagination for minimally initialized threads', async () => { + const minimalThread = createMinimalThread(); + const hydratedThread = createTestThread({ + latest_replies: [ + generateMsg({ parent_id: parentMessageResponse.id }) as MessageResponse, + ], + reply_count: 3, + }); + hydratedThread.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + nextCursor: 'next-cursor', + }, + })); + + sinon.stub(client, 'getThread').resolves(hydratedThread); + + const stateBefore = minimalThread.state.getLatestValue(); + expect(stateBefore.pagination.prevCursor).to.be.null; + expect(stateBefore.pagination.nextCursor).to.be.null; + + await minimalThread.reload(); + + const stateAfter = minimalThread.state.getLatestValue(); + expect(stateAfter.pagination.prevCursor).to.not.be.null; + expect(stateAfter.pagination.nextCursor).to.equal('next-cursor'); + }); + }); + describe('deleteReplyLocally', () => { it('deletes appropriate message', () => { const createdAt = new Date().getTime(); From 2ab25b96ede79f9143818b2ec5f15b8cbaa80bd1 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 3 Mar 2026 15:37:37 +0100 Subject: [PATCH 14/48] feat: add reactive states to ChannelState --- .../decisions.md | 38 ++ specs/thread-constructor-minimal-init/plan.md | 26 +- .../state.json | 6 +- src/channel.ts | 303 ++++++++++++---- src/channel_state.ts | 203 ++++++++++- src/client.ts | 40 ++- .../middleware/textComposer/types.ts | 7 +- src/messageComposer/textComposer.ts | 33 +- src/messageDelivery/MessageReceiptsTracker.ts | 273 ++++++++++++++- .../unit/MessageComposer/textComposer.test.ts | 9 +- test/unit/channel.test.js | 162 ++++++++- test/unit/channel_state.test.js | 320 +++++++++++++++++ test/unit/client.test.js | 80 +++++ .../MessageReceiptsTracker.test.ts | 329 ++++++++++++++++-- 14 files changed, 1672 insertions(+), 157 deletions(-) diff --git a/specs/thread-constructor-minimal-init/decisions.md b/specs/thread-constructor-minimal-init/decisions.md index 1525bec088..b7586edcc9 100644 --- a/specs/thread-constructor-minimal-init/decisions.md +++ b/specs/thread-constructor-minimal-init/decisions.md @@ -77,3 +77,41 @@ Minimal constructor threads initialize with null pagination cursors, so paginati **Tradeoffs / Consequences:** Hydration treats fetched thread pagination as source-of-truth and replaces local pagination state at once. + +## Decision: ThreadProvider should be thread-context-only and not mount Channel + +**Date:** 2026-02-27 +**Context:** +Task 3 requires enabling sibling rendering of `Channel` and `Thread` in layoutController flow, which is blocked when `ThreadProvider` internally mounts ``. + +**Decision:** +`ThreadProvider` now renders only `` and no longer wraps children with ``. + +**Reasoning:** +This removes hidden channel-context coupling from thread provider composition and makes thread context provisioning independent from channel rendering topology. + +**Alternatives considered:** + +- Keep `` wrapper and adapt Thread internals only — rejected because it preserves structural coupling and prevents true sibling layout control. + +**Tradeoffs / Consequences:** +`Thread.tsx` must no longer rely on channel action/state contexts in thread-instance mode; this is addressed in Task 4. + +## Decision: Thread.tsx runs in thread-instance-only mode without channel contexts + +**Date:** 2026-02-27 +**Context:** +After removing `` from `ThreadProvider`, `Thread.tsx` must render outside channel providers in the layoutController sibling setup. + +**Decision:** +`Thread.tsx` now depends only on `Thread` instance data (`useThreadContext` + `useStateStore(thread.state, ...)`) and uses thread instance methods for close, hydration (`reload` when stale), and pagination. + +**Reasoning:** +This removes hard runtime coupling to `ChannelStateContext`/`ChannelActionContext`, which are not guaranteed to exist in the target composition. + +**Alternatives considered:** + +- Keep optional reads from `ChannelStateContext` as fallback — rejected because that still makes Thread behavior coupled to channel context presence. + +**Tradeoffs / Consequences:** +Legacy channel-thread-only usage of `Thread.tsx` without a thread instance is no longer handled by this flow and must be adapted through thread-instance provisioning. diff --git a/specs/thread-constructor-minimal-init/plan.md b/specs/thread-constructor-minimal-init/plan.md index c85d5247e1..21702c0609 100644 --- a/specs/thread-constructor-minimal-init/plan.md +++ b/specs/thread-constructor-minimal-init/plan.md @@ -60,9 +60,9 @@ Tasks are self-contained and parallelizable where possible; tasks touching the s **Dependencies:** None -**Status:** pending +**Status:** done -**Owner:** unassigned +**Owner:** codex **Scope:** @@ -72,8 +72,8 @@ Tasks are self-contained and parallelizable where possible; tasks touching the s **Acceptance Criteria:** -- [ ] `ThreadProvider` no longer renders ``. -- [ ] Thread context remains available to downstream components. +- [x] `ThreadProvider` no longer renders ``. +- [x] Thread context remains available to downstream components. ## Task 4: Make `Thread.tsx` Thread-Instance-Driven (No `ChannelActionContext` Thread Actions) @@ -81,9 +81,9 @@ Tasks are self-contained and parallelizable where possible; tasks touching the s **Dependencies:** Task 3 -**Status:** pending +**Status:** done -**Owner:** unassigned +**Owner:** codex **Scope:** @@ -93,9 +93,9 @@ Tasks are self-contained and parallelizable where possible; tasks touching the s **Acceptance Criteria:** -- [ ] `Thread.tsx` renders with a minimal thread instance and self-hydrates. -- [ ] Pagination in thread-instance mode uses `threadInstance` methods. -- [ ] Thread-instance flow does not require `ChannelActionContext.loadMoreThread/closeThread`. +- [x] `Thread.tsx` renders with a minimal thread instance and self-hydrates. +- [x] Pagination in thread-instance mode uses `threadInstance` methods. +- [x] Thread-instance flow does not require `ChannelActionContext.loadMoreThread/closeThread`. ## Task 5: Add `stream-chat-js` Unit Coverage for Minimal Constructor + Hydration @@ -103,9 +103,9 @@ Tasks are self-contained and parallelizable where possible; tasks touching the s **Dependencies:** Task 2 -**Status:** pending +**Status:** done -**Owner:** unassigned +**Owner:** codex **Scope:** @@ -115,8 +115,8 @@ Tasks are self-contained and parallelizable where possible; tasks touching the s **Acceptance Criteria:** -- [ ] Tests cover minimal construction, missing id validation, and reload hydration path. -- [ ] Tests verify pagination becomes usable after hydration. +- [x] Tests cover minimal construction, missing id validation, and reload hydration path. +- [x] Tests verify pagination becomes usable after hydration. ## Task 6: Add `stream-chat-react` Coverage for Thread-Instance-Only Flow diff --git a/specs/thread-constructor-minimal-init/state.json b/specs/thread-constructor-minimal-init/state.json index fecfcabe5f..a8ec53b195 100644 --- a/specs/thread-constructor-minimal-init/state.json +++ b/specs/thread-constructor-minimal-init/state.json @@ -2,9 +2,9 @@ "tasks": { "thread-minimal-constructor": "done", "thread-hydration-pagination": "done", - "react-thread-instance-flow": "pending", - "thread-provider-decoupling": "pending", - "js-tests": "pending", + "react-thread-instance-flow": "done", + "thread-provider-decoupling": "done", + "js-tests": "done", "react-tests": "pending", "verification": "pending" }, diff --git a/src/channel.ts b/src/channel.ts index bd94876f20..c2f5c0f313 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -2,6 +2,7 @@ import { ChannelState } from './channel_state'; import { CooldownTimer } from './CooldownTimer'; import { MessageComposer } from './messageComposer'; import { MessageReceiptsTracker } from './messageDelivery'; +import type { ReadStoreReconcileMeta } from './messageDelivery'; import { generateChannelTempCid, logChatPromiseExecution, @@ -164,12 +165,8 @@ export class Channel { compositionContext: this, }); - this.messageReceiptsTracker = new MessageReceiptsTracker({ - locateMessage: (timestampMs) => { - const msg = this.state.findMessageByTimestamp(timestampMs); - return msg && { timestampMs, msgId: msg.id }; - }, - }); + this.messageReceiptsTracker = new MessageReceiptsTracker({ channel: this }); + this.messageReceiptsTracker.registerSubscriptions(); this.cooldownTimer = new CooldownTimer({ channel: this }); } @@ -641,7 +638,9 @@ export class Channel { ] .sort() .join(); + const previousData = this.data; this.data = data.channel; + this._syncStateFromChannelData(this.data, previousData); // If the capabiltities are changed, we trigger the `capabilities.changed` event. if (areCapabilitiesChanged) { this.getClient().dispatchEvent({ @@ -666,7 +665,9 @@ export class Channel { cooldown: coolDownInterval, }, ); + const previousData = this.data; this.data = data.channel; + this._syncStateFromChannelData(this.data, previousData); return data; } @@ -682,7 +683,9 @@ export class Channel { cooldown: 0, }, ); + const previousData = this.data; this.data = data.channel; + this._syncStateFromChannelData(this.data, previousData); return data; } @@ -903,7 +906,9 @@ export class Channel { this._channelURL(), payload, ); + const previousData = this.data; this.data = data.channel; + this._syncStateFromChannelData(this.data, previousData); return data; } @@ -1261,7 +1266,9 @@ export class Channel { const combined = { ...defaultOptions, ...options }; const state = await this.query(combined, 'latest'); this.initialized = true; + const previousData = this.data; this.data = state.channel; + this._syncStateFromChannelData(this.data, previousData); this._client.logger( 'info', @@ -1583,7 +1590,9 @@ export class Channel { ] .sort() .join(); + const previousData = this.data; this.data = state.channel; + this._syncStateFromChannelData(this.data, previousData); this.offlineMode = false; this.cooldownTimer.refresh(); @@ -1908,6 +1917,56 @@ export class Channel { this.listeners[key] = this.listeners[key].filter((value) => value !== callback); } + private _patchReadState( + patch: (currentReadState: ChannelState['read']) => ChannelState['read'], + reconcileMeta?: ReadStoreReconcileMeta, + ) { + let hasStateChanged = false; + this.messageReceiptsTracker.setPendingReadStoreReconcileMeta(reconcileMeta); + + this.state.readStore.next((currentReadStoreState) => { + const nextReadState = patch(currentReadStoreState.read); + + if (nextReadState === currentReadStoreState.read) { + return currentReadStoreState; + } + hasStateChanged = true; + + return { + ...currentReadStoreState, + read: nextReadState, + }; + }); + + if (!hasStateChanged) { + this.messageReceiptsTracker.setPendingReadStoreReconcileMeta(undefined); + } + } + + private _upsertReadState( + userId: string, + update: ( + currentUserReadState: ChannelState['read'][string] | undefined, + ) => ChannelState['read'][string], + reconcileMeta?: ReadStoreReconcileMeta, + ) { + let nextUserReadState: ChannelState['read'][string] | undefined; + + this._patchReadState((currentReadState) => { + const currentUserReadState = currentReadState[userId]; + const updatedUserReadState = update(currentUserReadState); + + nextUserReadState = updatedUserReadState; + + return { + ...currentReadState, + [userId]: updatedUserReadState, + }; + }, reconcileMeta); + + return nextUserReadState; + } + _handleChannelEvent(event: Event) { // eslint-disable-next-line @typescript-eslint/no-this-alias const channel = this; @@ -1924,32 +1983,54 @@ export class Channel { switch (event.type) { case 'typing.start': if (event.user?.id) { - channelState.typing[event.user.id] = event; + channelState.setTypingEvent(event.user.id, event); } break; case 'typing.stop': if (event.user?.id) { - delete channelState.typing[event.user.id]; + channelState.removeTypingEvent(event.user.id); } break; case 'message.read': if (event.user?.id && event.created_at) { - const previousReadState = channelState.read[event.user.id]; - channelState.read[event.user.id] = { - // in case we already have delivery information - ...previousReadState, - last_read: new Date(event.created_at), - last_read_message_id: event.last_read_message_id, - user: event.user, - unread_messages: 0, - }; - this.messageReceiptsTracker.onMessageRead({ - user: event.user, - readAt: event.created_at, - lastReadMessageId: event.last_read_message_id, - }); - const client = this.getClient(); + const eventUser = event.user; + const readAtDate = new Date(event.created_at); + const toDate = (value?: string | Date) => + value ? (value instanceof Date ? value : new Date(value)) : undefined; + const userReadState = this._upsertReadState( + eventUser.id, + (currentUserReadState) => { + const currentDeliveredAt = toDate(currentUserReadState?.last_delivered_at); + + return { + // preserve delivery information already known for user + ...currentUserReadState, + ...(currentUserReadState?.last_read + ? { last_read: toDate(currentUserReadState.last_read) } + : null), + ...(currentDeliveredAt + ? { last_delivered_at: currentDeliveredAt } + : null), + last_read: readAtDate, + last_read_message_id: event.last_read_message_id, + last_delivered_at: + !currentDeliveredAt || currentDeliveredAt < readAtDate + ? readAtDate + : currentDeliveredAt, + last_delivered_message_id: + !currentDeliveredAt || currentDeliveredAt < readAtDate + ? event.last_read_message_id ?? + currentUserReadState?.last_delivered_message_id + : currentUserReadState?.last_delivered_message_id, + user: eventUser, + unread_messages: 0, + }; + }, + { changedUserIds: [eventUser.id] }, + ); + void userReadState; + const client = this.getClient(); const isOwnEvent = event.user?.id === client.user?.id; if (isOwnEvent) { @@ -1961,21 +2042,40 @@ export class Channel { case 'message.delivered': // todo: update also on thread if (event.user?.id && event.created_at) { - const previousReadState = channelState.read[event.user.id]; - channelState.read[event.user.id] = { - ...previousReadState, - last_delivered_at: event.last_delivered_at - ? new Date(event.last_delivered_at) - : undefined, - last_delivered_message_id: event.last_delivered_message_id, - user: event.user, - }; - - this.messageReceiptsTracker.onMessageDelivered({ - user: event.user, - deliveredAt: event.created_at, - lastDeliveredMessageId: event.last_delivered_message_id, - }); + const eventUser = event.user; + const createdAt = event.created_at; + const toDate = (value?: string | Date) => + value ? (value instanceof Date ? value : new Date(value)) : undefined; + const resolvedDeliveredAt = new Date(event.last_delivered_at ?? createdAt); + const userReadState = this._upsertReadState( + eventUser.id, + (currentUserReadState) => { + const currentDeliveredAt = toDate(currentUserReadState?.last_delivered_at); + const currentReadAt = toDate(currentUserReadState?.last_read); + + return { + ...currentUserReadState, + ...(currentReadAt ? { last_read: currentReadAt } : null), + ...(currentDeliveredAt + ? { last_delivered_at: currentDeliveredAt } + : null), + last_delivered_at: + currentDeliveredAt && currentDeliveredAt > resolvedDeliveredAt + ? currentDeliveredAt + : resolvedDeliveredAt, + last_delivered_message_id: + currentDeliveredAt && currentDeliveredAt > resolvedDeliveredAt + ? currentUserReadState?.last_delivered_message_id + : event.last_delivered_message_id, + user: eventUser, + // delivery events can be received before read events + last_read: currentReadAt ?? new Date(createdAt), + unread_messages: currentUserReadState?.unread_messages ?? 0, + }; + }, + { changedUserIds: [eventUser.id] }, + ); + void userReadState; const client = this.getClient(); const isOwnEvent = event.user?.id === client.user?.id; @@ -2046,19 +2146,36 @@ export class Channel { if (preventUnreadCountUpdate) break; if (event.user?.id) { - for (const userId in channelState.read) { - if (userId === event.user.id) { - channelState.read[event.user.id] = { - last_read: new Date(event.created_at as string), - user: event.user, - unread_messages: 0, - last_delivered_at: new Date(event.created_at as string), - last_delivered_message_id: event.message.id, - }; - } else { - channelState.read[userId].unread_messages += 1; + const eventUser = event.user; + const eventUserId = eventUser.id; + const createdAt = new Date(event.created_at ?? Date.now()); + const eventMessageId = event.message.id; + this._patchReadState((currentReadState) => { + const userIds = Object.keys(currentReadState); + if (!userIds.length) return currentReadState; + + const nextReadState = { ...currentReadState }; + + for (const userId of userIds) { + if (userId === eventUserId) { + nextReadState[eventUserId] = { + last_read: createdAt, + user: eventUser, + unread_messages: 0, + last_delivered_at: createdAt, + last_delivered_message_id: eventMessageId, + }; + } else { + nextReadState[userId] = { + ...currentReadState[userId], + unread_messages: + (currentReadState[userId]?.unread_messages ?? 0) + 1, + }; + } } - } + + return nextReadState; + }, { changedUserIds: Object.keys(channelState.read) }); } if (this._countMessageAsUnread(event.message)) { @@ -2132,7 +2249,10 @@ export class Channel { ...channelState.members, [memberCopy.user.id]: memberCopy, }; - if (channel.data?.member_count && event.type === 'member.added') { + if ( + event.type === 'member.added' && + typeof channel.data?.member_count === 'number' + ) { channel.data.member_count += 1; } } @@ -2157,7 +2277,7 @@ export class Channel { channelState.members = newMembers; - if (channel.data?.member_count) { + if (typeof channel.data?.member_count === 'number') { channel.data.member_count = Math.max(channel.data.member_count - 1, 0); } @@ -2166,26 +2286,26 @@ export class Channel { break; case 'notification.mark_unread': { const ownMessage = event.user?.id === this.getClient().user?.id; - if (!ownMessage || !event.user) break; - + if (!ownMessage || !event.user || !event.last_read_at) break; + const eventUser = event.user; + const lastReadAt = event.last_read_at; const unreadCount = event.unread_messages ?? 0; - const currentState = channelState.read[event.user.id]; - channelState.read[event.user.id] = { - // keep the message delivery info - ...currentState, - first_unread_message_id: event.first_unread_message_id, - last_read: new Date(event.last_read_at as string), - last_read_message_id: event.last_read_message_id, - user: event.user, - unread_messages: unreadCount, - }; + const userReadState = this._upsertReadState( + eventUser.id, + (currentUserReadState) => ({ + // keep the message delivery info + ...currentUserReadState, + first_unread_message_id: event.first_unread_message_id, + last_read: new Date(lastReadAt), + last_read_message_id: event.last_read_message_id, + user: eventUser, + unread_messages: unreadCount, + }), + { changedUserIds: [eventUser.id] }, + ); + void userReadState; channelState.unreadCount = unreadCount; - this.messageReceiptsTracker.onNotificationMarkUnread({ - user: event.user, - lastReadAt: event.last_read_at, - lastReadMessageId: event.last_read_message_id, - }); break; } case 'channel.updated': @@ -2196,13 +2316,16 @@ export class Channel { if (isFrozenChanged) { this.query({ state: false, messages: { limit: 0 }, watchers: { limit: 0 } }); } + const previousChannelData = channel.data; const newChannelData = { ...event.channel, hidden: event.channel?.hidden ?? channel.data?.hidden, + member_count: event.channel?.member_count ?? channel.data?.member_count, own_capabilities: event.channel?.own_capabilities ?? channel.data?.own_capabilities, }; channel.data = newChannelData; + channel._syncStateFromChannelData(channel.data, previousChannelData); this.cooldownTimer.refresh(); } break; @@ -2229,24 +2352,30 @@ export class Channel { ) as MessageResponse; } break; - case 'channel.hidden': + case 'channel.hidden': { + const previousChannelData = channel.data; channel.data = { ...channel.data, blocked: !!event.channel?.blocked, hidden: true, }; + channel._syncStateFromChannelData(channel.data, previousChannelData); if (event.clear_history) { channelState.clearMessages(); } break; - case 'channel.visible': + } + case 'channel.visible': { + const previousChannelData = channel.data; channel.data = { ...channel.data, blocked: !!event.channel?.blocked, hidden: false, }; + channel._syncStateFromChannelData(channel.data, previousChannelData); this.getClient().offlineDb?.handleChannelVisibilityEvent({ event }); break; + } case 'user.banned': if (!event.user?.id) break; channelState.members[event.user.id] = { @@ -2320,6 +2449,14 @@ export class Channel { } } + _syncStateFromChannelData( + data: Channel['data'], + fallbackData: Channel['data'] = this.data, + ) { + this.state.syncOwnCapabilitiesFromChannelData(data, fallbackData); + this.state.syncMemberCountFromChannelData(data, fallbackData); + } + _initializeState( state: ChannelAPIResponse, messageSetToAddToIfDoesNotExist: MessageSetType = 'latest', @@ -2374,10 +2511,11 @@ export class Channel { // initialize read state to last message or current time if the channel is empty // if the user is a member, this value will be overwritten later on otherwise this ensures // that everything up to this point is not marked as unread + const readUpdates: ChannelState['read'] = {}; if (userID != null) { const last_read = this.state.last_message_at || new Date(); if (user) { - this.state.read[user.id] = { + readUpdates[user.id] = { user, last_read, unread_messages: 0, @@ -2388,7 +2526,7 @@ export class Channel { // apply read state if part of the state if (state.read) { for (const read of state.read) { - this.state.read[read.user.id] = { + readUpdates[read.user.id] = { last_delivered_at: read.last_delivered_at ? new Date(read.last_delivered_at) : undefined, @@ -2400,11 +2538,25 @@ export class Channel { }; if (read.user.id === user?.id) { - this.state.unreadCount = this.state.read[read.user.id].unread_messages; + this.state.unreadCount = readUpdates[read.user.id].unread_messages; } } + } + + const entries = Object.entries(readUpdates); + if (entries.length) { + this._patchReadState((currentReadState) => { + let hasChanges = false; + const nextReadState = { ...currentReadState }; + + for (const [userId, readState] of entries) { + if (nextReadState[userId] === readState) continue; + nextReadState[userId] = readState; + hasChanges = true; + } - this.messageReceiptsTracker.ingestInitial(state.read); + return hasChanges ? nextReadState : currentReadState; + }, { changedUserIds: entries.map(([userId]) => userId) }); } return { @@ -2466,6 +2618,7 @@ export class Channel { ); this.disconnected = true; + this.messageReceiptsTracker.unregisterSubscriptions(); this.cooldownTimer.clearTimeout(); this.state.setIsUpToDate(false); } diff --git a/src/channel_state.ts b/src/channel_state.ts index aa8b600140..44e62e2f35 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -18,6 +18,7 @@ import { isBlockedMessage, } from './utils'; import { DEFAULT_MESSAGE_SET_PAGINATION } from './constants'; +import { StateStore } from './store'; type ChannelReadStatus = Record< string, @@ -32,6 +33,32 @@ type ChannelReadStatus = Record< } >; +export type WatcherState = { + watcherCount: number; + watchers: Record; +}; + +export type TypingUsersState = { + typing: Record; +}; + +export type ReadState = { + read: ChannelReadStatus; +}; + +export type MutedUsersState = { + mutedUsers: Array; +}; + +export type MembersState = { + members: Record; + memberCount: number; +}; + +export type OwnCapabilitiesState = { + ownCapabilities: string[]; +}; + const messageSetBounds = ( a: LocalMessage[] | MessageResponse[], b: LocalMessage[] | MessageResponse[], @@ -69,15 +96,16 @@ const messageSetsOverlapByTimestamp = (a: LocalMessage[], b: LocalMessage[]) => */ export class ChannelState { _channel: Channel; - watcher_count: number; - typing: Record; - read: ChannelReadStatus; + readonly watcherStore: StateStore; + readonly typingStore: StateStore; + readonly readStore: StateStore; + readonly membersStore: StateStore; + readonly ownCapabilitiesStore: StateStore; + // todo: is this actually used somewhere? + readonly mutedUsersStore: StateStore; pinnedMessages: Array>; pending_messages: Array; threads: Record>>; - mutedUsers: Array; - watchers: Record; - members: Record; unreadCount: number; membership: ChannelMemberResponse; last_message_at: Date | null; @@ -98,17 +126,26 @@ export class ChannelState { constructor(channel: Channel) { this._channel = channel; - this.watcher_count = 0; - this.typing = {}; - this.read = {}; + this.watcherStore = new StateStore({ + watcherCount: 0, + watchers: {}, + }); + this.typingStore = new StateStore({ + typing: {}, + }); + this.readStore = new StateStore({ read: {} }); + // a list of users to hide messages from + this.mutedUsersStore = new StateStore({ mutedUsers: [] }); + this.membersStore = new StateStore({ members: {}, memberCount: 0 }); + this.ownCapabilitiesStore = new StateStore({ + ownCapabilities: [], + }); + this.syncMemberCountFromChannelData(channel?.data); + this.syncOwnCapabilitiesFromChannelData(channel?.data); this.initMessages(); this.pinnedMessages = []; this.pending_messages = []; this.threads = {}; - // a list of users to hide messages from - this.mutedUsers = []; - this.watchers = {}; - this.members = {}; this.membership = {}; this.unreadCount = 0; /** @@ -146,6 +183,144 @@ export class ChannelState { this.messageSets[index].messages = messages; } + get members() { + return this.membersStore.getLatestValue().members; + } + + set members(members: Record) { + this.membersStore.partialNext({ members }); + } + + get member_count() { + return this.membersStore.getLatestValue().memberCount; + } + + set member_count(memberCount: number) { + this.membersStore.partialNext({ memberCount }); + } + + get read() { + return this.readStore.getLatestValue().read; + } + + set read(read: ChannelReadStatus) { + this.readStore.next({ read }); + } + + get typing() { + return this._channel?.messageComposer?.textComposer.typing ?? + this.typingStore.getLatestValue().typing; + } + + set typing(typing: Record) { + this.typingStore.next({ typing }); + + if (this._channel?.messageComposer) { + this._channel.messageComposer.textComposer.setTyping(typing); + } + } + + syncMemberCountFromChannelData( + data: Channel['data'], + fallbackData: Channel['data'] = this._channel?.data, + ) { + const fallbackMemberCount = + typeof fallbackData?.member_count === 'number' + ? fallbackData.member_count + : this.membersStore.getLatestValue().memberCount; + + if (!data || typeof data !== 'object') { + this.membersStore.partialNext({ memberCount: fallbackMemberCount ?? 0 }); + return; + } + + const dataDescriptor = Object.getOwnPropertyDescriptor(data, 'member_count'); + let memberCount = + typeof data.member_count === 'number' + ? data.member_count + : typeof fallbackMemberCount === 'number' + ? fallbackMemberCount + : undefined; + + this.membersStore.partialNext({ memberCount: memberCount ?? 0 }); + + Object.defineProperty(data, 'member_count', { + configurable: true, + enumerable: dataDescriptor?.enumerable ?? false, + get: () => memberCount, + set: (nextMemberCount: number | undefined) => { + memberCount = typeof nextMemberCount === 'number' ? nextMemberCount : undefined; + this.membersStore.partialNext({ memberCount: memberCount ?? 0 }); + }, + }); + } + + syncOwnCapabilitiesFromChannelData( + data: Channel['data'], + fallbackData: Channel['data'] = this._channel?.data, + ) { + if (!data || typeof data !== 'object') { + this.ownCapabilitiesStore.next({ ownCapabilities: [] }); + return; + } + + let ownCapabilities = Array.isArray(data.own_capabilities) + ? [...data.own_capabilities] + : Array.isArray(fallbackData?.own_capabilities) + ? [...fallbackData.own_capabilities] + : []; + + this.ownCapabilitiesStore.next({ ownCapabilities: ownCapabilities }); + + Object.defineProperty(data, 'own_capabilities', { + configurable: true, + enumerable: true, + get: () => ownCapabilities, + set: (nextOwnCapabilities: string[] | undefined) => { + ownCapabilities = Array.isArray(nextOwnCapabilities) + ? [...nextOwnCapabilities] + : []; + this.ownCapabilitiesStore.next({ ownCapabilities: ownCapabilities }); + }, + }); + } + + setTypingEvent(userID: string, event: Event) { + this.typing = { ...this.typing, [userID]: event }; + } + + removeTypingEvent(userID: string) { + if (!this.typing[userID]) return; + + const typing = { ...this.typing }; + delete typing[userID]; + this.typing = typing; + } + + get mutedUsers() { + return this.mutedUsersStore.getLatestValue().mutedUsers; + } + + set mutedUsers(mutedUsers: Array) { + this.mutedUsersStore.next({ mutedUsers }); + } + + get watchers() { + return this.watcherStore.getLatestValue().watchers; + } + + set watchers(watchers: Record) { + this.watcherStore.partialNext({ watchers }); + } + + get watcher_count() { + return this.watcherStore.getLatestValue().watcherCount; + } + + set watcher_count(watcherCount: number) { + this.watcherStore.partialNext({ watcherCount }); + } + get messagePagination() { return ( this.messageSets.find((s) => s.isCurrent)?.pagination || @@ -820,7 +995,7 @@ export class ChannelState { ? new Date(lastEvent.received_at) : lastEvent.received_at || new Date(); if (now.getTime() - receivedAt.getTime() > 7000) { - delete this.typing[userID]; + this.removeTypingEvent(userID); this._channel.getClient().dispatchEvent({ cid: this._channel.cid, type: 'typing.stop', diff --git a/src/client.ts b/src/client.ts index 0bdf1dcbf7..204439831f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -275,6 +275,10 @@ type MessageComposerSetupFunction = ({ export type BlockedUsersState = { userIds: string[] }; +export type ChannelConfigsState = { + configs: Configs; +}; + export type MessageComposerSetupState = { /** * Each `MessageComposer` runs this function each time its signature changes or @@ -307,7 +311,6 @@ export class StreamChat { browser: boolean; cleaningIntervalRef?: NodeJS.Timeout; clientID?: string; - configs: Configs; key: string; listeners: Record void>>; logger: Logger; @@ -323,7 +326,8 @@ export class StreamChat { recoverStateOnReconnect?: boolean; moderation: Moderation; mutedChannels: ChannelMute[]; - mutedUsers: Mute[]; + readonly mutedUsersStore: StateStore<{ mutedUsers: Mute[] }>; + readonly configsStore: StateStore; blockedUsers: StateStore; node: boolean; options: StreamChatOptions; @@ -384,7 +388,12 @@ export class StreamChat { this.state = new ClientState({ client: this }); // a list of channels to hide ws events from this.mutedChannels = []; - this.mutedUsers = []; + this.mutedUsersStore = new StateStore<{ mutedUsers: Mute[] }>({ + mutedUsers: [], + }); + this.configsStore = new StateStore<{ configs: Configs }>({ + configs: {}, + }); this.blockedUsers = new StateStore({ userIds: [] }); this.moderation = new Moderation(this); @@ -525,6 +534,22 @@ export class StreamChat { this.messageDeliveryReporter = new MessageDeliveryReporter({ client: this }); } + get mutedUsers() { + return this.mutedUsersStore.getLatestValue().mutedUsers; + } + + set mutedUsers(mutedUsers: Mute[]) { + this.mutedUsersStore.next({ mutedUsers }); + } + + get configs() { + return this.configsStore.getLatestValue().configs; + } + + set configs(configs: Configs) { + this.configsStore.next({ configs }); + } + /** * Get a client instance * @@ -2021,7 +2046,9 @@ export class StreamChat { for (const channelState of channelsFromApi) { this._addChannelConfig(channelState.channel); const c = this.channel(channelState.channel.type, channelState.channel.id); + const previousData = c.data; c.data = channelState.channel; + c._syncStateFromChannelData(c.data, previousData); c.offlineMode = offlineMode; c.initialized = !offlineMode; c.push_preferences = channelState.push_preferences; @@ -2248,7 +2275,10 @@ export class StreamChat { _addChannelConfig({ cid, config }: ChannelResponse) { if (this._cacheEnabled()) { - this.configs[cid] = config; + this.configs = { + ...this.configs, + [cid]: config, + }; } } @@ -2398,7 +2428,9 @@ export class StreamChat { ) { const channel = this.activeChannels[cid]; if (Object.keys(custom).length > 0) { + const previousData = channel.data; channel.data = { ...channel.data, ...custom }; + channel._syncStateFromChannelData(channel.data, previousData); channel._data = { ...channel._data, ...custom }; } return channel; diff --git a/src/messageComposer/middleware/textComposer/types.ts b/src/messageComposer/middleware/textComposer/types.ts index f7aaf70d2c..5179253f48 100644 --- a/src/messageComposer/middleware/textComposer/types.ts +++ b/src/messageComposer/middleware/textComposer/types.ts @@ -1,5 +1,5 @@ import type { MessageComposer } from '../../messageComposer'; -import type { CommandResponse, UserResponse } from '../../../types'; +import type { CommandResponse, Event, UserResponse } from '../../../types'; import type { TokenizationPayload } from './textMiddlewareUtils'; import type { SearchSource, SearchSourceSync } from '../../../search'; import type { CustomTextComposerSuggestion } from '../../types.custom'; @@ -38,6 +38,11 @@ export type TextComposerState = { mentionedUsers: UserResponse[]; selection: TextSelection; text: string; + /** + * Live typing events keyed by user id. + * Maps `user.id` -> latest typing event (`typing.start`/`typing.stop`) for that user. + */ + typing: Record; command?: CommandResponse | null; suggestions?: Suggestions; }; diff --git a/src/messageComposer/textComposer.ts b/src/messageComposer/textComposer.ts index 6e1514958e..39c5d5bf96 100644 --- a/src/messageComposer/textComposer.ts +++ b/src/messageComposer/textComposer.ts @@ -6,7 +6,13 @@ import type { TextSelection } from './middleware/textComposer/types'; import type { TextComposerState } from './middleware/textComposer/types'; import type { Suggestions } from './middleware/textComposer/types'; import type { MessageComposer } from './messageComposer'; -import type { CommandResponse, DraftMessage, LocalMessage, UserResponse } from '../types'; +import type { + CommandResponse, + DraftMessage, + Event, + LocalMessage, + UserResponse, +} from '../types'; export type TextComposerOptions = { composer: MessageComposer; @@ -40,6 +46,7 @@ const initState = ({ command: null, mentionedUsers: [], text, + typing: {}, selection: { start: text.length, end: text.length }, }; } @@ -49,6 +56,7 @@ const initState = ({ typeof item === 'string' ? ({ id: item } as UserResponse) : item, ), text, + typing: {}, selection: { start: text.length, end: text.length }, }; }; @@ -139,6 +147,29 @@ export class TextComposer { return this.state.getLatestValue().text; } + get typing() { + return this.state.getLatestValue().typing; + } + + set typing(typing: Record) { + this.state.partialNext({ typing }); + } + + setTyping = (typing: Record) => { + this.typing = typing; + }; + + setTypingEvent = (userId: string, event: Event) => { + this.typing = { ...this.typing, [userId]: event }; + }; + + removeTypingEvent = (userId: string) => { + if (!this.typing[userId]) return; + const typing = { ...this.typing }; + delete typing[userId]; + this.typing = typing; + }; + get textIsEmpty() { return textIsEmpty(this.text); } diff --git a/src/messageDelivery/MessageReceiptsTracker.ts b/src/messageDelivery/MessageReceiptsTracker.ts index 06860314b7..6a42f06b6c 100644 --- a/src/messageDelivery/MessageReceiptsTracker.ts +++ b/src/messageDelivery/MessageReceiptsTracker.ts @@ -1,4 +1,7 @@ import type { ReadResponse, UserResponse } from '../types'; +import { StateStore } from '../store'; +import type { Channel } from '../channel'; +import { WithSubscriptions } from '../utils/WithSubscriptions'; type UserId = string; type MessageId = string; @@ -11,11 +14,38 @@ export type UserProgress = { lastReadRef: MsgRef; // MIN_REF if none lastDeliveredRef: MsgRef; // MIN_REF if none; always >= readRef }; +export type MessageReceiptsSnapshot = { + revision: number; + readersByMessageId: Record; + deliveredByMessageId: Record; +}; +export type ReadStoreReconcileMeta = { + changedUserIds?: string[]; + removedUserIds?: string[]; +}; +type ReadStoreUserState = { + last_read?: Date | string; + unread_messages?: number; + user?: UserResponse; + first_unread_message_id?: string; + last_read_message_id?: string; + last_delivered_at?: Date | string; + last_delivered_message_id?: string; +}; // ---------- ordering utilities ---------- const MIN_REF: MsgRef = { timestampMs: Number.NEGATIVE_INFINITY, msgId: '' } as const; +const toTimestampMs = (value: Date | string) => value instanceof Date ? value.getTime() : new Date(value).getTime(); + +const isValidReadState = ( + readState: ReadStoreUserState | undefined, +): readState is ReadStoreUserState & { + last_read: Date | string; + user: UserResponse; +} => !!readState?.user && !!readState.last_read; + const compareRefsAsc = (a: MsgRef, b: MsgRef) => a.timestampMs !== b.timestampMs ? a.timestampMs - b.timestampMs : 0; @@ -71,7 +101,8 @@ const removeByOldKey = ( }; export type OwnMessageReceiptsTrackerOptions = { - locateMessage: OwnMessageReceiptsTrackerMessageLocator; + channel: Channel; + locateMessage?: OwnMessageReceiptsTrackerMessageLocator; }; /** @@ -92,9 +123,10 @@ export type OwnMessageReceiptsTrackerOptions = { * * Construction * ------------ - * `new MessageReceiptsTracker({locateMessage})` - * - `locateMessage(timestamp) => MsgRef | null` must resolve a message ref representation - `{ timestamp, msgId }`. - * - If `locateMessage` returns `null`, the event is ignored (message unknown locally). + * `new MessageReceiptsTracker({ channel, locateMessage? })` + * - By default, message references are read through `channel.state.findMessageByTimestamp`. + * - `locateMessage` can override this lookup strategy. + * If a message cannot be resolved locally, the event is ignored. * * Event ingestion * --------------- @@ -131,14 +163,98 @@ export type OwnMessageReceiptsTrackerOptions = { * equal-timestamp plateau (upper-bound insertion), preserving intuitive arrival order. * - This tracker models **others’ progress toward own messages**; */ -export class MessageReceiptsTracker { +export class MessageReceiptsTracker extends WithSubscriptions { private byUser = new Map(); private readSorted: UserProgress[] = []; // asc by lastReadRef private deliveredSorted: UserProgress[] = []; // asc by lastDeliveredRef + private channel: Channel; private locateMessage: OwnMessageReceiptsTrackerMessageLocator; + private pendingReadStoreReconcileMeta?: ReadStoreReconcileMeta; + readonly snapshotStore = new StateStore({ + revision: 0, + readersByMessageId: {}, + deliveredByMessageId: {}, + }); + + constructor({ channel, locateMessage }: OwnMessageReceiptsTrackerOptions) { + super(); + this.channel = channel; + this.locateMessage = locateMessage ?? ((timestampMs: number) => { + const message = this.channel.state.findMessageByTimestamp(timestampMs); + return message ? { timestampMs, msgId: message.id } : null; + }); + } + + public registerSubscriptions = () => { + this.incrementRefCount(); + if (this.hasSubscriptions) return; + + this.addUnsubscribeFunction( + this.channel.state.readStore.subscribe((next, prev) => { + this.reconcileFromReadStore({ + previousReadState: prev?.read, + nextReadState: next.read, + meta: this.pendingReadStoreReconcileMeta, + }); + this.pendingReadStoreReconcileMeta = undefined; + }), + ); + }; - constructor({ locateMessage }: OwnMessageReceiptsTrackerOptions) { - this.locateMessage = locateMessage; + public unregisterSubscriptions = () => { + this.pendingReadStoreReconcileMeta = undefined; + return super.unregisterSubscriptions(); + }; + + public setPendingReadStoreReconcileMeta(meta?: ReadStoreReconcileMeta) { + this.pendingReadStoreReconcileMeta = meta; + } + + reconcileFromReadStore({ + previousReadState, + nextReadState, + meta, + }: { + previousReadState?: Record; + nextReadState: Record; + meta?: ReadStoreReconcileMeta; + }) { + if (!previousReadState) { + this.ingestInitial(this.readStoreStateToResponses(nextReadState)); + return; + } + + // For non-bootstrap updates, we require patch metadata from channel read-store mutations. + if (!meta) return; + + const removedUserIds = new Set(meta?.removedUserIds ?? []); + const changedUserIds = new Set(meta?.changedUserIds ?? []); + + const changedOrRemovedUserIds = new Set([ + ...changedUserIds, + ...removedUserIds, + ]); + + if (!changedOrRemovedUserIds.size) return; + + let hasEffectiveChange = false; + + for (const userId of changedOrRemovedUserIds) { + if (removedUserIds.has(userId) || !nextReadState[userId]) { + hasEffectiveChange = this.removeUserProgress(userId) || hasEffectiveChange; + continue; + } + + const nextUserReadState = nextReadState[userId]; + if (!isValidReadState(nextUserReadState)) continue; + const resolvedProgress = this.readStateToUserProgress(nextUserReadState); + hasEffectiveChange = + this.upsertUserProgress(resolvedProgress) || hasEffectiveChange; + } + + if (hasEffectiveChange) { + this.emitSnapshot(); + } } /** Build initial state from server snapshots (single pass + sort). */ @@ -173,6 +289,8 @@ export class MessageReceiptsTracker { userProgress, ); } + + this.emitSnapshot(); } /** message.delivered — user device confirmed delivery up to and including messageId. */ @@ -207,6 +325,7 @@ export class MessageReceiptsTracker { ); userProgress.lastDeliveredRef = newDelivered; insertByKey(this.deliveredSorted, userProgress, (x) => x.lastDeliveredRef); + this.emitSnapshot(); } /** message.read — user read up to and including messageId. */ @@ -249,6 +368,8 @@ export class MessageReceiptsTracker { userProgress.lastDeliveredRef = userProgress.lastReadRef; insertByKey(this.deliveredSorted, userProgress, (x) => x.lastDeliveredRef); } + + this.emitSnapshot(); } /** notification.mark_unread — user marked messages unread starting at `first_unread_message_id`. @@ -300,6 +421,8 @@ export class MessageReceiptsTracker { userProgress.lastDeliveredRef = userProgress.lastReadRef; insertByKey(this.deliveredSorted, userProgress, (x) => x.lastDeliveredRef); } + + this.emitSnapshot(); } /** All users who READ this message. */ @@ -414,4 +537,140 @@ export class MessageReceiptsTracker { } return up; } + + private removeUserProgress(userId: string) { + const userProgress = this.byUser.get(userId); + if (!userProgress) return false; + + removeByOldKey(this.readSorted, userProgress, userProgress.lastReadRef, (x) => x.lastReadRef); + removeByOldKey( + this.deliveredSorted, + userProgress, + userProgress.lastDeliveredRef, + (x) => x.lastDeliveredRef, + ); + this.byUser.delete(userId); + + return true; + } + + private upsertUserProgress(nextUserProgress: UserProgress) { + const existingUserProgress = this.byUser.get(nextUserProgress.user.id); + if (!existingUserProgress) { + this.byUser.set(nextUserProgress.user.id, nextUserProgress); + insertByKey(this.readSorted, nextUserProgress, (x) => x.lastReadRef); + insertByKey(this.deliveredSorted, nextUserProgress, (x) => x.lastDeliveredRef); + return true; + } + + const hasSameReadRef = + compareRefsAsc(existingUserProgress.lastReadRef, nextUserProgress.lastReadRef) === 0 && + existingUserProgress.lastReadRef.msgId === nextUserProgress.lastReadRef.msgId; + const hasSameDeliveredRef = + compareRefsAsc( + existingUserProgress.lastDeliveredRef, + nextUserProgress.lastDeliveredRef, + ) === 0 && + existingUserProgress.lastDeliveredRef.msgId === + nextUserProgress.lastDeliveredRef.msgId; + const hasSameUser = existingUserProgress.user.id === nextUserProgress.user.id; + + if (hasSameReadRef && hasSameDeliveredRef && hasSameUser) { + return false; + } + + removeByOldKey( + this.readSorted, + existingUserProgress, + existingUserProgress.lastReadRef, + (x) => x.lastReadRef, + ); + removeByOldKey( + this.deliveredSorted, + existingUserProgress, + existingUserProgress.lastDeliveredRef, + (x) => x.lastDeliveredRef, + ); + + existingUserProgress.user = nextUserProgress.user; + existingUserProgress.lastReadRef = nextUserProgress.lastReadRef; + existingUserProgress.lastDeliveredRef = nextUserProgress.lastDeliveredRef; + + insertByKey(this.readSorted, existingUserProgress, (x) => x.lastReadRef); + insertByKey(this.deliveredSorted, existingUserProgress, (x) => x.lastDeliveredRef); + + return true; + } + + private readStateToUserProgress(readState: { + last_read: Date | string; + unread_messages?: number; + user: UserResponse; + first_unread_message_id?: string; + last_read_message_id?: string; + last_delivered_at?: Date | string; + last_delivered_message_id?: string; + }): UserProgress { + const lastReadTimestamp = toTimestampMs(readState.last_read); + const lastDeliveredTimestamp = readState.last_delivered_at + ? toTimestampMs(readState.last_delivered_at) + : null; + const lastReadRef = readState.last_read_message_id + ? { timestampMs: lastReadTimestamp, msgId: readState.last_read_message_id } + : this.locateMessage(lastReadTimestamp) ?? MIN_REF; + let lastDeliveredRef = readState.last_delivered_message_id + ? { + timestampMs: lastDeliveredTimestamp ?? lastReadTimestamp, + msgId: readState.last_delivered_message_id, + } + : lastDeliveredTimestamp + ? (this.locateMessage(lastDeliveredTimestamp) ?? MIN_REF) + : MIN_REF; + + if (compareRefsAsc(lastDeliveredRef, lastReadRef) < 0) { + lastDeliveredRef = lastReadRef; + } + + return { + user: readState.user, + lastReadRef, + lastDeliveredRef, + }; + } + + private readStoreStateToResponses( + readState: Record, + ): ReadResponse[] { + return Object.values(readState).reduce((responses, userReadState) => { + if (!isValidReadState(userReadState)) return responses; + const lastReadDate = new Date(userReadState.last_read); + if (Number.isNaN(lastReadDate.getTime())) return responses; + const lastReadIso = lastReadDate.toISOString(); + + responses.push({ + last_read: lastReadIso, + user: userReadState.user, + last_read_message_id: userReadState.last_read_message_id, + unread_messages: userReadState.unread_messages ?? 0, + last_delivered_at: userReadState.last_delivered_at + ? new Date(userReadState.last_delivered_at).toISOString() + : undefined, + last_delivered_message_id: userReadState.last_delivered_message_id, + }); + + return responses; + }, []); + } + + private emitSnapshot() { + const readersByMessageId = this.groupUsersByLastReadMessage(); + const deliveredByMessageId = this.groupUsersByLastDeliveredMessage(); + const currentSnapshot = this.snapshotStore.getLatestValue(); + + this.snapshotStore.next({ + revision: currentSnapshot.revision + 1, + readersByMessageId, + deliveredByMessageId, + }); + } } diff --git a/test/unit/MessageComposer/textComposer.test.ts b/test/unit/MessageComposer/textComposer.test.ts index cf81aa0f5e..ab6453f80b 100644 --- a/test/unit/MessageComposer/textComposer.test.ts +++ b/test/unit/MessageComposer/textComposer.test.ts @@ -105,8 +105,9 @@ describe('TextComposer', () => { expect(messageComposer.textComposer.state.getLatestValue()).toEqual({ command: null, mentionedUsers: [], - text: '', selection: { start: 0, end: 0 }, + text: '', + typing: {}, }); }); @@ -116,8 +117,9 @@ describe('TextComposer', () => { expect(messageComposer.textComposer.state.getLatestValue()).toEqual({ command: null, mentionedUsers: [], - text: defaultValue, selection: { start: defaultValue.length, end: defaultValue.length }, + text: defaultValue, + typing: {}, }); }); @@ -228,8 +230,9 @@ describe('TextComposer', () => { const initialState = { command: null, mentionedUsers: [], - text: '', selection: { start: 0, end: 0 }, + text: '', + typing: {}, }; const { messageComposer: { textComposer }, diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 36d4d853be..2bc18bd595 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -32,6 +32,7 @@ describe('Channel count unread', function () { channel = client.channel(channelResponse.channel.type, channelResponse.channel.id); channel.initialized = true; channel.lastRead = () => lastRead; + channel.data.own_capabilities = ['read-events']; const ignoredMessages = [ generateMsg({ date: '2018-01-01T00:00:00', mentioned_users: [user] }), @@ -223,6 +224,7 @@ describe('Channel _handleChannelEvent', function () { client.userID = user.id; client.userMuteStatus = (targetId) => targetId.startsWith('mute'); channel = client.channel('messaging', 'id'); + channel.data.own_capabilities = ['read-events']; channel.initialized = true; }); @@ -277,6 +279,21 @@ describe('Channel _handleChannelEvent', function () { expect(channel.state.membership).to.equal(channel.state.members[user.id]); }); + it('increments member_count from zero on member.added and syncs state member_count', () => { + channel.data.member_count = 0; + + channel._handleChannelEvent({ + type: 'member.added', + user, + member: generateMember({ + user: { id: 'new-user' }, + }), + }); + + expect(channel.data.member_count).to.equal(1); + expect(channel.state.member_count).to.equal(1); + }); + it('message.new does not reset the unreadCount for current user messages', function () { channel.state.unreadCount = 100; channel._handleChannelEvent({ @@ -701,6 +718,24 @@ describe('Channel _handleChannelEvent', function () { expect(channel.state.read[user.id].last_delivered_message_id).toBe( initialReadState.last_delivered_message_id, ); + expect( + channel.messageReceiptsTracker.getUserProgress(user.id)?.lastReadRef.msgId, + ).toBe(event.last_read_message_id); + }); + + it('should reconcile tracker with metadata patch for notification.mark_unread', () => { + channel.state.read[user.id] = initialReadState; + const reconcileSpy = vi.spyOn( + channel.messageReceiptsTracker, + 'reconcileFromReadStore', + ); + + channel._handleChannelEvent(notificationMarkUnreadEvent); + + expect(reconcileSpy).toHaveBeenCalledTimes(1); + expect(reconcileSpy.mock.calls[0][0].meta).toEqual({ + changedUserIds: [user.id], + }); }); it('should not update channel read state produced for another user or user is missing', () => { @@ -771,12 +806,15 @@ describe('Channel _handleChannelEvent', function () { event.last_read_message_id, ); expect(channel.state.read[user.id].unread_messages).toBe(0); - expect(channel.state.read[user.id].last_delivered_at).toBe( - initialReadState.last_delivered_at, + expect(new Date(channel.state.read[user.id].last_delivered_at).getTime()).toBe( + new Date(messageReadEvent.created_at).getTime(), ); expect(channel.state.read[user.id].last_delivered_message_id).toBe( - initialReadState.last_delivered_message_id, + event.last_read_message_id, ); + expect( + channel.messageReceiptsTracker.getUserProgress(user.id)?.lastReadRef.msgId, + ).toBe(event.last_read_message_id); }); it('should update channel read state produced for another user', () => { @@ -795,11 +833,32 @@ describe('Channel _handleChannelEvent', function () { event.last_read_message_id, ); expect(channel.state.read[anotherUser.id].unread_messages).toBe(0); - expect(channel.state.read[anotherUser.id].last_delivered_at).toBe( - initialReadState.last_delivered_at, + expect(new Date(channel.state.read[anotherUser.id].last_delivered_at).getTime()).toBe( + new Date(messageReadEvent.created_at).getTime(), ); expect(channel.state.read[anotherUser.id].last_delivered_message_id).toBe( - initialReadState.last_delivered_message_id, + event.last_read_message_id, + ); + }); + + it('should emit readStore subscription updates for single-user message.read events', () => { + channel.state.read[user.id] = initialReadState; + const changes = []; + const unsubscribe = channel.state.readStore.subscribe((next, prev) => { + if (!prev) return; + changes.push({ + next: next.read[user.id], + prev: prev.read[user.id], + }); + }); + + channel._handleChannelEvent(messageReadEvent); + unsubscribe(); + + expect(changes).to.have.length(1); + expect(changes[0].next).to.not.equal(changes[0].prev); + expect(new Date(changes[0].next.last_read).getTime()).toBe( + new Date(messageReadEvent.created_at).getTime(), ); }); }); @@ -856,6 +915,29 @@ describe('Channel _handleChannelEvent', function () { ); }); + it('should not move canonical delivered state backwards on out-of-order events', () => { + channel.state.read[user.id] = { + ...initialReadState, + last_delivered_at: new Date(3000).toISOString(), + last_delivered_message_id: 'newer-message-id', + }; + const olderDeliveryEvent = { + ...messageDeliveredEvent, + created_at: new Date(2000).toISOString(), + last_delivered_at: new Date(2000).toISOString(), + last_delivered_message_id: 'older-message-id', + }; + + channel._handleChannelEvent(olderDeliveryEvent); + + expect(new Date(channel.state.read[user.id].last_delivered_at).getTime()).toBe( + new Date(3000).getTime(), + ); + expect(channel.state.read[user.id].last_delivered_message_id).toBe( + 'newer-message-id', + ); + }); + it('should update channel read state produced for another user', () => { const anotherUser = { id: 'another-user' }; channel.state.unreadCount = initialCountUnread; @@ -1202,7 +1284,7 @@ describe('Channel _handleChannelEvent', function () { expect(channel.data.blocked).eq(false); }); - it('should update the frozen flag and reload channel state to update `own_capabilities`', () => { + it('should update the frozen flag and reload channel state when frozen changes', () => { const event = { channel: { frozen: true }, type: 'channel.updated', @@ -1220,6 +1302,18 @@ describe('Channel _handleChannelEvent', function () { // Make sure that we don't wipe out any data }); + it('preserves member_count on channel.updated when event payload omits member_count', () => { + channel.data.member_count = 3; + channel.data.frozen = false; + channel._handleChannelEvent({ + channel: { frozen: false }, + type: 'channel.updated', + }); + + expect(channel.data.member_count).to.equal(3); + expect(channel.state.member_count).to.equal(3); + }); + it(`should make sure that state reload doesn't wipe out existing data`, async () => { const mock = sinon.mock(client); mock.expects('post').returns(Promise.resolve(mockChannelQueryResponse)); @@ -1381,16 +1475,17 @@ describe('Channels - Constructor', function () { const channel = client.channel('messaging', '123', { cool: true }); expect(channel.cid).to.eql('messaging:123'); expect(channel.id).to.eql('123'); - expect(channel.data).to.eql({ cool: true }); + expect(channel.data.cool).to.eql(true); }); it('custom data merges to the right with current data', function () { let channel = client.channel('messaging', 'brand_new_123', { cool: true }); expect(channel.cid).to.eql('messaging:brand_new_123'); expect(channel.id).to.eql('brand_new_123'); - expect(channel.data).to.eql({ cool: true }); + expect(channel.data.cool).to.eql(true); channel = client.channel('messaging', 'brand_new_123', { custom_cool: true }); - expect(channel.data).to.eql({ cool: true, custom_cool: true }); + expect(channel.data.cool).to.eql(true); + expect(channel.data.custom_cool).to.eql(true); }); it('default options', function () { @@ -1407,12 +1502,13 @@ describe('Channels - Constructor', function () { it('undefined ID no options', function () { const channel = client.channel('messaging', undefined); expect(channel.id).to.eql(undefined); - expect(channel.data).to.eql({}); + expect(channel.data.own_capabilities).to.eql([]); + expect(Object.keys(channel.data)).to.eql(['own_capabilities']); }); it('short version with options', function () { const channel = client.channel('messaging', { members: ['tommaso', 'thierry'] }); - expect(channel.data).to.eql({ members: ['tommaso', 'thierry'] }); + expect(channel.data.members).to.eql(['tommaso', 'thierry']); expect(channel.id).to.eql(undefined); }); @@ -1420,7 +1516,7 @@ describe('Channels - Constructor', function () { const channel = client.channel('messaging', null, { members: ['tommaso', 'thierry'], }); - expect(channel.data).to.eql({ members: ['tommaso', 'thierry'] }); + expect(channel.data.members).to.eql(['tommaso', 'thierry']); expect(channel.id).to.eql(undefined); }); @@ -1428,7 +1524,7 @@ describe('Channels - Constructor', function () { const channel = client.channel('messaging', '', { members: ['tommaso', 'thierry'], }); - expect(channel.data).to.eql({ members: ['tommaso', 'thierry'] }); + expect(channel.data.members).to.eql(['tommaso', 'thierry']); expect(channel.id).to.eql(undefined); }); @@ -1436,7 +1532,7 @@ describe('Channels - Constructor', function () { const channel = client.channel('messaging', undefined, { members: ['tommaso', 'thierry'], }); - expect(channel.data).to.eql({ members: ['tommaso', 'thierry'] }); + expect(channel.data.members).to.eql(['tommaso', 'thierry']); expect(channel.id).to.eql(undefined); }); }); @@ -1891,6 +1987,42 @@ describe('Channel _initializeState', () => { expect(Object.keys(channel.state.members)).deep.to.be.equal(['alice']); }); + + it('should merge read state without overwriting existing users', async () => { + const client = await getClientWithUser(); + const channel = client.channel('messaging', uuidv4()); + const existingUser = { id: 'existing-user' }; + const newUser = { id: 'new-user' }; + channel.messageReceiptsTracker.setPendingReadStoreReconcileMeta({ + changedUserIds: [existingUser.id], + }); + channel.state.read = { + [existingUser.id]: { + last_read: new Date('2026-01-01T00:00:00.000Z'), + unread_messages: 1, + user: existingUser, + }, + }; + + channel._initializeState({ + read: [ + { + last_delivered_at: new Date('2026-01-02T00:00:00.000Z').toISOString(), + last_delivered_message_id: 'delivered-message-id', + last_read: new Date('2026-01-02T00:00:00.000Z').toISOString(), + last_read_message_id: 'read-message-id', + unread_messages: 0, + user: newUser, + }, + ], + }); + + expect(channel.state.read[existingUser.id]).toBeDefined(); + expect(channel.state.read[newUser.id]).toBeDefined(); + expect(channel.state.read[newUser.id].last_read_message_id).toBe('read-message-id'); + expect(channel.messageReceiptsTracker.getUserProgress(existingUser.id)).toBeTruthy(); + expect(channel.messageReceiptsTracker.getUserProgress(newUser.id)).toBeTruthy(); + }); }); describe('Channel.query', async () => { diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index 8ac7345b85..99748a5135 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -1654,6 +1654,326 @@ describe('messagePagination', () => { }); }); +describe('ChannelState members store', () => { + it('initializes members store with an empty members map', () => { + const state = new ChannelState(); + + expect(state.members).to.eql({}); + expect(state.member_count).to.equal(0); + expect(state.membersStore.getLatestValue()).to.eql({ members: {}, memberCount: 0 }); + }); + + it('keeps members getter/setter backward compatible while syncing the store', () => { + const state = new ChannelState(); + const members = { + alice: { user: { id: 'alice' }, user_id: 'alice' }, + }; + + state.members = members; + + expect(state.members).to.equal(members); + expect(state.membersStore.getLatestValue()).to.eql({ + memberCount: 0, + members, + }); + }); + + it('keeps member_count getter/setter backward compatible while syncing the store', () => { + const state = new ChannelState(); + + state.member_count = 42; + + expect(state.member_count).to.equal(42); + expect(state.membersStore.getLatestValue()).to.eql({ + memberCount: 42, + members: {}, + }); + }); +}); + +describe('ChannelState member count bridge', () => { + it('initializes membersStore memberCount from channel.data.member_count', () => { + const client = new StreamChat(); + const channel = new Channel(client, 'type', 'id', { member_count: 3 }); + const state = channel.state; + + expect(state.member_count).to.equal(3); + expect(state.membersStore.getLatestValue()).to.eql({ + memberCount: 3, + members: {}, + }); + expect(channel.data?.member_count).to.equal(3); + }); + + it('syncs memberCount when channel.data is replaced', () => { + const client = new StreamChat(); + const channel = new Channel(client, 'type', 'id', { member_count: 1 }); + const state = channel.state; + + channel.data = { ...channel.data, member_count: 7 }; + state.syncMemberCountFromChannelData(channel.data); + + expect(state.member_count).to.equal(7); + expect(state.membersStore.getLatestValue()).to.eql({ + memberCount: 7, + members: {}, + }); + expect(channel.data?.member_count).to.equal(7); + }); + + it('keeps backward-compatible channel.data.member_count assignments in sync', () => { + const client = new StreamChat(); + const channel = new Channel(client, 'type', 'id', {}); + const state = channel.state; + + channel.data.member_count = 5; + + expect(state.member_count).to.equal(5); + expect(state.membersStore.getLatestValue()).to.eql({ + memberCount: 5, + members: {}, + }); + expect(channel.data.member_count).to.equal(5); + }); +}); + +describe('ChannelState read store', () => { + it('initializes read store with an empty read map', () => { + const state = new ChannelState(); + + expect(state.read).to.eql({}); + expect(state.readStore.getLatestValue()).to.eql({ read: {} }); + }); + + it('keeps read getter/setter backward compatible while syncing the store', () => { + const state = new ChannelState(); + const read = { + alice: { + last_read: new Date('2026-02-28T00:00:00.000Z'), + unread_messages: 3, + user: { id: 'alice' }, + }, + }; + + state.read = read; + + expect(state.read).to.equal(read); + expect(state.readStore.getLatestValue()).to.eql({ read }); + }); +}); + +describe('ChannelState watcher count store', () => { + it('initializes watcher count store with zero', () => { + const state = new ChannelState(); + + expect(state.watcher_count).to.equal(0); + expect(state.watcherStore.getLatestValue()).to.eql({ + watcherCount: 0, + watchers: {}, + }); + }); + + it('keeps watcher_count getter/setter backward compatible while syncing the store', () => { + const state = new ChannelState(); + + state.watcher_count = 42; + + expect(state.watcher_count).to.equal(42); + expect(state.watcherStore.getLatestValue()).to.eql({ + watcherCount: 42, + watchers: {}, + }); + }); +}); + +describe('ChannelState watchers store', () => { + it('initializes watchers store with an empty watchers map', () => { + const state = new ChannelState(); + + expect(state.watchers).to.eql({}); + expect(state.watcherStore.getLatestValue()).to.eql({ + watcherCount: 0, + watchers: {}, + }); + }); + + it('keeps watchers getter/setter backward compatible while syncing the store', () => { + const state = new ChannelState(); + const watchers = { + alice: { id: 'alice' }, + }; + + state.watchers = watchers; + + expect(state.watchers).to.equal(watchers); + expect(state.watcherStore.getLatestValue()).to.eql({ + watcherCount: 0, + watchers, + }); + }); +}); + +describe('ChannelState muted users store', () => { + it('initializes muted users store with an empty list', () => { + const state = new ChannelState(); + + expect(state.mutedUsers).to.eql([]); + expect(state.mutedUsersStore.getLatestValue()).to.eql({ mutedUsers: [] }); + }); + + it('keeps mutedUsers getter/setter backward compatible while syncing the store', () => { + const state = new ChannelState(); + const mutedUsers = [{ id: 'alice' }]; + + state.mutedUsers = mutedUsers; + + expect(state.mutedUsers).to.equal(mutedUsers); + expect(state.mutedUsersStore.getLatestValue()).to.eql({ mutedUsers }); + }); +}); + +describe('ChannelState typing store', () => { + it('initializes typing store with an empty typing map', () => { + const state = new ChannelState(); + + expect(state.typing).to.eql({}); + expect(state.typingStore.getLatestValue()).to.eql({ typing: {} }); + }); + + it('keeps typing store and textComposer typing in sync via setTypingEvent/removeTypingEvent', () => { + const client = new StreamChat(); + const channel = new Channel(client, 'type', 'id', {}); + const state = channel.state; + const typingStartEvent = { + type: 'typing.start', + user: { id: 'alice' }, + }; + + state.setTypingEvent('alice', typingStartEvent); + + expect(state.typing).to.have.property('alice'); + expect(state.typingStore.getLatestValue().typing).to.have.property('alice'); + expect(channel.messageComposer.textComposer.typing).to.have.property('alice'); + + state.removeTypingEvent('alice'); + + expect(state.typing).to.not.have.property('alice'); + expect(state.typingStore.getLatestValue().typing).to.not.have.property('alice'); + expect(channel.messageComposer.textComposer.typing).to.not.have.property('alice'); + }); +}); + +describe('ChannelState own capabilities store', () => { + it('does not redefine channel.data as an accessor property', () => { + const client = new StreamChat(); + const channel = new Channel(client, 'type', 'id', { + own_capabilities: ['send-message'], + }); + const descriptor = Object.getOwnPropertyDescriptor(channel, 'data'); + + expect(descriptor).toBeDefined(); + expect('value' in descriptor).toBe(true); + expect('get' in descriptor).toBe(false); + expect('set' in descriptor).toBe(false); + }); + + it('initializes ownCapabilitiesStore from channel.data.own_capabilities', () => { + const client = new StreamChat(); + const channel = new Channel(client, 'type', 'id', { + own_capabilities: ['send-message', 'upload-file'], + }); + const state = channel.state; + + expect(state.ownCapabilitiesStore.getLatestValue()).to.eql({ + ownCapabilities: ['send-message', 'upload-file'], + }); + expect(channel.data?.own_capabilities).to.eql(['send-message', 'upload-file']); + }); + + it('syncs ownCapabilitiesStore when channel.data is replaced', () => { + const client = new StreamChat(); + const channel = new Channel(client, 'type', 'id', { + own_capabilities: ['send-message'], + }); + const state = channel.state; + + channel.data = { + ...channel.data, + own_capabilities: ['pin-message'], + }; + state.syncOwnCapabilitiesFromChannelData(channel.data); + + expect(state.ownCapabilitiesStore.getLatestValue()).to.eql({ + ownCapabilities: ['pin-message'], + }); + expect(channel.data?.own_capabilities).to.eql(['pin-message']); + }); + + it('keeps backward-compatible channel.data.own_capabilities assignments in sync', () => { + const client = new StreamChat(); + const channel = new Channel(client, 'type', 'id', {}); + const state = channel.state; + + channel.data.own_capabilities = ['delete-message']; + + expect(state.ownCapabilitiesStore.getLatestValue()).to.eql({ + ownCapabilities: ['delete-message'], + }); + expect(channel.data.own_capabilities).to.eql(['delete-message']); + }); + + it('only wraps own_capabilities and keeps other channel.data fields as value properties', () => { + const client = new StreamChat(); + const channel = new Channel(client, 'type', 'id', { + hidden: false, + member_count: 3, + own_capabilities: ['send-message'], + }); + + const ownCapabilitiesDescriptor = Object.getOwnPropertyDescriptor( + channel.data, + 'own_capabilities', + ); + const hiddenDescriptor = Object.getOwnPropertyDescriptor(channel.data, 'hidden'); + const memberCountDescriptor = Object.getOwnPropertyDescriptor( + channel.data, + 'member_count', + ); + + expect(ownCapabilitiesDescriptor).toBeDefined(); + expect('get' in ownCapabilitiesDescriptor).toBe(true); + expect('set' in ownCapabilitiesDescriptor).toBe(true); + expect(hiddenDescriptor).toBeDefined(); + expect('value' in hiddenDescriptor).toBe(true); + expect('get' in hiddenDescriptor).toBe(false); + expect('set' in hiddenDescriptor).toBe(false); + expect(memberCountDescriptor).toBeDefined(); + expect('get' in memberCountDescriptor).toBe(true); + expect('set' in memberCountDescriptor).toBe(true); + }); + + it('does not overwrite non-capability fields when own_capabilities is updated', () => { + const client = new StreamChat(); + const channel = new Channel(client, 'type', 'id', { + hidden: false, + member_count: 3, + own_capabilities: ['send-message'], + }); + const state = channel.state; + + channel.data.hidden = true; + channel.data.member_count = 5; + channel.data.own_capabilities = ['pin-message']; + + expect(channel.data.hidden).to.equal(true); + expect(channel.data.member_count).to.equal(5); + expect(state.member_count).to.equal(5); + expect(state.ownCapabilitiesStore.getLatestValue()).to.eql({ + ownCapabilities: ['pin-message'], + }); + }); +}); + describe('loadMessageIntoState', () => { let state; diff --git a/test/unit/client.test.js b/test/unit/client.test.js index 35e26adea4..f97ebec14a 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -131,6 +131,48 @@ describe('StreamChat getInstance', () => { }); }); +describe('StreamChat config(s) store', () => { + it('initializes configsStore and keeps configs access backward compatible', () => { + const client = new StreamChat('key', 'secret'); + + expect(client.configs).to.eql({}); + expect(client.configsStore.getLatestValue()).to.eql({ configs: {} }); + + const nextConfigs = { 'messaging:next': { typing_events: true } }; + client.configs = nextConfigs; + + expect(client.configs).to.equal(nextConfigs); + expect(client.configsStore.getLatestValue()).to.eql({ configs: nextConfigs }); + }); + + it('updates configsStore through _addChannelConfig when cache is enabled', () => { + const client = new StreamChat('key', 'secret'); + + client._addChannelConfig({ + cid: 'messaging:channel-1', + config: { replies: true }, + }); + + expect(client.configsStore.getLatestValue()).to.eql({ + configs: { + 'messaging:channel-1': { replies: true }, + }, + }); + }); + + it('does not update configsStore through _addChannelConfig when cache is disabled', () => { + const client = new StreamChat('key', 'secret'); + client._cacheEnabled = () => false; + + client._addChannelConfig({ + cid: 'messaging:channel-1', + config: { replies: true }, + }); + + expect(client.configsStore.getLatestValue()).to.eql({ configs: {} }); + }); +}); + describe('Client userMuteStatus', function () { const client = new StreamChat('', ''); const user = { id: 'user' }; @@ -724,6 +766,44 @@ describe('StreamChat.queryChannels', async () => { postStub.restore(); }); + it('should sync channel data-backed stores when hydrating channels from queryChannels', async () => { + const client = await getClientWithUser(); + const mockedChannelsQueryResponse = [ + { + ...mockChannelQueryResponse, + channel: { + ...mockChannelQueryResponse.channel, + member_count: 7, + own_capabilities: ['send-message', 'read-events'], + }, + messages: Array.from( + { length: DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE }, + generateMsg, + ), + }, + ]; + const postStub = sinon + .stub(client, 'post') + .returns(Promise.resolve({ channels: mockedChannelsQueryResponse })); + + const [channel] = await client.queryChannels(); + + expect(channel.state.member_count).to.equal(7); + expect(channel.state.ownCapabilitiesStore.getLatestValue()).to.eql({ + ownCapabilities: ['send-message', 'read-events'], + }); + + channel.data.member_count = 8; + channel.data.own_capabilities = ['send-message']; + + expect(channel.state.member_count).to.equal(8); + expect(channel.state.ownCapabilitiesStore.getLatestValue()).to.eql({ + ownCapabilities: ['send-message'], + }); + + postStub.restore(); + }); + it('should return the raw channels response from queryChannelsRequest', async () => { const client = await getClientWithUser(); const mockedChannelsQueryResponse = Array.from({ length: 10 }, () => ({ diff --git a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts index 380aad2d21..a0f3348cc2 100644 --- a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts +++ b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts @@ -5,6 +5,8 @@ import { ReadResponse, UserResponse, } from '../../../src'; +import { StateStore } from '../../../src/store'; +import type { Channel } from '../../../src/channel'; const ownUserId = 'author'; const U = (id: string): UserResponse => ({ id, name: id }); // matches UserResponse shape for the service @@ -20,11 +22,30 @@ const msgs = [ const byTs = new Map(msgs.map((m) => [m.ts, m])); const ref = (ts: number): MsgRef => ({ timestampMs: ts, msgId: byTs.get(ts)!.id }); -// Message locator used by the service (O(1) lookup by exact timestamp) -const makeLocator = () => (timestampMs?: number) => { - if (!timestampMs) return null; +const defaultFindMessageByTimestamp = (timestampMs?: number) => { + if (!timestampMs) return undefined; const m = byTs.get(timestampMs); - return m ? { timestampMs: m.ts, msgId: m.id } : null; + return m ? { id: m.id } : undefined; +}; + +const createChannelMock = ({ + findMessageByTimestamp = defaultFindMessageByTimestamp, +}: { + findMessageByTimestamp?: (timestampMs?: number) => { id: string } | undefined; +} = {}) => { + const readStore = new StateStore({ + read: {}, + }); + + return { + channel: { + state: { + findMessageByTimestamp, + readStore, + }, + } as unknown as Channel, + readStore, + }; }; // ISO builders (service parses Date strings) @@ -37,9 +58,34 @@ const ids = (users: any[]) => users.map((u) => u.id); describe('MessageDeliveryReadTracker', () => { let tracker: MessageReceiptsTracker; + let channelMock: ReturnType; beforeEach(() => { - tracker = new MessageReceiptsTracker({ locateMessage: makeLocator() }); + channelMock = createChannelMock(); + tracker = new MessageReceiptsTracker({ channel: channelMock.channel }); + }); + + describe('constructor', () => { + it('allows locateMessage constructor override while requiring channel', () => { + const customLocateMessage = vi.fn((timestampMs: number) => ({ + timestampMs, + msgId: 'custom', + })); + const trackerWithCustomLocator = new MessageReceiptsTracker({ + channel: channelMock.channel, + locateMessage: customLocateMessage, + }); + + trackerWithCustomLocator.onMessageRead({ + user: U('compat-user'), + readAt: iso(2000), + }); + + expect(customLocateMessage).toHaveBeenCalledWith(2000); + expect( + trackerWithCustomLocator.getUserProgress('compat-user')?.lastReadRef.msgId, + ).toBe('custom'); + }); }); describe('ingestInitial', () => { @@ -126,10 +172,12 @@ describe('MessageDeliveryReadTracker', () => { }); it('ignores read events with unknown timestamps (locator returns null)', () => { - // re-init with a locator that knows only m1..m3 (m4 is unknown) - const locator = (ts?: number) => - ts && ts <= 3000 ? { timestampMs: ts, msgId: byTs.get(ts)!.id } : null; - tracker = new MessageReceiptsTracker({ locateMessage: locator }); + // re-init with channel state that knows only m1..m3 (m4 is unknown) + channelMock = createChannelMock({ + findMessageByTimestamp: (ts?: number) => + ts && ts <= 3000 ? { id: byTs.get(ts)!.id } : undefined, + }); + tracker = new MessageReceiptsTracker({ channel: channelMock.channel }); const dave = U('dave'); tracker.onMessageRead({ user: dave, readAt: iso(4000) }); // unknown -> ignored @@ -143,11 +191,12 @@ describe('MessageDeliveryReadTracker', () => { }); it('prevents search for message if last read message id is provided', () => { - const locator = vi.fn().mockImplementation(() => {}); - tracker = new MessageReceiptsTracker({ locateMessage: locator }); + const findMessageByTimestamp = vi.fn().mockImplementation(() => {}); + channelMock = createChannelMock({ findMessageByTimestamp }); + tracker = new MessageReceiptsTracker({ channel: channelMock.channel }); const user = U('frank'); tracker.onMessageRead({ user, readAt: iso(3000), lastReadMessageId: 'X' }); // unknown -> ignored - expect(locator).not.toHaveBeenCalled(); + expect(findMessageByTimestamp).not.toHaveBeenCalled(); expect(tracker.getUserProgress('frank')).toStrictEqual({ lastDeliveredRef: { msgId: 'X', @@ -201,9 +250,11 @@ describe('MessageDeliveryReadTracker', () => { }); it('ignores delivered events with unknown timestamps (locator returns null)', () => { - const locator = (t?: number) => - t && t <= 2000 ? { timestampMs: t, msgId: byTs.get(t)!.id } : null; - tracker = new MessageReceiptsTracker({ locateMessage: locator }); + channelMock = createChannelMock({ + findMessageByTimestamp: (t?: number) => + t && t <= 2000 ? { id: byTs.get(t)!.id } : undefined, + }); + tracker = new MessageReceiptsTracker({ channel: channelMock.channel }); const frank = U('frank'); tracker.onMessageDelivered({ user: frank, deliveredAt: iso(3000) }); // unknown -> ignored @@ -215,15 +266,16 @@ describe('MessageDeliveryReadTracker', () => { }); it('prevents search for message if last read message id is provided', () => { - const locator = vi.fn().mockImplementation(() => {}); - tracker = new MessageReceiptsTracker({ locateMessage: locator }); + const findMessageByTimestamp = vi.fn().mockImplementation(() => {}); + channelMock = createChannelMock({ findMessageByTimestamp }); + tracker = new MessageReceiptsTracker({ channel: channelMock.channel }); const user = U('frank'); tracker.onMessageDelivered({ user, deliveredAt: iso(3000), lastDeliveredMessageId: 'X', }); // unknown -> ignored - expect(locator).not.toHaveBeenCalled(); + expect(findMessageByTimestamp).not.toHaveBeenCalled(); expect(tracker.getUserProgress('frank')).toStrictEqual({ lastDeliveredRef: { msgId: 'X', @@ -311,8 +363,9 @@ describe('MessageDeliveryReadTracker', () => { }); it('does not call locateMessage when lastReadMessageId is provided', () => { - const locator = vi.fn().mockImplementation(makeLocator()); - tracker = new MessageReceiptsTracker({ locateMessage: locator }); + const findMessageByTimestamp = vi.fn().mockImplementation(defaultFindMessageByTimestamp); + channelMock = createChannelMock({ findMessageByTimestamp }); + tracker = new MessageReceiptsTracker({ channel: channelMock.channel }); tracker.onNotificationMarkUnread({ user, @@ -325,7 +378,42 @@ describe('MessageDeliveryReadTracker', () => { expect(userProgress.lastReadRef).toEqual(ref(2000)); // ensure locator wasn’t used to derive the read ref - expect(locator).not.toHaveBeenCalled(); + expect(findMessageByTimestamp).not.toHaveBeenCalled(); + }); + }); + + describe('subscriptions', () => { + it('reconciles from readStore emissions when subscribed and stops after unsubscribe', () => { + const user = U('subscribed-user'); + tracker.registerSubscriptions(); + tracker.setPendingReadStoreReconcileMeta({ changedUserIds: [user.id] }); + + channelMock.readStore.next({ + read: { + [user.id]: { + last_read: new Date(2000), + user, + unread_messages: 0, + last_read_message_id: 'm2', + }, + }, + }); + expect(tracker.getUserProgress(user.id)?.lastReadRef).toEqual(ref(2000)); + + tracker.unregisterSubscriptions(); + channelMock.readStore.next({ + read: { + [user.id]: { + last_read: new Date(3000), + user, + unread_messages: 0, + last_read_message_id: 'm3', + }, + }, + }); + + // no longer subscribed -> unchanged + expect(tracker.getUserProgress(user.id)?.lastReadRef).toEqual(ref(2000)); }); }); @@ -461,4 +549,203 @@ describe('MessageDeliveryReadTracker', () => { expect(ids(tracker.readersForMessage(ref(4000)))).toEqual(['x']); }); }); + + describe('snapshotStore', () => { + it('updates revision on every ingestInitial call', () => { + const snapshot = [ + { user: U('alice'), last_read: iso(2000), last_delivered_at: iso(2000) }, + ]; + + tracker.ingestInitial(snapshot); + expect(tracker.snapshotStore.getLatestValue().revision).toBe(1); + expect(tracker.snapshotStore.getLatestValue().readersByMessageId).toEqual({ + m2: [U('alice')], + }); + + // same state still emits for full ingest calls + tracker.ingestInitial(snapshot); + expect(tracker.snapshotStore.getLatestValue().revision).toBe(2); + + // changed state -> new revision + tracker.ingestInitial([ + { user: U('alice'), last_read: iso(3000), last_delivered_at: iso(3000) }, + ]); + expect(tracker.snapshotStore.getLatestValue().revision).toBe(3); + }); + + it('updates revision for effective message.read changes only', () => { + const user = U('reader'); + + tracker.onMessageRead({ user, readAt: iso(2000) }); + expect(tracker.snapshotStore.getLatestValue().revision).toBe(1); + + // same/older read should be a no-op + tracker.onMessageRead({ user, readAt: iso(2000) }); + tracker.onMessageRead({ user, readAt: iso(1000) }); + expect(tracker.snapshotStore.getLatestValue().revision).toBe(1); + + tracker.onMessageRead({ user, readAt: iso(3000) }); + expect(tracker.snapshotStore.getLatestValue().revision).toBe(2); + }); + + it('updates revision for effective message.delivered changes only', () => { + const user = U('delivered-user'); + + tracker.onMessageDelivered({ user, deliveredAt: iso(2000) }); + expect(tracker.snapshotStore.getLatestValue().revision).toBe(1); + + // same/older delivery should be a no-op + tracker.onMessageDelivered({ user, deliveredAt: iso(2000) }); + tracker.onMessageDelivered({ user, deliveredAt: iso(1000) }); + expect(tracker.snapshotStore.getLatestValue().revision).toBe(1); + + tracker.onMessageDelivered({ user, deliveredAt: iso(3000) }); + expect(tracker.snapshotStore.getLatestValue().revision).toBe(2); + }); + + it('updates revision for effective notification.mark_unread changes only', () => { + const user = U('mark-unread-user'); + + tracker.onMessageRead({ user, readAt: iso(3000), lastReadMessageId: 'm3' }); + expect(tracker.snapshotStore.getLatestValue().revision).toBe(1); + + tracker.onNotificationMarkUnread({ + user, + lastReadAt: iso(2000), + lastReadMessageId: 'm2', + }); + expect(tracker.snapshotStore.getLatestValue().revision).toBe(2); + + // same boundary -> no-op + tracker.onNotificationMarkUnread({ + user, + lastReadAt: iso(2000), + lastReadMessageId: 'm2', + }); + expect(tracker.snapshotStore.getLatestValue().revision).toBe(2); + }); + }); + + describe('reconcileFromReadStore', () => { + it('reconciles changed/removed users from metadata deltas', () => { + const alice = U('alice'); + const bob = U('bob'); + const carol = U('carol'); + const previousReadState = { + [alice.id]: { + last_read: new Date(2000), + unread_messages: 0, + user: alice, + last_read_message_id: 'm2', + }, + [bob.id]: { + last_read: new Date(3000), + unread_messages: 0, + user: bob, + last_read_message_id: 'm3', + last_delivered_at: new Date(3000), + last_delivered_message_id: 'm3', + }, + }; + const nextReadState = { + [bob.id]: { + last_read: new Date(4000), + unread_messages: 0, + user: bob, + last_read_message_id: 'm4', + last_delivered_at: new Date(4000), + last_delivered_message_id: 'm4', + }, + [carol.id]: { + last_read: new Date(2000), + unread_messages: 0, + user: carol, + last_read_message_id: 'm2', + last_delivered_at: new Date(2000), + last_delivered_message_id: 'm2', + }, + }; + + tracker.ingestInitial([ + { user: alice, last_read: iso(2000), last_delivered_at: iso(2000) }, + { user: bob, last_read: iso(3000), last_delivered_at: iso(3000) }, + ]); + + tracker.reconcileFromReadStore({ + previousReadState, + nextReadState, + meta: { + changedUserIds: [bob.id, carol.id], + removedUserIds: [alice.id], + }, + }); + + expect(tracker.getUserProgress(alice.id)).toBeNull(); + expect(tracker.getUserProgress(bob.id)?.lastReadRef).toEqual(ref(4000)); + expect(tracker.getUserProgress(carol.id)?.lastReadRef).toEqual(ref(2000)); + }); + + it('ignores non-bootstrap reconcile when metadata is absent', () => { + const user = U('missing-meta-user'); + + tracker.reconcileFromReadStore({ + previousReadState: {}, + nextReadState: { + [user.id]: { + last_read: new Date(3000), + unread_messages: 0, + user, + last_read_message_id: 'm3', + last_delivered_at: new Date(3000), + last_delivered_message_id: 'm3', + }, + }, + }); + + expect(tracker.getUserProgress(user.id)).toBeNull(); + expect(tracker.snapshotStore.getLatestValue().revision).toBe(0); + }); + + it('applies only metadata-declared user deltas', () => { + const user = U('meta-user'); + tracker.ingestInitial([ + { + user, + last_read: iso(2000), + last_delivered_at: iso(2000), + last_read_message_id: 'm2', + last_delivered_message_id: 'm2', + }, + ]); + expect(tracker.snapshotStore.getLatestValue().revision).toBe(1); + + tracker.reconcileFromReadStore({ + previousReadState: { + [user.id]: { + last_read: new Date(2000), + unread_messages: 0, + user, + last_read_message_id: 'm2', + last_delivered_at: new Date(2000), + last_delivered_message_id: 'm2', + }, + }, + nextReadState: { + [user.id]: { + last_read: new Date(4000), + unread_messages: 0, + user, + last_read_message_id: 'm4', + last_delivered_at: new Date(4000), + last_delivered_message_id: 'm4', + }, + }, + meta: { changedUserIds: [] }, + }); + + // Metadata drives reconciliation; undeclared users are ignored. + expect(tracker.getUserProgress(user.id)?.lastReadRef).toEqual(ref(2000)); + expect(tracker.snapshotStore.getLatestValue().revision).toBe(1); + }); + }); }); From 892f6e6e17b115192566c51b0d6e2194b98338e6 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 3 Mar 2026 17:19:34 +0100 Subject: [PATCH 15/48] test: fix message composer tests --- .../MessageOperationStatePolicy.ts | 12 +- .../MessageComposer/messageComposer.test.ts | 215 ++++++++++++++++-- .../unit/MessageComposer/textComposer.test.ts | 1 + 3 files changed, 198 insertions(+), 30 deletions(-) diff --git a/src/messageOperations/MessageOperationStatePolicy.ts b/src/messageOperations/MessageOperationStatePolicy.ts index 82cc253356..329690d835 100644 --- a/src/messageOperations/MessageOperationStatePolicy.ts +++ b/src/messageOperations/MessageOperationStatePolicy.ts @@ -52,11 +52,13 @@ export class MessageOperationStatePolicy { const formatted = formatMessage({ ...messageFromResponse, status: 'received' }); const existing = this.ctx.get(messageId); - if ( - !existing || - existing.updated_at.getTime() < formatted.updated_at.getTime() || - existing.status === 'sending' - ) { + const serverNewer = + !existing || formatted.updated_at.getTime() > existing.updated_at.getTime(); + const serverSameOrNewer = + !existing || formatted.updated_at.getTime() >= existing.updated_at.getTime(); + const existingIsOurOptimisticSend = existing?.status === 'sending'; + + if (serverNewer || (existingIsOurOptimisticSend && serverSameOrNewer)) { this.ctx.ingest(formatted); } } diff --git a/test/unit/MessageComposer/messageComposer.test.ts b/test/unit/MessageComposer/messageComposer.test.ts index c72eab5d26..b2dfe626cb 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -976,6 +976,7 @@ describe('MessageComposer', () => { expect(result).toEqual({ localMessage: { attachments: [], + cid: 'messaging:test-channel-id', created_at: expect.any(Date), deleted_at: null, error: null, @@ -1043,6 +1044,7 @@ describe('MessageComposer', () => { expect(result).toEqual({ localMessage: { attachments: [{ type: 'file' }], + cid: 'messaging:test-channel-id', created_at: date, deleted_at: null, error: null, @@ -1120,25 +1122,175 @@ describe('MessageComposer', () => { }); describe('sendMessage', () => { - it.fails('performs optimistic update before sending the message'); - it.fails( - 'updates the message in state after successful response if message has not arrived over WS', - ); - it.fails( - 'does not update the message in state after successful response if message has arrived over WS and the update timestamp is <= existing message timestamp', - ); - it.fails( - 'does not update the message in state if it already exists on the server and in the local state as not delivered', - ); - it.fails( - 'does not update the message in state if it already exists on the server and in the local state as not failed', - ); - it.fails( - 'updates the message in state if it already exists on the server and in the local state with status sending', - ); - it.fails( - 'updates the message in state if it does not exist on the server and the send request failed', - ); + it('performs optimistic update before sending the message', async () => { + const { messageComposer, mockChannel } = setup(); + messageComposer.textComposer.setText('Hello'); + const composed = await messageComposer.compose(); + expect(composed).toBeDefined(); + let resolveSend: (v: { message: MessageResponse }) => void = () => {}; + const sendPromise = mockChannel.sendMessageWithLocalUpdate({ + localMessage: composed!.localMessage, + message: composed!.message, + options: composed!.sendOptions, + sendMessageRequestFn: () => + new Promise((resolve) => { + resolveSend = resolve; + }), + }); + await Promise.resolve(); + const optimistic = mockChannel.messagePaginator.getItem( + composed!.localMessage.id, + ); + expect(optimistic?.status).toBe('sending'); + resolveSend({ message: generateMsg({ id: composed!.localMessage.id }) }); + await sendPromise; + }); + + it('updates the message in state after successful response if message has not arrived over WS', async () => { + const { messageComposer, mockChannel } = setup(); + messageComposer.textComposer.setText('Hello'); + const composed = await messageComposer.compose(); + const serverMessage = generateMsg({ + id: composed!.localMessage.id, + updated_at: new Date( + composed!.localMessage.updated_at.getTime() + 100, + ).toISOString(), + }); + await mockChannel.sendMessageWithLocalUpdate({ + localMessage: composed!.localMessage, + message: composed!.message, + options: composed!.sendOptions, + sendMessageRequestFn: async () => ({ message: serverMessage }), + }); + const after = mockChannel.messagePaginator.getItem(composed!.localMessage.id); + expect(after?.status).toBe('received'); + }); + + it('does not update the message in state after successful response if message has arrived over WS and the update timestamp is <= existing message timestamp', async () => { + const { messageComposer, mockChannel } = setup(); + messageComposer.textComposer.setText('Hello'); + const composed = await messageComposer.compose(); + const messageId = composed!.localMessage.id; + const composedUpdatedAt = composed!.localMessage.updated_at.getTime(); + const olderServerTime = new Date(composedUpdatedAt - 5000); + const serverMessage = generateMsg({ + id: messageId, + updated_at: olderServerTime.toISOString(), + }); + await mockChannel.sendMessageWithLocalUpdate({ + localMessage: composed!.localMessage, + message: composed!.message, + options: composed!.sendOptions, + sendMessageRequestFn: async () => ({ message: serverMessage }), + }); + const after = mockChannel.messagePaginator.getItem(messageId); + expect(after?.status).toBe('sending'); + expect(after?.updated_at.getTime()).toBeGreaterThanOrEqual( + composedUpdatedAt - 100, + ); + }); + + it('does not update the message in state if it already exists on the server and in the local state as not delivered', async () => { + const { messageComposer, mockChannel } = setup(); + messageComposer.textComposer.setText('Hello'); + const composed = await messageComposer.compose(); + const messageId = composed!.localMessage.id; + const composedUpdatedAt = composed!.localMessage.updated_at.getTime(); + const olderServerTime = new Date(composedUpdatedAt - 2000); + await mockChannel.sendMessageWithLocalUpdate({ + localMessage: composed!.localMessage, + message: composed!.message, + options: composed!.sendOptions, + sendMessageRequestFn: async () => ({ + message: generateMsg({ + id: messageId, + updated_at: olderServerTime.toISOString(), + }), + }), + }); + const after = mockChannel.messagePaginator.getItem(messageId); + expect(after?.status).toBe('sending'); + expect(after?.updated_at.getTime()).toBeGreaterThanOrEqual( + composedUpdatedAt - 100, + ); + }); + + it('does not update the message in state if it already exists on the server and in the local state as not failed', async () => { + const { messageComposer, mockChannel } = setup(); + messageComposer.textComposer.setText('Hello'); + const composed = await messageComposer.compose(); + const messageId = composed!.localMessage.id; + const composedUpdatedAt = composed!.localMessage.updated_at.getTime(); + const olderServerTime = new Date(composedUpdatedAt - 1000); + await mockChannel.sendMessageWithLocalUpdate({ + localMessage: composed!.localMessage, + message: composed!.message, + options: composed!.sendOptions, + sendMessageRequestFn: async () => ({ + message: generateMsg({ + id: messageId, + updated_at: olderServerTime.toISOString(), + }), + }), + }); + const after = mockChannel.messagePaginator.getItem(messageId); + expect(after?.status).toBe('sending'); + expect(after?.updated_at.getTime()).toBe(composedUpdatedAt); + }); + + it('updates the message in state if it already exists on the server and in the local state with status sending', async () => { + const { messageComposer, mockChannel } = setup(); + messageComposer.textComposer.setText('Hello'); + const composed = await messageComposer.compose(); + const messageId = composed!.localMessage.id; + const existingSending = { + ...composed!.localMessage, + status: 'sending' as const, + updated_at: new Date(Date.now() - 5000), + }; + mockChannel.messagePaginator.ingestItem(existingSending); + const serverUpdatedAt = new Date( + composed!.localMessage.updated_at.getTime() + 100, + ); + await mockChannel.sendMessageWithLocalUpdate({ + localMessage: composed!.localMessage, + message: composed!.message, + options: composed!.sendOptions, + sendMessageRequestFn: async () => ({ + message: generateMsg({ + id: messageId, + updated_at: serverUpdatedAt.toISOString(), + }), + }), + }); + const after = mockChannel.messagePaginator.getItem(messageId); + expect(after?.status).toBe('received'); + expect(after?.updated_at.getTime()).toBe(serverUpdatedAt.getTime()); + }); + + it('updates the message in state if it does not exist on the server and the send request failed', async () => { + const { messageComposer, mockChannel } = setup(); + messageComposer.textComposer.setText('Hello'); + const composed = await messageComposer.compose(); + const messageId = composed!.localMessage.id; + const apiError = Object.assign(new Error('Network error'), { + code: 16, + response: { statusCode: 500 }, + }); + await expect( + mockChannel.sendMessageWithLocalUpdate({ + localMessage: composed!.localMessage, + message: composed!.message, + options: composed!.sendOptions, + sendMessageRequestFn: async () => { + throw apiError; + }, + }), + ).rejects.toThrow('Network error'); + const after = mockChannel.messagePaginator.getItem(messageId); + expect(after?.status).toBe('failed'); + expect(after?.error).toBeDefined(); + }); }); it('should compose draft', async () => { @@ -1818,16 +1970,29 @@ describe('MessageComposer', () => { }); describe('subscribeMessageComposerSetupStateChange', () => { - it('should apply modifications when setup state changes', () => { + it('calls setupFunction with { composer } when client setMessageComposerSetupFunction is invoked', () => { const { messageComposer, mockClient } = setup(); - const mockModifications = vi.fn(); + const setupFn = vi.fn(); messageComposer.registerSubscriptions(); - mockClient._messageComposerSetupState.next({ - setupFunction: mockModifications, - }); + mockClient.setMessageComposerSetupFunction(setupFn); + + expect(setupFn).toHaveBeenCalledWith({ composer: messageComposer }); + }); + + it('invokes previous tearDown before applying new setup when setup function changes', () => { + const { messageComposer, mockClient } = setup(); + const tearDown1 = vi.fn(); + const setup1 = vi.fn().mockReturnValue(tearDown1); + const setup2 = vi.fn(); + + messageComposer.registerSubscriptions(); + mockClient.setMessageComposerSetupFunction(setup1); + expect(tearDown1).not.toHaveBeenCalled(); - expect(mockModifications).toHaveBeenCalledWith({ composer: messageComposer }); + mockClient.setMessageComposerSetupFunction(setup2); + expect(tearDown1).toHaveBeenCalledOnce(); + expect(setup2).toHaveBeenCalledWith({ composer: messageComposer }); }); }); diff --git a/test/unit/MessageComposer/textComposer.test.ts b/test/unit/MessageComposer/textComposer.test.ts index ab6453f80b..88dd5ad007 100644 --- a/test/unit/MessageComposer/textComposer.test.ts +++ b/test/unit/MessageComposer/textComposer.test.ts @@ -40,6 +40,7 @@ vi.mock('../../../src/utils', () => ({ isLocalMessage: vi.fn().mockReturnValue(true), formatMessage: vi.fn().mockImplementation((msg) => msg), throttle: vi.fn().mockImplementation((fn) => fn), + normalizeQuerySort: vi.fn().mockReturnValue([{ field: 'created_at', direction: -1 }]), })); const setup = ({ From 3a7c93894491349af8b5e6231d4863de7bf75a33 Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 3 Mar 2026 20:54:06 +0100 Subject: [PATCH 16/48] feat: add message send retry cache --- src/messageOperations/MessageOperations.ts | 122 +++++++++++++-- .../MessageOperations.test.ts | 140 +++++++++++++++++- 2 files changed, 245 insertions(+), 17 deletions(-) diff --git a/src/messageOperations/MessageOperations.ts b/src/messageOperations/MessageOperations.ts index b2833cb2b9..e5fee1cc01 100644 --- a/src/messageOperations/MessageOperations.ts +++ b/src/messageOperations/MessageOperations.ts @@ -9,9 +9,19 @@ import type { OperationRequestFn, } from './types'; +const FAILED_SEND_CACHE_MAX_SIZE = 100; +const FAILED_SEND_CACHE_TTL_MS = 5 * 60 * 1000; + +type FailedSendCacheEntry = { + message: Message; + options?: OperationParams<'send'>['options']; + cachedAt: number; +}; + export class MessageOperations { private ctx: MessageOperationsContext; private policy: MessageOperationStatePolicy; + private failedSendCache = new Map(); constructor(ctx: MessageOperationsContext) { this.ctx = ctx; @@ -24,6 +34,56 @@ export class MessageOperations { : message; } + private pruneExpiredFailedSendCache() { + const now = Date.now(); + + for (const [messageId, entry] of this.failedSendCache) { + if (now - entry.cachedAt > FAILED_SEND_CACHE_TTL_MS) { + this.clearCachedFailedSend(messageId); + } + } + } + + private cacheFailedSend(params: { + messageId: string; + message: Message; + options?: OperationParams<'send'>['options']; + }) { + this.pruneExpiredFailedSendCache(); + + if ( + !this.failedSendCache.has(params.messageId) && + this.failedSendCache.size >= FAILED_SEND_CACHE_MAX_SIZE + ) { + const oldestMessageId = this.failedSendCache.keys().next().value; + if (oldestMessageId) { + this.clearCachedFailedSend(oldestMessageId); + } + } + + this.failedSendCache.set(params.messageId, { + cachedAt: Date.now(), + message: params.message, + options: params.options, + }); + } + + private getCachedFailedSend(messageId: string) { + const cached = this.failedSendCache.get(messageId); + if (!cached) return; + + if (Date.now() - cached.cachedAt > FAILED_SEND_CACHE_TTL_MS) { + this.clearCachedFailedSend(messageId); + return; + } + + return cached; + } + + private clearCachedFailedSend(messageId: string) { + this.failedSendCache.delete(messageId); + } + private async run( params: OperationParams, doRequest: OperationRequestFn, @@ -50,13 +110,24 @@ export class MessageOperations { params.message ?? localMessageToNewMessagePayload(params.localMessage), ); - return await this.run<'send'>( - { ...params, message: messageToSend }, - requestFn ?? - handlers.send ?? - (async (p) => - await this.ctx.defaults.send(p.message ?? messageToSend, p.options)), - ); + try { + await this.run<'send'>( + { ...params, message: messageToSend }, + requestFn ?? + handlers.send ?? + (async (p) => + await this.ctx.defaults.send(p.message ?? messageToSend, p.options)), + ); + + this.clearCachedFailedSend(params.localMessage.id); + } catch (error) { + this.cacheFailedSend({ + messageId: params.localMessage.id, + message: messageToSend, + options: params.options, + }); + throw error; + } } async retry( @@ -64,23 +135,42 @@ export class MessageOperations { requestFn?: OperationRequestFn<'retry'>, ): Promise { const handlers = this.ctx.handlers(); + const cachedPayload = this.getCachedFailedSend(params.localMessage.id); const messageToSend = this.normalizeMessage( - params.message ?? localMessageToNewMessagePayload(params.localMessage), + params.message ?? + cachedPayload?.message ?? + localMessageToNewMessagePayload(params.localMessage), ); + const optionsToSend = params.options ?? cachedPayload?.options; const send = handlers.send; const sendAsRetry: OperationRequestFn<'retry'> | undefined = send ? (p) => send({ ...p } as OperationParams<'send'>) : undefined; - return await this.run<'retry'>( - { ...params, message: messageToSend }, - requestFn ?? - handlers.retry ?? - sendAsRetry ?? - (async (p) => - await this.ctx.defaults.send(p.message ?? messageToSend, p.options)), - ); + try { + await this.run<'retry'>( + { + ...params, + message: messageToSend, + options: optionsToSend, + }, + requestFn ?? + handlers.retry ?? + sendAsRetry ?? + (async (p) => + await this.ctx.defaults.send(p.message ?? messageToSend, p.options)), + ); + + this.clearCachedFailedSend(params.localMessage.id); + } catch (error) { + this.cacheFailedSend({ + messageId: params.localMessage.id, + message: messageToSend, + options: optionsToSend, + }); + throw error; + } } async update( diff --git a/test/unit/messageOperations/MessageOperations.test.ts b/test/unit/messageOperations/MessageOperations.test.ts index 2f588bcd7c..378651038c 100644 --- a/test/unit/messageOperations/MessageOperations.test.ts +++ b/test/unit/messageOperations/MessageOperations.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { MessageOperations } from '../../../src/messageOperations/MessageOperations'; import type { LocalMessage, Message, MessageResponse } from '../../../src/types'; @@ -114,6 +114,144 @@ describe('MessageOperations', () => { expect(store.get('m1')?.status).toBe('failed'); }); + it('reuses cached payload and options when retry is called without explicit params', async () => { + const store: Store = new Map(); + const sendCalls: Array<{ message: Message; options: unknown }> = []; + + const ops = new MessageOperations({ + ingest: (m) => store.set(m.id, m), + get: (id) => store.get(id), + handlers: () => ({}), + defaults: { + send: async (message, options) => { + sendCalls.push({ message, options }); + if (sendCalls.length === 1) { + throw new Error('send failed'); + } + return { message: makeMessageResponse({ id: 'm1', text: 'retried' }) }; + }, + update: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + }, + }); + + const localMessage = makeLocalMessage({ id: 'm1', text: 'local text' }); + const cachedMessage = { + id: 'm1', + text: 'cached text', + type: 'regular', + } as Message; + const cachedOptions = { skip_push: true }; + + await expect( + ops.send({ + localMessage, + message: cachedMessage, + options: cachedOptions, + }), + ).rejects.toThrow('send failed'); + + await ops.retry({ localMessage }); + + expect(sendCalls[1].message).toEqual(cachedMessage); + expect(sendCalls[1].options).toEqual(cachedOptions); + }); + + it('does not reuse expired cached payload and options', async () => { + vi.useFakeTimers(); + try { + const store: Store = new Map(); + const sendCalls: Array<{ message: Message; options: unknown }> = []; + + const ops = new MessageOperations({ + ingest: (m) => store.set(m.id, m), + get: (id) => store.get(id), + handlers: () => ({}), + defaults: { + send: async (message, options) => { + sendCalls.push({ message, options }); + if (sendCalls.length === 1) { + throw new Error('send failed'); + } + return { message: makeMessageResponse({ id: 'm1', text: 'retried' }) }; + }, + update: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + }, + }); + + const localMessage = makeLocalMessage({ id: 'm1', text: 'local text' }); + const cachedMessage = { + id: 'm1', + text: 'cached text', + type: 'regular', + } as Message; + const cachedOptions = { skip_push: true }; + + await expect( + ops.send({ + localMessage, + message: cachedMessage, + options: cachedOptions, + }), + ).rejects.toThrow('send failed'); + + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + + await ops.retry({ localMessage }); + + expect(sendCalls[1].message.text).toBe('local text'); + expect(sendCalls[1].options).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + + it('clears cached payload after successful retry', async () => { + const store: Store = new Map(); + const sendCalls: Array<{ message: Message; options: unknown }> = []; + + const ops = new MessageOperations({ + ingest: (m) => store.set(m.id, m), + get: (id) => store.get(id), + handlers: () => ({}), + defaults: { + send: async (message, options) => { + sendCalls.push({ message, options }); + if (sendCalls.length === 1) { + throw new Error('send failed'); + } + return { + message: makeMessageResponse({ id: 'm1', text: `ok-${sendCalls.length}` }), + }; + }, + update: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + }, + }); + + const localMessage = makeLocalMessage({ id: 'm1', text: 'local text' }); + const cachedMessage = { + id: 'm1', + text: 'cached text', + type: 'regular', + } as Message; + const cachedOptions = { skip_push: true }; + + await expect( + ops.send({ + localMessage, + message: cachedMessage, + options: cachedOptions, + }), + ).rejects.toThrow('send failed'); + + await ops.retry({ localMessage }); + await ops.retry({ localMessage }); + + expect(sendCalls[1].message).toEqual(cachedMessage); + expect(sendCalls[1].options).toEqual(cachedOptions); + expect(sendCalls[2].message.text).toBe('local text'); + expect(sendCalls[2].options).toBeUndefined(); + }); + it('normalizes outgoing message for send', async () => { const store: Store = new Map(); From 9172f11e9e8af09b1526594cae1484b090198cb4 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 4 Mar 2026 11:28:00 +0100 Subject: [PATCH 17/48] feat: query replies with MessagePaginator --- src/pagination/paginators/MessagePaginator.ts | 22 +++++++-- src/thread.ts | 5 +- .../paginators/MessagePaginator.test.ts | 48 ++++++++++++++++++- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index ea46d3f1f7..e27e0e2f9c 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -41,6 +41,7 @@ export type MessagePaginatorSort = { created_at: AscDesc } | { created_at: AscDe export type MessagePaginatorFilter = { cid: string; + parent_id?: string; }; const DEFAULT_BACKEND_SORT: MessagePaginatorSort = { @@ -72,6 +73,7 @@ export type MessagePaginatorOptions = { channel: Channel; id?: string; itemIndex?: ItemIndex; + parentMessageId?: string; paginatorOptions?: PaginatorOptions; /** * Controls whether `jumpToTheFirstUnreadMessage()` should prefer the `unreadStateSnapshot` @@ -107,6 +109,7 @@ export type UnreadSnapshotState = { export class MessagePaginator extends BasePaginator { private readonly _id: string; private channel: Channel; + private parentMessageId?: string; private unreadReferencePolicy: 'snapshot' | 'read-state-only'; /** * Independent unread reference state (not tied to `channel.state.read`). @@ -140,6 +143,7 @@ export class MessagePaginator extends BasePaginator item.id }), + parentMessageId, paginatorOptions, unreadReferencePolicy = 'snapshot', }: MessagePaginatorOptions) { @@ -152,6 +156,7 @@ export class MessagePaginator extends BasePaginator ({ cid: this.channel.cid, + ...(this.parentMessageId ? { parent_id: this.parentMessageId } : {}), }); // invoked inside BasePaginator.executeQuery() to keep it as a query descriptor; @@ -258,11 +264,17 @@ export class MessagePaginator extends BasePaginator { let itemIndex: ItemIndex; beforeEach(() => { - channel = { cid: 'channel-id', query: vi.fn() } as unknown as Channel; + channel = { + cid: 'channel-id', + getReplies: vi.fn(), + query: vi.fn(), + } as unknown as Channel; itemIndex = new ItemIndex({ getId: (message) => message.id }); }); @@ -101,6 +105,18 @@ describe('MessagePaginator', () => { expect(paginator.buildFilters()).toEqual({ cid: 'channel-id' }); }); + it('builds thread-scoped filters when parentMessageId is provided', () => { + const paginator = new MessagePaginator({ + channel, + itemIndex, + parentMessageId: 'parent-1', + }); + expect(paginator.buildFilters()).toEqual({ + cid: 'channel-id', + parent_id: 'parent-1', + }); + }); + it('computes next query shape from cursor and direction', () => { const paginator = new MessagePaginator({ channel, itemIndex }); const currentState = paginator.state.getLatestValue(); @@ -172,6 +188,36 @@ describe('MessagePaginator', () => { expect(result.items[0].created_at).toBeInstanceOf(Date); expect(result.items[1].created_at).toBeInstanceOf(Date); }); + + it('queries replies endpoint when parentMessageId is provided', async () => { + const messages = [ + { id: 'first-reply', created_at: '2022-01-01T00:00:00.000Z' }, + { id: 'last-reply', created_at: '2022-01-02T00:00:00.000Z' }, + ]; + (channel.getReplies as unknown as ReturnType).mockResolvedValue({ + messages, + }); + const paginator = new MessagePaginator({ + channel, + itemIndex, + parentMessageId: 'parent-1', + }); + // @ts-expect-error setting protected field for test coverage + paginator._nextQueryShape = { id_gt: 'from-cursor', limit: 30 }; + + const result = await paginator.query({}); + + expect(channel.getReplies).toHaveBeenCalledWith( + 'parent-1', + { id_gt: 'from-cursor', limit: 30 }, + [{ created_at: 1 }], + ); + expect(channel.query).not.toHaveBeenCalled(); + expect(result.tailward).toBe('first-reply'); + expect(result.headward).toBe('last-reply'); + expect(result.items[0].created_at).toBeInstanceOf(Date); + expect(result.items[1].created_at).toBeInstanceOf(Date); + }); }); describe('jumpToMessage()', () => { From a703f7c4e620a2cb973e9526b6ea8331b5a62195 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 4 Mar 2026 14:30:25 +0100 Subject: [PATCH 18/48] feat: support delete operation on MessageOperations --- src/channel.ts | 41 ++++++++++++ src/messageOperations/MessageOperations.ts | 17 ++++- src/messageOperations/types.ts | 11 +++- src/thread.ts | 26 ++++++++ .../MessageOperations.test.ts | 64 +++++++++++++++++++ 5 files changed, 156 insertions(+), 3 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index f31169f106..102a317655 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -28,6 +28,7 @@ import type { ChannelUpdateOptions, CreateDraftResponse, DeleteChannelAPIResponse, + DeleteMessageOptions, DraftMessagePayload, Event, EventAPIResponse, @@ -111,6 +112,16 @@ export type UpdateMessageWithStateUpdateParams = { updateMessageRequestFn?: CustomUpdateMessageRequestFn; }; +export type DeleteMessageWithStateUpdateParams = { + localMessage: LocalMessage; + options?: DeleteMessageOptions; + /** + * Per-call override for the delete request (advanced). + * If set, it takes precedence over channel instance configuration handlers. + */ + deleteMessageRequestFn?: CustomDeleteMessageRequestFn; +}; + // Custom request function types for configuration export type CustomSendMessageRequestFn = ( params: Omit, @@ -120,8 +131,13 @@ export type CustomUpdateMessageRequestFn = ( params: Omit, ) => Promise<{ message: MessageResponse }>; +export type CustomDeleteMessageRequestFn = ( + params: Omit, +) => Promise<{ message: MessageResponse }>; + export type ChannelInstanceConfig = { requestHandlers?: { + deleteMessageRequest?: CustomDeleteMessageRequestFn; sendMessageRequest?: CustomSendMessageRequestFn; retrySendMessageRequest?: CustomSendMessageRequestFn; updateMessageRequest?: CustomUpdateMessageRequestFn; @@ -228,10 +244,18 @@ export class Channel { get: (id) => this.messagePaginator.getItem(id), handlers: () => { const { requestHandlers } = this.configState.getLatestValue(); + const deleteMessageRequest = requestHandlers?.deleteMessageRequest; const sendMessageRequest = requestHandlers?.sendMessageRequest; const retrySendMessageRequest = requestHandlers?.retrySendMessageRequest; const updateMessageRequest = requestHandlers?.updateMessageRequest; return { + delete: deleteMessageRequest + ? (p) => + deleteMessageRequest({ + localMessage: p.localMessage, + options: p.options, + }) + : undefined, send: sendMessageRequest ? (p) => sendMessageRequest({ @@ -258,6 +282,10 @@ export class Channel { }; }, defaults: { + delete: async (id, o) => { + const result = await this.getClient().deleteMessage(id, o); + return { message: result.message }; + }, send: async (m, o) => { const result = await this.sendMessage(m, o); return { message: result.message }; @@ -386,6 +414,19 @@ export class Channel { ); } + /** + * Deletes a message with local state update. + */ + async deleteMessageWithLocalUpdate(params: DeleteMessageWithStateUpdateParams) { + await this.messageOperations.delete( + { + localMessage: params.localMessage, + options: params.options, + }, + params.deleteMessageRequestFn, + ); + } + sendFile( uri: string | NodeJS.ReadableStream | Buffer | File, name?: string, diff --git a/src/messageOperations/MessageOperations.ts b/src/messageOperations/MessageOperations.ts index e5fee1cc01..8fb2314016 100644 --- a/src/messageOperations/MessageOperations.ts +++ b/src/messageOperations/MessageOperations.ts @@ -1,6 +1,6 @@ // todo: add tests import type { Message, UpdateMessageOptions } from '../types'; -import { localMessageToNewMessagePayload } from '../utils'; +import { formatMessage, localMessageToNewMessagePayload } from '../utils'; import { MessageOperationStatePolicy } from './MessageOperationStatePolicy'; import type { MessageOperationsContext, @@ -194,4 +194,19 @@ export class MessageOperations { (async (p) => await this.ctx.defaults.update(p.localMessage, updateOptions)), ); } + + async delete( + params: OperationParams<'delete'>, + requestFn?: OperationRequestFn<'delete'>, + ): Promise { + const handlers = this.ctx.handlers(); + const doRequest = + requestFn ?? + handlers.delete ?? + (async (p: OperationParams<'delete'>) => + await this.ctx.defaults.delete(p.localMessage.id, p.options)); + + const { message: messageFromResponse } = await doRequest(params); + this.ctx.ingest(formatMessage(messageFromResponse)); + } } diff --git a/src/messageOperations/types.ts b/src/messageOperations/types.ts index 1403646f40..4d7ce185e6 100644 --- a/src/messageOperations/types.ts +++ b/src/messageOperations/types.ts @@ -1,4 +1,5 @@ import type { + DeleteMessageOptions, LocalMessage, Message, MessageResponse, @@ -8,7 +9,7 @@ import type { UpdateMessageOptions, } from '../types'; -export type OperationKind = 'send' | 'retry' | 'update'; +export type OperationKind = 'send' | 'retry' | 'update' | 'delete'; export type MessageOperationSpec = { send: { @@ -23,12 +24,16 @@ export type MessageOperationSpec = { options: UpdateMessageOptions; requestResult: UpdateMessageAPIResponse; }; + delete: { + options: DeleteMessageOptions; + requestResult: { message: MessageResponse }; + }; }; export type OperationParams = { localMessage: LocalMessage; options?: MessageOperationSpec[K]['options']; -} & (K extends 'update' ? {} : { message?: Message }); +} & (K extends 'send' | 'retry' ? { message?: Message } : {}); export type OperationResponse = { message: MessageResponse }; @@ -37,6 +42,7 @@ export type OperationRequestFn = ( ) => Promise; export type MessageOperationsHandlers = { + delete?: OperationRequestFn<'delete'>; send?: OperationRequestFn<'send'>; retry?: OperationRequestFn<'retry'>; update?: OperationRequestFn<'update'>; @@ -49,6 +55,7 @@ export type MessageOperationsContext = { normalizeOutgoingMessage?: (m: Message) => Message; defaults: { + delete: (id: string, o?: DeleteMessageOptions) => Promise; send: (m: Message, o?: SendMessageOptions) => Promise; update: (m: LocalMessage, o?: UpdateMessageOptions) => Promise; }; diff --git a/src/thread.ts b/src/thread.ts index 5595ee4cd7..79e3bb7251 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -18,6 +18,7 @@ import type { } from './types'; import type { Channel, + DeleteMessageWithStateUpdateParams, SendMessageWithStateUpdateParams, UpdateMessageWithStateUpdateParams, } from './channel'; @@ -243,10 +244,18 @@ export class Thread extends WithSubscriptions { }), handlers: () => { const { requestHandlers } = this.channel.configState.getLatestValue(); + const deleteMessageRequest = requestHandlers?.deleteMessageRequest; const sendMessageRequest = requestHandlers?.sendMessageRequest; const retrySendMessageRequest = requestHandlers?.retrySendMessageRequest; const updateMessageRequest = requestHandlers?.updateMessageRequest; return { + delete: deleteMessageRequest + ? (p) => + deleteMessageRequest({ + localMessage: p.localMessage, + options: p.options, + }) + : undefined, send: sendMessageRequest ? (p) => sendMessageRequest({ @@ -273,6 +282,10 @@ export class Thread extends WithSubscriptions { }; }, defaults: { + delete: async (id, o) => { + const result = await this.channel.getClient().deleteMessage(id, o); + return { message: result.message }; + }, send: async (m, o) => { const result = await this.channel.sendMessage(m, o); return { message: result.message }; @@ -686,6 +699,19 @@ export class Thread extends WithSubscriptions { ); } + /** + * Deletes a message with local state update. + */ + async deleteMessageWithLocalUpdate(params: DeleteMessageWithStateUpdateParams) { + await this.messageOperations.delete( + { + localMessage: params.localMessage, + options: params.options, + }, + params.deleteMessageRequestFn, + ); + } + public markAsRead = async ({ force = false }: { force?: boolean } = {}) => { if (this.ownUnreadCount === 0 && !force) { return null; diff --git a/test/unit/messageOperations/MessageOperations.test.ts b/test/unit/messageOperations/MessageOperations.test.ts index 378651038c..90f358868d 100644 --- a/test/unit/messageOperations/MessageOperations.test.ts +++ b/test/unit/messageOperations/MessageOperations.test.ts @@ -30,6 +30,8 @@ const makeMessageResponse = (overrides?: Partial): MessageRespo ...overrides, }) as MessageResponse; +const defaultDelete = async () => ({ message: makeMessageResponse({ id: 'm1' }) }); + describe('MessageOperations', () => { it('marks optimistic message as sending, then ingests received response', async () => { const store: Store = new Map(); @@ -39,6 +41,7 @@ describe('MessageOperations', () => { get: (id) => store.get(id), handlers: () => ({}), defaults: { + delete: defaultDelete, send: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), update: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), }, @@ -58,6 +61,7 @@ describe('MessageOperations', () => { get: (id) => store.get(id), handlers: () => ({}), defaults: { + delete: defaultDelete, send: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), update: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), }, @@ -80,6 +84,7 @@ describe('MessageOperations', () => { get: (id) => store.get(id), handlers: () => ({}), defaults: { + delete: defaultDelete, send: async () => { throw Object.assign(new Error('message already exists'), { code: 4 }); }, @@ -101,6 +106,7 @@ describe('MessageOperations', () => { get: (id) => store.get(id), handlers: () => ({}), defaults: { + delete: defaultDelete, send: async () => { throw new Error('nope'); }, @@ -123,6 +129,7 @@ describe('MessageOperations', () => { get: (id) => store.get(id), handlers: () => ({}), defaults: { + delete: defaultDelete, send: async (message, options) => { sendCalls.push({ message, options }); if (sendCalls.length === 1) { @@ -167,6 +174,7 @@ describe('MessageOperations', () => { get: (id) => store.get(id), handlers: () => ({}), defaults: { + delete: defaultDelete, send: async (message, options) => { sendCalls.push({ message, options }); if (sendCalls.length === 1) { @@ -214,6 +222,7 @@ describe('MessageOperations', () => { get: (id) => store.get(id), handlers: () => ({}), defaults: { + delete: defaultDelete, send: async (message, options) => { sendCalls.push({ message, options }); if (sendCalls.length === 1) { @@ -266,6 +275,7 @@ describe('MessageOperations', () => { }, }), defaults: { + delete: defaultDelete, send: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), update: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), }, @@ -288,6 +298,7 @@ describe('MessageOperations', () => { get: (id) => store.get(id), handlers: () => ({}), defaults: { + delete: defaultDelete, send: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), update: async (_m, options) => { seenOptions = options; @@ -325,6 +336,7 @@ describe('MessageOperations', () => { get: (id) => store.get(id), handlers: () => ({}), defaults: { + delete: defaultDelete, send: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), update: async (_m, options) => { seenOptions = options; @@ -338,4 +350,56 @@ describe('MessageOperations', () => { await ops.update({ localMessage }); expect(seenOptions).toBeUndefined(); }); + + it('delete uses defaults.delete and ingests deleted message', async () => { + const store: Store = new Map(); + const defaultsDelete = vi.fn(async () => ({ + message: makeMessageResponse({ id: 'm1', deleted_at: new Date().toISOString() }), + })); + + const ops = new MessageOperations({ + ingest: (m) => store.set(m.id, m), + get: (id) => store.get(id), + handlers: () => ({}), + defaults: { + delete: defaultsDelete, + send: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + update: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + }, + }); + + const localMessage = makeLocalMessage({ id: 'm1', status: 'received' }); + await ops.delete({ localMessage }); + + expect(defaultsDelete).toHaveBeenCalledWith('m1', undefined); + expect(store.get('m1')?.deleted_at).toBeInstanceOf(Date); + }); + + it('delete uses per-call requestFn override', async () => { + const store: Store = new Map(); + + const ops = new MessageOperations({ + ingest: (m) => store.set(m.id, m), + get: (id) => store.get(id), + handlers: () => ({}), + defaults: { + delete: defaultDelete, + send: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + update: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + }, + }); + + const localMessage = makeLocalMessage({ id: 'm1', status: 'received' }); + + await ops.delete({ localMessage }, async () => ({ + message: makeMessageResponse({ + id: 'm1', + deleted_at: new Date().toISOString(), + text: 'deleted via override', + }), + })); + + expect(store.get('m1')?.text).toBe('deleted via override'); + expect(store.get('m1')?.deleted_at).toBeInstanceOf(Date); + }); }); From 314ea2cb41107b23e26319730535f70397106d6b Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 4 Mar 2026 23:05:07 +0100 Subject: [PATCH 19/48] feat: allow to define custom mark-read request function for Thread and Channel --- src/channel.ts | 10 ++++- .../MessageDeliveryReporter.ts | 40 ++++++++++++++---- src/thread.ts | 42 ++++++++++++------- 3 files changed, 69 insertions(+), 23 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 102a317655..43dc7fd9bc 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -12,6 +12,7 @@ import { normalizeQuerySort, } from './utils'; import type { StreamChat } from './client'; +import type { Thread } from './thread'; import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from './constants'; import type { AIState, @@ -135,9 +136,16 @@ export type CustomDeleteMessageRequestFn = ( params: Omit, ) => Promise<{ message: MessageResponse }>; +export type CustomMarkReadRequestFn = (params: { + channel: Channel; + thread?: Thread; + options?: MarkReadOptions; +}) => Promise; + export type ChannelInstanceConfig = { requestHandlers?: { deleteMessageRequest?: CustomDeleteMessageRequestFn; + markReadRequest?: CustomMarkReadRequestFn; sendMessageRequest?: CustomSendMessageRequestFn; retrySendMessageRequest?: CustomSendMessageRequestFn; updateMessageRequest?: CustomUpdateMessageRequestFn; @@ -1377,7 +1385,7 @@ export class Channel { } /** - * markReadRequest - Send the mark read event for this user, only works if the `read_events` setting is enabled + * markAsReadRequest - Send the mark read event for this user, only works if the `read_events` setting is enabled * * @param {MarkReadOptions} data * @return {Promise} Description diff --git a/src/messageDelivery/MessageDeliveryReporter.ts b/src/messageDelivery/MessageDeliveryReporter.ts index 682cc50542..2919815307 100644 --- a/src/messageDelivery/MessageDeliveryReporter.ts +++ b/src/messageDelivery/MessageDeliveryReporter.ts @@ -279,14 +279,40 @@ export class MessageDeliveryReporter { * @param options */ public markRead = async (collection: Channel | Thread, options?: MarkReadOptions) => { + const isThreadCollection = isThread(collection); + const channel = isThreadCollection ? collection.channel : collection; + const requestOptions = isThreadCollection + ? { ...options, thread_id: collection.id } + : options; + let result: EventAPIResponse | null = null; - if (isChannel(collection)) { - result = await collection.markAsReadRequest(options); - } else if (isThread(collection)) { - result = await collection.channel.markAsReadRequest({ - ...options, - thread_id: collection.id, - }); + + if (isThreadCollection) { + const markReadRequestHandler = collection.configState.getLatestValue() + .requestHandlers?.markReadRequest as + | ((params: { + thread: Thread; + options?: MarkReadOptions; + }) => Promise | void) + | undefined; + result = markReadRequestHandler + ? ((await markReadRequestHandler({ + options: requestOptions, + thread: collection, + })) ?? null) + : await channel.markAsReadRequest(requestOptions); + } else { + const markReadRequestHandler = channel.configState.getLatestValue().requestHandlers + ?.markReadRequest as + | ((params: { + channel: Channel; + thread?: Thread; + options?: MarkReadOptions; + }) => Promise | void) + | undefined; + result = markReadRequestHandler + ? ((await markReadRequestHandler({ channel, options: requestOptions })) ?? null) + : await channel.markAsReadRequest(requestOptions); } this.removeCandidateFor(collection); diff --git a/src/thread.ts b/src/thread.ts index 79e3bb7251..d25328cbe4 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -1,15 +1,12 @@ import { StateStore } from './store'; -import { - addToMessageList, - findIndexInSortedArray, - formatMessage, - throttle, -} from './utils'; +import { addToMessageList, findIndexInSortedArray, formatMessage } from './utils'; import type { AscDesc, DraftResponse, + EventAPIResponse, EventTypes, LocalMessage, + MarkReadOptions, MessagePaginationOptions, MessageResponse, ReadResponse, @@ -77,7 +74,6 @@ export type ThreadReadState = Record; const DEFAULT_PAGE_LIMIT = 50; const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }]; -const MARK_AS_READ_THROTTLE_TIMEOUT = 1000; // TODO: remove this once we move to API v2 export const THREAD_RESPONSE_RESERVED_KEYS: Record = { active_participant_count: true, @@ -117,7 +113,19 @@ const constructCustomDataObject = (threadData: T) => { return custom; }; +export type CustomThreadMarkReadRequestFn = (params: { + thread: Thread; + options?: MarkReadOptions; +}) => Promise | void; + +export type ThreadInstanceConfig = { + requestHandlers?: { + markReadRequest?: CustomThreadMarkReadRequestFn; + }; +}; + export class Thread extends WithSubscriptions { + public readonly configState = new StateStore({}); public readonly state: StateStore; public readonly id: string; public readonly messageComposer: MessageComposer; @@ -419,7 +427,7 @@ export class Thread extends WithSubscriptions { }), ({ active, unreadMessageCount }) => { if (!active || !unreadMessageCount) return; - this.throttledMarkAsRead(); + this.throttledMarkRead(); }, ); @@ -465,7 +473,7 @@ export class Thread extends WithSubscriptions { }); if (active) { - this.throttledMarkAsRead(); + this.throttledMarkRead(); } const nextRead: ThreadReadState = {}; @@ -712,7 +720,7 @@ export class Thread extends WithSubscriptions { ); } - public markAsRead = async ({ force = false }: { force?: boolean } = {}) => { + public markRead = async ({ force = false }: { force?: boolean } = {}) => { if (this.ownUnreadCount === 0 && !force) { return null; } @@ -720,11 +728,15 @@ export class Thread extends WithSubscriptions { return await this.client.messageDeliveryReporter.markRead(this); }; - private throttledMarkAsRead = throttle( - () => this.markAsRead(), - MARK_AS_READ_THROTTLE_TIMEOUT, - { trailing: true }, - ); + private throttledMarkRead = () => { + this.client.messageDeliveryReporter.throttledMarkRead(this); + }; + + /** + * @deprecated Use `thread.markRead` instead. + */ + public markAsRead = ({ force = false }: { force?: boolean } = {}) => + this.markRead({ force }); public queryReplies = ({ limit = DEFAULT_PAGE_LIMIT, From fe7a68de3188ffddef4e4f104dc329a2e556dc39 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 4 Mar 2026 23:05:30 +0100 Subject: [PATCH 20/48] feat: add messageFocusSignal state to MessagePaginator --- src/pagination/paginators/MessagePaginator.ts | 115 +++++++++++++++++- .../paginators/MessagePaginator.test.ts | 32 ++++- 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index e27e0e2f9c..ad39d49264 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -35,7 +35,40 @@ import { deriveCreatedAtAroundPaginationFlags } from '../cursorDerivation'; import { deriveIdAroundPaginationFlags } from '../cursorDerivation/idAroundPaginationFlags'; import { deriveLinearPaginationFlags } from '../cursorDerivation/linearPaginationFlags'; -export type JumpToMessageOptions = { pageSize?: number }; +export type MessageFocusReason = + | 'jump-to-message' + | 'jump-to-first-unread' + | 'jump-to-latest'; + +export type MessageFocusSignal = { + messageId: string; + reason: MessageFocusReason; + token: number; + createdAt: number; + ttlMs: number; +}; + +export type MessageFocusSignalState = { + signal: MessageFocusSignal | null; +}; + +export type JumpToMessageOptions = { + pageSize?: number; + /** + * Optional reason attached to emitted focus signal. + * Defaults to `jump-to-message`. + */ + focusReason?: MessageFocusReason; + /** + * TTL for the emitted focus signal in milliseconds. + * Defaults to `3000`. + */ + focusSignalTtlMs?: number; + /** + * If true, suppresses focus signal emission after a successful jump. + */ + suppressFocusSignal?: boolean; +}; export type MessagePaginatorSort = { created_at: AscDesc } | { created_at: AscDesc }[]; @@ -116,6 +149,9 @@ export class MessagePaginator extends BasePaginator; + readonly messageFocusSignal: StateStore; + private clearMessageFocusSignalTimeoutId: ReturnType | null = null; + private messageFocusSignalToken = 0; protected _sort = DEFAULT_BACKEND_SORT; protected _nextQueryShape: MessageQueryShape | undefined; sortComparator: (a: LocalMessage, b: LocalMessage) => number; @@ -166,6 +202,9 @@ export class MessagePaginator extends BasePaginator({ + signal: null, + }); this.sortComparator = makeComparator({ sort: this._sort, resolvePathValue: resolveDotPathValue, @@ -317,7 +356,12 @@ export class MessagePaginator extends BasePaginator => { let localMessage = this.getItem(messageId); let interval: AnyInterval | undefined; @@ -366,6 +410,13 @@ export class MessagePaginator extends BasePaginator { + this.messageFocusSignalToken += 1; + const signal: MessageFocusSignal = { + messageId, + reason, + token: this.messageFocusSignalToken, + createdAt: Date.now(), + ttlMs, + }; + + if (this.clearMessageFocusSignalTimeoutId) { + clearTimeout(this.clearMessageFocusSignalTimeoutId); + this.clearMessageFocusSignalTimeoutId = null; + } + + this.messageFocusSignal.next({ signal }); + + this.clearMessageFocusSignalTimeoutId = setTimeout(() => { + this.clearMessageFocusSignal({ token: signal.token }); + }, ttlMs); + + return signal; + }; + + clearMessageFocusSignal = ({ token }: { token?: number } = {}) => { + const current = this.messageFocusSignal.getLatestValue().signal; + if (!current) return; + if (typeof token !== 'undefined' && current.token !== token) return; + + if (this.clearMessageFocusSignalTimeoutId) { + clearTimeout(this.clearMessageFocusSignalTimeoutId); + this.clearMessageFocusSignalTimeoutId = null; + } + + this.messageFocusSignal.next({ signal: null }); }; setUnreadSnapshot = (next: Partial): UnreadSnapshotState => { diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index a1beb1e8e3..94f6c4cab1 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -318,7 +318,10 @@ describe('MessagePaginator', () => { const ok = await paginator.jumpToTheFirstUnreadMessage(); expect(ok).toBe(true); - expect(jumpSpy).toHaveBeenCalledWith('m-unread', undefined); + expect(jumpSpy).toHaveBeenCalledWith( + 'm-unread', + expect.objectContaining({ focusReason: 'jump-to-first-unread' }), + ); }); it('can ignore snapshot and rely on channel read state only', async () => { @@ -368,6 +371,33 @@ describe('MessagePaginator', () => { }); }); + describe('messageFocusSignal', () => { + it('emits focus signal with unique token and clears stale timer safely', async () => { + vi.useFakeTimers(); + const paginator = new MessagePaginator({ channel, itemIndex }); + + const first = paginator.emitMessageFocusSignal({ + messageId: 'm1', + reason: 'jump-to-message', + ttlMs: 3000, + }); + const second = paginator.emitMessageFocusSignal({ + messageId: 'm1', + reason: 'jump-to-message', + ttlMs: 3000, + }); + + expect(second.token).toBeGreaterThan(first.token); + expect(paginator.messageFocusSignal.getLatestValue().signal?.token).toBe( + second.token, + ); + + vi.advanceTimersByTime(3000); + expect(paginator.messageFocusSignal.getLatestValue().signal).toBe(null); + vi.useRealTimers(); + }); + }); + describe.todo('postQueryReconcile and deriveCursor for', () => {}); describe('linear pagination', () => { describe('updates the hasMoreTail flag only if the first message on page is the first message in interval', () => { From cf4252ed7c45e6aa697988bbcc3c02f442707f7a Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 4 Mar 2026 23:10:19 +0100 Subject: [PATCH 21/48] refactor: remove thread from Channel's CustomMarkReadRequestFn --- src/channel.ts | 2 -- src/messageDelivery/MessageDeliveryReporter.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 43dc7fd9bc..8173abce78 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -12,7 +12,6 @@ import { normalizeQuerySort, } from './utils'; import type { StreamChat } from './client'; -import type { Thread } from './thread'; import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from './constants'; import type { AIState, @@ -138,7 +137,6 @@ export type CustomDeleteMessageRequestFn = ( export type CustomMarkReadRequestFn = (params: { channel: Channel; - thread?: Thread; options?: MarkReadOptions; }) => Promise; diff --git a/src/messageDelivery/MessageDeliveryReporter.ts b/src/messageDelivery/MessageDeliveryReporter.ts index 2919815307..59a0a743d8 100644 --- a/src/messageDelivery/MessageDeliveryReporter.ts +++ b/src/messageDelivery/MessageDeliveryReporter.ts @@ -306,7 +306,6 @@ export class MessageDeliveryReporter { ?.markReadRequest as | ((params: { channel: Channel; - thread?: Thread; options?: MarkReadOptions; }) => Promise | void) | undefined; From aa706ded26a60b7de9cbaa54fe34dbcdf4d94f1c Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Mar 2026 07:40:33 +0100 Subject: [PATCH 22/48] test: fix failing tests --- .../MessageOperations.test.ts | 31 +++++++++++++++++++ test/unit/threads.test.ts | 10 +++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/test/unit/messageOperations/MessageOperations.test.ts b/test/unit/messageOperations/MessageOperations.test.ts index 90f358868d..ef83969a9b 100644 --- a/test/unit/messageOperations/MessageOperations.test.ts +++ b/test/unit/messageOperations/MessageOperations.test.ts @@ -402,4 +402,35 @@ describe('MessageOperations', () => { expect(store.get('m1')?.text).toBe('deleted via override'); expect(store.get('m1')?.deleted_at).toBeInstanceOf(Date); }); + + it('delete uses configured handlers.delete when provided', async () => { + const store: Store = new Map(); + const configuredDelete = vi.fn(async () => ({ + message: makeMessageResponse({ + id: 'm1', + deleted_at: new Date().toISOString(), + text: 'deleted via configured handler', + }), + })); + + const ops = new MessageOperations({ + ingest: (m) => store.set(m.id, m), + get: (id) => store.get(id), + handlers: () => ({ delete: configuredDelete }), + defaults: { + delete: defaultDelete, + send: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + update: async () => ({ message: makeMessageResponse({ id: 'm1' }) }), + }, + }); + + const localMessage = makeLocalMessage({ id: 'm1', status: 'received' }); + await ops.delete({ localMessage, options: { hard: true } }); + + expect(configuredDelete).toHaveBeenCalledWith({ + localMessage, + options: { hard: true }, + }); + expect(store.get('m1')?.text).toBe('deleted via configured handler'); + }); }); diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index 2adbf057ee..26d9721c5b 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -692,17 +692,19 @@ describe('Threads 2.0', () => { thread.registerSubscriptions(); const stateBefore = thread.state.getLatestValue(); - const stubbedMarkAsRead = sinon.stub(thread, 'markAsRead').resolves(); + const stubbedMarkRead = sinon + .stub(client.messageDeliveryReporter, 'throttledMarkRead') + .returns(undefined); expect(stateBefore.active).to.be.false; expect(thread.ownUnreadCount).to.equal(42); - expect(stubbedMarkAsRead.called).to.be.false; + expect(stubbedMarkRead.called).to.be.false; thread.activate(); clock.runAll(); const stateAfter = thread.state.getLatestValue(); expect(stateAfter.active).to.be.true; - expect(stubbedMarkAsRead.calledOnce).to.be.true; + expect(stubbedMarkRead.calledOnce).to.be.true; client.dispatchEvent({ type: 'message.new', @@ -714,7 +716,7 @@ describe('Threads 2.0', () => { }); clock.runAll(); - expect(stubbedMarkAsRead.calledTwice).to.be.true; + expect(stubbedMarkRead.calledTwice).to.be.true; thread.unregisterSubscriptions(); clock.restore(); From 1fbf4206a23ecae907b65dc1a6401a4026930bde Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Mar 2026 11:51:52 +0100 Subject: [PATCH 23/48] feat: export configuration service --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index e75c01948b..336bd4eaed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ export * from './client'; export * from './client_state'; export * from './channel'; export * from './channel_state'; +export * from './configuration'; export * from './connection'; export { type CooldownTimerState } from './CooldownTimer'; export * from './events'; From a3f4bd7a353f94da6c711fe9a89236e18016b69e Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Mar 2026 11:53:52 +0100 Subject: [PATCH 24/48] feat: add backwards compatible APIs --- .../breaking-change-summary.md | 36 ++++ .../compatibility-report.release-v13.md | 96 +++++++++++ .../decisions.md | 64 +++++++ .../message-paginator-release-compat/plan.md | 161 ++++++++++++++++++ .../message-paginator-release-compat/spec.md | 74 ++++++++ .../state.json | 20 +++ src/pagination/paginators/BasePaginator.ts | 71 +++++++- src/pagination/paginators/MessagePaginator.ts | 127 +++++++++++++- test/unit/channel.test.js | 30 ++++ .../paginators/BasePaginator.test.ts | 100 +++++++++++ .../paginators/MessagePaginator.test.ts | 108 ++++++++++++ 11 files changed, 874 insertions(+), 13 deletions(-) create mode 100644 specs/message-paginator-release-compat/breaking-change-summary.md create mode 100644 specs/message-paginator-release-compat/compatibility-report.release-v13.md create mode 100644 specs/message-paginator-release-compat/decisions.md create mode 100644 specs/message-paginator-release-compat/plan.md create mode 100644 specs/message-paginator-release-compat/spec.md create mode 100644 specs/message-paginator-release-compat/state.json diff --git a/specs/message-paginator-release-compat/breaking-change-summary.md b/specs/message-paginator-release-compat/breaking-change-summary.md new file mode 100644 index 0000000000..d408d1b5f2 --- /dev/null +++ b/specs/message-paginator-release-compat/breaking-change-summary.md @@ -0,0 +1,36 @@ +# Breaking-Change Summary (`master`...`feat/message-paginator`) + +## Highest-Risk Changes + +1. **`BasePaginator` API contract changed behind same public export name** + +- Old API shape (`next/prev`, `hasNext/hasPrev`, `cursor.next/prev`, direction `next|prev`) was replaced by (`toHead/toTail`, `hasMoreHead/hasMoreTail`, `cursor.headward/tailward`, direction `headward|tailward`). +- This can break downstream subclasses and direct usages. + +2. **Moved paginator source files can break unsupported deep-import paths (out of scope)** + +- Files moved from `src/pagination/*` to `src/pagination/paginators/*`. +- Root exports remain available via `src/index.ts -> export * from './pagination'`. +- Risk exists only for consumers importing internal paths such as `stream-chat/src/pagination/BasePaginator`. +- This is out of scope for this compatibility pass because the supported interface is root exports. + +3. **`MessageReceiptsTracker` constructor options changed (mostly internal/pseudo-break)** + +- Old usage expected `{ locateMessage }`; new usage requires `{ channel, locateMessage? }`. +- Existing direct instantiation can break, but expected impact is low because this class is primarily used internally by `Channel`. + +## Medium-Risk Changes + +4. **Setup-related type exports moved out of `client.ts`** + +- `MessageComposerSetupState` and related setup types were exported previously from `client.ts` and now live in `configuration/types.ts`. +- This is resolved if root `index.ts` re-exports `./configuration`. + +5. **`StreamChat._messageComposerSetupState` removed (internal-only)** + +- This is internal API and not part of supported semver surface. + +## Suggested Release Classification + +- If compatibility shims/re-exports are **not** added: treat as **major**. +- If shims/re-exports are added and `BasePaginator` compatibility is preserved/aliased: could remain **minor**. diff --git a/specs/message-paginator-release-compat/compatibility-report.release-v13.md b/specs/message-paginator-release-compat/compatibility-report.release-v13.md new file mode 100644 index 0000000000..528d8b7c2c --- /dev/null +++ b/specs/message-paginator-release-compat/compatibility-report.release-v13.md @@ -0,0 +1,96 @@ +# Compatibility Report: `stream-chat-react@release-v13` vs local `stream-chat-js@feat/message-paginator` + +## Environment + +- React worktree: `/Users/martincupela/Projects/stream/chat/stream-chat-react-worktrees/chatview-layout-controller` +- React branch: `release-v13` +- JS SDK repo: `/Users/martincupela/Projects/stream/chat/stream-chat-js` +- JS branch: `feat/message-paginator` + +## Dependency wiring + +`stream-chat-react` resolved `stream-chat` through existing yarn-link symlink: + +- `node_modules/stream-chat -> /Users/martincupela/.config/yarn/link/stream-chat` +- `/Users/martincupela/.config/yarn/link/stream-chat -> /Users/martincupela/Projects/stream/chat/stream-chat-js` + +So test runs consumed local SDK code from this branch. + +## Commands run + +1. Build / type readiness on JS SDK side (already validated in prior steps): + +- `yarn build` (in `stream-chat-js`) + +2. Targeted release-v13 compatibility tests (React worktree): + +- `yarn test --watchman=false src/components/Channel/__tests__/Channel.test.js src/components/MessageList/__tests__/MessageList.test.js src/components/Thread/__tests__/Thread.test.js` + +3. Typecheck in React worktree: + +- `yarn types` + +4. Full React test matrix: + +- `yarn test --watchman=false` + +5. Reproduction of initially failing suites only: + +- `yarn test --watchman=false src/components/MessageInput/__tests__/ThreadMessageInput.test.js src/components/Poll/__tests__/PollCreationDialog.test.js` + +6. Full React test matrix after test updates: + +- `yarn test --watchman=false` + +## Results + +- Targeted test suites: **PASS** + - `Channel.test.js`: pass + - `MessageList.test.js`: pass + - `Thread.test.js`: pass + - Total: 3 suites, 133 tests passed +- React typecheck: **PASS** +- Full suite (final): **PASS** + - `139 passed, 0 failed` + - `2024 passed tests, 2 skipped` + +## Observations + +- `--watchman=false` was required due to sandbox watchman permission errors; this is environment-related, not product behavior. +- There were pre-existing console warnings in tests (`MessageTimestamp ... invalid created_at date`, React `act(...)` warnings), but no assertion failures. + +## Compatibility conclusion (targeted) + +For the validated `release-v13` compatibility surfaces, no breaking regressions were detected when using local `stream-chat-js@feat/message-paginator`: + +- Legacy channel pagination usage (`channel.state.messagePagination` / `messageSets`) continues to work in tested flows. +- mark-read / `doMarkReadRequest`-related Channel and MessageList flows pass. +- Thread flows in tested suite pass. + +## Full-suite findings and resolution + +1. `ThreadMessageInput` draft test triggered unexpected network delete request + +- Failing test: + - `src/components/MessageInput/__tests__/ThreadMessageInput.test.js` +- case: `draft › is queried when drafts are enabled` +- Error: + - `AxiosError: Network Error` from `Channel._deleteDraft` via `MessageComposer.deleteDraft`. +- Resolution: + - mocked `customChannel._deleteDraft` in test setup to avoid external HTTP in test env. + +2. Poll max-vote validation behavior changed (value clamping) + +- Failing test: + - `src/components/Poll/__tests__/PollCreationDialog.test.js` +- case updated to `clamps max vote count to 10 and allows submission` +- Resolution: + - adjusted assertions to new behavior: + - error text is empty + - value is clamped to `10` + - submit button is enabled + - no translation updates required (`i18n/en.json` already contains the previous key). + +## Remaining risk + +- Full Jest matrix is green for this setup; no blocking compatibility failures remain. diff --git a/specs/message-paginator-release-compat/decisions.md b/specs/message-paginator-release-compat/decisions.md new file mode 100644 index 0000000000..514910aaac --- /dev/null +++ b/specs/message-paginator-release-compat/decisions.md @@ -0,0 +1,64 @@ +# Message Paginator Release Compatibility Decisions + +## Decision: Treat BasePaginator API drift as the primary release risk + +**Date:** 2026-03-05 +**Context:** +`BasePaginator` remains publicly exported but its method names, direction values, cursor shape, and state fields changed. + +**Decision:** +Prioritize compatibility strategy for `BasePaginator` before merging branch to `master`. + +**Reasoning:** +This is the most likely downstream compile/runtime break for advanced integrators that extend paginator classes. + +**Alternatives considered:** + +- Ignore and treat as internal-only: rejected because `BasePaginator` is exported. +- Delay until post-merge: rejected because release classification would be unclear. + +## Decision: Compatibility scope is root exports from `src/index.ts` only + +**Date:** 2026-03-05 +**Context:** +The release compatibility target is the public package API exposed through root exports. + +**Decision:** +Do not add deep-import compatibility shims for moved paginator files. +Compatibility work is limited to symbols exported via `src/index.ts`. + +**Reasoning:** +Deep imports are not the supported interface contract for this release. +Focusing on root exports keeps the compatibility scope explicit and maintainable. + +**Alternatives considered:** + +- Add shims for old deep-import file paths: rejected as out-of-scope for public API compatibility. + +## Decision: Restore removed setup type exports on root surface + +**Date:** 2026-03-05 +**Context:** +`MessageComposerSetupState` moved into configuration internals and is no longer exported from root API. + +**Decision:** +Plan includes restoring root exports (directly or via re-export) to avoid unintended TypeScript breakage. + +**Reasoning:** +Type-only breaks still impact consumers and should be avoided in non-major release. + +## Decision: Add transitional BasePaginator compatibility aliases + +**Date:** 2026-03-05 +**Context:** +`BasePaginator` introduced head/tail naming (`toTail`, `toHead`, `hasMoreTail`, `hasMoreHead`, `tailward/headward` cursors), while older consumers may still call legacy APIs. + +**Decision:** +Add deprecated alias APIs on `BasePaginator`: + +- methods: `next`, `prev`, `nextDebounced`, `prevDebounced` +- getters: `hasNext`, `hasPrev` +- query response compatibility: accept `next/prev` cursor fields as fallback to `tailward/headward`. + +**Reasoning:** +This preserves backward compatibility for non-migrated paginator consumers while keeping new naming as canonical. diff --git a/specs/message-paginator-release-compat/plan.md b/specs/message-paginator-release-compat/plan.md new file mode 100644 index 0000000000..61c5067c02 --- /dev/null +++ b/specs/message-paginator-release-compat/plan.md @@ -0,0 +1,161 @@ +# Message Paginator Release Compatibility Plan + +## Worktree + +**Worktree path:** `/Users/martincupela/Projects/stream/chat/stream-chat-js` +**Branch:** `feat/message-paginator` +**Base branch:** `master` + +## Task overview + +Scope is limited to the public interface exported via `src/index.ts`. +Deep-import path compatibility is explicitly out of scope. + +## Task 1: Confirm Public Interface Scope + +**File(s) to create/modify:** `specs/message-paginator-release-compat/decisions.md`, `specs/message-paginator-release-compat/spec.md` + +**Dependencies:** None + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Lock compatibility target to root exports from `src/index.ts`. +- Mark deep-import path stability as non-goal. + +**Acceptance Criteria:** + +- [x] Scope decision is documented. +- [x] Breaking-change summary reflects this scope. + +## Task 2: Restore Root Export Coverage for Configuration Types + +**File(s) to create/modify:** `src/index.ts`, `test/typescript/unit-test.ts` + +**Dependencies:** Task 1 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Ensure configuration setup types are root-exported (`export * from './configuration'`). +- Add a type-level regression check for configuration setup types. + +**Acceptance Criteria:** + +- [x] Root index exports configuration module. +- [x] Type-level regression check compiles. + +## Task 3: Add Legacy BasePaginator API Aliases + +**File(s) to create/modify:** `src/pagination/paginators/BasePaginator.ts` + +**Dependencies:** Task 1 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add compatibility aliases: + - `next`/`prev` + - `nextDebounced`/`prevDebounced` + - `hasNext`/`hasPrev` +- Support legacy `next/prev` cursor fields in query result fallback. + +**Acceptance Criteria:** + +- [x] Existing legacy paginator call sites compile and run via aliases. +- [x] New API remains primary and unchanged. +- [x] Aliases are documented as transitional compatibility layer. + +## Task 4: Add Regression Tests for Alias Compatibility + +**File(s) to create/modify:** `test/unit/pagination/paginators/BasePaginator.test.ts` + +**Dependencies:** Task 3 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add tests for legacy method/getter aliases. +- Add test for `next/prev` cursor field fallback. + +**Acceptance Criteria:** + +- [x] Alias tests pass. +- [x] Existing paginator tests stay green. + +## Task 5: Final Release Notes and Compatibility Summary + +**File(s) to create/modify:** `specs/message-paginator-release-compat/spec.md`, `specs/message-paginator-release-compat/decisions.md`, `specs/message-paginator-release-compat/breaking-change-summary.md` + +**Dependencies:** Task 2, Task 4 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Finalize real vs pseudo breaking changes for public root API. +- Document deprecations and migration notes. + +**Acceptance Criteria:** + +- [x] Summary is aligned with public root export scope. +- [x] Remaining intentional breaks are explicitly listed. + +## Task 6: Cross-Repo `release-v13` Compatibility Validation + +**File(s) to create/modify:** `specs/message-paginator-release-compat/compatibility-report.release-v13.md` + +**Dependencies:** Task 2, Task 4 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Use `stream-chat-react` worktree at `/Users/martincupela/Projects/stream/chat/stream-chat-react-worktrees/chatview-layout-controller` (`release-v13` branch). +- Run tests against local `stream-chat-js` branch build (`feat/message-paginator`) by wiring React worktree dependency to local SDK. +- Focus on legacy compatibility surfaces: + - `channel.state.messagePagination` and `messageSets` behavior used by `release-v13`. + - mark-read and `doMarkReadRequest` flows. + - paginator compatibility behavior where relevant. +- Record exact commands, results, and failures. + +**Acceptance Criteria:** + +- [x] Targeted `release-v13` tests for Channel/MessageList/Thread run against local JS SDK. +- [x] Any failures are categorized as real break, expected behavior shift, or test issue. +- [x] Compatibility report is committed to specs folder. + +## Execution order + +- **Phase 1 (serial):** Task 1 +- **Phase 2 (parallel):** Task 2, Task 3 +- **Phase 3 (serial):** Task 4 +- **Phase 4 (serial):** Task 5 +- **Phase 5 (serial):** Task 6 + +## File ownership summary + +| Task | Creates/Modifies | +| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Task 1 | `specs/message-paginator-release-compat/decisions.md`, `specs/message-paginator-release-compat/spec.md` | +| Task 2 | `src/index.ts`, `test/typescript/unit-test.ts` | +| Task 3 | `src/pagination/paginators/BasePaginator.ts` | +| Task 4 | `test/unit/pagination/paginators/BasePaginator.test.ts` | +| Task 5 | `specs/message-paginator-release-compat/spec.md`, `specs/message-paginator-release-compat/decisions.md`, `specs/message-paginator-release-compat/breaking-change-summary.md` | +| Task 6 | `specs/message-paginator-release-compat/compatibility-report.release-v13.md` | diff --git a/specs/message-paginator-release-compat/spec.md b/specs/message-paginator-release-compat/spec.md new file mode 100644 index 0000000000..91eda96c94 --- /dev/null +++ b/specs/message-paginator-release-compat/spec.md @@ -0,0 +1,74 @@ +# Message Paginator Release Compatibility Spec + +## Problem Statement + +Branch `feat/message-paginator` introduces large pagination/runtime refactors. Before merging to `master`, we need a focused semver review to identify changes that can break existing consumers of `stream-chat`. + +## Goal + +Document concrete breaking-change risks and define mitigation tasks so merge/release can be done safely. + +Additionally, validate compatibility against `stream-chat-react@release-v13` using the local `stream-chat-js@feat/message-paginator` build. + +## Non-Goals + +- Re-implementing the feature set in this spec task. +- Exhaustive behavioral QA of all new pagination flows. + +## Breaking-Change Risk Summary + +### High Risk + +1. `BasePaginator` public contract changed while keeping the same export name + +- Evidence: + - Old API in `src/pagination/BasePaginator.ts` (deleted): `next/prev`, `hasNext/hasPrev`, `PaginationDirection = 'next' | 'prev'`, `cursor.next/cursor.prev`. + - New API in `src/pagination/paginators/BasePaginator.ts`: `toHead/toTail`, `hasMoreHead/hasMoreTail`, `PaginationDirection = 'headward' | 'tailward'`, `cursor.headward/cursor.tailward`. +- Impact: + - Consumers subclassing or directly using exported `BasePaginator` from `stream-chat` can fail at compile time and behavior level. + +2. Deep import paths removed from shipped `src/` tree + +- Evidence: + - Deleted files: `src/pagination/BasePaginator.ts`, `src/pagination/ReminderPaginator.ts`. + - Package ships `/src` (`package.json -> files`), so many consumers rely on internal deep imports despite `exports` map only exposing `.`. +- Impact: + - Runtime/module-resolution failure for imports like `stream-chat/src/pagination/BasePaginator` and `stream-chat/src/pagination/ReminderPaginator`. + +3. `MessageReceiptsTracker` constructor contract changed + +- Evidence: + - Old: `new MessageReceiptsTracker({ locateMessage })`. + - New: `new MessageReceiptsTracker({ channel, locateMessage? })` in `src/messageDelivery/MessageReceiptsTracker.ts`. +- Impact: + - External instantiation with previous options shape breaks (type and runtime). + +### Medium Risk + +4. Previously exported setup types no longer exported from root package surface + +- Evidence: + - `src/client.ts` no longer exports `MessageComposerSetupState`/related setup types. + - New types live under `src/configuration/types.ts` but root `src/index.ts` does not export `./configuration`. +- Impact: + - TS consumers importing these types from `'stream-chat'` or `'stream-chat/src/client'` can break. + +5. Undocumented but reachable `StreamChat._messageComposerSetupState` removed + +- Evidence: + - Property removed from `src/client.ts`; replaced by `instanceConfigurationService`. +- Impact: + - Integrations depending on this internal field break. + +## Lower-Risk (Mostly Additive) + +- New exports: `ChannelPaginatorsOrchestrator`, `EventHandlerPipeline`. +- New optimistic wrappers on `Channel`/`Thread` (`send/retry/update/delete...WithLocalUpdate`). +- `Thread.markAsRead` remains available as deprecated alias to `markRead`. + +## Success Criteria + +- Breaking points are either mitigated with compatibility shims/re-exports or explicitly released as major version changes. +- Test coverage is added for all compatibility shims. +- Release notes explicitly call out any intentional breaks. +- Cross-repo compatibility validation is executed against `stream-chat-react@release-v13` with local `stream-chat-js` artifacts. diff --git a/specs/message-paginator-release-compat/state.json b/specs/message-paginator-release-compat/state.json new file mode 100644 index 0000000000..f2339bd96a --- /dev/null +++ b/specs/message-paginator-release-compat/state.json @@ -0,0 +1,20 @@ +{ + "tasks": { + "task-1-confirm-public-interface-scope": "done", + "task-2-restore-root-export-coverage-for-configuration-types": "done", + "task-3-add-legacy-basepaginator-api-aliases": "done", + "task-4-add-regression-tests-for-alias-compatibility": "done", + "task-5-final-release-notes-and-compatibility-summary": "done", + "task-6-cross-repo-release-v13-compatibility-validation": "done" + }, + "flags": { + "blocked": false, + "needs-review": false + }, + "meta": { + "last_updated": "2026-03-05", + "worktree": "/Users/martincupela/Projects/stream/chat/stream-chat-js", + "branch": "feat/message-paginator", + "base_branch": "master" + } +} diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 8758226bef..658a6b4a0f 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -211,6 +211,14 @@ export type ExecuteQueryReturnValue = { export type PaginationQueryReturnValue = { items: T[] } & { headward?: string; tailward?: string; + /** + * @deprecated Use `tailward` instead. + */ + next?: string; + /** + * @deprecated Use `headward` instead. + */ + prev?: string; }; export type PaginatorDebounceOptions = { debounceMs: number; @@ -449,6 +457,20 @@ export abstract class BasePaginator { return this.state.getLatestValue().hasMoreHead; } + /** + * @deprecated Use `hasMoreTail` instead. + */ + get hasNext() { + return this.hasMoreTail; + } + + /** + * @deprecated Use `hasMoreHead` instead. + */ + get hasPrev() { + return this.hasMoreHead; + } + get hasResults() { return Array.isArray(this.state.getLatestValue().items); } @@ -1967,7 +1989,15 @@ export abstract class BasePaginator { return { stateCandidate: stateUpdate, targetInterval: null }; } - const { items, headward, tailward } = results; + // Backward compatibility for custom BasePaginator subclasses: + // - old PaginationQueryReturnValue used next/prev + // - new contract uses tailward/headward + // + // Internal SDK paginators already return tailward/headward, so this fallback is + // only to keep non-migrated external subclasses working during transition. + const { items, headward, tailward, next, prev } = results; + const resolvedHeadward = headward ?? prev; + const resolvedTailward = tailward ?? next; stateUpdate.lastQueryError = undefined; const filteredItems = await this.filterQueryResults(items); @@ -2036,9 +2066,12 @@ export abstract class BasePaginator { stateUpdate.hasMoreTail = hasMoreTail; stateUpdate.hasMoreHead = hasMoreHead; } else { - stateUpdate.cursor = { tailward: tailward || null, headward: headward || null }; - stateUpdate.hasMoreTail = !!tailward; - stateUpdate.hasMoreHead = !!headward; + stateUpdate.cursor = { + tailward: resolvedTailward || null, + headward: resolvedHeadward || null, + }; + stateUpdate.hasMoreTail = !!resolvedTailward; + stateUpdate.hasMoreHead = !!resolvedHeadward; } } else { // todo: we could keep the offset in two directions (initial tailward offset would be taken from config.initialOffset) @@ -2093,6 +2126,18 @@ export abstract class BasePaginator { toHead = (params: Omit, 'direction' | 'queryShape'> = {}) => this.executeQuery({ direction: 'headward', ...params }); + /** + * @deprecated Use `toTail` instead. + */ + next = (params: Omit, 'direction' | 'queryShape'> = {}) => + this.toTail(params); + + /** + * @deprecated Use `toHead` instead. + */ + prev = (params: Omit, 'direction' | 'queryShape'> = {}) => + this.toHead(params); + toTailDebounced = ( params: Omit, 'direction' | 'queryShape'> = {}, ) => { @@ -2105,6 +2150,24 @@ export abstract class BasePaginator { this._executeQueryDebounced({ direction: 'headward', ...params }); }; + /** + * @deprecated Use `toTailDebounced` instead. + */ + nextDebounced = ( + params: Omit, 'direction' | 'queryShape'> = {}, + ) => { + this.toTailDebounced(params); + }; + + /** + * @deprecated Use `toHeadDebounced` instead. + */ + prevDebounced = ( + params: Omit, 'direction' | 'queryShape'> = {}, + ) => { + this.toHeadDebounced(params); + }; + reload = async () => { await this.toTail({ reset: 'yes' }); }; diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index ad39d49264..a3665f5e20 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -102,6 +102,12 @@ const dataFieldFilterResolver: FieldToDataResolver = { resolve: (message, path) => resolveDotPathValue(message, path), }; +const getMessageCreatedAtTimestamp = (message: LocalMessage): number | null => { + if (!(message.created_at instanceof Date)) return null; + const timestamp = message.created_at.getTime(); + return Number.isFinite(timestamp) ? timestamp : null; +}; + export type MessagePaginatorOptions = { channel: Channel; id?: string; @@ -454,6 +460,14 @@ export class MessagePaginator extends BasePaginator { const ownUserId = this.channel.getClient().user?.id; @@ -462,14 +476,15 @@ export class MessagePaginator extends BasePaginator { + // Messages are expected in chronological order. We find: + // - lastReadMessageId: newest message with created_at <= lastReadAt + // - firstUnreadMessageId: first message with created_at > lastReadAt + // + // If the page starts after lastReadAt, the entire page is unread and the first message is + // used as unread anchor (legacy "whole channel is unread" behavior for this queried window). + const lastReadTimestamp = lastReadAt.getTime(); + if (!Number.isFinite(lastReadTimestamp) || !messages.length) { + return { firstUnreadMessageId: null, lastReadMessageId: null }; + } + + let firstUnreadMessageId: string | null = null; + let lastReadMessageId: string | null = null; + + for (const message of messages) { + const messageTimestamp = getMessageCreatedAtTimestamp(message); + if (messageTimestamp === null) continue; + + if (messageTimestamp <= lastReadTimestamp) { + lastReadMessageId = message.id; + } else if (!firstUnreadMessageId) { + firstUnreadMessageId = message.id; + } + } + + const firstMessageWithTimestamp = messages.find( + (message) => getMessageCreatedAtTimestamp(message) !== null, + ); + const firstMessageTimestamp = + firstMessageWithTimestamp && + getMessageCreatedAtTimestamp(firstMessageWithTimestamp); + if ( + firstMessageWithTimestamp && + typeof firstMessageTimestamp === 'number' && + lastReadTimestamp < firstMessageTimestamp + ) { + return { + firstUnreadMessageId: firstMessageWithTimestamp.id, + lastReadMessageId, + }; + } + + return { firstUnreadMessageId, lastReadMessageId }; }; emitMessageFocusSignal = ({ diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 8d096464c0..07247c5682 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -396,6 +396,30 @@ describe('Channel _handleChannelEvent', function () { expect(channel.state.messages.length).to.be.equal(0); }); + it('message.truncate clears messagePaginator unread snapshot', function () { + channel.messagePaginator.setUnreadSnapshot({ + firstUnreadMessageId: 'm-1', + lastReadAt: new Date('2021-01-01T00:00:00.000Z'), + lastReadMessageId: 'm-0', + unreadCount: 7, + }); + + channel._handleChannelEvent({ + type: 'channel.truncated', + user: { id: 'id' }, + channel: { + truncated_at: new Date().toISOString(), + }, + }); + + expect(channel.messagePaginator.unreadStateSnapshot.getLatestValue()).toEqual({ + firstUnreadMessageId: null, + lastReadAt: null, + lastReadMessageId: null, + unreadCount: 0, + }); + }); + it('message.truncate removes messages up to specified date', function () { const messages = [ { created_at: '2021-01-01T00:01:00' }, @@ -721,6 +745,12 @@ describe('Channel _handleChannelEvent', function () { expect( channel.messageReceiptsTracker.getUserProgress(user.id)?.lastReadRef.msgId, ).toBe(event.last_read_message_id); + expect(channel.messagePaginator.unreadStateSnapshot.getLatestValue()).toEqual({ + firstUnreadMessageId: event.first_unread_message_id, + lastReadAt: new Date(event.last_read_at), + lastReadMessageId: event.last_read_message_id, + unreadCount: event.unread_messages, + }); }); it('should reconcile tracker with metadata patch for notification.mark_unread', () => { diff --git a/test/unit/pagination/paginators/BasePaginator.test.ts b/test/unit/pagination/paginators/BasePaginator.test.ts index 4e05c82052..ac54c07dd0 100644 --- a/test/unit/pagination/paginators/BasePaginator.test.ts +++ b/test/unit/pagination/paginators/BasePaginator.test.ts @@ -280,6 +280,24 @@ describe('BasePaginator', () => { expect(paginator.mockClientQuery).toHaveBeenCalledTimes(3); }); + it('supports legacy next/prev cursor fields from query response', async () => { + const paginator = new Paginator({ initialCursor: ZERO_PAGE_CURSOR }); + + const nextPromise = paginator.toTail(); + await sleep(0); + + paginator.queryResolve({ + items: [{ id: 'id1' }], + next: 'next1', + prev: 'prev1', + }); + + await nextPromise; + expect(paginator.cursor).toEqual({ tailward: 'next1', headward: 'prev1' }); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(true); + }); + it('paginates to next pages (offset)', async () => { const paginator = new Paginator({ pageSize: 1 }); let nextPromise = paginator.toTail(); @@ -405,6 +423,88 @@ describe('BasePaginator', () => { vi.useRealTimers(); }); + it('supports legacy pagination aliases', async () => { + const paginator = new Paginator({ initialCursor: ZERO_PAGE_CURSOR }); + expect(paginator.hasNext).toBe(true); + expect(paginator.hasPrev).toBe(true); + + const nextPromise = paginator.next(); + await sleep(0); + paginator.queryResolve({ + items: [{ id: 'id1' }], + tailward: 'next1', + headward: 'prev1', + }); + await nextPromise; + expect(paginator.mockClientQuery).toHaveBeenNthCalledWith(1, { + direction: 'tailward', + queryShape: defaultNextQueryShape, + reset: undefined, + retryCount: 0, + }); + + const prevPromise = paginator.prev(); + paginator.queryResolve({ + items: [{ id: 'id0' }], + tailward: 'next2', + headward: 'prev0', + }); + await prevPromise; + expect(paginator.mockClientQuery).toHaveBeenNthCalledWith(2, { + direction: 'headward', + queryShape: defaultNextQueryShape, + reset: undefined, + retryCount: 0, + }); + }); + + it('supports legacy debounced pagination aliases', async () => { + vi.useFakeTimers(); + try { + const paginator = new Paginator({ + debounceMs: 2000, + initialCursor: ZERO_PAGE_CURSOR, + }); + + paginator.nextDebounced(); + vi.advanceTimersByTime(2000); + await toNextTick(); + paginator.queryResolve({ + items: [{ id: 'id2' }], + tailward: null, + headward: 'prev0', + }); + await paginator.queryPromise; + await toNextTick(); + + paginator.prevDebounced(); + vi.advanceTimersByTime(2000); + await toNextTick(); + paginator.queryResolve({ + items: [{ id: 'id-1' }], + tailward: 'next2', + headward: null, + }); + await paginator.queryPromise; + await toNextTick(); + + expect(paginator.mockClientQuery).toHaveBeenNthCalledWith(1, { + direction: 'tailward', + queryShape: defaultNextQueryShape, + reset: undefined, + retryCount: 0, + }); + expect(paginator.mockClientQuery).toHaveBeenNthCalledWith(2, { + direction: 'headward', + queryShape: defaultNextQueryShape, + reset: undefined, + retryCount: 0, + }); + } finally { + vi.useRealTimers(); + } + }); + it('paginates to a previous page (cursor only)', async () => { const paginator = new Paginator({ initialCursor: ZERO_PAGE_CURSOR }); let nextPromise = paginator.toHead(); diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 94f6c4cab1..563552e8e8 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -358,6 +358,114 @@ describe('MessagePaginator', () => { expect(ok).toBe(false); expect(jumpSpy).not.toHaveBeenCalled(); }); + + it('falls back to created_at_around query when unread ids are missing and lastReadAt exists', async () => { + const lastReadAt = new Date('2021-01-02T00:00:00.000Z'); + const channelWithReadState = { + cid: 'channel-id', + query: vi.fn(), + state: { + read: { + user1: { + first_unread_message_id: null, + last_read: lastReadAt, + last_read_message_id: null, + }, + }, + }, + getClient: () => ({ + user: { id: 'user1' }, + }), + } as unknown as Channel; + + const paginator = new MessagePaginator({ + channel: channelWithReadState, + itemIndex, + }); + const executeQuerySpy = vi.spyOn(paginator, 'executeQuery').mockResolvedValue({ + stateCandidate: { + items: [ + createMessage({ created_at: '2021-01-01T00:00:00.000Z', id: 'm-read' }), + createMessage({ created_at: '2021-01-03T00:00:00.000Z', id: 'm-unread' }), + ], + }, + targetInterval: null, + }); + const jumpSpy = vi.spyOn(paginator, 'jumpToMessage').mockResolvedValue(true); + + const ok = await paginator.jumpToTheFirstUnreadMessage({ pageSize: 25 }); + + expect(ok).toBe(true); + expect(executeQuerySpy).toHaveBeenCalledWith({ + queryShape: { created_at_around: lastReadAt.toISOString(), limit: 25 }, + updateState: false, + }); + expect(jumpSpy).toHaveBeenCalledWith( + 'm-unread', + expect.objectContaining({ focusReason: 'jump-to-first-unread' }), + ); + expect(paginator.unreadStateSnapshot.getLatestValue()).toEqual({ + firstUnreadMessageId: 'm-unread', + lastReadAt, + lastReadMessageId: 'm-read', + unreadCount: 0, + }); + }); + + it('hydrates firstUnreadMessageId when the queried page starts after lastReadAt', async () => { + const lastReadAt = new Date('2021-01-01T00:00:00.000Z'); + const channelWithReadState = { + cid: 'channel-id', + query: vi.fn(), + state: { + read: { + user1: { + first_unread_message_id: null, + last_read: lastReadAt, + last_read_message_id: null, + }, + }, + }, + getClient: () => ({ + user: { id: 'user1' }, + }), + } as unknown as Channel; + + const paginator = new MessagePaginator({ + channel: channelWithReadState, + itemIndex, + }); + vi.spyOn(paginator, 'executeQuery').mockResolvedValue({ + stateCandidate: { + items: [ + createMessage({ + created_at: '2021-01-02T00:00:00.000Z', + id: 'm-first-unread', + }), + createMessage({ + created_at: '2021-01-03T00:00:00.000Z', + id: 'm-newer-unread', + }), + ], + }, + targetInterval: null, + }); + const jumpSpy = vi.spyOn(paginator, 'jumpToMessage').mockResolvedValue(true); + + const ok = await paginator.jumpToTheFirstUnreadMessage(); + + expect(ok).toBe(true); + expect(jumpSpy).toHaveBeenCalledWith( + 'm-first-unread', + expect.objectContaining({ focusReason: 'jump-to-first-unread' }), + ); + expect(paginator.unreadStateSnapshot.getLatestValue()).toEqual({ + firstUnreadMessageId: 'm-first-unread', + lastReadAt, + lastReadMessageId: null, + unreadCount: 0, + }); + }); }); describe('filterQueryResults()', () => { From ed0ce7157cae861026ce142ae3b9f9552765767b Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Mar 2026 16:06:02 +0100 Subject: [PATCH 25/48] fix: nullify first_unread_message_id on message.read event --- src/channel.ts | 1 + test/unit/channel.test.js | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/channel.ts b/src/channel.ts index 8173abce78..3d5141da0d 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -2213,6 +2213,7 @@ export class Channel { ? (event.last_read_message_id ?? currentUserReadState?.last_delivered_message_id) : currentUserReadState?.last_delivered_message_id, + first_unread_message_id: undefined, user: eventUser, unread_messages: 0, }; diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 07247c5682..dd281e94a7 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -804,6 +804,7 @@ describe('Channel _handleChannelEvent', function () { initialReadState = { last_read: new Date(1500).toISOString(), last_read_message_id: '6', + first_unread_message_id: 'first-unread-msg-id', user, unread_messages: initialCountUnread, last_delivered_at: new Date(1000).toISOString(), @@ -835,6 +836,7 @@ describe('Channel _handleChannelEvent', function () { expect(channel.state.read[user.id].last_read_message_id).toBe( event.last_read_message_id, ); + expect(channel.state.read[user.id].first_unread_message_id).toBeUndefined(); expect(channel.state.read[user.id].unread_messages).toBe(0); expect(new Date(channel.state.read[user.id].last_delivered_at).getTime()).toBe( new Date(messageReadEvent.created_at).getTime(), @@ -862,6 +864,7 @@ describe('Channel _handleChannelEvent', function () { expect(channel.state.read[anotherUser.id].last_read_message_id).toBe( event.last_read_message_id, ); + expect(channel.state.read[anotherUser.id].first_unread_message_id).toBeUndefined(); expect(channel.state.read[anotherUser.id].unread_messages).toBe(0); expect( new Date(channel.state.read[anotherUser.id].last_delivered_at).getTime(), From c972ee930ef77aec18d5dd49f0b15c543ff19ac6 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Mar 2026 21:45:46 +0100 Subject: [PATCH 26/48] fix: update thread participant counts and reply counts on message.new and message.updated --- src/channel.ts | 2 + src/thread.ts | 65 +++++++++++++++++++++++++++++- test/unit/channel.test.js | 23 +++++++++++ test/unit/threads.test.ts | 84 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 170 insertions(+), 4 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 3d5141da0d..cb86550e01 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -6,6 +6,7 @@ import type { ReadStoreReconcileMeta } from './messageDelivery'; import { MessagePaginator } from './pagination/paginators'; import { MessageOperations } from './messageOperations'; import { + formatMessage, generateChannelTempCid, logChatPromiseExecution, messageSetPagination, @@ -2388,6 +2389,7 @@ export class Channel { if (event.message) { this._extendEventWithOwnReactions(event); channelState.addMessageSorted(event.message, false, false); + this.messagePaginator.ingestItem(formatMessage(event.message)); channelState._updateQuotedMessageReferences({ message: event.message }); if (event.message.pinned) { channelState.addPinnedMessage(event.message); diff --git a/src/thread.ts b/src/thread.ts index d25328cbe4..9f79009d65 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -236,6 +236,10 @@ export class Thread extends WithSubscriptions { this.messagePaginator = new MessagePaginator({ channel: this.channel, parentMessageId: this.id, + sort: DEFAULT_SORT, + paginatorOptions: { + pageSize: DEFAULT_PAGE_LIMIT, + }, }); this.messageComposer = new MessageComposer({ client, @@ -463,8 +467,12 @@ export class Thread extends WithSubscriptions { } const isOwnMessage = event.message.user?.id === this.client.userID; - const { active, read } = this.state.getLatestValue(); + const { active, read, replies } = this.state.getLatestValue(); + const hasReplyAlready = + replies.some((reply) => reply.id === event.message?.id) || + !!this.messagePaginator.getItem(event.message.id); + this.messagePaginator.ingestItem(formatMessage(event.message)); this.upsertReplyLocally({ message: event.message, // Message from current user could have been added optimistically, @@ -472,6 +480,10 @@ export class Thread extends WithSubscriptions { timestampChanged: isOwnMessage, }); + if (!hasReplyAlready) { + this.incrementReplyCountLocally(); + } + if (active) { this.throttledMarkRead(); } @@ -510,6 +522,21 @@ export class Thread extends WithSubscriptions { this.state.partialNext({ read: nextRead }); }).unsubscribe; + private incrementReplyCountLocally = () => { + this.state.next((current) => { + const nextReplyCount = current.replyCount + 1; + + return { + ...current, + parentMessage: { + ...current.parentMessage, + reply_count: nextReplyCount, + }, + replyCount: nextReplyCount, + }; + }); + }; + private subscribeRepliesRead = () => this.client.on('message.read', (event) => { if (!event.user || !event.created_at || !event.thread) return; @@ -579,6 +606,7 @@ export class Thread extends WithSubscriptions { return symbol; }; + // todo: can be removed with the next breaking change and use MessagePaginator only public deleteReplyLocally = ({ message }: { message: MessageResponse }) => { const { replies } = this.state.getLatestValue(); @@ -602,6 +630,7 @@ export class Thread extends WithSubscriptions { }); }; + // todo: can be removed with the next breaking change and use MessagePaginator only public upsertReplyLocally = ({ message, timestampChanged = false, @@ -629,6 +658,7 @@ export class Thread extends WithSubscriptions { })); }; + // todo: can be removed with the next breaking change and use MessagePaginator only public updateParentMessageLocally = ({ message }: { message: MessageResponse }) => { if (message.id !== this.id) { throw new Error('Message does not belong to this thread'); @@ -641,11 +671,15 @@ export class Thread extends WithSubscriptions { ...current, deletedAt: formattedMessage.deleted_at, parentMessage: formattedMessage, + participants: + normalizeThreadParticipants(message.thread_participants, current.channel.cid) ?? + current.participants, replyCount: message.reply_count ?? current.replyCount, }; }); }; + // todo: can be removed with the next breaking change and use MessagePaginator only public updateParentMessageOrReplyLocally = (message: MessageResponse) => { if (message.parent_id === this.id) { this.upsertReplyLocally({ message }); @@ -738,6 +772,7 @@ export class Thread extends WithSubscriptions { public markAsRead = ({ force = false }: { force?: boolean } = {}) => this.markRead({ force }); + // todo: can be removed with the next breaking change and use MessagePaginator only public queryReplies = ({ limit = DEFAULT_PAGE_LIMIT, sort = DEFAULT_SORT, @@ -745,12 +780,14 @@ export class Thread extends WithSubscriptions { }: QueryRepliesOptions = {}) => this.channel.getReplies(this.id, { limit, ...otherOptions }, sort); + // todo: can be removed with the next breaking change and use MessagePaginator only public loadNextPage = ({ limit = DEFAULT_PAGE_LIMIT }: { limit?: number } = {}) => this.loadPage(limit); + // todo: can be removed with the next breaking change and use MessagePaginator only public loadPrevPage = ({ limit = DEFAULT_PAGE_LIMIT }: { limit?: number } = {}) => this.loadPage(-limit); - + // todo: can be removed with the next breaking change and use MessagePaginator only private loadPage = async (count: number) => { const { pagination } = this.state.getLatestValue(); const [loadingKey, cursorKey, insertionMethodKey] = @@ -802,6 +839,30 @@ export class Thread extends WithSubscriptions { }; } +type MessageThreadParticipant = NonNullable< + MessageResponse['thread_participants'] +>[number]; +type ThreadParticipant = NonNullable[number]; + +const normalizeThreadParticipants = ( + participants: MessageResponse['thread_participants'] | undefined, + channelCid: string, +): ThreadResponse['thread_participants'] | undefined => { + if (!participants) return undefined; + + const nowIso = new Date().toISOString(); + + return participants.map( + (participant: MessageThreadParticipant): ThreadParticipant => ({ + channel_cid: channelCid, + created_at: nowIso, + last_read_at: nowIso, + user: participant, + user_id: participant.id, + }), + ); +}; + const formatReadState = (read: ReadResponse[]): ThreadReadState => read.reduce((state, userRead) => { state[userRead.user.id] = { diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index dd281e94a7..daa9f26cb4 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -373,6 +373,29 @@ describe('Channel _handleChannelEvent', function () { expect(channel.state.unreadCount).to.be.equal(30); }); + it('message.updated syncs reply metadata into messagePaginator', function () { + const parentMessage = generateMsg({ + id: 'parent-message-id', + reply_count: 1, + thread_participants: [{ id: 'user-1' }], + }); + + channel.messagePaginator.ingestItem(parentMessage); + + channel._handleChannelEvent({ + type: 'message.updated', + message: { + ...parentMessage, + reply_count: 29, + thread_participants: [{ id: 'user-1' }, { id: 'user-2' }], + }, + }); + + const parentFromPaginator = channel.messagePaginator.getItem(parentMessage.id); + expect(parentFromPaginator?.reply_count).to.be.equal(29); + expect(parentFromPaginator?.thread_participants).to.have.length(2); + }); + it('does not override the delivery information in the read status', () => {}); it('message.truncate removes all messages if "truncated_at" is "now"', function () { diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index 26d9721c5b..f73140a2ed 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -99,6 +99,8 @@ describe('Threads 2.0', () => { expect(thread.id).to.equal(parentMessageResponse.id); // @ts-expect-error `name` is a custom property expect(thread.channel.data?.name).to.equal(channelResponse.name); + expect(thread.messagePaginator.sort).to.deep.equal([{ created_at: -1 }]); + expect(thread.messagePaginator.pageSize).to.equal(50); }); it('initializes properly without threadData', () => { @@ -114,6 +116,8 @@ describe('Threads 2.0', () => { expect(state.pagination.prevCursor).to.be.null; expect(state.pagination.nextCursor).to.be.null; expect(state.read).to.have.keys([TEST_USER_ID]); + expect(thread.messagePaginator.sort).to.deep.equal([{ created_at: -1 }]); + expect(thread.messagePaginator.pageSize).to.equal(50); }); it('throws if minimal init parent message id is missing', () => { @@ -236,11 +240,15 @@ describe('Threads 2.0', () => { expect(stateBefore.replyCount).to.equal(0); expect(stateBefore.parentMessage.text).to.equal(parentMessageResponse.text); + const nextParticipants = [ + { id: 'participant-1' }, + ] as unknown as ThreadResponse['thread_participants']; const updatedMessage = generateMsg({ + deleted_at: new Date().toISOString(), id: parentMessageResponse.id, - text: 'aaa', reply_count: 10, - deleted_at: new Date().toISOString(), + text: 'aaa', + thread_participants: nextParticipants, }) as MessageResponse; thread.updateParentMessageLocally({ message: updatedMessage }); @@ -249,6 +257,8 @@ describe('Threads 2.0', () => { expect(stateAfter.deletedAt).to.be.not.null; expect(stateAfter.deletedAt!.toISOString()).to.equal(updatedMessage.deleted_at); expect(stateAfter.replyCount).to.equal(updatedMessage.reply_count); + expect(stateAfter.participants).to.have.lengthOf(1); + expect(stateAfter.participants?.[0].user_id).to.equal('participant-1'); expect(stateAfter.parentMessage.text).to.equal(updatedMessage.text); }); }); @@ -999,6 +1009,76 @@ describe('Threads 2.0', () => { thread.unregisterSubscriptions(); }); + it('increments local reply_count on new reply', () => { + const thread = createTestThread({ + reply_count: 0, + read: [ + { + last_read: new Date().toISOString(), + user: { id: TEST_USER_ID }, + unread_messages: 0, + }, + ], + }); + thread.registerSubscriptions(); + + const newMessage = generateMsg({ + parent_id: thread.id, + user: { id: 'bob' }, + }) as MessageResponse; + + client.dispatchEvent({ + type: 'message.new', + message: newMessage, + user: { id: 'bob' }, + }); + + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.replyCount).to.equal(1); + expect(stateAfter.parentMessage.reply_count).to.equal(1); + + thread.unregisterSubscriptions(); + }); + + it('does not increment local reply_count for duplicate message.new events', () => { + const existingReply = generateMsg({ + parent_id: parentMessageResponse.id, + user: { id: 'bob' }, + }) as MessageResponse; + const thread = createTestThread({ + latest_replies: [existingReply], + reply_count: 1, + read: [ + { + user: { id: TEST_USER_ID }, + last_read: new Date().toISOString(), + unread_messages: 0, + }, + ], + }); + thread.registerSubscriptions(); + + thread.state.next((current) => ({ + ...current, + parentMessage: { + ...current.parentMessage, + reply_count: 1, + }, + })); + + client.dispatchEvent({ + type: 'message.new', + message: existingReply, + user: { id: 'bob' }, + }); + + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.replyCount).to.equal(1); + expect(stateAfter.parentMessage.reply_count).to.equal(1); + + thread.unregisterSubscriptions(); + }); + it('handles receiving a reply that was previously optimistically added', () => { const thread = createTestThread({ latest_replies: [generateMsg() as MessageResponse], From 43d365faf5e6c9045e6486ac13d5ba9b5e9767b0 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Mar 2026 23:07:27 +0100 Subject: [PATCH 27/48] feat: decouple request sort from in-memory item order via BasePaginator itemOrderComparator --- specs/message-paginator/decisions.md | 31 +++++ specs/message-paginator/plan.md | 111 ++++++++++++++++++ specs/message-paginator/spec.md | 29 +++++ specs/message-paginator/state.json | 14 +++ src/pagination/paginators/BasePaginator.ts | 41 ++++--- src/pagination/paginators/MessagePaginator.ts | 59 ++++++++-- src/thread.ts | 4 +- .../paginators/MessagePaginator.test.ts | 73 ++++++++++++ test/unit/threads.test.ts | 4 + 9 files changed, 342 insertions(+), 24 deletions(-) create mode 100644 specs/message-paginator/decisions.md create mode 100644 specs/message-paginator/plan.md create mode 100644 specs/message-paginator/spec.md create mode 100644 specs/message-paginator/state.json diff --git a/specs/message-paginator/decisions.md b/specs/message-paginator/decisions.md new file mode 100644 index 0000000000..47a102cc06 --- /dev/null +++ b/specs/message-paginator/decisions.md @@ -0,0 +1,31 @@ +# Decisions + +## 2026-03-05 - Decouple request sort from in-memory ordering + +- Decision: `MessagePaginator` will keep request `sort` configurable for backend calls, but internal interval/state ordering remains chronological (oldest -> newest). +- Why: Backend sort should not redefine paginator semantics used by Channel/Thread traversal and cursor/head-tail logic. + +## 2026-03-05 - Thread requests newest-first while preserving chronological iteration + +- Decision: `Thread` will request replies using `created_at: -1`, while paginator output remains oldest -> newest. +- Why: This satisfies thread loading expectations without changing consumer iteration assumptions. + +## 2026-03-05 - Do not modify BasePaginator contract + +- Decision: Decoupling will be implemented entirely in `MessagePaginator` via explicit request sort and item order handling. +- Why: `BasePaginator` is used by multiple subclasses and changing its contract would risk cross-paginator regressions. + +## 2026-03-05 - Preserve `sort` option as backward-compatible alias + +- Decision: Keep `MessagePaginatorOptions.sort` working as an alias for request sorting and add explicit `requestSort`. +- Why: Existing integrations may already pass `sort`; alias keeps semver compatibility while making intent explicit. + +## 2026-03-05 - Canonicalize query pages inside MessagePaginator + +- Decision: Normalize queried message pages to canonical chronological order before cursor derivation and interval ingestion. +- Why: `BasePaginator` interval/head-tail semantics in `MessagePaginator` assume chronological item ordering. + +## 2026-03-05 - Additive BasePaginator item-order extension + +- Decision: Add optional `itemOrderComparator` to `BasePaginator` options/config and use it for interval/item ordering, while defaulting to `sortComparator`. +- Why: This keeps backward compatibility (`itemOrder = requestOrder` by default) and lets specific paginators decouple backend request order from in-memory ordering. diff --git a/specs/message-paginator/plan.md b/specs/message-paginator/plan.md new file mode 100644 index 0000000000..f72222e861 --- /dev/null +++ b/specs/message-paginator/plan.md @@ -0,0 +1,111 @@ +# Worktree + +- Path: `/Users/martincupela/Projects/stream/chat/stream-chat-js` +- Branch: `feat/message-paginator` +- Base branch: `master` + +Task plan assumes self-contained tasks; same-file tasks are explicitly chained to avoid overlap. + +## Task 1: Define Decoupled Ordering Contract + +**File(s) to create/modify:** `specs/message-paginator/spec.md`, `specs/message-paginator/decisions.md` + +**Dependencies:** None + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Document that request sort and in-memory paginator order are separate concerns. +- Capture rationale and boundary with `ChannelState.messageSets`. + +**Acceptance Criteria:** + +- [x] Spec states desired behavior and constraints. +- [x] Decision log records why decoupling is required. + +## Task 2: Implement MessagePaginator Decoupling + +**File(s) to create/modify:** `src/pagination/paginators/MessagePaginator.ts`, `src/thread.ts` + +**Dependencies:** Task 1 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Introduce generic item-order comparator support in `BasePaginator`. +- Introduce explicit request sort and item-order semantics in `MessagePaginator` options. +- Keep backend request sort configurable. +- Keep internal paginator comparator/order chronological and independent from request sort. +- Ensure cursor derivation works even if backend returns pages in reverse order. +- Keep `Thread` request sort newest-first and default page size behavior. + +**Acceptance Criteria:** + +- [x] BasePaginator has additive item-order comparator support, defaulting to existing behavior. +- [x] Thread paginator requests `created_at: -1` while `state.items` ordering remains chronological. +- [x] Channel paginator behavior remains unchanged. +- [x] Cursors/head-tail flags remain correct in tests. + +## Task 3: Add Regression Tests + +**File(s) to create/modify:** `test/unit/pagination/paginators/MessagePaginator.test.ts`, `test/unit/threads.test.ts` + +**Dependencies:** Task 2 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add tests proving request sort does not redefine item iteration order. +- Verify thread defaults (`sort`, page size) and query behavior. +- Run regression tests for other paginators extending `BasePaginator` to confirm compatibility. + +**Acceptance Criteria:** + +- [x] Unit tests fail before implementation and pass after. +- [x] New assertions cover both request call params and returned item ordering. +- [x] Existing tests for other paginator subclasses pass without modifications in their implementations. + +## Task 4: Reflect Results in Ralph State + +**File(s) to create/modify:** `specs/message-paginator/state.json`, `specs/message-paginator/decisions.md`, `specs/message-paginator/plan.md` + +**Dependencies:** Task 3 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Update task statuses and summary of outcomes. +- Record any follow-up risks. + +**Acceptance Criteria:** + +- [x] state.json mirrors real task status. +- [x] decisions.md has append-only entries for key choices. + +## Execution Order + +1. Phase 1 (serial): Task 1 +2. Phase 2 (serial, same-file dependency): Task 2 +3. Phase 3 (serial, same-file dependency): Task 3 +4. Phase 4 (serial): Task 4 + +## File Ownership Summary + +| Task | Creates/Modifies | +| ------ | --------------------------------------------------------------------------------------------------------------- | +| Task 1 | `specs/message-paginator/spec.md`, `specs/message-paginator/decisions.md` | +| Task 2 | `src/pagination/paginators/MessagePaginator.ts`, `src/thread.ts` | +| Task 3 | `test/unit/pagination/paginators/MessagePaginator.test.ts`, `test/unit/threads.test.ts` | +| Task 4 | `specs/message-paginator/state.json`, `specs/message-paginator/decisions.md`, `specs/message-paginator/plan.md` | diff --git a/specs/message-paginator/spec.md b/specs/message-paginator/spec.md new file mode 100644 index 0000000000..26a1740884 --- /dev/null +++ b/specs/message-paginator/spec.md @@ -0,0 +1,29 @@ +# Message Paginator: Request Sort vs Internal Order + +## Goal + +Decouple backend request sort parameters from in-memory message ordering in `MessagePaginator` using a generic `BasePaginator` ordering extension, so consumers can request newest-first pages while still iterating messages oldest-to-newest. + +## Success Criteria + +- `MessagePaginator` can call backend APIs (`channel.query` / `channel.getReplies`) with configurable `sort` values. +- `BasePaginator` supports a generic item-order comparator that controls interval/item ordering. +- For paginators that do not provide item-order comparator, behavior remains unchanged (item order follows existing request/comparator semantics). +- `MessagePaginator` exposes explicit request sorting configuration separate from item ordering semantics. +- `MessagePaginator.state.items` remain in stable chronological order (oldest -> newest) regardless of request sort. +- Cursor/head-tail semantics remain correct for message pagination after the decoupling. +- `Thread` can request replies with `created_at: -1` without changing paginator output order. +- Unit tests cover the decoupled behavior. + +## Constraints + +- Keep backward compatibility for existing channel-level pagination behavior. +- Preserve existing public exports and avoid breaking API removals. +- Do not rely on `ChannelState.messageSets` for paginator ordering behavior. +- `BasePaginator` extension must be additive and backward compatible. + +## Non-Goals + +- Rewriting legacy `Thread.state.replies` pagination flow. +- Refactoring unrelated paginator types. +- UI-level rendering changes. diff --git a/specs/message-paginator/state.json b/specs/message-paginator/state.json new file mode 100644 index 0000000000..abfb1bda7c --- /dev/null +++ b/specs/message-paginator/state.json @@ -0,0 +1,14 @@ +{ + "active_task": null, + "tasks": { + "Task 1": "done", + "Task 2": "done", + "Task 3": "done", + "Task 4": "done" + }, + "flags": { + "blocked": false, + "needs-review": false + }, + "last_updated": "2026-03-05" +} diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 658a6b4a0f..52bdf89a76 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -302,6 +302,11 @@ export type PaginatorOptions = { initialOffset?: number; /** If item index is provided, this index ensures updates in a single place and all consumers have access to a single source of data. */ itemIndex?: ItemIndex; + /** + * Comparator defining in-memory item ordering for interval math and visible list rendering. + * Defaults to `sortComparator` to preserve existing paginator behavior. + */ + itemOrderComparator?: (a: T, b: T) => number; /** * Will prevent changing the index of existing items in state. * If true, an item that is already visible keeps its relative position in the current items array when updated. @@ -320,6 +325,7 @@ type OptionalPaginatorConfigFields = | 'initialCursor' | 'initialOffset' | 'itemIndex' + | 'itemOrderComparator' | 'throwErrors'; export type BasePaginatorConfig = Pick< @@ -537,6 +543,10 @@ export abstract class BasePaginator { return this.boostComparator; } + protected get itemOrderComparator() { + return this.config.itemOrderComparator ?? this.sortComparator; + } + get intervalComparator() { return (a: AnyInterval, b: AnyInterval) => { const aEdges = this.getIntervalPaginationEdges(a); @@ -657,7 +667,7 @@ export abstract class BasePaginator { const seqDistance = (boostB.seq ?? 0) - (boostA.seq ?? 0); if (seqDistance !== 0) return seqDistance > 0 ? 1 : -1; } - return this.sortComparator(a, b); + return this.itemOrderComparator(a, b); }; /** @@ -719,7 +729,7 @@ export abstract class BasePaginator { } makeInterval({ page, isHead, isTail }: MakeIntervalParams): Interval { - const sorted = [...page].sort((a, b) => this.sortComparator(a, b)); + const sorted = [...page].sort((a, b) => this.itemOrderComparator(a, b)); return { id: this.generateIntervalId(page), // Default semantics: @@ -815,20 +825,20 @@ export abstract class BasePaginator { } protected compareIntervalHeadEdges(a: T, b: T): number { - const cmp = this.sortComparator(a, b); + const cmp = this.itemOrderComparator(a, b); return this.intervalSortDirection === 'asc' ? cmp : -cmp; } protected aIsMoreHeadwardThanB(a: T, b: T): boolean { return this.intervalItemIdsAreHeadFirst - ? this.sortComparator(a, b) === ComparisonResult.A_PRECEDES_B - : this.sortComparator(b, a) === ComparisonResult.A_PRECEDES_B; + ? this.itemOrderComparator(a, b) === ComparisonResult.A_PRECEDES_B + : this.itemOrderComparator(b, a) === ComparisonResult.A_PRECEDES_B; } protected aIsMoreTailwardThanB(a: T, b: T): boolean { return this.intervalItemIdsAreHeadFirst - ? this.sortComparator(b, a) === ComparisonResult.A_PRECEDES_B - : this.sortComparator(a, b) === ComparisonResult.A_PRECEDES_B; + ? this.itemOrderComparator(b, a) === ComparisonResult.A_PRECEDES_B + : this.itemOrderComparator(a, b) === ComparisonResult.A_PRECEDES_B; } protected getHeadIntervalFromSortedIntervals( @@ -874,8 +884,8 @@ export abstract class BasePaginator { const bBounds = this.getIntervalSortBounds(b); if (!aBounds || !bBounds) return false; return ( - this.sortComparator(aBounds.start, bBounds.end) <= 0 && - this.sortComparator(bBounds.start, aBounds.end) <= 0 + this.itemOrderComparator(aBounds.start, bBounds.end) <= 0 && + this.itemOrderComparator(bBounds.start, aBounds.end) <= 0 ); } @@ -904,8 +914,8 @@ export abstract class BasePaginator { // Strict overlap if: // a.first <= b.last && b.first <= a.last if ( - this.sortComparator(aBounds.start, bBounds.end) <= 0 && - this.sortComparator(bBounds.start, aBounds.end) <= 0 + this.itemOrderComparator(aBounds.start, bBounds.end) <= 0 && + this.itemOrderComparator(bBounds.start, aBounds.end) <= 0 ) return true; @@ -936,7 +946,10 @@ export abstract class BasePaginator { const sortBounds = this.getIntervalSortBounds(interval); if (!sortBounds) return false; const { start, end } = sortBounds; - if (this.sortComparator(start, item) <= 0 && this.sortComparator(item, end) <= 0) + if ( + this.itemOrderComparator(start, item) <= 0 && + this.itemOrderComparator(item, end) <= 0 + ) return true; const edges = this.getIntervalPaginationEdges(interval); @@ -973,7 +986,7 @@ export abstract class BasePaginator { itemIdentityEquals: (item1, item2) => this.getItemId(item1) === this.getItemId(item2), // inter-interval operation sorts using the base comparator - compare: this.sortComparator.bind(this), + compare: this.itemOrderComparator.bind(this), }); if (insertionIndex > -1) { merged.splice(insertionIndex, 0, item); @@ -1086,7 +1099,7 @@ export abstract class BasePaginator { itemIdentityEquals: (item1, item2) => this.getItemId(item1) === this.getItemId(item2), // items in intervals are not sorted by effectiveComparator - compare: this.sortComparator.bind(this), + compare: this.itemOrderComparator.bind(this), plateauScan: true, }); } diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index a3665f5e20..10f0b4a955 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -113,6 +113,19 @@ export type MessagePaginatorOptions = { id?: string; itemIndex?: ItemIndex; parentMessageId?: string; + /** + * Sort passed to backend message/replies query. + * Does not affect in-memory item ordering. + */ + requestSort?: MessagePaginatorSort; + /** + * @deprecated Use `requestSort` instead. + */ + sort?: MessagePaginatorSort; + /** + * In-memory ordering for items exposed by paginator state. + */ + itemOrder?: MessagePaginatorSort; paginatorOptions?: PaginatorOptions; /** * Controls whether `jumpToTheFirstUnreadMessage()` should prefer the `unreadStateSnapshot` @@ -142,8 +155,8 @@ export type UnreadSnapshotState = { }; /** - * MessagePaginator does not allow for sorting or filtering the items, because it is based on channe.query() and - * not client.search() calls. So the paginator just updates the cursor. + * MessagePaginator allows configuring backend request sort, while keeping internal item ordering stable. + * Filtering of ingested items is still limited to local predicates (`filterQueryResults`). */ export class MessagePaginator extends BasePaginator { private readonly _id: string; @@ -158,7 +171,8 @@ export class MessagePaginator extends BasePaginator; private clearMessageFocusSignalTimeoutId: ReturnType | null = null; private messageFocusSignalToken = 0; - protected _sort = DEFAULT_BACKEND_SORT; + protected _requestSort = DEFAULT_BACKEND_SORT; + protected _itemOrder: MessagePaginatorSort = DEFAULT_BACKEND_SORT; protected _nextQueryShape: MessageQueryShape | undefined; sortComparator: (a: LocalMessage, b: LocalMessage) => number; /** @@ -186,9 +200,14 @@ export class MessagePaginator extends BasePaginator item.id }), parentMessageId, + requestSort, + sort, + itemOrder, paginatorOptions, unreadReferencePolicy = 'snapshot', }: MessagePaginatorOptions) { + const resolvedRequestSort = requestSort ?? sort ?? DEFAULT_BACKEND_SORT; + const resolvedItemOrder = itemOrder ?? resolvedRequestSort; super({ hasPaginationQueryShapeChanged, initialCursor: ZERO_PAGE_CURSOR, @@ -200,7 +219,8 @@ export class MessagePaginator extends BasePaginator({ lastReadAt: null, @@ -212,7 +232,16 @@ export class MessagePaginator extends BasePaginator({ - sort: this._sort, + sort: this._requestSort, + resolvePathValue: resolveDotPathValue, + tiebreaker: (l, r) => { + const leftId = this.getItemId(l); + const rightId = this.getItemId(r); + 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); @@ -228,7 +257,15 @@ export class MessagePaginator extends BasePaginator items.filter(this.shouldIncludeMessageInInterval.bind(this)); + + private getCanonicalQueryItems(items: LocalMessage[]): LocalMessage[] { + return [...items].sort(this.itemOrderComparator); + } } const makeDeriveCursor = diff --git a/src/thread.ts b/src/thread.ts index 9f79009d65..54b6d25970 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -74,6 +74,7 @@ export type ThreadReadState = Record; const DEFAULT_PAGE_LIMIT = 50; const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }]; +const DEFAULT_ITEM_ORDER: { created_at: AscDesc } = { created_at: 1 }; // TODO: remove this once we move to API v2 export const THREAD_RESPONSE_RESERVED_KEYS: Record = { active_participant_count: true, @@ -236,7 +237,8 @@ export class Thread extends WithSubscriptions { this.messagePaginator = new MessagePaginator({ channel: this.channel, parentMessageId: this.id, - sort: DEFAULT_SORT, + requestSort: DEFAULT_SORT, + itemOrder: DEFAULT_ITEM_ORDER, paginatorOptions: { pageSize: DEFAULT_PAGE_LIMIT, }, diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 563552e8e8..fc54efbebe 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -83,6 +83,44 @@ describe('MessagePaginator', () => { expect(paginator.sort).toEqual({ created_at: 1 }); expect(paginator.config.doRequest).toBe(doRequest); }); + + it('respects provided sort option', () => { + const paginator = new MessagePaginator({ + channel, + sort: [{ created_at: -1 }], + }); + + expect(paginator.sort).toEqual([{ created_at: -1 }]); + expect(paginator.requestSort).toEqual([{ created_at: -1 }]); + expect(paginator.itemOrder).toEqual([{ created_at: -1 }]); + + const newer = createMessage({ id: 'b', created_at: '2021-01-01T00:00:00.000Z' }); + const older = createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }); + expect(paginator.sortComparator(older, newer)).toBeGreaterThan(0); + }); + + it('prefers requestSort over deprecated sort alias', () => { + const paginator = new MessagePaginator({ + channel, + requestSort: [{ created_at: 1 }], + sort: [{ created_at: -1 }], + }); + + expect(paginator.requestSort).toEqual([{ created_at: 1 }]); + expect(paginator.sort).toEqual([{ created_at: 1 }]); + expect(paginator.itemOrder).toEqual([{ created_at: 1 }]); + }); + + it('uses itemOrder when provided to decouple in-memory order from request sort', () => { + const paginator = new MessagePaginator({ + channel, + requestSort: [{ created_at: -1 }], + itemOrder: [{ created_at: 1 }], + }); + + expect(paginator.requestSort).toEqual([{ created_at: -1 }]); + expect(paginator.itemOrder).toEqual([{ created_at: 1 }]); + }); }); describe('query shape handling', () => { @@ -218,6 +256,41 @@ describe('MessagePaginator', () => { expect(result.items[0].created_at).toBeInstanceOf(Date); expect(result.items[1].created_at).toBeInstanceOf(Date); }); + + it('keeps items ordered chronologically when itemOrder is ascending and request sort is descending', async () => { + const messages = [ + { id: 'newest-reply', created_at: '2022-01-03T00:00:00.000Z' }, + { id: 'middle-reply', created_at: '2022-01-02T00:00:00.000Z' }, + { id: 'oldest-reply', created_at: '2022-01-01T00:00:00.000Z' }, + ]; + (channel.getReplies as unknown as ReturnType).mockResolvedValue({ + messages, + }); + const paginator = new MessagePaginator({ + channel, + itemIndex, + parentMessageId: 'parent-1', + requestSort: [{ created_at: -1 }], + itemOrder: [{ created_at: 1 }], + }); + // @ts-expect-error setting protected field for test coverage + paginator._nextQueryShape = { id_gt: 'from-cursor', limit: 30 }; + + const result = await paginator.query({}); + + expect(channel.getReplies).toHaveBeenCalledWith( + 'parent-1', + { id_gt: 'from-cursor', limit: 30 }, + [{ created_at: -1 }], + ); + expect(result.items.map((message) => message.id)).toEqual([ + 'oldest-reply', + 'middle-reply', + 'newest-reply', + ]); + expect(result.tailward).toBe('oldest-reply'); + expect(result.headward).toBe('newest-reply'); + }); }); describe('jumpToMessage()', () => { diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index f73140a2ed..13e39ee250 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -100,6 +100,8 @@ describe('Threads 2.0', () => { // @ts-expect-error `name` is a custom property expect(thread.channel.data?.name).to.equal(channelResponse.name); expect(thread.messagePaginator.sort).to.deep.equal([{ created_at: -1 }]); + expect(thread.messagePaginator.requestSort).to.deep.equal([{ created_at: -1 }]); + expect(thread.messagePaginator.itemOrder).to.deep.equal({ created_at: 1 }); expect(thread.messagePaginator.pageSize).to.equal(50); }); @@ -117,6 +119,8 @@ describe('Threads 2.0', () => { expect(state.pagination.nextCursor).to.be.null; expect(state.read).to.have.keys([TEST_USER_ID]); expect(thread.messagePaginator.sort).to.deep.equal([{ created_at: -1 }]); + expect(thread.messagePaginator.requestSort).to.deep.equal([{ created_at: -1 }]); + expect(thread.messagePaginator.itemOrder).to.deep.equal({ created_at: 1 }); expect(thread.messagePaginator.pageSize).to.equal(50); }); From 24441990b7588e03b73fa8e847c0caf8d18325ac Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Mar 2026 23:08:41 +0100 Subject: [PATCH 28/48] fix: make Channel's MessagePaginator ingest message on message.new --- src/channel.ts | 2 ++ test/unit/channel.test.js | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/channel.ts b/src/channel.ts index cb86550e01..9bc9483c02 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -2329,6 +2329,8 @@ export class Channel { channelState.addPinnedMessage(event.message); } + this.messagePaginator.ingestItem(formatMessage(event.message)); + // do not increase the unread count - the back-end does not increase the count neither in the following cases: // 1. the message is mine // 2. the message is a thread reply from any user diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index daa9f26cb4..30c635055f 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -335,6 +335,18 @@ describe('Channel _handleChannelEvent', function () { expect(channel.state.unreadCount).to.be.equal(100); }); + it('message.new ingests message into messagePaginator even for own messages', function () { + const message = generateMsg({ id: 'own-message-id', user }); + + channel._handleChannelEvent({ + type: 'message.new', + user, + message, + }); + + expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); + }); + it('message.new increment unreadCount properly', function () { channel.state.unreadCount = 20; channel._handleChannelEvent({ From a97105629cb276190f27d4b9701d8839238a1f0a Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 5 Mar 2026 23:32:40 +0100 Subject: [PATCH 29/48] fix: make Channel's MessagePaginator react to channel.truncated, user.messages.deleted and message.deleted events --- src/channel.ts | 25 ++-- src/pagination/paginators/MessagePaginator.ts | 52 +++++++- test/unit/channel.test.js | 113 ++++++++++++++++++ .../paginators/MessagePaginator.test.ts | 79 ++++++++++++ 4 files changed, 260 insertions(+), 9 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 9bc9483c02..b2ef0e9107 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -2294,8 +2294,13 @@ export class Channel { case 'message.deleted': if (event.message) { this._extendEventWithOwnReactions(event); - if (event.hard_delete) channelState.removeMessage(event.message); - else channelState.addMessageSorted(event.message, false, false); + if (event.hard_delete) { + channelState.removeMessage(event.message); + this.messagePaginator.removeItem({ id: event.message.id }); + } else { + channelState.addMessageSorted(event.message, false, false); + this.messagePaginator.ingestItem(formatMessage(event.message)); + } channelState.removeQuotedMessageReferences(event.message); @@ -2306,11 +2311,15 @@ export class Channel { break; case 'user.messages.deleted': if (event.user) { - this.state.deleteUserMessages( - event.user, - !!event.hard_delete, - new Date(event.created_at ?? Date.now()), - ); + const deletedAt = new Date(event.created_at ?? Date.now()); + const hardDelete = !!event.hard_delete; + this.messagePaginator.applyMessageDeletionForUser({ + userId: event.user.id, + hardDelete, + deletedAt, + }); + + this.state.deleteUserMessages(event.user, hardDelete, deletedAt); } break; case 'message.new': @@ -2431,7 +2440,7 @@ export class Channel { } } - this.messagePaginator.clearUnreadSnapshot(); + this.messagePaginator.clearStateAndCache(); break; case 'member.added': diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 10f0b4a955..87055df480 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -26,7 +26,7 @@ import type { } from '../../types'; import type { Channel } from '../../channel'; import { StateStore } from '../../store'; -import { formatMessage, generateUUIDv4 } from '../../utils'; +import { formatMessage, generateUUIDv4, toDeletedMessage } from '../../utils'; import { makeComparator } from '../sortCompiler'; import type { FieldToDataResolver } from '../types.normalization'; import { resolveDotPathValue } from '../utility.normalization'; @@ -692,6 +692,56 @@ export class MessagePaginator extends BasePaginator { + this.resetState(); + this._itemIndex.clear(); + this.clearUnreadSnapshot(); + this.clearMessageFocusSignal(); + }; + + applyMessageDeletionForUser = ({ + userId, + hardDelete = false, + deletedAt, + }: { + userId: string; + hardDelete?: boolean; + deletedAt: Date; + }) => { + const loadedMessages = this.items ?? []; + + for (const message of loadedMessages) { + if (message.user?.id === userId) { + if (hardDelete) { + this.removeItem({ id: message.id }); + } else { + this.ingestItem( + toDeletedMessage({ + message, + hardDelete, + deletedAt, + }) as LocalMessage, + ); + } + continue; + } + + if ( + message.quoted_message?.user?.id === userId && + message.quoted_message.type !== 'deleted' + ) { + this.ingestItem({ + ...message, + quoted_message: toDeletedMessage({ + message: message.quoted_message, + hardDelete, + deletedAt, + }) as LocalMessage, + }); + } + } + }; + filterQueryResults = (items: LocalMessage[]) => items.filter(this.shouldIncludeMessageInInterval.bind(this)); diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 30c635055f..492df3bf5a 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -432,6 +432,12 @@ describe('Channel _handleChannelEvent', function () { }); it('message.truncate clears messagePaginator unread snapshot', function () { + const cachedMessage = generateMsg({ id: 'truncate-cached-message-id' }); + channel.messagePaginator.setItems({ + valueOrFactory: [cachedMessage], + isFirstPage: true, + isLastPage: true, + }); channel.messagePaginator.setUnreadSnapshot({ firstUnreadMessageId: 'm-1', lastReadAt: new Date('2021-01-01T00:00:00.000Z'), @@ -453,6 +459,8 @@ describe('Channel _handleChannelEvent', function () { lastReadMessageId: null, unreadCount: 0, }); + expect(channel.messagePaginator.items).toBeUndefined(); + expect(channel.messagePaginator.getItem(cachedMessage.id)).toBeUndefined(); }); it('message.truncate removes messages up to specified date', function () { @@ -540,6 +548,38 @@ describe('Channel _handleChannelEvent', function () { ).to.be.ok; }); + it('message.deleted hard delete removes message from messagePaginator', function () { + const message = generateMsg({ id: 'hard-delete-message-id', silent: true }); + channel.messagePaginator.ingestItem(message); + expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); + + channel._handleChannelEvent({ + type: 'message.deleted', + user: { id: 'id' }, + hard_delete: true, + message, + }); + + expect( + channel.messagePaginator.items?.find((m) => m.id === message.id), + ).toBeUndefined(); + }); + + it('message.deleted soft delete updates message in messagePaginator', function () { + const message = generateMsg({ id: 'soft-delete-message-id', text: 'before delete' }); + channel.messagePaginator.ingestItem(message); + + const deletedAt = new Date().toISOString(); + channel._handleChannelEvent({ + type: 'message.deleted', + user: { id: 'id' }, + message: { ...message, deleted_at: deletedAt }, + }); + + const itemFromPaginator = channel.messagePaginator.getItem(message.id); + expect(itemFromPaginator?.deleted_at?.toISOString()).to.equal(deletedAt); + }); + describe('user.messages.deleted', () => { const bannedUser = { id: 'banned-user' }; const otherUser = { id: 'other-user' }; @@ -718,6 +758,79 @@ describe('Channel _handleChannelEvent', function () { channel.state.pinnedMessages.forEach(check); Object.values(channel.state.threads).forEach((replies) => replies.forEach(check)); }); + + it('updates messagePaginator items on soft delete', () => { + const deletedAt = new Date('2025-02-01T14:01:30.000Z'); + const bannedMessage = generateMsg({ id: 'mp-soft-banned', user: bannedUser }); + const quoteCarrier = generateMsg({ + id: 'mp-soft-quote-carrier', + quoted_message: bannedMessage, + quoted_message_id: bannedMessage.id, + user: otherUser, + }); + channel.messagePaginator.setItems({ + valueOrFactory: [bannedMessage, quoteCarrier], + isFirstPage: true, + isLastPage: true, + }); + + channel._handleChannelEvent({ + type: 'user.messages.deleted', + cid: channel.cid, + channel_type: channel.type, + channel_id: channel.id, + user: bannedUser, + soft_delete: true, + created_at: deletedAt.toISOString(), + }); + + const deletedFromPaginator = channel.messagePaginator.getItem(bannedMessage.id); + expect(deletedFromPaginator?.type).to.equal('deleted'); + expect(deletedFromPaginator?.deleted_at?.toISOString()).to.equal( + deletedAt.toISOString(), + ); + + const quoteCarrierFromPaginator = channel.messagePaginator.getItem(quoteCarrier.id); + expect(quoteCarrierFromPaginator?.quoted_message?.type).to.equal('deleted'); + expect( + quoteCarrierFromPaginator?.quoted_message?.deleted_at?.toISOString(), + ).to.equal(deletedAt.toISOString()); + }); + + it('updates messagePaginator items on hard delete', () => { + const deletedAt = new Date('2025-02-01T14:01:30.000Z'); + const bannedMessage = generateMsg({ id: 'mp-hard-banned', user: bannedUser }); + const quoteCarrier = generateMsg({ + id: 'mp-hard-quote-carrier', + quoted_message: bannedMessage, + quoted_message_id: bannedMessage.id, + user: otherUser, + }); + channel.messagePaginator.setItems({ + valueOrFactory: [bannedMessage, quoteCarrier], + isFirstPage: true, + isLastPage: true, + }); + + channel._handleChannelEvent({ + type: 'user.messages.deleted', + cid: channel.cid, + channel_type: channel.type, + channel_id: channel.id, + user: bannedUser, + hard_delete: true, + created_at: deletedAt.toISOString(), + }); + + expect( + channel.messagePaginator.items?.find((m) => m.id === bannedMessage.id), + ).toBeUndefined(); + const quoteCarrierFromPaginator = channel.messagePaginator.getItem(quoteCarrier.id); + expect(quoteCarrierFromPaginator?.quoted_message?.type).to.equal('deleted'); + expect( + quoteCarrierFromPaginator?.quoted_message?.deleted_at?.toISOString(), + ).to.equal(deletedAt.toISOString()); + }); }); describe('notification.mark_unread', () => { diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index fc54efbebe..a79b96de96 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -579,6 +579,85 @@ describe('MessagePaginator', () => { }); }); + describe('applyMessageDeletionForUser()', () => { + it('soft deletes user messages and quoted messages in paginator items', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + const deletedAt = new Date('2025-02-01T14:01:30.000Z'); + + const bannedUser = { id: 'banned-user' }; + const otherUser = { id: 'other-user' }; + const bannedMessage = createMessage({ id: 'banned-message', user: bannedUser }); + const quoteCarrier = createMessage({ + id: 'quote-carrier', + quoted_message: bannedMessage, + quoted_message_id: bannedMessage.id, + user: otherUser, + }); + + paginator.setItems({ + valueOrFactory: [bannedMessage, quoteCarrier], + isFirstPage: true, + isLastPage: true, + }); + + paginator.applyMessageDeletionForUser({ + userId: bannedUser.id, + hardDelete: false, + deletedAt, + }); + + const deletedFromPaginator = paginator.getItem(bannedMessage.id); + expect(deletedFromPaginator?.type).toBe('deleted'); + expect(deletedFromPaginator?.deleted_at?.toISOString()).toBe( + deletedAt.toISOString(), + ); + + const quoteCarrierFromPaginator = paginator.getItem(quoteCarrier.id); + expect(quoteCarrierFromPaginator?.quoted_message?.type).toBe('deleted'); + expect(quoteCarrierFromPaginator?.quoted_message?.deleted_at?.toISOString()).toBe( + deletedAt.toISOString(), + ); + }); + + it('hard deletes user messages and marks quoted messages as deleted', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + const deletedAt = new Date('2025-02-01T14:01:30.000Z'); + + const bannedUser = { id: 'banned-user' }; + const otherUser = { id: 'other-user' }; + const bannedMessage = createMessage({ + id: 'banned-message-hard', + user: bannedUser, + }); + const quoteCarrier = createMessage({ + id: 'quote-carrier-hard', + quoted_message: bannedMessage, + quoted_message_id: bannedMessage.id, + user: otherUser, + }); + + paginator.setItems({ + valueOrFactory: [bannedMessage, quoteCarrier], + isFirstPage: true, + isLastPage: true, + }); + + paginator.applyMessageDeletionForUser({ + userId: bannedUser.id, + hardDelete: true, + deletedAt, + }); + + expect(paginator.items?.find((m) => m.id === bannedMessage.id)).toBeUndefined(); + + const quoteCarrierFromPaginator = paginator.getItem(quoteCarrier.id); + expect(quoteCarrierFromPaginator?.quoted_message?.type).toBe('deleted'); + expect(quoteCarrierFromPaginator?.quoted_message?.deleted_at?.toISOString()).toBe( + deletedAt.toISOString(), + ); + }); + }); + describe.todo('postQueryReconcile and deriveCursor for', () => {}); describe('linear pagination', () => { describe('updates the hasMoreTail flag only if the first message on page is the first message in interval', () => { From 08d207324cadefe91d7a0ea72416d9cc5a0babeb Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 6 Mar 2026 00:43:07 +0100 Subject: [PATCH 30/48] fix: reflect quoted message update among MessagePaginator items and reflect reaction events --- src/channel.ts | 23 +- src/pagination/paginators/MessagePaginator.ts | 20 ++ src/thread.ts | 15 ++ test/unit/channel.test.js | 224 ++++++++++++++++++ .../paginators/MessagePaginator.test.ts | 36 +++ test/unit/threads.test.ts | 207 +++++++++++++++- 6 files changed, 521 insertions(+), 4 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index b2ef0e9107..029d8f89cc 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -2294,13 +2294,15 @@ export class Channel { case 'message.deleted': if (event.message) { this._extendEventWithOwnReactions(event); + const formattedMessage = formatMessage(event.message); if (event.hard_delete) { channelState.removeMessage(event.message); this.messagePaginator.removeItem({ id: event.message.id }); } else { channelState.addMessageSorted(event.message, false, false); - this.messagePaginator.ingestItem(formatMessage(event.message)); + this.messagePaginator.ingestItem(formattedMessage); } + this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage); channelState.removeQuotedMessageReferences(event.message); @@ -2338,7 +2340,9 @@ export class Channel { channelState.addPinnedMessage(event.message); } - this.messagePaginator.ingestItem(formatMessage(event.message)); + if (!isThreadMessage) { + this.messagePaginator.ingestItem(formatMessage(event.message)); + } // do not increase the unread count - the back-end does not increase the count neither in the following cases: // 1. the message is mine @@ -2399,8 +2403,12 @@ export class Channel { case 'message.undeleted': if (event.message) { this._extendEventWithOwnReactions(event); + const formattedMessage = formatMessage(event.message); channelState.addMessageSorted(event.message, false, false); - this.messagePaginator.ingestItem(formatMessage(event.message)); + if (!event.message.parent_id) { + this.messagePaginator.ingestItem(formattedMessage); + this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage); + } channelState._updateQuotedMessageReferences({ message: event.message }); if (event.message.pinned) { channelState.addPinnedMessage(event.message); @@ -2553,12 +2561,18 @@ export class Channel { if (event.message && event.reaction) { const { message, reaction } = event; event.message = channelState.addReaction(reaction, message) as MessageResponse; + if (!event.message?.parent_id) { + this.messagePaginator.ingestItem(formatMessage(event.message)); + } } break; case 'reaction.deleted': if (event.message && event.reaction) { const { message, reaction } = event; event.message = channelState.removeReaction(reaction, message); + if (event.message && !event.message.parent_id) { + this.messagePaginator.ingestItem(formatMessage(event.message)); + } } break; case 'reaction.updated': @@ -2570,6 +2584,9 @@ export class Channel { message, true, ) as MessageResponse; + if (!event.message?.parent_id) { + this.messagePaginator.ingestItem(formatMessage(event.message)); + } } break; case 'channel.hidden': { diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 87055df480..12f3b4be5e 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -742,6 +742,26 @@ export class MessagePaginator extends BasePaginator { + const cachedMessages = this._itemIndex.values(); + + for (const cachedMessage of cachedMessages) { + if (cachedMessage.quoted_message_id !== message.id) continue; + + this.ingestItem({ + ...cachedMessage, + quoted_message: message, + }); + } + }; + filterQueryResults = (items: LocalMessage[]) => items.filter(this.shouldIncludeMessageInInterval.bind(this)); diff --git a/src/thread.ts b/src/thread.ts index 54b6d25970..d9ef2286c6 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -565,6 +565,7 @@ export class Thread extends WithSubscriptions { private subscribeMessageDeleted = () => this.client.on('message.deleted', (event) => { if (!event.message) return; + const formattedMessage = formatMessage(event.message); // Deleted message is a reply of this thread if (event.message.parent_id === this.id) { @@ -580,11 +581,14 @@ export class Thread extends WithSubscriptions { if (event.message.id === this.id) { this.updateParentMessageLocally({ message: event.message }); } + + this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage); }).unsubscribe; private subscribeMessageUpdated = () => { const eventTypes: EventTypes[] = [ 'message.updated', + 'message.undeleted', 'reaction.new', 'reaction.deleted', 'reaction.updated', @@ -595,6 +599,17 @@ export class Thread extends WithSubscriptions { this.client.on(eventType, (event) => { if (event.message) { this.updateParentMessageOrReplyLocally(event.message); + if ( + ['reaction.new', 'reaction.deleted', 'reaction.updated'].includes( + eventType, + ) && + event.message.parent_id === this.id + ) { + this.messagePaginator.ingestItem(formatMessage(event.message)); + } + this.messagePaginator.reflectQuotedMessageUpdate( + formatMessage(event.message), + ); } }).unsubscribe, ); diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 492df3bf5a..e1a54d2b1b 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -347,6 +347,22 @@ describe('Channel _handleChannelEvent', function () { expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); }); + it('message.new ignores thread replies in messagePaginator', function () { + const message = generateMsg({ + id: 'thread-reply-message-id', + parent_id: 'parent-message-id', + user: { id: 'another-user' }, + }); + + channel._handleChannelEvent({ + type: 'message.new', + user: message.user, + message, + }); + + expect(channel.messagePaginator.getItem(message.id)).to.be.undefined; + }); + it('message.new increment unreadCount properly', function () { channel.state.unreadCount = 20; channel._handleChannelEvent({ @@ -408,6 +424,105 @@ describe('Channel _handleChannelEvent', function () { expect(parentFromPaginator?.thread_participants).to.have.length(2); }); + it('message.updated ignores thread replies in messagePaginator', function () { + const parentMessage = generateMsg({ id: 'thread-parent-id' }); + const threadReply = generateMsg({ + id: 'thread-reply-id', + parent_id: parentMessage.id, + text: 'before update', + }); + + channel.messagePaginator.ingestItem(parentMessage); + channel._handleChannelEvent({ + type: 'message.updated', + message: { ...threadReply, text: 'after update' }, + }); + + expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; + }); + + it('message.updated syncs quoted_message references in messagePaginator', function () { + const quotedMessage = generateMsg({ + id: 'quoted-message-id', + text: 'before update', + }); + const quoteCarrier = generateMsg({ + id: 'quote-carrier-id', + quoted_message_id: quotedMessage.id, + quoted_message: quotedMessage, + }); + + channel.messagePaginator.setItems({ + valueOrFactory: [quotedMessage, quoteCarrier], + isFirstPage: true, + isLastPage: true, + }); + + channel._handleChannelEvent({ + type: 'message.updated', + message: { + ...quotedMessage, + text: 'after update', + }, + }); + + expect( + channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.text, + ).to.equal('after update'); + }); + + it('message.undeleted ignores thread replies in messagePaginator', function () { + const parentMessage = generateMsg({ id: 'thread-parent-id-2' }); + const threadReply = generateMsg({ + id: 'thread-reply-id-2', + parent_id: parentMessage.id, + text: 'undeleted reply', + }); + + channel.messagePaginator.ingestItem(parentMessage); + channel._handleChannelEvent({ + type: 'message.undeleted', + message: threadReply, + }); + + expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; + }); + + it('message.undeleted syncs quoted_message references in messagePaginator', function () { + const quotedMessage = generateMsg({ + id: 'quoted-message-id-undeleted', + type: 'deleted', + text: 'before undelete', + }); + const quoteCarrier = generateMsg({ + id: 'quote-carrier-id-undeleted', + quoted_message_id: quotedMessage.id, + quoted_message: quotedMessage, + }); + + channel.messagePaginator.setItems({ + valueOrFactory: [quotedMessage, quoteCarrier], + isFirstPage: true, + isLastPage: true, + }); + + channel._handleChannelEvent({ + type: 'message.undeleted', + message: { + ...quotedMessage, + type: 'regular', + text: 'after undelete', + }, + }); + + expect( + channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.text, + ).to.equal('after undelete'); + expect( + channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.type, + ).to.equal('regular'); + }); + it('does not override the delivery information in the read status', () => {}); it('message.truncate removes all messages if "truncated_at" is "now"', function () { @@ -580,6 +695,115 @@ describe('Channel _handleChannelEvent', function () { expect(itemFromPaginator?.deleted_at?.toISOString()).to.equal(deletedAt); }); + it('message.deleted syncs quoted_message references in messagePaginator', function () { + const quotedMessage = generateMsg({ + id: 'quoted-message-id-on-delete', + text: 'before delete', + }); + const quoteCarrier = generateMsg({ + id: 'quote-carrier-id-on-delete', + quoted_message_id: quotedMessage.id, + quoted_message: quotedMessage, + }); + + channel.messagePaginator.setItems({ + valueOrFactory: [quotedMessage, quoteCarrier], + isFirstPage: true, + isLastPage: true, + }); + + channel._handleChannelEvent({ + type: 'message.deleted', + user: { id: 'id' }, + message: { + ...quotedMessage, + type: 'deleted', + text: 'after delete', + deleted_at: new Date().toISOString(), + }, + }); + + expect( + channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.type, + ).to.equal('deleted'); + }); + + it('reaction.new ingests message into messagePaginator for non-thread messages', function () { + const message = generateMsg({ id: 'reaction-channel-message-id' }); + + channel._handleChannelEvent({ + type: 'reaction.new', + message, + reaction: { + type: 'love', + user_id: 'user-1', + message_id: message.id, + created_at: new Date().toISOString(), + }, + }); + + expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); + }); + + it('reaction.new ignores thread replies in messagePaginator', function () { + const message = generateMsg({ + id: 'reaction-thread-message-id', + parent_id: 'thread-parent-id', + }); + + channel._handleChannelEvent({ + type: 'reaction.new', + message, + reaction: { + type: 'love', + user_id: 'user-1', + message_id: message.id, + created_at: new Date().toISOString(), + }, + }); + + expect(channel.messagePaginator.getItem(message.id)).to.be.undefined; + }); + + ['reaction.deleted', 'reaction.updated'].forEach((eventType) => { + it(`${eventType} ingests message into messagePaginator for non-thread messages`, function () { + const message = generateMsg({ id: `${eventType}-channel-message-id` }); + + channel._handleChannelEvent({ + type: eventType, + message, + reaction: { + type: 'love', + user_id: 'user-1', + message_id: message.id, + created_at: new Date().toISOString(), + }, + }); + + expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); + }); + + it(`${eventType} ignores thread replies in messagePaginator`, function () { + const message = generateMsg({ + id: `${eventType}-thread-message-id`, + parent_id: 'thread-parent-id', + }); + + channel._handleChannelEvent({ + type: eventType, + message, + reaction: { + type: 'love', + user_id: 'user-1', + message_id: message.id, + created_at: new Date().toISOString(), + }, + }); + + expect(channel.messagePaginator.getItem(message.id)).to.be.undefined; + }); + }); + describe('user.messages.deleted', () => { const bannedUser = { id: 'banned-user' }; const otherUser = { id: 'other-user' }; diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index a79b96de96..b95f6e4b70 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -658,6 +658,42 @@ describe('MessagePaginator', () => { }); }); + describe('reflectQuotedMessageUpdate()', () => { + it('updates quoted_message for cached items that quote provided message', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + const quoted = createMessage({ + id: 'quoted-1', + text: 'before update', + }); + const quoteCarrier = createMessage({ + id: 'carrier-1', + quoted_message_id: quoted.id, + quoted_message: quoted, + }); + const nonCarrier = createMessage({ + id: 'other-1', + quoted_message_id: 'another-quoted-id', + }); + + paginator.setItems({ + valueOrFactory: [quoted, quoteCarrier, nonCarrier], + isFirstPage: true, + isLastPage: true, + }); + + const updatedQuoted = { + ...quoted, + text: 'after update', + }; + paginator.reflectQuotedMessageUpdate(updatedQuoted); + + expect(paginator.getItem(quoteCarrier.id)?.quoted_message?.text).toBe( + 'after update', + ); + expect(paginator.getItem(nonCarrier.id)?.quoted_message).toBeNull(); + }); + }); + describe.todo('postQueryReconcile and deriveCursor for', () => {}); describe('linear pagination', () => { describe('updates the hasMoreTail flag only if the first message on page is the first message in interval', () => { diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index 13e39ee250..47b6e5fc1d 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -2,7 +2,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 { generateUUIDv4 as uuidv4 } from '../../src/utils'; +import { formatMessage, generateUUIDv4 as uuidv4 } from '../../src/utils'; import sinon from 'sinon'; import { @@ -1269,6 +1269,43 @@ describe('Threads 2.0', () => { parentMessage.deleted_at, ); }); + + it('reflects quoted_message updates in messagePaginator cache', () => { + const thread = createTestThread(); + thread.registerSubscriptions(); + + const quotedMessage = generateMsg({ + id: uuidv4(), + text: 'before delete', + }) as MessageResponse; + const quoteCarrier = generateMsg({ + id: uuidv4(), + parent_id: thread.id, + quoted_message_id: quotedMessage.id, + quoted_message: quotedMessage, + }) as MessageResponse; + + thread.messagePaginator.setItems({ + valueOrFactory: [quoteCarrier].map(formatMessage), + isFirstPage: true, + isLastPage: true, + }); + + client.dispatchEvent({ + type: 'message.deleted', + message: { + ...quotedMessage, + type: 'deleted', + deleted_at: new Date().toISOString(), + }, + }); + + expect( + thread.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.type, + ).to.equal('deleted'); + + thread.unregisterSubscriptions(); + }); }); describe('Events: message.updated, reaction.new, reaction.deleted', () => { @@ -1298,6 +1335,174 @@ describe('Threads 2.0', () => { thread.unregisterSubscriptions(); }); }); + + it('ingests "reaction.new" message into thread messagePaginator when parent_id matches thread.id', () => { + const thread = createTestThread(); + thread.registerSubscriptions(); + const message = generateMsg({ + id: uuidv4(), + parent_id: thread.id, + }) as MessageResponse; + + client.dispatchEvent({ + type: 'reaction.new', + message, + reaction: { + type: 'love', + user_id: TEST_USER_ID, + message_id: message.id, + created_at: new Date().toISOString(), + }, + }); + + expect(thread.messagePaginator.getItem(message.id)?.id).to.equal(message.id); + + thread.unregisterSubscriptions(); + }); + + it('ignores "reaction.new" message in thread messagePaginator when parent_id does not match thread.id', () => { + const thread = createTestThread(); + thread.registerSubscriptions(); + const message = generateMsg({ + id: uuidv4(), + parent_id: uuidv4(), + }) as MessageResponse; + + client.dispatchEvent({ + type: 'reaction.new', + message, + reaction: { + type: 'love', + user_id: TEST_USER_ID, + message_id: message.id, + created_at: new Date().toISOString(), + }, + }); + + expect(thread.messagePaginator.getItem(message.id)).to.be.undefined; + + thread.unregisterSubscriptions(); + }); + + (['reaction.deleted', 'reaction.updated'] as const).forEach((eventType) => { + it(`ingests "${eventType}" message into thread messagePaginator when parent_id matches thread.id`, () => { + const thread = createTestThread(); + thread.registerSubscriptions(); + const message = generateMsg({ + id: uuidv4(), + parent_id: thread.id, + }) as MessageResponse; + + client.dispatchEvent({ + type: eventType, + message, + reaction: { + type: 'love', + user_id: TEST_USER_ID, + message_id: message.id, + created_at: new Date().toISOString(), + }, + }); + + expect(thread.messagePaginator.getItem(message.id)?.id).to.equal(message.id); + + thread.unregisterSubscriptions(); + }); + + it(`ignores "${eventType}" message in thread messagePaginator when parent_id does not match thread.id`, () => { + const thread = createTestThread(); + thread.registerSubscriptions(); + const message = generateMsg({ + id: uuidv4(), + parent_id: uuidv4(), + }) as MessageResponse; + + client.dispatchEvent({ + type: eventType, + message, + reaction: { + type: 'love', + user_id: TEST_USER_ID, + message_id: message.id, + created_at: new Date().toISOString(), + }, + }); + + expect(thread.messagePaginator.getItem(message.id)).to.be.undefined; + + thread.unregisterSubscriptions(); + }); + }); + + it('reflects quoted_message updates in messagePaginator on "message.updated"', () => { + const thread = createTestThread(); + thread.registerSubscriptions(); + + const quotedMessage = generateMsg({ + id: uuidv4(), + text: 'before update', + }) as MessageResponse; + const quoteCarrier = generateMsg({ + id: uuidv4(), + parent_id: thread.id, + quoted_message_id: quotedMessage.id, + quoted_message: quotedMessage, + }) as MessageResponse; + + thread.messagePaginator.setItems({ + valueOrFactory: [quoteCarrier].map(formatMessage), + isFirstPage: true, + isLastPage: true, + }); + + client.dispatchEvent({ + type: 'message.updated', + message: { ...quotedMessage, text: 'after update' }, + }); + + expect( + thread.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.text, + ).to.equal('after update'); + + thread.unregisterSubscriptions(); + }); + + it('reflects quoted_message updates in messagePaginator on "message.undeleted"', () => { + const thread = createTestThread(); + thread.registerSubscriptions(); + + const quotedMessage = generateMsg({ + id: uuidv4(), + text: 'before undelete', + type: 'deleted', + }) as MessageResponse; + const quoteCarrier = generateMsg({ + id: uuidv4(), + parent_id: thread.id, + quoted_message_id: quotedMessage.id, + quoted_message: quotedMessage, + }) as MessageResponse; + + thread.messagePaginator.setItems({ + valueOrFactory: [quoteCarrier].map(formatMessage), + isFirstPage: true, + isLastPage: true, + }); + + client.dispatchEvent({ + type: 'message.undeleted', + message: { ...quotedMessage, type: 'regular', text: 'after undelete' }, + }); + + expect( + thread.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.text, + ).to.equal('after undelete'); + expect( + thread.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.type, + ).to.equal('regular'); + + thread.unregisterSubscriptions(); + }); }); }); }); From 31eedab4702b10e2b593c41d7431c47748d1726e Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 6 Mar 2026 01:19:32 +0100 Subject: [PATCH 31/48] fix: emit new paginator state always when jumping to a message --- src/pagination/paginators/MessagePaginator.ts | 3 +- .../paginators/MessagePaginator.test.ts | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 12f3b4be5e..82b0a95d15 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -451,8 +451,8 @@ export class MessagePaginator extends BasePaginator { // jumping back to the head interval should restore its tailward cursor expect(paginator.cursor?.tailward).toBe('m8'); }); + + it('emits merged state when jump resolves inside the active interval', async () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + const existing = createMessage({ + cid: 'channel-id', + id: 'm-existing', + created_at: '2020-01-01T00:00:00.000Z', + }); + const target = createMessage({ + cid: 'channel-id', + id: 'm-target', + created_at: '2020-01-02T00:00:00.000Z', + }); + + const activeInterval = paginator.ingestPage({ + page: [existing], + isHead: true, + isTail: true, + setActive: true, + }); + + const partialNextSpy = vi.spyOn(paginator.state, 'partialNext'); + vi.spyOn(paginator, 'executeQuery').mockImplementation(async () => { + itemIndex.setOne(target); + if (activeInterval?.itemIds) { + activeInterval.itemIds = [existing.id, target.id]; + } + return { + stateCandidate: { + hasMoreHead: false, + hasMoreTail: false, + items: [existing, target], + isLoading: false, + }, + targetInterval: activeInterval ?? null, + }; + }); + + const ok = await paginator.jumpToMessage(target.id); + + expect(ok).toBe(true); + expect(partialNextSpy).toHaveBeenCalledWith( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ id: existing.id }), + expect.objectContaining({ id: target.id }), + ]), + }), + ); + expect(paginator.items?.map((m) => m.id)).toEqual([existing.id, target.id]); + }); }); describe.todo('jumpToTheLatestMessage', () => {}); From 02608e34921155421918f52fd761f36a38ff78fb Mon Sep 17 00:00:00 2001 From: martincupela Date: Fri, 3 Jul 2026 13:16:06 +0200 Subject: [PATCH 32/48] test/fix: green up PR #1674 merge (types, lint, 3467 tests pass) - own_capabilities (Option B): keep reactive getter/setter but return undefined until loaded, preserving #1732 unread on uninitialized channels; update PR's 'undefined ID no options' expectation. - UserGroupPaginator: stable query shape + apply forward cursor in query() so pages accumulate (new base resets list on query-shape change). - orchestrator test: expect item:undefined on notification.removed_from_channel (master #1788 evicts channel from activeChannels). - messageComposer config test: add master's trackUploadProgress + commands. Co-Authored-By: Claude Opus 4.8 --- .../decisions.md | 15 +++++--- specs/message-paginator-master-merge/plan.md | 36 ++++++++++++------- .../message-paginator-master-merge/state.json | 15 ++++---- src/channel_state.ts | 14 +++++--- src/pagination/UserGroupPaginator.ts | 19 +++++----- .../ChannelPaginatorsOrchestrator.test.ts | 8 +++-- .../MessageComposer/messageComposer.test.ts | 2 ++ test/unit/channel.test.js | 4 ++- 8 files changed, 70 insertions(+), 43 deletions(-) diff --git a/specs/message-paginator-master-merge/decisions.md b/specs/message-paginator-master-merge/decisions.md index 30d3911010..7ef8a0dd13 100644 --- a/specs/message-paginator-master-merge/decisions.md +++ b/specs/message-paginator-master-merge/decisions.md @@ -32,14 +32,19 @@ Integration branch `feat/message-paginator-master-merge` re-based onto `origin/r - test files: merged both sides; kept master's #1761 "does not change member_count" test; kept both channel.updated member_count tests; unioned imports; kept `mentions: []` in textComposer state expectations (master added `mentions`, merged runtime has it). **Hidden semantic conflicts fixed (types now pass):** + - `MessageComposerEffectHandlers.ts` — added `typing: {}` (merged `TextComposerState` requires it). - `src/pagination/UserGroupPaginator.ts` — **ported from old `BasePaginator` to new `paginators/BasePaginator`** (master's #1743 feature vs PR's paginator rewrite): new import path, ``, `getNextQueryShape` override, `headward`/`tailward` cursor model, `initialState` override `hasMoreHead:false`. Test file updated to new cursor API. **5 remaining test failures — need decisions / further work (types green, 3462/3469 pass):** -1–2. **channel #1732 (2 tests) — DECISION NEEDED.** Irreconcilable without a call: the PR's reactive `own_capabilities` store (channel_state.ts) makes fresh channels report `own_capabilities: []`, tripping the `read-events` guard in `_countMessageAsUnread`. Master's #1732 expects fresh (unloaded) channels to still count unread (own_capabilities `undefined` = unknown). But the PR's own tests (`undefined ID no options`, `keeps ... assignments in sync`) require fresh channels to be `[]`. Same input, opposite required outputs → one side's tests must change. **Option A** (keep PR: own_capabilities always `[]`) → update master's #1732 unread assertion. **Option B** (preserve master: `undefined` until loaded) → update the PR's 2 own_capabilities tests + verify no React #2909 dependency on always-`[]`. Recommend B (semantically, unloaded ≠ "no capabilities"), pending React-impact check. -3. **ChannelPaginatorsOrchestrator "removes the channel from all paginators"** — `removeItem` now called with `{ id, item: Channel }`; test expects `{ id, ... }` without the channel instance. Needs diagnosis (merged channel shape vs PR test expectation). -4. **messageComposer "should initialize with custom config"** — merged attachments config has one extra key vs expected. Needs diagnosis (master vs PR default config). -5. **UserGroupPaginator "paginates ... synthesized cursors"** — ported paginator does not accumulate items across pages (new base uses interval/itemIndex storage). Port likely needs `itemIndex` config or the test updated to the new accumulation model. +1–2. **channel #1732 (2 tests) — DECISION NEEDED.** Irreconcilable without a call: the PR's reactive `own_capabilities` store (channel_state.ts) makes fresh channels report `own_capabilities: []`, tripping the `read-events` guard in `_countMessageAsUnread`. Master's #1732 expects fresh (unloaded) channels to still count unread (own_capabilities `undefined` = unknown). But the PR's own tests (`undefined ID no options`, `keeps ... assignments in sync`) require fresh channels to be `[]`. Same input, opposite required outputs → one side's tests must change. **Option A** (keep PR: own_capabilities always `[]`) → update master's #1732 unread assertion. **Option B** (preserve master: `undefined` until loaded) → update the PR's 2 own_capabilities tests + verify no React #2909 dependency on always-`[]`. Recommend B (semantically, unloaded ≠ "no capabilities"), pending React-impact check. 3. **ChannelPaginatorsOrchestrator "removes the channel from all paginators"** — `removeItem` now called with `{ id, item: Channel }`; test expects `{ id, ... }` without the channel instance. Needs diagnosis (merged channel shape vs PR test expectation). 4. **messageComposer "should initialize with custom config"** — merged attachments config has one extra key vs expected. Needs diagnosis (master vs PR default config). 5. **UserGroupPaginator "paginates ... synthesized cursors"** — ported paginator does not accumulate items across pages (new base uses interval/itemIndex storage). Port likely needs `itemIndex` config or the test updated to the new accumulation model. + +### Resolution of the 5 failures — ALL FIXED (2026-07-03). Types + lint pass; 3467/3469 tests pass (0 failures). + +1–2. **channel #1732 — resolved via Option B (user decision: preserve master).** `channel_state.ts syncOwnCapabilitiesFromChannelData` now keeps the reactive getter/setter but returns `undefined` (not `[]`) until capabilities are actually provided/loaded. This preserves master's #1732 unread behavior AND the PR's "keeps assignments in sync" (setter still defined). Only the PR's `undefined ID no options` test value expectation updated (`[]` → `undefined`); `Object.keys` unchanged (getter still enumerable). **React follow-up:** verify #2909 does not depend on `own_capabilities` being an always-`[]` array on unloaded channels. +3. **orchestrator — resolved.** Master #1788 evicts the channel from `activeChannels` on `notification.removed_from_channel`, so the orchestrator legitimately removes by `id` with `item: undefined`. Updated the PR-era test to expect `{ id, item: undefined }` (channel.deleted / channel.hidden still pass with `item: ch`). +4. **messageComposer custom config — resolved.** Merged `DEFAULT_COMPOSER_CONFIG` gained master's `attachments.trackUploadProgress` (#1708) and a top-level `commands` section. Added both to the PR-era test's expected object (preserve master config). +5. **UserGroupPaginator accumulation — resolved (real port bug).** The new base resets its accumulated list when the query shape changes (`'auto'` policy). My initial port embedded the cursor in `getNextQueryShape`, so each page's shape differed → reset → items replaced. Fixed by keeping the query shape stable (`{ limit, team_id }`) and applying the forward cursor inside `query` from `this.cursor.tailward` (as the original did). Test updated to the new `tailward`/`headward` cursor API. -(Record each conflict resolution below as it is made, with file, chosen side, and why.) +**Status: JS merge complete.** `git merge --no-ff pr-1674` committed on `feat/message-paginator-master-merge` (checkpoint b58912f4, amended/extended with the fixes above). Next: `yarn build` (Task 5) for the React symlink. diff --git a/specs/message-paginator-master-merge/plan.md b/specs/message-paginator-master-merge/plan.md index 4c33ecede9..44a94aa5f4 100644 --- a/specs/message-paginator-master-merge/plan.md +++ b/specs/message-paginator-master-merge/plan.md @@ -18,12 +18,12 @@ Default is **merge PR #1674 into a branch cut from `origin/master`** (`git merge **Runtime (`src/`, 4):** -| File | Nature | -| ---- | ------ | -| `src/channel.ts` | Both sides changed heavily. PR: paginator wiring, minimal-init thread, message operations, custom mark-read. Master: 92 commits of fixes. **Highest-risk file.** | -| `src/client.ts` | PR adds `InstanceConfigurationService`, store wiring, `messageDeliveryReporter`. Master added client-level features (e.g. AppIdentifier user-agent #1789). Note prior attempt hit a **missing `StateStore` import** regression here — watch for it. | -| `src/messageComposer/middleware/textComposer/types.ts` | Small type-shape conflict. | -| `src/pagination/index.ts` | PR restructured `pagination/` (moved `BasePaginator`/`ReminderPaginator` into `pagination/paginators/`); master edited the old barrel. | +| File | Nature | +| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/channel.ts` | Both sides changed heavily. PR: paginator wiring, minimal-init thread, message operations, custom mark-read. Master: 92 commits of fixes. **Highest-risk file.** | +| `src/client.ts` | PR adds `InstanceConfigurationService`, store wiring, `messageDeliveryReporter`. Master added client-level features (e.g. AppIdentifier user-agent #1789). Note prior attempt hit a **missing `StateStore` import** regression here — watch for it. | +| `src/messageComposer/middleware/textComposer/types.ts` | Small type-shape conflict. | +| `src/pagination/index.ts` | PR restructured `pagination/` (moved `BasePaginator`/`ReminderPaginator` into `pagination/paginators/`); master edited the old barrel. | **Tests (3):** `test/unit/MessageComposer/messageComposer.test.ts`, `test/unit/MessageComposer/textComposer.test.ts`, `test/unit/channel.test.js`. @@ -44,10 +44,12 @@ Serialized by file ownership; conflicting files are resolved in dependency order **Owner:** unassigned **Scope:** + - From the worktree: `git merge --no-ff --no-commit pr-1674` (per D1; if D1 → rebase, `git rebase origin/master` on a branch cut from `pr-1674` instead). - Do **not** commit yet. Record the conflicted file list and any `add/add` surprises in `decisions.md`. **Acceptance Criteria:** + - [ ] Merge started; 7 conflicts present as measured. - [ ] Conflict inventory recorded in `decisions.md`. @@ -62,10 +64,12 @@ Serialized by file ownership; conflicting files are resolved in dependency order **Owner:** unassigned **Scope:** + - `pagination/index.ts`: keep the PR's re-export structure (`paginators/*`) as the source of truth; re-apply any master-added exports on top. Verify no dangling exports to the removed `pagination/BasePaginator.ts`/`ReminderPaginator.ts`. - `textComposer/types.ts`: union both type additions. **Acceptance Criteria:** + - [ ] Both files compile in isolation (`yarn types` after Task 4). - [ ] No exports reference deleted modules. @@ -80,10 +84,12 @@ Serialized by file ownership; conflicting files are resolved in dependency order **Owner:** unassigned **Scope:** + - `channel.ts`: base = PR version (it owns the paginator/thread/message-operations rewrite); then **re-apply each master fix** from the 92-commit range that landed in `channel.ts` (enumerate with `git log ef2169f..origin/master --oneline -- src/channel.ts`). See D2. - `client.ts`: union PR's service/store/reporter wiring with master's client additions. Explicitly verify the `StateStore` import exists (prior regression). **Acceptance Criteria:** + - [ ] `yarn types` passes. - [ ] Every master `channel.ts` fix in range is present or consciously superseded (logged in `decisions.md`). @@ -98,11 +104,13 @@ Serialized by file ownership; conflicting files are resolved in dependency order **Owner:** unassigned **Scope:** + - Merge test additions from both sides; where master changed an assertion the PR also changed, prefer the assertion matching the merged runtime behavior. - Commit the merge. - Run `yarn types` then `yarn test`. Fix fallout (paginator, orchestrator, event-pipeline, thread, message-operations, delivery, composer suites are the ones the PR expands). **Acceptance Criteria:** + - [ ] `yarn types` passes. - [ ] `yarn test` passes (or every residual failure is triaged in `decisions.md`). - [ ] Merge committed on `feat/message-paginator-master-merge`. @@ -118,10 +126,12 @@ Serialized by file ownership; conflicting files are resolved in dependency order **Owner:** unassigned **Scope:** + - `yarn build` → produce `dist/`. - Document the link method chosen in D3 (yarn link / portal / file: dependency) so the React worktree can consume this exact build. **Acceptance Criteria:** + - [ ] `dist/` built. - [ ] Link command reproducible and recorded. @@ -137,13 +147,13 @@ Phase 5: Task 5 (build + link) ──▶ unblocks React plan Task 8 ## File ownership summary -| Task | Creates/Modifies | -| ---- | ---------------- | -| 1 | git index; `state.json`, `decisions.md` | -| 2 | `src/pagination/index.ts`, `src/messageComposer/middleware/textComposer/types.ts` | -| 3 | `src/channel.ts`, `src/client.ts` | -| 4 | `test/unit/MessageComposer/messageComposer.test.ts`, `test/unit/MessageComposer/textComposer.test.ts`, `test/unit/channel.test.js` | -| 5 | build artifacts only | +| Task | Creates/Modifies | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------- | +| 1 | git index; `state.json`, `decisions.md` | +| 2 | `src/pagination/index.ts`, `src/messageComposer/middleware/textComposer/types.ts` | +| 3 | `src/channel.ts`, `src/client.ts` | +| 4 | `test/unit/MessageComposer/messageComposer.test.ts`, `test/unit/MessageComposer/textComposer.test.ts`, `test/unit/channel.test.js` | +| 5 | build artifacts only | ## Decisions — ALL RESOLVED 2026-07-03 (see decisions.md) diff --git a/specs/message-paginator-master-merge/state.json b/specs/message-paginator-master-merge/state.json index 1f4a3d6ce0..c5214c88b9 100644 --- a/specs/message-paginator-master-merge/state.json +++ b/specs/message-paginator-master-merge/state.json @@ -1,20 +1,21 @@ { "tasks": { - "task-1-perform-merge-capture-conflicts": "pending", - "task-2-resolve-pagination-index-and-textcomposer-types": "pending", - "task-3-resolve-channel-and-client": "pending", - "task-4-reconcile-tests-and-verify": "pending", - "task-5-build-and-expose-for-react-linking": "pending" + "task-1-perform-merge-capture-conflicts": "done", + "task-2-resolve-pagination-index-and-textcomposer-types": "done", + "task-3-resolve-channel-and-client": "done", + "task-4-reconcile-tests-and-verify": "done", + "task-5-build-and-expose-for-react-linking": "in-progress" }, "flags": { "blocked": false, "blocked_reason": "", "needs-review": false, "decisions_resolved": "D1=merge, D2=PR-base+reapply-master (channel.ts), D3=symlink", - "ready_to_execute": true + "ready_to_execute": true, + "tests": "3467 pass / 0 fail" }, "meta": { - "last_updated": "2026-07-02", + "last_updated": "2026-07-03", "active_task": null, "worktree": "/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/message-paginator-master-merge", "branch": "feat/message-paginator-master-merge", diff --git a/src/channel_state.ts b/src/channel_state.ts index 9715edae86..5845635463 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -266,14 +266,18 @@ export class ChannelState { return; } - let ownCapabilities = Array.isArray(data.own_capabilities) + let ownCapabilities: string[] | undefined = Array.isArray(data.own_capabilities) ? [...data.own_capabilities] : Array.isArray(fallbackData?.own_capabilities) ? [...fallbackData.own_capabilities] - : []; + : undefined; - this.ownCapabilitiesStore.next({ ownCapabilities }); + this.ownCapabilitiesStore.next({ ownCapabilities: ownCapabilities ?? [] }); + // Keep the reactive getter/setter so backward-compatible assignments still sync to + // the store, but return `undefined` until capabilities are actually known. Forcing + // `[]` on an unloaded channel would make read-events–gated logic (e.g. unread + // counting, regression #1732) treat "not yet loaded" as "explicitly no capabilities". Object.defineProperty(data, 'own_capabilities', { configurable: true, enumerable: true, @@ -281,8 +285,8 @@ export class ChannelState { set: (nextOwnCapabilities: string[] | undefined) => { ownCapabilities = Array.isArray(nextOwnCapabilities) ? [...nextOwnCapabilities] - : []; - this.ownCapabilitiesStore.next({ ownCapabilities }); + : undefined; + this.ownCapabilitiesStore.next({ ownCapabilities: ownCapabilities ?? [] }); }, }); } diff --git a/src/pagination/UserGroupPaginator.ts b/src/pagination/UserGroupPaginator.ts index 21cc0a11c4..8d603f438e 100644 --- a/src/pagination/UserGroupPaginator.ts +++ b/src/pagination/UserGroupPaginator.ts @@ -78,17 +78,13 @@ export class UserGroupPaginator extends BasePaginator< } satisfies UserGroupListCursor); }; - protected getNextQueryShape({ - direction, - }: Required< - Pick, 'direction'> - >): QueryUserGroupsOptions { - const cursor = decodeCursor(this.cursor?.[direction]); + // The query shape must stay stable across pages: the paginator resets its + // accumulated list when the query shape changes ('auto' reset policy), so the + // forward cursor is NOT part of the shape — it is applied per request in `query`. + protected getNextQueryShape(): QueryUserGroupsOptions { return { limit: this.pageSize, ...(this.teamId ? { team_id: this.teamId } : {}), - ...(cursor?.id_gt ? { id_gt: cursor.id_gt } : {}), - ...(cursor?.created_at_gt ? { created_at_gt: cursor.created_at_gt } : {}), }; } @@ -102,7 +98,12 @@ export class UserGroupPaginator extends BasePaginator< return { items: [] }; } - const options = queryShape ?? this.getNextQueryShape({ direction: 'tailward' }); + const cursor = decodeCursor(this.cursor?.tailward); + const options: QueryUserGroupsOptions = { + ...(queryShape ?? this.getNextQueryShape()), + ...(cursor?.id_gt ? { id_gt: cursor.id_gt } : {}), + ...(cursor?.created_at_gt ? { created_at_gt: cursor.created_at_gt } : {}), + }; const { user_groups: items } = await this.client.queryUserGroups(options); return { items, tailward: this.buildNextCursor(items) }; diff --git a/test/unit/ChannelPaginatorsOrchestrator.test.ts b/test/unit/ChannelPaginatorsOrchestrator.test.ts index dc50dc8cd9..7321518535 100644 --- a/test/unit/ChannelPaginatorsOrchestrator.test.ts +++ b/test/unit/ChannelPaginatorsOrchestrator.test.ts @@ -655,9 +655,11 @@ describe('ChannelPaginatorsOrchestrator', () => { client.dispatchEvent({ type: eventType, cid } as const); await vi.waitFor(() => { - // client.activeChannels contains the hidden channel, therefore the search is performed with item - expect(r1).toHaveBeenCalledWith({ id: ch.cid, item: ch }); - expect(r2).toHaveBeenCalledWith({ id: ch.cid, item: ch }); + // 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. + expect(r1).toHaveBeenCalledWith({ id: ch.cid, item: undefined }); + expect(r2).toHaveBeenCalledWith({ id: ch.cid, item: undefined }); }); }); diff --git a/test/unit/MessageComposer/messageComposer.test.ts b/test/unit/MessageComposer/messageComposer.test.ts index b10a6276bb..6d791322ca 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -203,7 +203,9 @@ describe('MessageComposer', () => { fileUploadFilter: DEFAULT_COMPOSER_CONFIG.attachments.fileUploadFilter, maxNumberOfFilesPerMessage: customConfig.attachments!.maxNumberOfFilesPerMessage, + trackUploadProgress: DEFAULT_COMPOSER_CONFIG.attachments.trackUploadProgress, }, + commands: DEFAULT_COMPOSER_CONFIG.commands, drafts: customConfig.drafts, linkPreviews: { debounceURLEnrichmentMs: customConfig.linkPreviews!.debounceURLEnrichmentMs, diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 86ceee3fcf..62b5babf2b 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -2022,7 +2022,9 @@ describe('Channels - Constructor', function () { it('undefined ID no options', function () { const channel = client.channel('messaging', undefined); expect(channel.id).to.eql(undefined); - expect(channel.data.own_capabilities).to.eql([]); + // own_capabilities stays undefined ("not yet loaded") until the channel is + // hydrated; the reactive getter is still defined (hence enumerable). + expect(channel.data.own_capabilities).to.be.undefined; expect(Object.keys(channel.data)).to.eql(['own_capabilities']); }); From 19489c03099351569a29dcd2740b7516915ffdb3 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 8 Jul 2026 10:37:59 +0200 Subject: [PATCH 33/48] feat: add UserGroupPaginator.ts --- src/pagination/index.ts | 1 - src/pagination/{ => paginators}/UserGroupPaginator.ts | 8 ++++---- src/pagination/paginators/index.ts | 1 + 3 files changed, 5 insertions(+), 5 deletions(-) rename src/pagination/{ => paginators}/UserGroupPaginator.ts (94%) diff --git a/src/pagination/index.ts b/src/pagination/index.ts index 75f7e0855b..2b0bd0d523 100644 --- a/src/pagination/index.ts +++ b/src/pagination/index.ts @@ -1,3 +1,2 @@ export * from './paginators'; export * from './FilterBuilder'; -export * from './UserGroupPaginator'; diff --git a/src/pagination/UserGroupPaginator.ts b/src/pagination/paginators/UserGroupPaginator.ts similarity index 94% rename from src/pagination/UserGroupPaginator.ts rename to src/pagination/paginators/UserGroupPaginator.ts index 8d603f438e..5ee5ba0d15 100644 --- a/src/pagination/UserGroupPaginator.ts +++ b/src/pagination/paginators/UserGroupPaginator.ts @@ -1,12 +1,12 @@ -import { BasePaginator, ZERO_PAGE_CURSOR } from './paginators/BasePaginator'; +import { BasePaginator, ZERO_PAGE_CURSOR } from './BasePaginator'; import type { PaginationQueryParams, PaginationQueryReturnValue, PaginatorOptions, PaginatorState, -} from './paginators/BasePaginator'; -import type { QueryUserGroupsOptions, UserGroupResponse } from '../types'; -import type { StreamChat } from '../client'; +} from './BasePaginator'; +import type { QueryUserGroupsOptions, UserGroupResponse } from '../../types'; +import type { StreamChat } from '../../client'; type UserGroupListCursor = { created_at_gt: string; diff --git a/src/pagination/paginators/index.ts b/src/pagination/paginators/index.ts index 03cd6bae39..8a6b8ff394 100644 --- a/src/pagination/paginators/index.ts +++ b/src/pagination/paginators/index.ts @@ -3,3 +3,4 @@ export * from './ChannelPaginator'; export * from './MessagePaginator'; export * from './MessageReplyPaginator'; export * from './ReminderPaginator'; +export * from './UserGroupPaginator'; From 70d34ab703bc62f196cd8a3b4f5df43c793a83de Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 10 Jul 2026 01:35:53 +0200 Subject: [PATCH 34/48] fix: pagination cursors for offline support --- src/pagination/cursorDerivation/linearPaginationFlags.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pagination/cursorDerivation/linearPaginationFlags.ts b/src/pagination/cursorDerivation/linearPaginationFlags.ts index 2f2c337b15..0e7ed202e2 100644 --- a/src/pagination/cursorDerivation/linearPaginationFlags.ts +++ b/src/pagination/cursorDerivation/linearPaginationFlags.ts @@ -67,7 +67,10 @@ export const deriveLinearPaginationFlags = < typeof queriedMessagesTowardsTail !== 'undefined' || containsUnrecognizedOptionsOnly ) { - hasMoreTail = !hasMoreTail ? false : hasMore; + // Without the isFirstPage branch, a partial first page (e.g. an + // offline prehydrate returning fewer than `requestedPageSize`) latches `hasMoreTail` + // to `false` and a subsequent full network first page can never restore it. + hasMoreTail = isFirstPage ? hasMore : !hasMoreTail ? false : hasMore; } if (typeof queriedMessagesTowardsHead !== 'undefined') { hasMoreHead = !hasMoreHead || isFirstPage ? false : hasMore; From 9f4d3f195a3c77347b2f180db403cb78f08fd7d8 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 13 Jul 2026 10:37:19 +0200 Subject: [PATCH 35/48] feat(ChannelPaginatorOrchestrator): add ingestChannel method --- src/ChannelPaginatorsOrchestrator.ts | 31 ++++++++++++++++ .../ChannelPaginatorsOrchestrator.test.ts | 36 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/ChannelPaginatorsOrchestrator.ts b/src/ChannelPaginatorsOrchestrator.ts index 33458fcb20..ad03a2556f 100644 --- a/src/ChannelPaginatorsOrchestrator.ts +++ b/src/ChannelPaginatorsOrchestrator.ts @@ -373,6 +373,37 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { return new Set(this.ownershipResolver?.({ channel, matchingPaginators }) ?? []); } + /** + * Route a channel into the paginator(s) that should own it, and remove it from any list it + * no longer belongs to. Ownership is resolved exactly as for live WS updates — the channel is + * ingested into every paginator whose filter it matches (or, when an ownership resolver picks + * winners among several matches, only into the owner(s)). + * + * Use this to surface a channel the app just opened — a search result, a freshly created DM — + * in the list(s) without a full re-query. `ingestItem` dedupes by cid and inserts in sort + * order, so calling this repeatedly is safe. + * + * A channel that matches no paginator is not added anywhere. To have such channels still + * appear, register a catch-all paginator (empty filter) with the lowest ownership priority as + * a local fallback list. + */ + ingestChannel(channel: Channel) { + const matchingPaginators = this.paginators.filter((p) => p.matchesFilter(channel)); + const matchingIds = new Set(matchingPaginators.map((p) => p.id)); + const ownerIds = this.resolveOwnership(channel, matchingPaginators); + + this.paginators.forEach((paginator) => { + const isMatch = matchingIds.has(paginator.id); + const isOwner = ownerIds.size === 0 || ownerIds.has(paginator.id); + if (isMatch && isOwner) { + paginator.ingestItem(channel); + } else { + // Not a match, or matched but not the selected owner — enforce exclusivity. + paginator.removeItem({ item: channel }); + } + }); + } + /** * Filter a page of query results for a specific paginator according to ownership rules. * If no owners are specified by the resolver, all matching paginators keep the item. diff --git a/test/unit/ChannelPaginatorsOrchestrator.test.ts b/test/unit/ChannelPaginatorsOrchestrator.test.ts index 7321518535..61c4da07b4 100644 --- a/test/unit/ChannelPaginatorsOrchestrator.test.ts +++ b/test/unit/ChannelPaginatorsOrchestrator.test.ts @@ -1007,4 +1007,40 @@ describe('ChannelPaginatorsOrchestrator', () => { expect(partialNextSpy).not.toHaveBeenCalled(); }); }); + + describe('ingestChannel', () => { + it('ingests a channel into every paginator whose filter it matches', () => { + 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({ + client, + paginators: [p1, p2], + }); + + orchestrator.ingestChannel(ch); + + expect(p1.items?.map((c) => c.cid)).toEqual(['messaging:200']); + expect(p2.items?.map((c) => c.cid)).toEqual(['messaging:200']); + }); + + 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({ + client, + paginators: [primary, fallback], + ownershipResolver: createPriorityOwnershipResolver([primary.id, fallback.id]), + }); + + orchestrator.ingestChannel(makeChannel('messaging:201')); + orchestrator.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. + expect(primary.items?.map((c) => c.cid)).toEqual(['messaging:201']); + // A channel that matches only the catch-all lands in the fallback. + expect(fallback.items?.map((c) => c.cid)).toEqual(['team:202']); + }); + }); }); From 634a90ce4464fd27af52450b8be027b601b18d65 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 13 Jul 2026 10:38:08 +0200 Subject: [PATCH 36/48] fix(filterCompiler): prevent short-circuiting logical operators --- src/pagination/filterCompiler.ts | 10 +++++--- test/unit/pagination/filterCompiler.test.ts | 27 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/pagination/filterCompiler.ts b/src/pagination/filterCompiler.ts index a60f745167..fab5ad5f4f 100644 --- a/src/pagination/filterCompiler.ts +++ b/src/pagination/filterCompiler.ts @@ -38,11 +38,15 @@ export function itemMatchesFilter( const matches = (filterNode: QueryFilters): boolean => { if (!filterNode || typeof filterNode !== 'object') return true; - if (filterNode.$and) return filterNode.$and.every((n) => matches(n)); - if (filterNode.$or) return filterNode.$or.some((n) => matches(n)); - if (filterNode.$nor) return !filterNode.$nor.some((n) => matches(n)); + // Logical operators are ANDed with each other AND with any sibling field conditions on the + // same node (MongoDB / Stream server semantics: `{ $or: [...], field: value }` means + // `(field == value) AND ($or ...)`). They must not short-circuit past the field loop below. + if (filterNode.$and && !filterNode.$and.every((n) => matches(n))) return false; + if (filterNode.$or && !filterNode.$or.some((n) => matches(n))) return false; + if (filterNode.$nor && filterNode.$nor.some((n) => matches(n))) return false; for (const [field, condition] of Object.entries(filterNode)) { + if (field === '$and' || field === '$or' || field === '$nor') continue; const itemPropertyValue = resolveOnce(field); if ( diff --git a/test/unit/pagination/filterCompiler.test.ts b/test/unit/pagination/filterCompiler.test.ts index 38f96b6e11..3cb39e12f5 100644 --- a/test/unit/pagination/filterCompiler.test.ts +++ b/test/unit/pagination/filterCompiler.test.ts @@ -277,6 +277,33 @@ describe('itemMatchesFilter', () => { ).toBeFalsy(); }); + it('ANDs a logical operator with sibling field conditions on the same node', () => { + // `{ $or: [...], custom4: }` means `($or ...) AND custom4 == ` — the sibling + // field must not be ignored just because a `$or` is present on the same node. + const orMatchingItem: TestChannel = { + custom1: ['x', 'b', 'y'], + custom2: '15', + custom3: 9, + custom4: false, + }; + // $or matches (2nd branch), and custom4 === false → whole filter matches. + expect( + itemMatchesFilter( + orMatchingItem, + { ...filter, custom4: false }, + options, + ), + ).toBeTruthy(); + // $or still matches, but the sibling custom4 === true fails → whole filter must NOT match. + expect( + itemMatchesFilter( + orMatchingItem, + { ...filter, custom4: true }, + options, + ), + ).toBeFalsy(); + }); + it('determines that data match filter by property dot path', () => { const item: TestChannel = { data: { From 3f12e0c3d5388b2f514d6f4ef47ddf5376d378e9 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 13 Jul 2026 10:38:32 +0200 Subject: [PATCH 37/48] feat(ChannelPaginator): add mutedFilterResolver --- src/pagination/paginators/ChannelPaginator.ts | 10 +++++ .../paginators/ChannelPaginator.test.ts | 38 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/pagination/paginators/ChannelPaginator.ts b/src/pagination/paginators/ChannelPaginator.ts index 49f0a32f17..603ffdf23c 100644 --- a/src/pagination/paginators/ChannelPaginator.ts +++ b/src/pagination/paginators/ChannelPaginator.ts @@ -152,6 +152,15 @@ const pinnedFilterResolver: FieldToDataResolver = { resolve: (channel) => !!channel.state.membership.pinned_at, }; +const mutedFilterResolver: FieldToDataResolver = { + matchesField: (field) => field === 'muted', + // Mute state lives on the client (client.mutedChannels), not on channel.data — resolve it via + // the client so `{ muted: true/false }` matches client-side, rather than letting the generic + // data resolver read a non-existent `channel.data.muted` (which would resolve to undefined and + // never equal a boolean filter value). + resolve: (channel) => channel.getClient()._muteStatus(channel.cid).muted, +}; + const dataFieldFilterResolver: FieldToDataResolver = { matchesField: () => true, resolve: (channel, path) => resolveDotPathValue(channel.data, path), @@ -226,6 +235,7 @@ export class ChannelPaginator extends BasePaginator hasUnreadFilterResolver, lastUpdatedFilterResolver, pinnedFilterResolver, + mutedFilterResolver, membersFilterResolver, memberUserNameFilterResolver, dataFieldFilterResolver, diff --git a/test/unit/pagination/paginators/ChannelPaginator.test.ts b/test/unit/pagination/paginators/ChannelPaginator.test.ts index bd5cd225e4..9776d4bfd8 100644 --- a/test/unit/pagination/paginators/ChannelPaginator.test.ts +++ b/test/unit/pagination/paginators/ChannelPaginator.test.ts @@ -61,7 +61,7 @@ describe('ChannelPaginator', () => { paginator.filterBuilder.buildFilters({ baseFilters: paginator.staticFilters }), ).toStrictEqual({}); // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toHaveLength(8); + expect(paginator._filterFieldToDataResolvers).toHaveLength(9); expect(paginator.config.doRequest).toBeUndefined(); }); @@ -125,7 +125,7 @@ describe('ChannelPaginator', () => { ...initialFilterBuilderContext, }); // @ts-expect-error accessing protected property - expect(paginator._filterFieldToDataResolvers).toHaveLength(8); + expect(paginator._filterFieldToDataResolvers).toHaveLength(9); expect(paginator.config.debounceMs).toStrictEqual(paginatorOptions.debounceMs); expect(paginator.config.doRequest).toStrictEqual(doRequest); expect(paginator.config.hasPaginationQueryShapeChanged).toStrictEqual( @@ -469,6 +469,40 @@ describe('ChannelPaginator', () => { expect(paginator.matchesFilter(channel1)).toBeFalsy(); }); + it('resolves "muted" field from client mute state', () => { + const paginator = new ChannelPaginator({ + client, + filters: { members: { $in: [user.id] }, muted: true }, + }); + + channel1.state.members = { + [user.id]: { user }, + ['other-member']: { user: { id: 'other-member' } }, + }; + + // Mute state lives on the client, not on the channel data. + client.mutedChannels = [{ channel: { cid: channel1.cid } } as never]; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + client.mutedChannels = []; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + + it('excludes muted channels when filtering "muted: false"', () => { + const paginator = new ChannelPaginator({ + client, + filters: { members: { $in: [user.id] }, muted: false }, + }); + + channel1.state.members = { [user.id]: { user } }; + + client.mutedChannels = []; + expect(paginator.matchesFilter(channel1)).toBeTruthy(); + + client.mutedChannels = [{ channel: { cid: channel1.cid } } as never]; + expect(paginator.matchesFilter(channel1)).toBeFalsy(); + }); + it('resolves "members" field', () => { const paginator = new ChannelPaginator({ client, From fc273e0fc692b46e23f6506d8fe13604bbaab828 Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 13 Jul 2026 10:39:13 +0200 Subject: [PATCH 38/48] feat(MessagePaginator): add scheduleMessageFocusSignalClear method --- src/pagination/paginators/MessagePaginator.ts | 34 +++++++++-- .../paginators/MessagePaginator.test.ts | 57 ++++++++++++++++++- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 82b0a95d15..843fdf8bfb 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -659,13 +659,39 @@ export class MessagePaginator extends BasePaginator { - this.clearMessageFocusSignal({ token: signal.token }); - }, ttlMs); - + // NOTE: the auto-dismissal countdown is intentionally NOT started here. A focused message may + // be emitted while its message list is not yet visible (e.g. the channel is covered by a thread + // panel when a "view in channel" jump resolves), so measuring the highlight lifetime from the + // moment the jump resolved would burn it while the message is still off-screen. The consumer + // starts the countdown via `scheduleMessageFocusSignalClear` once the message is actually + // viewed. return signal; }; + /** + * Starts the auto-dismissal countdown for the currently active focus signal. Call this once the + * focused message has been viewed (rendered and visible), so the highlight's lifetime is measured + * from when the user could actually see it rather than from when the jump resolved. No-op if the + * signal has already been cleared or superseded (guarded by `token`). + */ + scheduleMessageFocusSignalClear = ({ + token, + ttlMs, + }: { token?: number; ttlMs?: number } = {}) => { + const current = this.messageFocusSignal.getLatestValue().signal; + if (!current) return; + if (typeof token !== 'undefined' && current.token !== token) return; + + if (this.clearMessageFocusSignalTimeoutId) { + clearTimeout(this.clearMessageFocusSignalTimeoutId); + this.clearMessageFocusSignalTimeoutId = null; + } + + this.clearMessageFocusSignalTimeoutId = setTimeout(() => { + this.clearMessageFocusSignal({ token: current.token }); + }, ttlMs ?? current.ttlMs); + }; + clearMessageFocusSignal = ({ token }: { token?: number } = {}) => { const current = this.messageFocusSignal.getLatestValue().signal; if (!current) return; diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 7d9abc0991..55302b5e16 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -604,7 +604,7 @@ describe('MessagePaginator', () => { }); describe('messageFocusSignal', () => { - it('emits focus signal with unique token and clears stale timer safely', async () => { + it('emits focus signal with unique token and does not auto-dismiss', async () => { vi.useFakeTimers(); const paginator = new MessagePaginator({ channel, itemIndex }); @@ -624,10 +624,63 @@ describe('MessagePaginator', () => { second.token, ); - vi.advanceTimersByTime(3000); + // The dismissal countdown is not started on emit — it must be scheduled explicitly once the + // message is viewed, so a signal emitted while its list is hidden survives until then. + vi.advanceTimersByTime(10000); + expect(paginator.messageFocusSignal.getLatestValue().signal?.token).toBe( + second.token, + ); + vi.useRealTimers(); + }); + + it('starts the dismissal countdown from scheduleMessageFocusSignalClear (viewed moment)', async () => { + vi.useFakeTimers(); + const paginator = new MessagePaginator({ channel, itemIndex }); + + const signal = paginator.emitMessageFocusSignal({ + messageId: 'm1', + reason: 'jump-to-message', + ttlMs: 3000, + }); + + // Time can pass while the message is off-screen without dismissing it. + vi.advanceTimersByTime(5000); + expect(paginator.messageFocusSignal.getLatestValue().signal).not.toBe(null); + + // Once viewed, the TTL is measured from this moment. + paginator.scheduleMessageFocusSignalClear({ token: signal.token }); + vi.advanceTimersByTime(2999); + expect(paginator.messageFocusSignal.getLatestValue().signal?.token).toBe( + signal.token, + ); + vi.advanceTimersByTime(1); expect(paginator.messageFocusSignal.getLatestValue().signal).toBe(null); vi.useRealTimers(); }); + + it('scheduleMessageFocusSignalClear is a no-op for a stale token', async () => { + vi.useFakeTimers(); + const paginator = new MessagePaginator({ channel, itemIndex }); + + paginator.emitMessageFocusSignal({ + messageId: 'm1', + reason: 'jump-to-message', + ttlMs: 3000, + }); + const current = paginator.emitMessageFocusSignal({ + messageId: 'm2', + reason: 'jump-to-message', + ttlMs: 3000, + }); + + // A schedule request carrying a superseded token must not dismiss the current signal. + paginator.scheduleMessageFocusSignalClear({ token: current.token - 1 }); + vi.advanceTimersByTime(3000); + expect(paginator.messageFocusSignal.getLatestValue().signal?.token).toBe( + current.token, + ); + vi.useRealTimers(); + }); }); describe('applyMessageDeletionForUser()', () => { From 179944bf9d7914b64230600840151bc2388b71df Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:23:13 +0200 Subject: [PATCH 39/48] fix: message paginator fixes and extra features (#1802) ## CLA - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required). - [ ] Code changes are tested ## Description of the changes, What, Why and How? ## Changelog - --- .../decisions.md | 5 +- src/client.ts | 30 +- src/pagination/paginators/BasePaginator.ts | 100 +++- src/pagination/paginators/MessagePaginator.ts | 257 ++++++--- src/thread.ts | 24 +- test/unit/client.test.js | 52 ++ .../paginators/BasePaginator.test.ts | 27 + .../paginators/MessagePaginator.test.ts | 524 +++++++++++++++++- test/unit/threads.test.ts | 120 ++++ 9 files changed, 1051 insertions(+), 88 deletions(-) diff --git a/specs/message-paginator-master-merge/decisions.md b/specs/message-paginator-master-merge/decisions.md index 7ef8a0dd13..40b572ed5c 100644 --- a/specs/message-paginator-master-merge/decisions.md +++ b/specs/message-paginator-master-merge/decisions.md @@ -42,9 +42,6 @@ Integration branch `feat/message-paginator-master-merge` re-based onto `origin/r ### Resolution of the 5 failures — ALL FIXED (2026-07-03). Types + lint pass; 3467/3469 tests pass (0 failures). -1–2. **channel #1732 — resolved via Option B (user decision: preserve master).** `channel_state.ts syncOwnCapabilitiesFromChannelData` now keeps the reactive getter/setter but returns `undefined` (not `[]`) until capabilities are actually provided/loaded. This preserves master's #1732 unread behavior AND the PR's "keeps assignments in sync" (setter still defined). Only the PR's `undefined ID no options` test value expectation updated (`[]` → `undefined`); `Object.keys` unchanged (getter still enumerable). **React follow-up:** verify #2909 does not depend on `own_capabilities` being an always-`[]` array on unloaded channels. -3. **orchestrator — resolved.** Master #1788 evicts the channel from `activeChannels` on `notification.removed_from_channel`, so the orchestrator legitimately removes by `id` with `item: undefined`. Updated the PR-era test to expect `{ id, item: undefined }` (channel.deleted / channel.hidden still pass with `item: ch`). -4. **messageComposer custom config — resolved.** Merged `DEFAULT_COMPOSER_CONFIG` gained master's `attachments.trackUploadProgress` (#1708) and a top-level `commands` section. Added both to the PR-era test's expected object (preserve master config). -5. **UserGroupPaginator accumulation — resolved (real port bug).** The new base resets its accumulated list when the query shape changes (`'auto'` policy). My initial port embedded the cursor in `getNextQueryShape`, so each page's shape differed → reset → items replaced. Fixed by keeping the query shape stable (`{ limit, team_id }`) and applying the forward cursor inside `query` from `this.cursor.tailward` (as the original did). Test updated to the new `tailward`/`headward` cursor API. +1–2. **channel #1732 — resolved via Option B (user decision: preserve master).** `channel_state.ts syncOwnCapabilitiesFromChannelData` now keeps the reactive getter/setter but returns `undefined` (not `[]`) until capabilities are actually provided/loaded. This preserves master's #1732 unread behavior AND the PR's "keeps assignments in sync" (setter still defined). Only the PR's `undefined ID no options` test value expectation updated (`[]` → `undefined`); `Object.keys` unchanged (getter still enumerable). **React follow-up:** verify #2909 does not depend on `own_capabilities` being an always-`[]` array on unloaded channels. 3. **orchestrator — resolved.** Master #1788 evicts the channel from `activeChannels` on `notification.removed_from_channel`, so the orchestrator legitimately removes by `id` with `item: undefined`. Updated the PR-era test to expect `{ id, item: undefined }` (channel.deleted / channel.hidden still pass with `item: ch`). 4. **messageComposer custom config — resolved.** Merged `DEFAULT_COMPOSER_CONFIG` gained master's `attachments.trackUploadProgress` (#1708) and a top-level `commands` section. Added both to the PR-era test's expected object (preserve master config). 5. **UserGroupPaginator accumulation — resolved (real port bug).** The new base resets its accumulated list when the query shape changes (`'auto'` policy). My initial port embedded the cursor in `getNextQueryShape`, so each page's shape differed → reset → items replaced. Fixed by keeping the query shape stable (`{ limit, team_id }`) and applying the forward cursor inside `query` from `this.cursor.tailward` (as the original did). Test updated to the new `tailward`/`headward` cursor API. **Status: JS merge complete.** `git merge --no-ff pr-1674` committed on `feat/message-paginator-master-merge` (checkpoint b58912f4, amended/extended with the fixes above). Next: `yarn build` (Task 5) for the React symlink. diff --git a/src/client.ts b/src/client.ts index faca091ca9..20189ca74f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2321,16 +2321,26 @@ export class StreamChat { const requestedPageSize = queryChannelsOptions?.message_limit ?? DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE; - c.messagePaginator.postQueryReconcile({ - direction: 'tailward', - isFirstPage: true, - queryShape: { limit: requestedPageSize }, - requestedPageSize, - results: { - items: channelState.messages.map(formatMessage), - tailward: channelState.messages[0]?.id, - }, - }); + // Skip the re-seed when this (shared) channel's paginator is already loaded AND the user has + // jumped to an older window (active interval is not the head): a first-page re-seed forces the + // newest page to merge into that jumped interval across the gap (missing messages in the + // middle). A cold paginator, or one still at the head (offline/at-latest), re-seeds normally so + // cursors/hasMoreTail get (re)derived and pagination keeps working. + if ( + !c.messagePaginator.isInitialized || + c.messagePaginator.isActiveIntervalAtHead + ) { + c.messagePaginator.postQueryReconcile({ + direction: 'tailward', + isFirstPage: true, + queryShape: { limit: requestedPageSize }, + requestedPageSize, + results: { + items: channelState.messages.map(formatMessage), + tailward: channelState.messages[0]?.id, + }, + }); + } c.messageComposer.initStateFromChannelResponse(channelState); c.cooldownTimer.refresh(); channels.push(c); diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 52bdf89a76..2214bba28c 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -179,19 +179,31 @@ export type PaginationQueryShapeChangeIdentifier = ( export type PaginationQueryParams = { direction?: PaginationDirection; + /** + * Keep the currently loaded items (and cursor/flags) visible while a first-page query runs + * instead of blanking to the empty initial state. The freshly fetched page is merged into the + * active interval by `postQueryReconcile` (upserting changed items, appending new ones), so the + * list is refreshed in place with no loading-screen flash. Used by the non-destructive refresh. + */ + keepPreviousItems?: boolean; /** Data that define the query (filters, sort, ...) */ queryShape?: Q; /** Per-call override of the reset behavior. */ reset?: StateResetPolicy; /** Should retry the failed request given number of times. Default is 0. */ retryCount?: number; + /** + * Suppress `isLoading` transitions for this query (a silent, background refresh). When falsy + * (default) the usual loading state is surfaced so the UI can show a spinner. + */ + silent?: boolean; /** Determines, whether the page loaded with the query will be committed to the paginator state. Default: true. */ updateState?: boolean; }; export type PostQueryReconcileParams = Pick< PaginationQueryParams, - 'direction' | 'queryShape' | 'updateState' + 'direction' | 'queryShape' | 'updateState' | 'keepPreviousItems' > & { isFirstPage: boolean; requestedPageSize: number; @@ -773,6 +785,23 @@ export abstract class BasePaginator { return this._activeIntervalId === interval.id; } + /** + * Whether the currently active (viewed) interval is the anchored head. Used to decide if it is + * safe to re-seed an already-loaded paginator with a fresh newest page: only when at the head + * (the newest page overlaps it, so the re-seed reconciles + re-derives cursors in place). When the + * caller has jumped to an older window (active interval is NOT the head), a first-page re-seed + * would force-merge the newest page into that window across the gap - so the re-seed is skipped. + */ + get isActiveIntervalAtHead(): boolean { + const head = this.getHeadIntervalFromSortedIntervals(this.itemIntervals); + return ( + !!head && + !isLogicalInterval(head) && + !!(head as Interval).isHead && + this.isActiveInterval(head) + ); + } + setActiveInterval(interval: AnyInterval | undefined, opts?: { updateState?: boolean }) { this._activeIntervalId = interval?.id; @@ -1549,6 +1578,7 @@ export abstract class BasePaginator { const keepOrderInState = this.config.lockItemOrder && originalIndexInState >= 0; // 1. Remove the old snapshot from state & intervals. + const activeIntervalIdBeforeRemoval = this._activeIntervalId; let removedItemCoordinates: ItemCoordinates | undefined; if (previousCoords) { removedItemCoordinates = this.removeItemAtCoordinates(previousCoords); @@ -1653,6 +1683,21 @@ export abstract class BasePaginator { targetInterval = this.insertItemIdIntoInterval(targetInterval, ingestedItem); } + // If removing the previous snapshot emptied and dropped what was the active interval + // (e.g. the sole reply in a freshly-opened thread), and we are re-adding the item into + // that same interval, restore it as the active interval. Otherwise the re-added item is + // never emitted to state.items below — the emit is gated on _activeIntervalId — so it + // silently disappears from the visible list until the interval is reloaded. + const removedIntervalId = removedItemCoordinates?.interval?.interval.id; + if ( + !this._activeIntervalId && + !!activeIntervalIdBeforeRemoval && + activeIntervalIdBeforeRemoval === removedIntervalId && + targetInterval.id === removedIntervalId + ) { + this.setActiveInterval(targetInterval); + } + const addedNewInterval = !this._itemIntervals.has(targetInterval.id); this._itemIntervals.set(targetInterval.id, targetInterval); @@ -1820,6 +1865,17 @@ export abstract class BasePaginator { return newState; }); + + // A populated page means a first page is effectively "loaded". Record a query shape so the + // paginator counts as initialized and the next pagination continues from this page - otherwise + // an undefined `_lastQueryShape` makes the first query look like a shape change, triggering a + // first page reset that wipes the seeded items and re-fetches the first page before paginating. + if ( + typeof this._lastQueryShape === 'undefined' && + (this.state.getLatestValue().items?.length ?? 0) > 0 + ) { + this._lastQueryShape = this.getNextQueryShape({}); + } } // --------------------------------------------------------------------------- @@ -1938,16 +1994,18 @@ export abstract class BasePaginator { */ async executeQuery({ direction, + keepPreviousItems, queryShape: forcedQueryShape, reset, retryCount = 0, + silent, updateState = true, }: PaginationQueryParams = {}): Promise | void> { const queryShape = forcedQueryShape ?? this.getNextQueryShape({ direction }); if (!this.canExecuteQuery({ direction, reset })) return; const isFirstPage = this.isFirstPageQuery({ queryShape, reset }); - if (isFirstPage) { + if (isFirstPage && !keepPreviousItems) { const state = this.getStateBeforeFirstQuery(); let items: T[] | undefined = undefined; if (!this.isInitialized) { @@ -1960,7 +2018,9 @@ export abstract class BasePaginator { })) ?? state.items; } this.state.next({ ...state, items }); - } else { + } else if (!silent) { + // Non-first-page, or a keepPreviousItems refresh: surface loading without blanking the list. + // The freshly fetched page is merged into the active interval in postQueryReconcile. this.state.partialNext({ isLoading: true }); } @@ -1975,6 +2035,7 @@ export abstract class BasePaginator { return await this.postQueryReconcile({ direction, isFirstPage, + keepPreviousItems, queryShape, requestedPageSize: this.pageSize, results, @@ -1985,6 +2046,7 @@ export abstract class BasePaginator { async postQueryReconcile({ direction, isFirstPage, + keepPreviousItems, queryShape, requestedPageSize, results, @@ -2048,6 +2110,20 @@ export abstract class BasePaginator { if (interval && updateState) { this.setActiveInterval(interval, { updateState: false }); stateUpdate.items = this.intervalToItems(interval); + } else if ( + updateState && + this.usesItemIntervalStorage && + !items.length && + (keepPreviousItems || !isFirstPage) + ) { + // An empty page must NOT wipe the loaded items on a non-destructive refresh + // (keepPreviousItems) or an incremental query. `ingestPage` returns null for an empty page + // (leaving the active interval untouched), so `stateUpdate.items` still holds the empty + // `filteredItems` here and committing that would blank the list. This happens when a refresh + // finds nothing, or when a paginate hits the dataset edge. Preserve the current view instead + // (mirrors state only mode, whose concat of an empty page is a noop). A genuine reset + // (isFirstPage without keepPreviousItems) still blanks, so an emptied dataset shows empty. + stateUpdate.items = this.items; } /** @@ -2107,6 +2183,24 @@ export abstract class BasePaginator { interval.hasMoreTail = resolvedHasMoreTail; interval.isHead = resolvedHasMoreHead === false; interval.isTail = resolvedHasMoreTail === false; + } else if (!items.length && direction) { + // An empty directional response means the dataset edge was reached in `direction`, but + // `ingestPage` returns no interval for an empty page so the block above never runs. Flag the + // currently active interval as reaching that edge; otherwise its `isHead`/`isTail` stay stale + // (e.g. `jumpToTheLatestMessage` would never see the head as loaded, and a "scroll to latest" + // affordance would never clear). + const activeInterval = this._activeIntervalId + ? this._itemIntervals.get(this._activeIntervalId) + : undefined; + if (activeInterval && !isLogicalInterval(activeInterval)) { + if (direction === 'headward') { + activeInterval.isHead = true; + activeInterval.hasMoreHead = false; + } else if (direction === 'tailward') { + activeInterval.isTail = true; + activeInterval.hasMoreTail = false; + } + } } const state = this.getStateAfterQuery(stateUpdate, isFirstPage); diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 843fdf8bfb..05d732b7b6 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -367,25 +367,42 @@ export class MessagePaginator extends BasePaginator { + const ownUserId = this.channel.getClient().user?.id; + const ownReadState = ownUserId ? this.channel.state.read[ownUserId] : undefined; + if (!ownReadState) return; + this.setUnreadSnapshot({ + firstUnreadMessageId: null, + lastReadAt: ownReadState.last_read ?? null, + lastReadMessageId: ownReadState.last_read_message_id ?? null, + unreadCount: ownReadState.unread_messages ?? 0, + }); + }; + + /** + * Invokes the super.postQueryReconcile() and takes an unread state snapshot on the first page + * query. The snapshot has to be taken immediately after the query as the viewed channel is marked + * read immediately after opening it. The snapshot can be used to display unread UI indicators. */ async postQueryReconcile( params: PostQueryReconcileParams, ): Promise> { const result = await super.postQueryReconcile(params); - // Take unread state snapshot - const ownUserId = this.channel.getClient().user?.id; - const ownReadState = ownUserId ? this.channel.state.read[ownUserId] : undefined; - if (ownReadState && params.isFirstPage) { - this.setUnreadSnapshot({ - firstUnreadMessageId: null, - lastReadAt: ownReadState.last_read, - lastReadMessageId: ownReadState.last_read_message_id, - unreadCount: ownReadState.unread_messages, - }); + if (params.isFirstPage) { + this.seedUnreadSnapshot(); } return result; } @@ -411,6 +428,21 @@ export class MessagePaginator extends BasePaginator> | undefined; if (localMessage) { interval = this.locateIntervalForItem(localMessage); + if ( + interval && + !isLogicalInterval(interval) && + !interval.itemIds.includes(messageId) + ) { + // locateIntervalForItem can match by created_at RANGE and return an interval whose range + // spans the target while its loaded itemIds do NOT contain it (e.g. a neighbouring interval + // grew across the target's position without merging). Prefer the interval that actually + // holds the id so the jump activates the window the message really lives in - otherwise, if + // the range-matched interval happens to be active, the jump becomes a no-op. + interval = this.itemIntervals.find( + (candidate) => + !isLogicalInterval(candidate) && candidate.itemIds.includes(messageId), + ); + } } if (localMessage && interval && !isLogicalInterval(interval)) { @@ -465,14 +497,16 @@ export class MessagePaginator extends BasePaginator => { let latestMessageId: string | undefined; - const intervals = this.itemIntervals; - if (!(intervals[0] as Interval)?.isHead) { - // get the first page (in case the pagination has not started at the head) + if (!(this.itemIntervals[0] as Interval)?.isHead) { + // load the newest page in case pagination is currently on an older window (an empty/partial + // headward response marks the interval as the head) await this.executeQuery({ direction: 'headward', updateState: false }); } - const headInterval = intervals[0]; - if ((intervals[0] as Interval)?.isHead) { + // Re-read itemIntervals AFTER the query: the getter returns a fresh array each call, so a + // reference captured before executeQuery would be stale and miss the head we just loaded. + const headInterval = this.itemIntervals[0] as Interval | undefined; + if (headInterval?.isHead) { latestMessageId = headInterval.itemIds.slice(-1)[0]; } @@ -492,6 +526,84 @@ export class MessagePaginator extends BasePaginator { + if (!page?.length) return; + const headInterval = this.itemIntervals[0] as Interval | undefined; + if (!headInterval?.isHead) return; + // Only reconcile when the head is the interval currently in view. If the caller jumped to a + // separate (older) window, that window is active and the head is merely still-loaded underneath; + // reconciling would switch the view to the head and yank them to the newest. Skip to preserve + // their position (the newest page is picked up on scroll / a later load). + if (!this.isActiveInterval(headInterval)) return; + + const loadedIds = new Set(headInterval.itemIds); + const overlapsLoadedHead = page.some((item) => loadedIds.has(this.getItemId(item))); + + if (!overlapsLoadedHead) { + // Disjoint window: rebuild from the fetched page as a fresh newest slice. Clearing + // the stale intervals first ensures `ingestPage` builds a single head interval instead of + // merging across the gap so reanchoring the cursor to this page's oldest item keeps the next + // "load older" contiguous. + this.setIntervals([]); + this.setActiveInterval(undefined); + const resetInterval = this.ingestPage({ + page, + isHead: true, + // Disjoint means the previously loaded head was entirely OLDER than this newest window, so + // there is always older data to load (the gap + the prior history) - keep hasMoreTail true. + isTail: false, + setActive: false, + }); + if (!resetInterval) return; + this.setActiveInterval(resetInterval, { updateState: false }); + this.state.partialNext({ + items: this.intervalToItems(resetInterval), + cursor: this.getCursorFromInterval(resetInterval), + hasMoreHead: resetInterval.hasMoreHead, + hasMoreTail: resetInterval.hasMoreTail, + }); + return; + } + + // Overlapping window: merge in place, preserving the older boundary. + const interval = this.ingestPage({ page, isHead: true, setActive: false }); + if (!interval) return; + + this.setActiveInterval(interval, { updateState: false }); + this.state.partialNext({ + items: this.intervalToItems(interval), + // The newest slice is loaded (head anchored), so after merging the head window there is + // nothing newer to load. hasMoreTail / cursor are deliberately preserved (see above). + hasMoreHead: false, + }); + }; + /** * Jumps to the unread reference message. * @@ -500,12 +612,15 @@ export class MessagePaginator extends BasePaginator { const ownUserId = this.channel.getClient().user?.id; @@ -524,6 +639,8 @@ export class MessagePaginator extends BasePaginator { diff --git a/test/unit/client.test.js b/test/unit/client.test.js index e326245fe3..ed3307f30c 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -1017,6 +1017,58 @@ describe('StreamChat.queryChannels', async () => { postStub.restore(); }); + it('does not weld a jumped/older window into the newest page when re-hydrating a shared channel on re-query', async () => { + const client = await getClientWithUser(); + const newest = [ + generateMsg({ id: 'm5', created_at: '2023-11-14T12:00:05.000Z' }), + generateMsg({ id: 'm6', created_at: '2023-11-14T12:00:06.000Z' }), + generateMsg({ id: 'm7', created_at: '2023-11-14T12:00:07.000Z' }), + ]; + const postStub = sinon.stub(client, 'post').returns( + Promise.resolve({ + channels: [{ ...mockChannelQueryResponse, messages: newest }], + }), + ); + + // Initial query seeds the (cold) paginator with the newest window. message_limit === page + // length so the seed is NOT flagged as the complete set (hasMoreTail stays true: older exist, + // so an older jumped window stays a separate interval instead of merging at the tail edge). + const [channel] = await client.queryChannels({}, {}, { message_limit: 3 }); + + // Simulate the user jumping to an OLDER window, disjoint from the newest, which becomes the + // active (visible) interval while the newest window stays loaded as a separate interval. + const older = [ + channel.state.formatMessage( + generateMsg({ id: 'm1', created_at: '2023-11-14T12:00:01.000Z' }), + ), + channel.state.formatMessage( + generateMsg({ id: 'm2', created_at: '2023-11-14T12:00:02.000Z' }), + ), + ]; + channel.messagePaginator.ingestPage({ + page: older, + isHead: false, + isTail: false, + setActive: true, + }); + const activeBefore = channel.messagePaginator.state + .getLatestValue() + .items?.map((m) => m.id); + expect(activeBefore).to.eql(['m1', 'm2']); + + // A channel-list re-query on reconnect re-hydrates the SAME channel instance with the newest + // window (disjoint from the jumped one). It must NOT weld them (which would drop m3/m4 in the + // middle) nor yank the user off the jumped window. + await client.queryChannels({}, {}, { message_limit: 3 }); + + const activeAfter = channel.messagePaginator.state + .getLatestValue() + .items?.map((m) => m.id); + expect(activeAfter).to.eql(['m1', 'm2']); + + postStub.restore(); + }); + it('should return the raw channels response from queryChannelsRequest', async () => { const client = await getClientWithUser(); const mockedChannelsQueryResponse = Array.from({ length: 10 }, () => ({ diff --git a/test/unit/pagination/paginators/BasePaginator.test.ts b/test/unit/pagination/paginators/BasePaginator.test.ts index ac54c07dd0..1089e38587 100644 --- a/test/unit/pagination/paginators/BasePaginator.test.ts +++ b/test/unit/pagination/paginators/BasePaginator.test.ts @@ -1869,6 +1869,33 @@ describe('BasePaginator', () => { }); }); + describe('re-ingesting the sole item of the active interval', () => { + it('keeps the item visible in state.items after an update', () => { + const paginator = new Paginator({ + itemIndex: new ItemIndex({ getId: ({ id }) => id }), + }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + const optimistic: TestItem = { id: 'r1', age: 10, name: 'sending' }; + const confirmed: TestItem = { id: 'r1', age: 10, name: 'received' }; + + // Optimistic ingest into the empty paginator -> sole item of the (active) logical head. + paginator.ingestItem(optimistic); + expect(paginator.items).toStrictEqual([optimistic]); + // @ts-expect-error accessing protected property _activeIntervalId + expect(paginator._activeIntervalId).toBe(LOGICAL_HEAD_INTERVAL_ID); + + // Confirmed re-ingest of the same id must keep it visible (not vanish). + paginator.ingestItem(confirmed); + expect(paginator.items).toStrictEqual([confirmed]); + // @ts-expect-error accessing protected property _activeIntervalId + expect(paginator._activeIntervalId).toBe(LOGICAL_HEAD_INTERVAL_ID); + }); + }); + describe('ingestItem to state only', () => { it.each([ ['on lockItemOrder: false', false], diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 55302b5e16..73b32e8e8f 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -406,9 +406,149 @@ describe('MessagePaginator', () => { ); expect(paginator.items?.map((m) => m.id)).toEqual([existing.id, target.id]); }); + + it('does not weld a disjoint id_around jump into the loaded head (no missing middle)', async () => { + const mk = (id: string, day: string) => + createMessage({ + cid: 'channel-id', + id, + created_at: `2020-01-${day}T00:00:00.000Z`, + }); + const paginator = new MessagePaginator({ channel, itemIndex }); + // Head loaded; older messages still available (isTail:false) → a real gap exists below it. + paginator.ingestPage({ + page: [mk('m8', '08'), mk('m9', '09'), mk('m10', '10')], + isHead: true, + isTail: false, + setActive: true, + }); + // Jump to an OLD message that is NOT loaded; the id_around query returns a disjoint older + // window (gap m4-m7 between it and the loaded head). + (channel as unknown as { getClient: () => unknown }).getClient = () => ({ + user: undefined, + notifications: { addError: () => {} }, + }); + (channel.query as unknown as ReturnType).mockResolvedValue({ + messages: [ + generateMsg({ id: 'm1', created_at: '2020-01-01T00:00:00.000Z' }), + generateMsg({ id: 'm2', created_at: '2020-01-02T00:00:00.000Z' }), + generateMsg({ id: 'm3', created_at: '2020-01-03T00:00:00.000Z' }), + ], + }); + + await paginator.jumpToMessage('m2'); + + // The jumped window and the head must stay SEPARATE intervals, not welded across the gap. + expect(paginator.itemIntervals.length).toBe(2); + expect(paginator.items?.map((message) => message.id)).toEqual(['m1', 'm2', 'm3']); + }); + + it('can re-jump to the same message after jumping back to the latest (regression)', async () => { + const mk = (id: string, day: string) => + createMessage({ + cid: 'channel-id', + id, + created_at: `2020-01-${day}T00:00:00.000Z`, + }); + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [mk('m8', '08'), mk('m9', '09'), mk('m10', '10')], + isHead: true, + isTail: false, + setActive: true, + }); + (channel as unknown as { getClient: () => unknown }).getClient = () => ({ + user: undefined, + notifications: { addError: () => {} }, + }); + (channel.query as unknown as ReturnType).mockResolvedValue({ + messages: [ + generateMsg({ id: 'm1', created_at: '2020-01-01T00:00:00.000Z' }), + generateMsg({ id: 'm2', created_at: '2020-01-02T00:00:00.000Z' }), + generateMsg({ id: 'm3', created_at: '2020-01-03T00:00:00.000Z' }), + ], + }); + + // 1) jump to an old message + expect(await paginator.jumpToMessage('m2')).toBe(true); + expect(paginator.items?.map((m) => m.id)).toEqual(['m1', 'm2', 'm3']); + + // 2) jump back to the latest (scroll-to-bottom button) + await paginator.jumpToTheLatestMessage(); + expect(paginator.items?.map((m) => m.id)).toEqual(['m8', 'm9', 'm10']); + + // 3) jump to the SAME old message again - must work, not no-op + expect(await paginator.jumpToMessage('m2')).toBe(true); + expect(paginator.items?.map((m) => m.id)).toEqual(['m1', 'm2', 'm3']); + }); }); - describe.todo('jumpToTheLatestMessage', () => {}); + describe('jumpToTheLatestMessage', () => { + it('jumps to the newest loaded message when the head is already loaded (no query)', async () => { + const paginator = new MessagePaginator({ + channel, + itemIndex, + parentMessageId: 'parent-1', + }); + const m1 = createMessage({ id: 'm1', created_at: '2020-01-01T00:00:00.000Z' }); + const m2 = createMessage({ id: 'm2', created_at: '2020-01-02T00:00:00.000Z' }); + paginator.ingestPage({ + page: [m1, m2], + isHead: true, + isTail: true, + setActive: true, + }); + + const result = await paginator.jumpToTheLatestMessage(); + + expect(result).toBe(true); + expect(channel.getReplies).not.toHaveBeenCalled(); + expect(paginator.items?.map((message) => message.id)).toEqual(['m1', 'm2']); + }); + + it('succeeds when a headward query hits the dataset edge (empty) - "all loaded" case', async () => { + const paginator = new MessagePaginator({ + channel, + itemIndex, + parentMessageId: 'parent-1', + }); + const m4 = createMessage({ id: 'm4', created_at: '2020-01-04T00:00:00.000Z' }); + const m5 = createMessage({ id: 'm5', created_at: '2020-01-05T00:00:00.000Z' }); + // A non-head window is active (as after jumping to an older message) with a headward cursor, + // so the "load newer" query is cursor-based (an incremental load, not a first-page reset). + paginator.ingestPage({ + page: [m4, m5], + isHead: false, + isTail: false, + setActive: true, + }); + paginator.state.partialNext({ + cursor: { headward: 'm5', tailward: 'm4' }, + hasMoreHead: true, + }); + expect((paginator.itemIntervals[0] as unknown as { isHead: boolean }).isHead).toBe( + false, + ); + // postQueryReconcile reads the client to take an unread snapshot; no user => snapshot skipped. + (channel as unknown as { getClient: () => unknown }).getClient = () => ({ + user: undefined, + }); + // No newer messages exist on the server → the headward query returns an empty page. + (channel.getReplies as unknown as ReturnType).mockResolvedValue({ + messages: [], + }); + + const result = await paginator.jumpToTheLatestMessage(); + + // The empty edge response flags the active interval as the head, so the newest loaded message + // (m5) is the latest and the jump succeeds - no "Jump to latest message unsuccessful" error. + expect(result).toBe(true); + expect((paginator.itemIntervals[0] as unknown as { isHead: boolean }).isHead).toBe( + true, + ); + expect(paginator.items?.map((message) => message.id)).toEqual(['m4', 'm5']); + }); + }); describe('jumpToTheFirstUnreadMessage()', () => { it('uses unreadState snapshot even if channel read state is already "read"', async () => { @@ -528,15 +668,17 @@ describe('MessagePaginator', () => { 'm-unread', expect.objectContaining({ focusReason: 'jump-to-first-unread' }), ); + // The inferred boundary is NOT persisted into the snapshot (persisting firstUnreadMessageId + // would look like an explicit mark-unread and suppress auto-mark-read). expect(paginator.unreadStateSnapshot.getLatestValue()).toEqual({ - firstUnreadMessageId: 'm-unread', - lastReadAt, - lastReadMessageId: 'm-read', + firstUnreadMessageId: null, + lastReadAt: null, + lastReadMessageId: null, unreadCount: 0, }); }); - it('hydrates firstUnreadMessageId when the queried page starts after lastReadAt', async () => { + it('jumps to the first unread message when the queried page starts after lastReadAt', async () => { const lastReadAt = new Date('2021-01-01T00:00:00.000Z'); const channelWithReadState = { cid: 'channel-id', @@ -583,13 +725,144 @@ describe('MessagePaginator', () => { 'm-first-unread', expect.objectContaining({ focusReason: 'jump-to-first-unread' }), ); + // Not persisted — see note above. expect(paginator.unreadStateSnapshot.getLatestValue()).toEqual({ - firstUnreadMessageId: 'm-first-unread', - lastReadAt, + firstUnreadMessageId: null, + lastReadAt: null, + lastReadMessageId: null, + unreadCount: 0, + }); + }); + + it('infers the first unread from the already-loaded window (no query) and jumps to it, not the last read message', async () => { + const lastReadAt = new Date('2021-01-02T00:00:00.000Z'); + const channelWithReadState = { + cid: 'channel-id', + query: vi.fn(), + state: { + read: { + user1: { + first_unread_message_id: null, + last_read: lastReadAt, + last_read_message_id: 'm-read', + }, + }, + }, + getClient: () => ({ + user: { id: 'user1' }, + }), + } as unknown as Channel; + + const paginator = new MessagePaginator({ + channel: channelWithReadState, + itemIndex, + }); + // Loaded newest window straddles the last-read boundary (some read, some unread) — the common + // "a few unreads at the bottom" case where no extra request is needed. + paginator.state.partialNext({ + items: [ + createMessage({ created_at: '2021-01-01T00:00:00.000Z', id: 'm-read' }), + createMessage({ created_at: '2021-01-03T00:00:00.000Z', id: 'm-unread' }), + ], + }); + const executeQuerySpy = vi.spyOn(paginator, 'executeQuery'); + const jumpSpy = vi.spyOn(paginator, 'jumpToMessage').mockResolvedValue(true); + + const ok = await paginator.jumpToTheFirstUnreadMessage(); + + expect(ok).toBe(true); + // No extra network round trip — the loaded window already straddles the boundary. + expect(executeQuerySpy).not.toHaveBeenCalled(); + // Lands ON (and highlights) the first unread message, not the last read one. + expect(jumpSpy).toHaveBeenCalledWith( + 'm-unread', + expect.objectContaining({ focusReason: 'jump-to-first-unread' }), + ); + // The inferred boundary is NOT written back to the snapshot. + expect(paginator.unreadStateSnapshot.getLatestValue()).toEqual({ + firstUnreadMessageId: null, + lastReadAt: null, lastReadMessageId: null, unreadCount: 0, }); }); + + it('re-seeds the unread snapshot from the current read state on demand (reopen from cache)', () => { + const lastReadAt = new Date('2021-05-01T00:00:00.000Z'); + const channelWithReadState = { + cid: 'channel-id', + query: vi.fn(), + state: { + read: { + user1: { + first_unread_message_id: null, + last_read: lastReadAt, + last_read_message_id: 'm-42', + unread_messages: 3, + }, + }, + }, + getClient: () => ({ + user: { id: 'user1' }, + }), + } as unknown as Channel; + + const paginator = new MessagePaginator({ + channel: channelWithReadState, + itemIndex, + }); + // Simulate a stale snapshot frozen from a previous open (e.g. an explicit mark-unread). + paginator.setUnreadSnapshot({ + firstUnreadMessageId: 'stale-old-id', + lastReadAt: new Date('2020-01-01T00:00:00.000Z'), + lastReadMessageId: 'stale-old-id', + unreadCount: 99, + }); + + paginator.seedUnreadSnapshot(); + + expect(paginator.unreadStateSnapshot.getLatestValue()).toEqual({ + firstUnreadMessageId: null, + lastReadAt, + lastReadMessageId: 'm-42', + unreadCount: 3, + }); + }); + + it('falls back to jumping to the last read message when no last-read timestamp is available', async () => { + const channelWithReadState = { + cid: 'channel-id', + query: vi.fn(), + state: { + read: { + user1: { + first_unread_message_id: null, + last_read: undefined, + last_read_message_id: 'm-read', + }, + }, + }, + getClient: () => ({ + user: { id: 'user1' }, + }), + } as unknown as Channel; + + const paginator = new MessagePaginator({ + channel: channelWithReadState, + itemIndex, + }); + const executeQuerySpy = vi.spyOn(paginator, 'executeQuery'); + const jumpSpy = vi.spyOn(paginator, 'jumpToMessage').mockResolvedValue(true); + + const ok = await paginator.jumpToTheFirstUnreadMessage(); + + expect(ok).toBe(true); + expect(executeQuerySpy).not.toHaveBeenCalled(); + expect(jumpSpy).toHaveBeenCalledWith( + 'm-read', + expect.objectContaining({ focusReason: 'jump-to-first-unread' }), + ); + }); }); describe('filterQueryResults()', () => { @@ -963,6 +1236,243 @@ describe('MessagePaginator', () => { }); }); + describe('mergeNewestPage()', () => { + const m = (id: string, day: string, overrides: Partial = {}) => + createMessage({ + cid: 'channel-id', + id, + created_at: `2020-01-${day}T00:00:00.000Z`, + ...overrides, + }); + + // Loads a newest (head-anchored) window. `isTail` controls whether older items remain: + // isTail:false => older loadable (hasMoreTail true); isTail:true => complete (hasMoreTail false). + const setupLoadedHead = ({ isTail }: { isTail: boolean }) => { + const paginator = new MessagePaginator({ + channel, + itemIndex, + parentMessageId: 'parent-1', + }); + const m1 = m('m1', '01'); + const m2 = m('m2', '02'); + const m3 = m('m3', '03', { text: 'original' }); + paginator.ingestPage({ page: [m1, m2, m3], isHead: true, isTail, setActive: true }); + return { paginator, m1, m2, m3 }; + }; + + it('reconciles in-place edits and appends new messages without a query', () => { + const { paginator, m1, m2 } = setupLoadedHead({ isTail: true }); + // While offline: m3 was edited and m4/m5 arrived; the hydrated newest window carries both. + const editedM3 = m('m3', '03', { text: 'edited' }); + const m4 = m('m4', '04'); + const m5 = m('m5', '05'); + + paginator.mergeNewestPage([m1, m2, editedM3, m4, m5]); + + expect(paginator.items?.map((message) => message.id)).toEqual([ + 'm1', + 'm2', + 'm3', + 'm4', + 'm5', + ]); + expect(paginator.getItem('m3')?.text).toBe('edited'); + // No network — the caller already holds the page. + expect(channel.getReplies).not.toHaveBeenCalled(); + }); + + it('does not blank the list when the page is empty (never wipes)', () => { + const { paginator } = setupLoadedHead({ isTail: true }); + let sawUndefinedItems = false; + const unsubscribe = paginator.state.subscribe((state) => { + if (typeof state.items === 'undefined') sawUndefinedItems = true; + }); + + paginator.mergeNewestPage([]); + unsubscribe(); + + expect(sawUndefinedItems).toBe(false); + expect(paginator.items?.map((message) => message.id)).toEqual(['m1', 'm2', 'm3']); + }); + + it('preserves hasMoreTail / cursor.tailward when merging a partial newest window', () => { + // Only the newest window is loaded and older items still exist (hasMoreTail true). Merging a + // short page (fewer than pageSize) whose first item is the set's first item must NOT clear + // hasMoreTail: re-deriving it from this page's length would wrongly break "load older", so the + // merge preserves the existing hasMoreTail / cursor instead. + const { paginator, m1, m2 } = setupLoadedHead({ isTail: false }); + expect(paginator.state.getLatestValue().hasMoreTail).toBe(true); + const tailwardBefore = paginator.state.getLatestValue().cursor?.tailward; + + const editedM3 = m('m3', '03', { text: 'edited' }); + paginator.mergeNewestPage([m1, m2, editedM3]); + + expect(paginator.state.getLatestValue().hasMoreTail).toBe(true); + expect(paginator.state.getLatestValue().cursor?.tailward).toBe(tailwardBefore); + expect(paginator.getItem('m3')?.text).toBe('edited'); + }); + + it('resets to the fetched window when it is DISJOINT from the loaded head (>= a page arrived while away)', () => { + const { paginator } = setupLoadedHead({ isTail: false }); + // A newest window that shares NO id with the loaded [m1,m2,m3]: more than a page arrived while + // offline, so there is a gap between the loaded set and this window. Merging would silently + // weld across the gap (dropping the in-between messages); instead we reset to this window. + const m10 = m('m10', '10'); + const m11 = m('m11', '11'); + const m12 = m('m12', '12'); + + paginator.mergeNewestPage([m10, m11, m12]); + + const state = paginator.state.getLatestValue(); + // The list is the fresh contiguous window — no silent gap, stale older items dropped from view. + expect(state.items?.map((message) => message.id)).toEqual(['m10', 'm11', 'm12']); + // A single interval (no gap-weld), at the newest, with older still loadable... + expect(paginator.itemIntervals).toHaveLength(1); + expect(state.hasMoreHead).toBe(false); + expect(state.hasMoreTail).toBe(true); + // ...and the cursor is re-anchored to this window's oldest item, so "load older" continues + // contiguously from here (id_lt m10) and refills the gap instead of skipping it. + expect(state.cursor?.tailward).toBe('m10'); + }); + + it('sets hasMoreHead false after merging the head window', () => { + const { paginator } = setupLoadedHead({ isTail: false }); + + paginator.mergeNewestPage([m('m4', '04')]); + + expect(paginator.state.getLatestValue().hasMoreHead).toBe(false); + }); + + it('is a no-op when the newest slice is not loaded (not anchored at head)', () => { + const paginator = new MessagePaginator({ + channel, + itemIndex, + parentMessageId: 'parent-1', + }); + paginator.ingestPage({ + page: [m('m4', '04'), m('m5', '05')], + isHead: false, + isTail: false, + setActive: true, + }); + + paginator.mergeNewestPage([m('m6', '06')]); + + // m6 not merged; the loaded window is unchanged. + expect(paginator.items?.map((message) => message.id)).toEqual(['m4', 'm5']); + expect(paginator.getItem('m6')).toBeUndefined(); + }); + + it('is a no-op when nothing is loaded', () => { + const paginator = new MessagePaginator({ + channel, + itemIndex, + parentMessageId: 'parent-1', + }); + + paginator.mergeNewestPage([m('m1', '01')]); + + expect(paginator.items).toBeUndefined(); + }); + + it('treats a window sharing only the loaded newest id as OVERLAP, not disjoint (boundary)', () => { + const { paginator, m3 } = setupLoadedHead({ isTail: false }); + const tailwardBefore = paginator.state.getLatestValue().cursor?.tailward; + // Exactly one shared id (the loaded newest, m3): the minimal-overlap boundary. This must merge + // (append m4/m5, keep older loadable), NOT reset to the window. + paginator.mergeNewestPage([m3, m('m4', '04'), m('m5', '05')]); + + expect(paginator.items?.map((message) => message.id)).toEqual([ + 'm1', + 'm2', + 'm3', + 'm4', + 'm5', + ]); + expect(paginator.itemIntervals).toHaveLength(1); + expect(paginator.state.getLatestValue().hasMoreTail).toBe(true); + expect(paginator.state.getLatestValue().cursor?.tailward).toBe(tailwardBefore); + }); + + // Builds the "jumped away" shape: the newest slice is loaded as one interval, and a separate + // OLDER interval (as after jumping to a quoted message) is the active/visible one. itemIntervals[0] + // is the head, but the ACTIVE interval is the older window - the case the active-interval guard + // must skip so the caller is not yanked to the newest. + const setupHeadPlusActiveJumpedInterval = () => { + const paginator = new MessagePaginator({ + channel, + itemIndex, + parentMessageId: 'parent-1', + }); + paginator.ingestPage({ + page: [m('m8', '08'), m('m9', '09'), m('m10', '10')], + isHead: true, + isTail: false, + setActive: true, + }); + paginator.ingestPage({ + page: [m('m1', '01'), m('m2', '02'), m('m3', '03')], + isHead: false, + isTail: false, + setActive: true, + }); + return paginator; + }; + + it('is a no-op when a separate jumped interval is active, preserving the caller position', () => { + const paginator = setupHeadPlusActiveJumpedInterval(); + // Precondition: two intervals, the head at [0], but the older jumped window is what is shown. + expect(paginator.itemIntervals).toHaveLength(2); + expect((paginator.itemIntervals[0] as unknown as { isHead: boolean }).isHead).toBe( + true, + ); + expect(paginator.items?.map((message) => message.id)).toEqual(['m1', 'm2', 'm3']); + + // A newest window overlapping the loaded head. The head is loaded but NOT the active interval, + // so the merge is skipped: the caller stays on the jumped window (no yank to the newest). + paginator.mergeNewestPage([m('m9', '09'), m('m10', '10'), m('m11', '11')]); + + expect(paginator.items?.map((message) => message.id)).toEqual(['m1', 'm2', 'm3']); + expect(paginator.itemIntervals).toHaveLength(2); + // The incoming page was not ingested at all. + expect(paginator.getItem('m11')).toBeUndefined(); + }); + + it('discards ALL intervals including a separate stale one on a disjoint reset (head active)', () => { + const paginator = new MessagePaginator({ + channel, + itemIndex, + parentMessageId: 'parent-1', + }); + // The head is loaded AND active; a separate older interval is also loaded (setActive: false), + // e.g. it lingers from an earlier jump the caller has since scrolled back from. + paginator.ingestPage({ + page: [m('m8', '08'), m('m9', '09'), m('m10', '10')], + isHead: true, + isTail: false, + setActive: true, + }); + paginator.ingestPage({ + page: [m('m1', '01'), m('m2', '02'), m('m3', '03')], + isHead: false, + isTail: false, + setActive: false, + }); + expect(paginator.itemIntervals).toHaveLength(2); + + // A newest window disjoint from the head (shares no id). The reset clears every interval, so + // the stale older one is dropped too; only the fetched window remains. + paginator.mergeNewestPage([m('m20', '20'), m('m21', '21'), m('m22', '22')]); + + const state = paginator.state.getLatestValue(); + expect(paginator.itemIntervals).toHaveLength(1); + expect(state.items?.map((message) => message.id)).toEqual(['m20', 'm21', 'm22']); + // The previously loaded items (head + older) are no longer part of the visible set. + expect(state.items?.some((message) => message.id === 'm1')).toBe(false); + expect(state.items?.some((message) => message.id === 'm8')).toBe(false); + }); + }); + it('cannot be customized', () => { const paginator = new MessagePaginator({ channel, itemIndex }); }); diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index fb4ab0e9fd..c4b41dc64d 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -105,6 +105,55 @@ describe('Threads 2.0', () => { expect(thread.messagePaginator.pageSize).to.equal(50); }); + it('seeds the reply paginator from latest_replies (complete window -> no older to load)', () => { + const reply1 = generateMsg({ + parent_id: parentMessageResponse.id, + }) as MessageResponse; + const reply2 = generateMsg({ + parent_id: parentMessageResponse.id, + }) as MessageResponse; + const thread = createTestThread({ + latest_replies: [reply1, reply2], + reply_count: 2, + }); + + const paginatorState = thread.messagePaginator.state.getLatestValue(); + expect(paginatorState.items).to.have.lengthOf(2); + expect(paginatorState.items?.map((reply) => reply.id)).to.have.members([ + reply1.id, + reply2.id, + ]); + // latest_replies already held every reply, so there is nothing older to fetch + expect(paginatorState.hasMoreTail).to.be.false; + }); + + it('seeds the reply paginator and keeps hasMoreTail for a partial latest_replies window', () => { + const reply1 = generateMsg({ + parent_id: parentMessageResponse.id, + }) as MessageResponse; + const reply2 = generateMsg({ + parent_id: parentMessageResponse.id, + }) as MessageResponse; + const thread = createTestThread({ + latest_replies: [reply1, reply2], + reply_count: 10, + }); + + const paginatorState = thread.messagePaginator.state.getLatestValue(); + expect(paginatorState.items).to.have.lengthOf(2); + // older replies exist beyond the most-recent window -> still paginable + expect(paginatorState.hasMoreTail).to.be.true; + // seeding records a query shape (isInitialized) so the first paginate continues from this + // page instead of first-page-resetting (wiping items + re-fetching page 1). + expect(thread.messagePaginator.isInitialized).to.be.true; + }); + + it('leaves the reply paginator unseeded (items undefined, not initialized) with no latest_replies', () => { + const thread = createTestThread({ latest_replies: [], reply_count: 0 }); + expect(thread.messagePaginator.state.getLatestValue().items).to.be.undefined; + expect(thread.messagePaginator.isInitialized).to.be.false; + }); + it('initializes properly without threadData', () => { const thread = createMinimalThread(); const state = thread.state.getLatestValue(); @@ -384,6 +433,50 @@ describe('Threads 2.0', () => { expect(stateAfter.replies).to.have.lengthOf(2); expect(stateAfter.replies[1].id).to.equal(failedMessage.id); }); + + it('merges the incoming newest reply window into the reply paginator', () => { + const existingReply = generateMsg({ + parent_id: parentMessageResponse.id, + created_at: '2020-01-01T00:00:00.000Z', + text: 'original', + }) as MessageResponse; + // Head-anchored, with older replies still to load (reply_count > loaded). + const thread = createTestThread({ + latest_replies: [existingReply], + reply_count: 10, + }); + expect(thread.messagePaginator.state.getLatestValue().hasMoreTail).to.be.true; + + // A fresh hydrate (as produced by reload/ThreadManager on reconnect): the existing reply + // edited + a brand-new reply that arrived while the connection was dropped. + const editedReply = generateMsg({ + id: existingReply.id, + parent_id: parentMessageResponse.id, + created_at: '2020-01-01T00:00:00.000Z', + text: 'edited', + }) as MessageResponse; + const newReply = generateMsg({ + parent_id: parentMessageResponse.id, + created_at: '2020-01-02T00:00:00.000Z', + }) as MessageResponse; + const hydrationThread = createTestThread({ + latest_replies: [editedReply, newReply], + reply_count: 11, + }); + + thread.hydrateState(hydrationThread); + + const paginatorState = thread.messagePaginator.state.getLatestValue(); + expect(paginatorState.items?.map((reply) => reply.id)).to.deep.equal([ + existingReply.id, + newReply.id, + ]); + expect(thread.messagePaginator.getItem(existingReply.id)?.text).to.equal( + 'edited', + ); + // Merging a partial newest window must not clear "load older". + expect(paginatorState.hasMoreTail).to.be.true; + }); }); describe('reload', () => { @@ -415,6 +508,33 @@ describe('Threads 2.0', () => { expect(stateAfter.pagination.prevCursor).to.not.be.null; expect(stateAfter.pagination.nextCursor).to.equal('next-cursor'); }); + + it('sizes getThread reply_limit to the loaded reply count, falling back to pageSize when unloaded', async () => { + const stub = sinon.stub(client, 'getThread').resolves(createTestThread()); + + // Unloaded (minimal) thread → falls back to pageSize. + const minimalThread = createMinimalThread(); + expect(minimalThread.messagePaginator.state.getLatestValue().items).to.be + .undefined; + await minimalThread.reload(); + expect(stub.firstCall.args[1]?.reply_limit).to.equal( + minimalThread.messagePaginator.pageSize, + ); + + // Loaded thread → sized to the loaded reply count (so the whole loaded window reconciles), + // NOT the paginator pageSize. + const loadedThread = createTestThread({ + latest_replies: Array.from( + { length: 7 }, + () => + generateMsg({ parent_id: parentMessageResponse.id }) as MessageResponse, + ), + reply_count: 20, + }); + await loadedThread.reload(); + expect(stub.secondCall.args[1]?.reply_limit).to.equal(7); + expect(loadedThread.messagePaginator.pageSize).to.not.equal(7); + }); }); describe('deleteReplyLocally', () => { From 720519d3d54121b412dc46b197e7d888316c56ea Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:30:42 +0200 Subject: [PATCH 40/48] fix: mark read live state freezing (#1803) ## CLA - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required). - [ ] Code changes are tested ## Description of the changes, What, Why and How? ## Changelog - --- src/channel.ts | 10 ++- .../MessageDeliveryReporter.ts | 2 +- src/pagination/paginators/MessagePaginator.ts | 49 +++++++++++- src/utils.ts | 6 +- test/unit/channel.test.js | 61 ++++++++++++++ .../MessageDeliveryReporter.test.ts | 20 ++++- test/unit/utils.test.ts | 79 +++++++++++++++++++ 7 files changed, 217 insertions(+), 10 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 9de0e1e6c3..238601d9a9 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -2463,7 +2463,15 @@ export class Channel { ); } - if (this._countMessageAsUnread(event.message)) { + // Skip the own-unread bump when the user is actively viewing the latest messages (app + // foregrounded + newest message on screen). Without this, a message read in real time + // momentarily bumps `unreadCount`/the snapshot — the "N new" separator/banner + the + // channel-list badge would flash until the SDK's mark-read resets it. The SDK reports the + // viewing state via `messagePaginator.setViewingLive` and marks the message read itself. + // Only the OWN unread accounting is gated; the per-user read/receipt tracking above is + // intentionally left intact. + const isViewingLive = this.messagePaginator.isViewingLive; + if (!isViewingLive && this._countMessageAsUnread(event.message)) { channelState.unreadCount = channelState.unreadCount + 1; this.messagePaginator.setUnreadSnapshot({ unreadCount: channelState.unreadCount, diff --git a/src/messageDelivery/MessageDeliveryReporter.ts b/src/messageDelivery/MessageDeliveryReporter.ts index e7c2021d09..5daef646f4 100644 --- a/src/messageDelivery/MessageDeliveryReporter.ts +++ b/src/messageDelivery/MessageDeliveryReporter.ts @@ -325,7 +325,7 @@ export class MessageDeliveryReporter { * @param options */ public throttledMarkRead = throttle(this.markRead, MARK_AS_READ_THROTTLE_TIMEOUT, { - leading: false, + leading: true, trailing: true, }); } diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 05d732b7b6..13beba1fae 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -154,6 +154,17 @@ export type UnreadSnapshotState = { lastReadMessageId: string | null; }; +/** + * External, UI-driven signal: `true` while the user is actively viewing the latest messages of + * this collection (app foregrounded AND the newest message on screen). The owning SDK sets it — the + * state layer has no viewport. When live, an incoming message is NOT counted as unread (the count / + * snapshot bump is skipped), so the "N new" separator/banner never flash for a message the user is + * already looking at. Defaults to `false` (assume not viewing until the SDK proves otherwise). + */ +export type LiveViewState = { + isViewingLive: boolean; +}; + /** * MessagePaginator allows configuring backend request sort, while keeping internal item ordering stable. * Filtering of ingested items is still limited to local predicates (`filterQueryResults`). @@ -168,6 +179,12 @@ export class MessagePaginator extends BasePaginator; + /** + * UI-driven "viewing the latest messages" signal (see {@link LiveViewState}). Set by the SDK via + * {@link setViewingLive}; read by the channel to gate the unread bump on `message.new`. Subscribe to + * this store for reactivity, or read the current boolean directly via the {@link isViewingLive} getter. + */ + readonly liveViewState: StateStore; readonly messageFocusSignal: StateStore; private clearMessageFocusSignalTimeoutId: ReturnType | null = null; private messageFocusSignalToken = 0; @@ -228,6 +245,9 @@ export class MessagePaginator extends BasePaginator({ + isViewingLive: false, + }); this.messageFocusSignal = new StateStore({ signal: null, }); @@ -650,10 +670,14 @@ export class MessagePaginator extends BasePaginator { + if (this.isViewingLive === isViewingLive) return; + this.liveViewState.next({ isViewingLive }); + }; + clearUnreadSnapshot = () => { this.unreadStateSnapshot.next({ firstUnreadMessageId: null, diff --git a/src/utils.ts b/src/utils.ts index 4eacfb35c3..3176dfe228 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -787,7 +787,11 @@ export const throttle = any>( return; } - if (leading) fn(...args); + if (leading) { + fn(...args); + } else if (trailing) { + storedArgs = args; + } const timeoutHandler = () => { if (storedArgs) { diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index d94c4ece91..10b6ba175f 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -212,6 +212,67 @@ describe('Channel count unread', function () { }); }); +describe('Channel isViewingLive (unread bump gating)', function () { + const user = { id: 'user' }; + const otherUser = { id: 'other-user' }; + + const setupChannel = () => { + const client = new StreamChat('apiKey'); + client.user = user; + client.userID = user.id; + client.userMuteStatus = () => false; + const channel = client.channel('messaging', 'live-mode-id'); + channel.initialized = true; + channel.data = { ...channel.data, own_capabilities: ['read-events'] }; + channel.state.unreadCount = 0; + return { channel }; + }; + + const dispatchNewMessageFromOther = (channel) => + channel._handleChannelEvent({ + type: 'message.new', + user: otherUser, + message: generateMsg({ user: otherUser }), + }); + + it('does not bump the unread count or snapshot on a new message while viewing live', () => { + const { channel } = setupChannel(); + channel.messagePaginator.setViewingLive(true); + + dispatchNewMessageFromOther(channel); + + expect(channel.countUnread()).to.be.equal(0); + expect( + channel.messagePaginator.unreadStateSnapshot.getLatestValue().unreadCount, + ).to.be.equal(0); + }); + + it('bumps the unread count and snapshot on a new message when not viewing live', () => { + const { channel } = setupChannel(); + // isViewingLive defaults to false + + dispatchNewMessageFromOther(channel); + + expect(channel.countUnread()).to.be.equal(1); + expect( + channel.messagePaginator.unreadStateSnapshot.getLatestValue().unreadCount, + ).to.be.equal(1); + }); + + it('setViewingLive no-ops when the value is unchanged', () => { + const { channel } = setupChannel(); + let emissions = 0; + channel.messagePaginator.liveViewState.subscribe(() => (emissions += 1)); + emissions = 0; // ignore the immediate subscribe callback + + channel.messagePaginator.setViewingLive(false); // already false + expect(emissions).to.be.equal(0); + + channel.messagePaginator.setViewingLive(true); + expect(emissions).to.be.equal(1); + }); +}); + describe('Channel localized unread count (isLocalUnreadCountEnabled)', function () { const user = { id: 'user' }; const otherUser = { id: 'other-user' }; diff --git a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts index 7e696c890f..83f501b782 100644 --- a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts +++ b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts @@ -644,7 +644,7 @@ describe('MessageDeliveryReporter', () => { }); }); - it('throttles markRead (burst collapses to one underlying request)', async () => { + it('throttles markRead (leading + trailing: fires immediately, then once more on the trailing edge)', async () => { const spy = vi.spyOn(channel, 'markAsReadRequest').mockResolvedValue({} as any); // burst @@ -652,8 +652,22 @@ describe('MessageDeliveryReporter', () => { client.messageDeliveryReporter.throttledMarkRead(channel); client.messageDeliveryReporter.throttledMarkRead(channel); - expect(spy).not.toHaveBeenCalled(); + expect(spy).toHaveBeenCalledTimes(1); // leading edge fires immediately vi.advanceTimersByTime(1000); - expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledTimes(2); // trailing edge coalesces the remaining calls into one more + }); + + it('marks read immediately on a single throttledMarkRead call (leading edge)', async () => { + const spy = vi.spyOn(channel, 'markAsReadRequest').mockResolvedValue({} as any); + + // A single call is the common case (e.g. scrolling to the bottom once). With `leading: true` it + // fires immediately on the leading edge — no delay — and a lone call schedules no extra trailing + // invocation. (The lone-call-drop regression for `leading: false` is covered by the `throttle` + // unit tests in utils.test.ts.) + client.messageDeliveryReporter.throttledMarkRead(channel); + + expect(spy).toHaveBeenCalledTimes(1); // leading edge fires immediately + vi.advanceTimersByTime(1000); + expect(spy).toHaveBeenCalledTimes(1); // no extra trailing for a solitary call }); }); diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index f633cb2938..2c45746f6e 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -16,6 +16,7 @@ import { channelTracksReadLocally, userHasReadReceipts, formatMessage, + throttle, generateChannelTempCid, shouldConsiderArchivedChannels, shouldConsiderPinnedChannels, @@ -1272,3 +1273,81 @@ describe('userHasReadReceipts', () => { expect(userHasReadReceipts(makeClient(undefined))).toBe(true); }); }); + +describe('throttle', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('fires a single (non-burst) call on the trailing edge when leading is false', () => { + const fn = vi.fn(); + const throttled = throttle(fn, 1000, { leading: false, trailing: true }); + + throttled('a'); + expect(fn).not.toHaveBeenCalled(); // leading:false so nothing on the leading edge + + vi.advanceTimersByTime(1000); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenLastCalledWith('a'); // the lone call's args reach the trailing edge + }); + + it('collapses a burst to one trailing call with the last args when leading is false', () => { + const fn = vi.fn(); + const throttled = throttle(fn, 1000, { leading: false, trailing: true }); + + throttled('a'); + throttled('b'); + throttled('c'); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1000); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenLastCalledWith('c'); + }); + + it('fires on the leading edge and drops within-window calls when trailing is false', () => { + const fn = vi.fn(); + const throttled = throttle(fn, 1000); // defaults { leading: true, trailing: false } + + throttled('a'); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenLastCalledWith('a'); + + throttled('b'); + throttled('c'); + expect(fn).toHaveBeenCalledTimes(1); // trailing: false so no trailing invocation + + vi.advanceTimersByTime(1000); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('fires on both leading and trailing edges when both are enabled', () => { + const fn = vi.fn(); + const throttled = throttle(fn, 1000, { leading: true, trailing: true }); + + throttled('a'); // leading edge + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenLastCalledWith('a'); + + throttled('b'); // captured for the trailing edge + expect(fn).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1000); + expect(fn).toHaveBeenCalledTimes(2); // trailing edge + expect(fn).toHaveBeenLastCalledWith('b'); + }); + + it('does not fire a duplicate trailing call for a solitary leading+trailing call', () => { + const fn = vi.fn(); + const throttled = throttle(fn, 1000, { leading: true, trailing: true }); + + throttled('a'); // leading only so no second call to schedule a trailing + expect(fn).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1000); + expect(fn).toHaveBeenCalledTimes(1); // no duplicate trailing + }); +}); From 7f0506c23f1019450036ba9983fcb9dac10b0176 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:05:41 +0200 Subject: [PATCH 41/48] fix: clean up thread state remnants (#1804) ## CLA - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required). - [ ] Code changes are tested ## Description of the changes, What, Why and How? ## Changelog - --- src/channel.ts | 10 +- src/index.ts | 7 +- .../MessageDeliveryReporter.ts | 2 +- src/thread.ts | 181 +------ test/unit/channel.test.js | 39 ++ test/unit/threads.test.ts | 498 +++++------------- 6 files changed, 216 insertions(+), 521 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 238601d9a9..33d6866477 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -2369,12 +2369,18 @@ export class Channel { if (event.message) { this._extendEventWithOwnReactions(event); const formattedMessage = formatMessage(event.message); + const isThreadReply = + !!event.message.parent_id && !event.message.show_in_channel; if (event.hard_delete) { channelState.removeMessage(event.message); - this.messagePaginator.removeItem({ id: event.message.id }); + if (!isThreadReply) { + this.messagePaginator.removeItem({ id: event.message.id }); + } } else { channelState.addMessageSorted(event.message, false, false); - this.messagePaginator.ingestItem(formattedMessage); + if (!isThreadReply) { + this.messagePaginator.ingestItem(formattedMessage); + } } this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage); diff --git a/src/index.ts b/src/index.ts index d227632586..402e55c30b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,12 +25,7 @@ export * from './segment'; export * from './signing'; export * from './store'; export { Thread } from './thread'; -export type { - ThreadState, - ThreadReadState, - ThreadRepliesPagination, - ThreadUserReadState, -} from './thread'; +export type { ThreadState, ThreadReadState, ThreadUserReadState } from './thread'; export * from './thread_manager'; export * from './token_manager'; export * from './types'; diff --git a/src/messageDelivery/MessageDeliveryReporter.ts b/src/messageDelivery/MessageDeliveryReporter.ts index 5daef646f4..2317b4dddd 100644 --- a/src/messageDelivery/MessageDeliveryReporter.ts +++ b/src/messageDelivery/MessageDeliveryReporter.ts @@ -140,7 +140,7 @@ export class MessageDeliveryReporter { lastDeliveredAt = ownReadState?.last_delivered_at; key = collection.cid; } else if (isThread(collection)) { - latestMessages = collection.state.getLatestValue().replies; + latestMessages = collection.messagePaginator.state.getLatestValue().items ?? []; const ownReadState = collection.state.getLatestValue().read[ownUserId] ?? ({} as ThreadUserReadState); lastReadAt = ownReadState?.lastReadAt; diff --git a/src/thread.ts b/src/thread.ts index b944f0ea68..0302c2463c 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -1,5 +1,5 @@ import { StateStore } from './store'; -import { addToMessageList, findIndexInSortedArray, formatMessage } from './utils'; +import { formatMessage } from './utils'; import type { AscDesc, DraftResponse, @@ -7,7 +7,6 @@ import type { EventTypes, LocalMessage, MarkReadOptions, - MessagePaginationOptions, MessageResponse, ReadResponse, ThreadResponse, @@ -26,10 +25,6 @@ import { MessageOperations } from './messageOperations'; import { WithSubscriptions } from './utils/WithSubscriptions'; import { MessagePaginator } from './pagination'; -type QueryRepliesOptions = { - sort?: { created_at: AscDesc }[]; -} & MessagePaginationOptions & { user?: UserResponse; user_id?: string }; - export type ThreadState = { /** * Determines if the thread is currently opened and on-screen. When the thread is active, @@ -42,7 +37,6 @@ export type ThreadState = { deletedAt: Date | null; isLoading: boolean; isStateStale: boolean; - pagination: ThreadRepliesPagination; /** * Thread is identified by and has a one-to-one relation with its parent message. * We use parent message id as a thread id. @@ -50,19 +44,11 @@ export type ThreadState = { parentMessage: LocalMessage; participants: ThreadResponse['thread_participants']; read: ThreadReadState; - replies: Array; replyCount: number; title: string; updatedAt: Date | null; }; -export type ThreadRepliesPagination = { - isLoadingNext: boolean; - isLoadingPrev: boolean; - nextCursor: string | null; - prevCursor: string | null; -}; - export type ThreadUserReadState = { lastReadAt: Date; unreadMessageCount: number; @@ -175,7 +161,6 @@ export class Thread extends WithSubscriptions { createdAt: new Date(threadData.created_at), // rest deletedAt: threadData.deleted_at ? new Date(threadData.deleted_at) : null, - pagination: repliesPaginationFromInitialThread(threadData), parentMessage: formatMessage(threadData.parent_message), participants: threadData.thread_participants, read: formatReadState( @@ -183,8 +168,12 @@ export class Thread extends WithSubscriptions { ? getPlaceholderReadResponse(client.userID) : threadData.read, ), - replies: threadData.latest_replies.map(formatMessage), - replyCount: threadData.reply_count ?? 0, + // Use the parent message's reply_count, not the top-level threadData.reply_count. The + // thread endpoints (getThread/queryThreads) return a top level reply_count that EXCLUDES + // soft-deleted replies, while parent_message.reply_count (and the channel's own copy) + // INCLUDE them so the top level value renders fewer replies than the channel badge shows. + // parent_message.reply_count is the authoritative, channel consistent count. + replyCount: threadData.parent_message.reply_count ?? 0, updatedAt: threadData.updated_at ? new Date(threadData.updated_at) : null, title: threadData.title, custom: constructCustomDataObject(threadData), @@ -215,16 +204,9 @@ export class Thread extends WithSubscriptions { deletedAt: formattedParentMessage.deleted_at, isLoading: false, isStateStale: false, - pagination: { - isLoadingNext: false, - isLoadingPrev: false, - nextCursor: null, - prevCursor: null, - }, parentMessage: formattedParentMessage, participants: [], read: formatReadState(getPlaceholderReadResponse(client.userID)), - replies: [], replyCount: parentMessage.reply_count ?? 0, title: '', updatedAt: parentMessage.updated_at ? new Date(parentMessage.updated_at) : null, @@ -385,16 +367,16 @@ export class Thread extends WithSubscriptions { custom, title, deletedAt, - pagination, parentMessage, participants, read, replyCount, - replies, updatedAt, } = thread.state.getLatestValue(); - // Preserve pending replies and append them to the updated list of replies + // Preserve pending (failed) replies so they survive the hydrate. The messagePaginator is now + // the sole reply source, so we merge the incoming newest page into it and re-ingest the + // pending replies (mirrors the previous state.replies concat behavior). const pendingReplies = Array.from(this.failedRepliesMap.values()); this.state.partialNext({ @@ -406,13 +388,14 @@ export class Thread extends WithSubscriptions { participants, read, replyCount, - pagination, - replies: pendingReplies.length ? replies.concat(pendingReplies) : replies, updatedAt, isStateStale: false, }); - this.messagePaginator.mergeNewestPage(replies); + this.messagePaginator.mergeNewestPage( + thread.messagePaginator.state.getLatestValue().items ?? [], + ); + pendingReplies.forEach((reply) => this.messagePaginator.ingestItem(reply)); }; public registerSubscriptions = () => { @@ -520,12 +503,8 @@ export class Thread extends WithSubscriptions { } const isOwnMessage = event.message.user?.id === this.client.userID; - const { active, read, replies } = this.state.getLatestValue(); - const hasReplyAlready = - replies.some((reply) => reply.id === event.message?.id) || - !!this.messagePaginator.getItem(event.message.id); + const { active, read } = this.state.getLatestValue(); - this.messagePaginator.ingestItem(formatMessage(event.message)); this.upsertReplyLocally({ message: event.message, // Message from current user could have been added optimistically, @@ -533,10 +512,6 @@ export class Thread extends WithSubscriptions { timestampChanged: isOwnMessage, }); - if (!hasReplyAlready) { - this.incrementReplyCountLocally(); - } - if (active) { this.throttledMarkRead(); } @@ -650,14 +625,6 @@ export class Thread extends WithSubscriptions { this.client.on(eventType, (event) => { if (event.message) { this.updateParentMessageOrReplyLocally(event.message); - if ( - ['reaction.new', 'reaction.deleted', 'reaction.updated'].includes( - eventType, - ) && - event.message.parent_id === this.id - ) { - this.messagePaginator.ingestItem(formatMessage(event.message)); - } this.messagePaginator.reflectQuotedMessageUpdate( formatMessage(event.message), ); @@ -676,34 +643,18 @@ export class Thread extends WithSubscriptions { // todo: can be removed with the next breaking change and use MessagePaginator only public deleteReplyLocally = ({ message }: { message: MessageResponse }) => { - const { replies } = this.state.getLatestValue(); - - const index = findIndexInSortedArray({ - needle: formatMessage(message), - sortedArray: replies, - sortDirection: 'ascending', - selectValueToCompare: (reply) => reply.created_at.getTime(), - selectKey: (reply) => reply.id, - }); - - if (replies[index]?.id !== message.id) { - return; - } - - const updatedReplies = [...replies]; - updatedReplies.splice(index, 1); - - this.state.partialNext({ - replies: updatedReplies, - }); + // The reply messagePaginator is the reply list source. removeItem is a no-op when the reply + // isn't loaded, so it's safe to run unconditionally. + this.messagePaginator.removeItem({ id: message.id }); }; // todo: can be removed with the next breaking change and use MessagePaginator only public upsertReplyLocally = ({ message, - timestampChanged = false, }: { message: MessageResponse | LocalMessage; + // Accepted for backward compatibility but no longer used — the messagePaginator repositions + // by created_at on ingest, so a changed timestamp is handled without an explicit flag. timestampChanged?: boolean; }) => { if (message.parent_id !== this.id) { @@ -720,10 +671,8 @@ export class Thread extends WithSubscriptions { this.failedRepliesMap.delete(message.id); } - this.state.next((current) => ({ - ...current, - replies: addToMessageList(current.replies, formattedMessage, timestampChanged), - })); + // The reply messagePaginator is the reply list source. + this.messagePaginator.ingestItem(formattedMessage); }; // todo: can be removed with the next breaking change and use MessagePaginator only @@ -795,9 +744,7 @@ export class Thread extends WithSubscriptions { /** * Updates a message with optimistic local state update. * - * NOTE: This updates message state via `messagePaginator` only. If you still rely on - * `Thread.state.replies` as UI source of truth, make sure it is wired to paginator updates - * (or keep upserting separately until migration is complete). + * The update flows through `messagePaginator`, which is the sole reply source. */ async updateMessageWithLocalUpdate(params: UpdateMessageWithStateUpdateParams) { await this.messageOperations.update( @@ -839,72 +786,6 @@ export class Thread extends WithSubscriptions { */ public markAsRead = ({ force = false }: { force?: boolean } = {}) => this.markRead({ force }); - - // todo: can be removed with the next breaking change and use MessagePaginator only - public queryReplies = ({ - limit = DEFAULT_PAGE_LIMIT, - sort = DEFAULT_SORT, - ...otherOptions - }: QueryRepliesOptions = {}) => - this.channel.getReplies(this.id, { limit, ...otherOptions }, sort); - - // todo: can be removed with the next breaking change and use MessagePaginator only - public loadNextPage = ({ limit = DEFAULT_PAGE_LIMIT }: { limit?: number } = {}) => - this.loadPage(limit); - - // todo: can be removed with the next breaking change and use MessagePaginator only - public loadPrevPage = ({ limit = DEFAULT_PAGE_LIMIT }: { limit?: number } = {}) => - this.loadPage(-limit); - // todo: can be removed with the next breaking change and use MessagePaginator only - private loadPage = async (count: number) => { - const { pagination } = this.state.getLatestValue(); - const [loadingKey, cursorKey, insertionMethodKey] = - count > 0 - ? (['isLoadingNext', 'nextCursor', 'push'] as const) - : (['isLoadingPrev', 'prevCursor', 'unshift'] as const); - - if (pagination[loadingKey] || pagination[cursorKey] === null) return; - - const queryOptions = { [count > 0 ? 'id_gt' : 'id_lt']: pagination[cursorKey] }; - const limit = Math.abs(count); - - this.state.partialNext({ pagination: { ...pagination, [loadingKey]: true } }); - - try { - const data = await this.queryReplies({ ...queryOptions, limit }); - const replies = data.messages.map(formatMessage); - const maybeNextCursor = replies.at(count > 0 ? -1 : 0)?.id ?? null; - - this.state.next((current) => { - let nextReplies = current.replies; - - // prevent re-creating array if there's nothing to add to the current one - if (replies.length > 0) { - nextReplies = [...current.replies]; - nextReplies[insertionMethodKey](...replies); - } - - return { - ...current, - replies: nextReplies, - pagination: { - ...current.pagination, - [cursorKey]: data.messages.length < limit ? null : maybeNextCursor, - [loadingKey]: false, - }, - }; - }); - } catch (error) { - this.client.logger('error', (error as Error).message); - this.state.next((current) => ({ - ...current, - pagination: { - ...current.pagination, - [loadingKey]: false, - }, - })); - } - }; } type MessageThreadParticipant = NonNullable< @@ -953,22 +834,6 @@ const getPlaceholderReadResponse = (currentUserId?: string): ReadResponse[] => ] : []; -const repliesPaginationFromInitialThread = ( - thread: ThreadResponse, -): ThreadRepliesPagination => { - const latestRepliesContainsAllReplies = - thread.latest_replies.length === thread.reply_count; - - return { - nextCursor: null, - prevCursor: latestRepliesContainsAllReplies - ? null - : (thread.latest_replies.at(0)?.id ?? null), - isLoadingNext: false, - isLoadingPrev: false, - }; -}; - const ownUnreadCountSelector = (currentUserId: string | undefined) => (state: ThreadState) => (currentUserId && state.read[currentUserId]?.unreadMessageCount) || 0; diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 10b6ba175f..12e7400a7b 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -890,6 +890,45 @@ describe('Channel _handleChannelEvent', function () { expect(itemFromPaginator?.deleted_at?.toISOString()).to.equal(deletedAt); }); + it('message.deleted (soft) ignores thread replies in messagePaginator', function () { + const parentMessage = generateMsg({ id: 'thread-parent-id-on-delete' }); + const threadReply = generateMsg({ + id: 'thread-reply-id-on-delete', + parent_id: parentMessage.id, + }); + + channel.messagePaginator.ingestItem(parentMessage); + channel._handleChannelEvent({ + type: 'message.deleted', + user: { id: 'id' }, + message: { ...threadReply, deleted_at: new Date().toISOString() }, + }); + + // A pure thread reply must never leak a "deleted" placeholder into the channel list. + expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; + }); + + it('message.deleted (hard) ignores thread replies in messagePaginator', function () { + const parentMessage = generateMsg({ id: 'thread-parent-id-on-hard-delete' }); + const threadReply = generateMsg({ + id: 'thread-reply-id-on-hard-delete', + parent_id: parentMessage.id, + }); + + channel.messagePaginator.ingestItem(parentMessage); + channel._handleChannelEvent({ + type: 'message.deleted', + user: { id: 'id' }, + hard_delete: true, + message: threadReply, + }); + + expect(channel.messagePaginator.getItem(parentMessage.id)?.id).to.equal( + parentMessage.id, + ); + expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; + }); + it('message.deleted syncs quoted_message references in messagePaginator', function () { const quotedMessage = generateMsg({ id: 'quoted-message-id-on-delete', diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index c4b41dc64d..c9e805de00 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -68,6 +68,20 @@ describe('Threads 2.0', () => { }); } + // The messagePaginator is the sole reply source (Thread.state.replies was removed). + const repliesOf = (thread: Thread) => + thread.messagePaginator.state.getLatestValue().items ?? []; + + // A realistic reply carries a cid + parent_id so it passes the reply paginator's client-side + // filter ({ cid, parent_id }) and shows up in messagePaginator.state.items (the rendered list). + // Without cid, ingestItem indexes the message but the filter excludes it from state.items. + const makeReply = (overrides: Partial = {}) => + generateMsg({ + cid: channel.cid, + parent_id: parentMessageResponse.id, + ...overrides, + }) as MessageResponse; + beforeEach(() => { client = new StreamChat('apiKey'); client._setUser({ id: TEST_USER_ID }); @@ -161,11 +175,9 @@ describe('Threads 2.0', () => { expect(thread.id).to.equal(parentMessageResponse.id); expect(thread.channel.cid).to.equal(channel.cid); expect(state.parentMessage.id).to.equal(parentMessageResponse.id); - expect(state.replies).to.deep.equal([]); + expect(repliesOf(thread)).to.deep.equal([]); expect(state.participants).to.deep.equal([]); expect(state.custom).to.deep.equal({}); - expect(state.pagination.prevCursor).to.be.null; - expect(state.pagination.nextCursor).to.be.null; expect(state.read).to.have.keys([TEST_USER_ID]); expect(thread.messagePaginator.sort).to.deep.equal([{ created_at: -1 }]); expect(thread.messagePaginator.requestSort).to.deep.equal([{ created_at: -1 }]); @@ -209,52 +221,47 @@ describe('Threads 2.0', () => { it('inserts a new message that belongs to the associated thread', () => { const thread = createTestThread(); - const message = generateMsg({ parent_id: thread.id }) as MessageResponse; - const stateBefore = thread.state.getLatestValue(); - expect(stateBefore.replies).to.have.lengthOf(0); + const message = makeReply({ parent_id: thread.id }); + expect(repliesOf(thread)).to.have.lengthOf(0); thread.upsertReplyLocally({ message }); - const stateAfter = thread.state.getLatestValue(); - expect(stateAfter.replies).to.have.lengthOf(1); - expect(stateAfter.replies[0].id).to.equal(message.id); + const replies = repliesOf(thread); + expect(replies).to.have.lengthOf(1); + expect(replies[0].id).to.equal(message.id); }); it('updates existing message', () => { - const message = generateMsg({ - parent_id: parentMessageResponse.id, - text: 'aaa', - }) as MessageResponse; - const thread = createTestThread({ latest_replies: [message] }); + const message = makeReply({ text: 'aaa' }); + const thread = createTestThread({ latest_replies: [message], reply_count: 1 }); const udpatedMessage = { ...message, text: 'bbb' }; - const stateBefore = thread.state.getLatestValue(); - expect(stateBefore.replies).to.have.lengthOf(1); - expect(stateBefore.replies[0].id).to.equal(message.id); - expect(stateBefore.replies[0].text).to.not.equal(udpatedMessage.text); + const repliesBefore = repliesOf(thread); + expect(repliesBefore).to.have.lengthOf(1); + expect(repliesBefore[0].id).to.equal(message.id); + expect(repliesBefore[0].text).to.not.equal(udpatedMessage.text); thread.upsertReplyLocally({ message: udpatedMessage }); - const stateAfter = thread.state.getLatestValue(); - expect(stateAfter.replies).to.have.lengthOf(1); - expect(stateAfter.replies[0].text).to.equal(udpatedMessage.text); + const repliesAfter = repliesOf(thread); + expect(repliesAfter).to.have.lengthOf(1); + expect(repliesAfter[0].text).to.equal(udpatedMessage.text); }); it('updates optimistically added message', () => { - const optimisticMessage = generateMsg({ - parent_id: parentMessageResponse.id, + const optimisticMessage = makeReply({ text: 'aaa', created_at: '2020-01-01T00:00:00Z', - }) as MessageResponse; + }); - const message = generateMsg({ - parent_id: parentMessageResponse.id, + const message = makeReply({ text: 'bbb', created_at: '2020-01-01T00:00:10Z', - }) as MessageResponse; + }); const thread = createTestThread({ latest_replies: [optimisticMessage, message], + reply_count: 2, }); const updatedMessage: MessageResponse = { ...optimisticMessage, @@ -262,19 +269,20 @@ describe('Threads 2.0', () => { created_at: '2020-01-01T00:00:20Z', }; - const stateBefore = thread.state.getLatestValue(); - expect(stateBefore.replies).to.have.lengthOf(2); - expect(stateBefore.replies[0].id).to.equal(optimisticMessage.id); - expect(stateBefore.replies[0].text).to.equal('aaa'); - expect(stateBefore.replies[1].id).to.equal(message.id); + const repliesBefore = repliesOf(thread); + expect(repliesBefore).to.have.lengthOf(2); + expect(repliesBefore[0].id).to.equal(optimisticMessage.id); + expect(repliesBefore[0].text).to.equal('aaa'); + expect(repliesBefore[1].id).to.equal(message.id); thread.upsertReplyLocally({ message: updatedMessage, timestampChanged: true }); - const stateAfter = thread.state.getLatestValue(); - expect(stateAfter.replies).to.have.lengthOf(2); - expect(stateAfter.replies[0].id).to.equal(message.id); - expect(stateAfter.replies[1].id).to.equal(optimisticMessage.id); - expect(stateAfter.replies[1].text).to.equal('ccc'); + // Updating the optimistic reply with a newer created_at repositions it after `message`. + const repliesAfter = repliesOf(thread); + expect(repliesAfter).to.have.lengthOf(2); + expect(repliesAfter[0].id).to.equal(message.id); + expect(repliesAfter[1].id).to.equal(optimisticMessage.id); + expect(repliesAfter[1].text).to.equal('ccc'); }); }); @@ -384,54 +392,24 @@ describe('Threads 2.0', () => { // compare non-primitive values only expect(stateAfter.read).to.equal(hydrationState.read); - expect(stateAfter.replies).to.equal(hydrationState.replies); expect(stateAfter.parentMessage).to.equal(hydrationState.parentMessage); expect(stateAfter.participants).to.equal(hydrationState.participants); }); - it('copies pagination state during hydration', () => { - const thread = createMinimalThread(); - const hydrationThread = createTestThread({ - latest_replies: [ - generateMsg({ parent_id: parentMessageResponse.id }) as MessageResponse, - ], - reply_count: 3, - }); - - hydrationThread.state.next((current) => ({ - ...current, - pagination: { - ...current.pagination, - nextCursor: 'next-cursor', - }, - })); - - thread.hydrateState(hydrationThread); - - const stateAfter = thread.state.getLatestValue(); - expect(stateAfter.pagination.prevCursor).to.not.be.null; - expect(stateAfter.pagination.nextCursor).to.equal('next-cursor'); - }); - it('retains failed replies after hydration', () => { const thread = createTestThread(); const hydrationThread = createTestThread({ - latest_replies: [ - generateMsg({ parent_id: parentMessageResponse.id }) as MessageResponse, - ], + latest_replies: [makeReply()], + reply_count: 1, }); - const failedMessage = generateMsg({ - status: 'failed', - parent_id: parentMessageResponse.id, - }) as MessageResponse; + const failedMessage = makeReply({ status: 'failed' }); thread.upsertReplyLocally({ message: failedMessage }); thread.hydrateState(hydrationThread); - const stateAfter = thread.state.getLatestValue(); - expect(stateAfter.replies).to.have.lengthOf(2); - expect(stateAfter.replies[1].id).to.equal(failedMessage.id); + // The failed reply survives the hydrate (it is re-ingested into the paginator). + expect(repliesOf(thread).map((reply) => reply.id)).to.include(failedMessage.id); }); it('merges the incoming newest reply window into the reply paginator', () => { @@ -480,35 +458,6 @@ describe('Threads 2.0', () => { }); describe('reload', () => { - it('bootstraps pagination for minimally initialized threads', async () => { - const minimalThread = createMinimalThread(); - const hydratedThread = createTestThread({ - latest_replies: [ - generateMsg({ parent_id: parentMessageResponse.id }) as MessageResponse, - ], - reply_count: 3, - }); - hydratedThread.state.next((current) => ({ - ...current, - pagination: { - ...current.pagination, - nextCursor: 'next-cursor', - }, - })); - - sinon.stub(client, 'getThread').resolves(hydratedThread); - - const stateBefore = minimalThread.state.getLatestValue(); - expect(stateBefore.pagination.prevCursor).to.be.null; - expect(stateBefore.pagination.nextCursor).to.be.null; - - await minimalThread.reload(); - - const stateAfter = minimalThread.state.getLatestValue(); - expect(stateAfter.pagination.prevCursor).to.not.be.null; - expect(stateAfter.pagination.nextCursor).to.equal('next-cursor'); - }); - it('sizes getThread reply_limit to the loaded reply count, falling back to pageSize when unloaded', async () => { const stub = sinon.stub(client, 'getThread').resolves(createTestThread()); @@ -550,8 +499,8 @@ describe('Threads 2.0', () => { ); const thread = createTestThread({ latest_replies: messages }); - const stateBefore = thread.state.getLatestValue(); - expect(stateBefore.replies).to.have.lengthOf(5); + const repliesBefore = repliesOf(thread); + expect(repliesBefore).to.have.lengthOf(5); const messageToDelete = generateMsg({ created_at: messages[2].created_at, @@ -560,11 +509,11 @@ describe('Threads 2.0', () => { thread.deleteReplyLocally({ message: messageToDelete }); - const stateAfter = thread.state.getLatestValue(); - expect(stateAfter.replies).to.not.equal(stateBefore.replies); - expect(stateAfter.replies).to.have.lengthOf(4); - expect(stateAfter.replies.find((reply) => reply.id === messageToDelete.id)).to - .be.undefined; + const repliesAfter = repliesOf(thread); + expect(repliesAfter).to.not.equal(repliesBefore); + expect(repliesAfter).to.have.lengthOf(4); + expect(repliesAfter.find((reply) => reply.id === messageToDelete.id)).to.be + .undefined; }); }); @@ -607,205 +556,48 @@ describe('Threads 2.0', () => { }); }); - describe('loadPage', () => { - it('sets up pagination on initialization (all replies included in response)', () => { - const thread = createTestThread({ - latest_replies: [generateMsg() as MessageResponse], - reply_count: 1, - }); - const state = thread.state.getLatestValue(); - expect(state.pagination.prevCursor).to.be.null; - expect(state.pagination.nextCursor).to.be.null; - }); - - it('sets up pagination on initialization (not all replies included in response)', () => { - const firstMessage = generateMsg() as MessageResponse; - const lastMessage = generateMsg() as MessageResponse; - const thread = createTestThread({ - latest_replies: [firstMessage, lastMessage], - reply_count: 3, - }); - const state = thread.state.getLatestValue(); - expect(state.pagination.prevCursor).not.to.be.null; - expect(state.pagination.nextCursor).to.be.null; - }); - - it('updates pagination after loading next page (end reached)', async () => { - const thread = createTestThread({ - latest_replies: [generateMsg(), generateMsg()] as MessageResponse[], - reply_count: 3, - }); - thread.state.next((current) => ({ - ...current, - pagination: { - ...current.pagination, - nextCursor: 'cursor', - }, - })); - sinon.stub(thread, 'queryReplies').resolves({ - messages: [generateMsg()] as MessageResponse[], - duration: '', - }); - - await thread.loadNextPage({ limit: 2 }); - - const state = thread.state.getLatestValue(); - expect(state.pagination.nextCursor).to.be.null; - }); - - it('updates pagination after loading next page (end not reached)', async () => { - const thread = createTestThread({ - latest_replies: [generateMsg(), generateMsg()] as MessageResponse[], - reply_count: 4, - }); - thread.state.next((current) => ({ - ...current, - pagination: { - ...current.pagination, - nextCursor: 'cursor', - }, - })); - const lastMessage = generateMsg() as MessageResponse; - sinon.stub(thread, 'queryReplies').resolves({ - messages: [generateMsg(), lastMessage] as MessageResponse[], - duration: '', - }); - - await thread.loadNextPage({ limit: 2 }); - - const state = thread.state.getLatestValue(); - expect(state.pagination.nextCursor).to.equal(lastMessage.id); - }); - - it('forms correct request when loading next page', async () => { - const firstMessage = generateMsg() as MessageResponse; - const lastMessage = generateMsg() as MessageResponse; - const thread = createTestThread({ - latest_replies: [firstMessage, lastMessage], - reply_count: 3, - }); - thread.state.next((current) => ({ - ...current, - pagination: { - ...current.pagination, - nextCursor: lastMessage.id, - }, - })); - const queryRepliesStub = sinon - .stub(thread, 'queryReplies') - .resolves({ messages: [], duration: '' }); - - await thread.loadNextPage({ limit: 42 }); - - expect( - queryRepliesStub.calledOnceWith({ - id_gt: lastMessage.id, - limit: 42, - }), - ).to.be.true; - }); - - it('updates pagination after loading previous page (end reached)', async () => { - const thread = createTestThread({ - latest_replies: [generateMsg(), generateMsg()] as MessageResponse[], - reply_count: 3, - }); - sinon.stub(thread, 'queryReplies').resolves({ - messages: [generateMsg()] as MessageResponse[], - duration: '', - }); - - await thread.loadPrevPage({ limit: 2 }); - - const state = thread.state.getLatestValue(); - expect(state.pagination.prevCursor).to.be.null; - }); - - it('updates pagination after loading previous page (end not reached)', async () => { - const thread = createTestThread({ - latest_replies: [generateMsg(), generateMsg()] as MessageResponse[], - reply_count: 4, - }); - const firstMessage = generateMsg() as MessageResponse; - sinon.stub(thread, 'queryReplies').resolves({ - messages: [firstMessage, generateMsg()] as MessageResponse[], - duration: '', - }); - - await thread.loadPrevPage({ limit: 2 }); - - const state = thread.state.getLatestValue(); - expect(state.pagination.prevCursor).to.equal(firstMessage.id); - }); + // Reply pagination now flows through the instance's messagePaginator (toTail = older, + // toHead = newer) — the replacement for the removed Thread.loadNextPage/loadPrevPage. The + // paginator's own suite covers the query-shape/cursor mechanics; these assert the end-to-end + // wiring through a real Thread (seeded from latest_replies) which the paginator suite doesn't. + describe('reply pagination (messagePaginator)', () => { + it('loads older replies via toTail() and scopes the request to the thread parent', async () => { + // Seeded newest window with older replies still to load (reply_count > loaded). + const newest = makeReply({ created_at: '2020-01-03T00:00:00.000Z' }); + const thread = createTestThread({ latest_replies: [newest], reply_count: 3 }); + expect(thread.messagePaginator.state.getLatestValue().hasMoreTail).to.be.true; - it('forms correct request when loading previous page', async () => { - const firstMessage = generateMsg() as MessageResponse; - const lastMessage = generateMsg() as MessageResponse; - const thread = createTestThread({ - latest_replies: [firstMessage, lastMessage], - reply_count: 3, - }); - const queryRepliesStub = sinon - .stub(thread, 'queryReplies') - .resolves({ messages: [], duration: '' }); + const older = makeReply({ created_at: '2020-01-02T00:00:00.000Z' }); + const getRepliesStub = sinon + .stub(thread.channel, 'getReplies') + .resolves({ messages: [older], duration: '' } as unknown as ReturnType< + Channel['getReplies'] + >); - await thread.loadPrevPage({ limit: 42 }); + await thread.messagePaginator.toTail(); - expect( - queryRepliesStub.calledOnceWith({ - id_lt: firstMessage.id, - limit: 42, - }), - ).to.be.true; + // The fetched older reply is now in the rendered reply list... + expect(repliesOf(thread).map((reply) => reply.id)).to.include(older.id); + // ...and the request was made against this thread's parent (the replies endpoint). + expect(getRepliesStub.calledOnce).to.be.true; + expect(getRepliesStub.firstCall.args[0]).to.equal(thread.id); }); - it('appends messages when loading next page', async () => { - const initialMessages = [generateMsg(), generateMsg()] as MessageResponse[]; - const nextMessages = [generateMsg(), generateMsg()] as MessageResponse[]; - const thread = createTestThread({ - latest_replies: initialMessages, - reply_count: 4, - }); - thread.state.next((current) => ({ - ...current, - pagination: { - ...current.pagination, - nextCursor: initialMessages[1].id, - }, - })); - sinon - .stub(thread, 'queryReplies') - .resolves({ messages: nextMessages, duration: '' }); - - await thread.loadNextPage({ limit: 2 }); - - const stateAfter = thread.state.getLatestValue(); - const expectedMessageOrder = [...initialMessages, ...nextMessages] - .map(({ id }) => id) - .join(', '); - const actualMessageOrder = stateAfter.replies.map(({ id }) => id).join(', '); - expect(actualMessageOrder).to.equal(expectedMessageOrder); - }); + it('clears hasMoreTail once toTail() reaches the start of the reply list', async () => { + const newest = makeReply({ created_at: '2020-01-03T00:00:00.000Z' }); + const thread = createTestThread({ latest_replies: [newest], reply_count: 2 }); + expect(thread.messagePaginator.state.getLatestValue().hasMoreTail).to.be.true; - it('prepends messages when loading previous page', async () => { - const initialMessages = [generateMsg(), generateMsg()] as MessageResponse[]; - const prevMessages = [generateMsg(), generateMsg()] as MessageResponse[]; - const thread = createTestThread({ - latest_replies: initialMessages, - reply_count: 4, - }); + const older = makeReply({ created_at: '2020-01-02T00:00:00.000Z' }); sinon - .stub(thread, 'queryReplies') - .resolves({ messages: prevMessages, duration: '' }); + .stub(thread.channel, 'getReplies') + .resolves({ messages: [older], duration: '' } as unknown as ReturnType< + Channel['getReplies'] + >); - await thread.loadPrevPage({ limit: 2 }); + await thread.messagePaginator.toTail(); - const stateAfter = thread.state.getLatestValue(); - const expectedMessageOrder = [...prevMessages, ...initialMessages] - .map(({ id }) => id) - .join(', '); - const actualMessageOrder = stateAfter.replies.map(({ id }) => id).join(', '); - expect(actualMessageOrder).to.equal(expectedMessageOrder); + expect(thread.messagePaginator.state.getLatestValue().hasMoreTail).to.be.false; }); }); }); @@ -857,15 +649,20 @@ describe('Threads 2.0', () => { }); it('reloads stale state when thread is active', async () => { - const thread = createTestThread(); + const initialReply = makeReply({ created_at: '2020-03-01T00:00:00.000Z' }); + const thread = createTestThread({ + latest_replies: [initialReply], + reply_count: 1, + }); thread.registerSubscriptions(); - const stateBefore = thread.state.getLatestValue(); - const stubbedGetThread = sinon - .stub(client, 'getThread') - .resolves( - createTestThread({ latest_replies: [generateMsg() as MessageResponse] }), - ); + const reloadedReply = makeReply({ created_at: '2020-03-01T00:00:01.000Z' }); + const stubbedGetThread = sinon.stub(client, 'getThread').resolves( + createTestThread({ + latest_replies: [initialReply, reloadedReply], + reply_count: 2, + }), + ); thread.state.partialNext({ isStateStale: true }); @@ -876,8 +673,7 @@ describe('Threads 2.0', () => { expect(stubbedGetThread.calledOnce).to.be.true; await stubbedGetThread.firstCall.returnValue; - const stateAfter = thread.state.getLatestValue(); - expect(stateAfter.replies).not.to.equal(stateBefore.replies); + expect(repliesOf(thread).map((reply) => reply.id)).to.include(reloadedReply.id); thread.unregisterSubscriptions(); }); @@ -1205,26 +1001,20 @@ describe('Threads 2.0', () => { }); thread.registerSubscriptions(); - const newMessage = generateMsg({ - parent_id: thread.id, - user: { id: 'bob' }, - }) as MessageResponse; + const newMessage = makeReply({ user: { id: 'bob' } }); client.dispatchEvent({ type: 'message.new', message: newMessage, user: { id: 'bob' }, }); - const stateAfter = thread.state.getLatestValue(); - expect(stateAfter.replies).to.have.length(1); - expect(stateAfter.replies.find((reply) => reply.id === newMessage.id)).not.to.be - .undefined; + expect(repliesOf(thread).map((reply) => reply.id)).to.include(newMessage.id); expect(thread.ownUnreadCount).to.equal(1); thread.unregisterSubscriptions(); }); - it('increments local reply_count on new reply', () => { + it('tracks reply_count from the authoritative parent update, not a local increment on new reply', () => { const thread = createTestThread({ reply_count: 0, read: [ @@ -1242,11 +1032,22 @@ describe('Threads 2.0', () => { user: { id: 'bob' }, }) as MessageResponse; + // A received reply must NOT locally bump replyCount. The count is kept authoritative by + // the parent's server-driven reply_count (below); a local increment double-counted + // received replies on top of that re-sync (see subscribeNewReplies). client.dispatchEvent({ type: 'message.new', message: newMessage, user: { id: 'bob' }, }); + expect(thread.state.getLatestValue().replyCount).to.equal(0); + + // The server delivers the authoritative reply_count via the parent's message.updated. + client.dispatchEvent({ + type: 'message.updated', + message: { ...parentMessageResponse, reply_count: 1 } as MessageResponse, + user: { id: 'bob' }, + }); const stateAfter = thread.state.getLatestValue(); expect(stateAfter.replyCount).to.equal(1); @@ -1255,7 +1056,7 @@ describe('Threads 2.0', () => { thread.unregisterSubscriptions(); }); - it('does not increment local reply_count for duplicate message.new events', () => { + it('does not change local reply_count on message.new (parent-message-driven, so duplicates are harmless)', () => { const existingReply = generateMsg({ parent_id: parentMessageResponse.id, user: { id: 'bob' }, @@ -1263,6 +1064,7 @@ describe('Threads 2.0', () => { const thread = createTestThread({ latest_replies: [existingReply], reply_count: 1, + parentMessageOverrides: { reply_count: 1 }, read: [ { user: { id: TEST_USER_ID }, @@ -1273,14 +1075,11 @@ describe('Threads 2.0', () => { }); thread.registerSubscriptions(); - thread.state.next((current) => ({ - ...current, - parentMessage: { - ...current.parentMessage, - reply_count: 1, - }, - })); + // reply_count is sourced from the parent message, so it starts at 1. + expect(thread.state.getLatestValue().replyCount).to.equal(1); + // A message.new (here a duplicate of an already-loaded reply) must not locally change the + // count — the authoritative reply_count comes from the parent message, so this is a no-op. client.dispatchEvent({ type: 'message.new', message: existingReply, @@ -1296,7 +1095,8 @@ describe('Threads 2.0', () => { it('handles receiving a reply that was previously optimistically added', () => { const thread = createTestThread({ - latest_replies: [generateMsg() as MessageResponse], + latest_replies: [makeReply()], + reply_count: 1, read: [ { user: { id: TEST_USER_ID }, @@ -1305,14 +1105,10 @@ describe('Threads 2.0', () => { }, ], }); - const message = generateMsg({ - parent_id: thread.id, - user: { id: TEST_USER_ID }, - }) as MessageResponse; + const message = makeReply({ user: { id: TEST_USER_ID } }); thread.upsertReplyLocally({ message }); - const stateBefore = thread.state.getLatestValue(); - expect(stateBefore.replies).to.have.length(2); + expect(repliesOf(thread)).to.have.length(2); expect(thread.ownUnreadCount).to.equal(0); client.dispatchEvent({ @@ -1321,8 +1117,8 @@ describe('Threads 2.0', () => { user: { id: TEST_USER_ID }, }); - const stateAfter = thread.state.getLatestValue(); - expect(stateAfter.replies).to.have.length(2); + // Receiving the same reply over the WS must not duplicate it. + expect(repliesOf(thread)).to.have.length(2); expect(thread.ownUnreadCount).to.equal(0); }); }); @@ -1405,10 +1201,10 @@ describe('Threads 2.0', () => { message: messageToDelete, }); - const stateAfter = thread.state.getLatestValue(); - expect(stateAfter.replies).to.have.lengthOf(4); - expect(stateAfter.replies.find((reply) => reply.id === messageToDelete.id)).to - .be.undefined; + const replies = repliesOf(thread); + expect(replies).to.have.lengthOf(4); + expect(replies.find((reply) => reply.id === messageToDelete.id)).to.be + .undefined; thread.unregisterSubscriptions(); }); @@ -1416,15 +1212,10 @@ describe('Threads 2.0', () => { it('updates deleted_at property of the reply if it was soft deleted', () => { const createdAt = new Date().getTime(); // five messages "created" second apart - const messages = Array.from( - { length: 5 }, - (_, i) => - generateMsg({ - parent_id: parentMessageResponse.id, - created_at: new Date(createdAt + 1000 * i).toISOString(), - }) as MessageResponse, + const messages = Array.from({ length: 5 }, (_, i) => + makeReply({ created_at: new Date(createdAt + 1000 * i).toISOString() }), ); - const thread = createTestThread({ latest_replies: messages }); + const thread = createTestThread({ latest_replies: messages, reply_count: 5 }); thread.registerSubscriptions(); const messageToDelete = messages[2]; @@ -1441,15 +1232,14 @@ describe('Threads 2.0', () => { }, }); - const stateAfter = thread.state.getLatestValue(); - expect(stateAfter.replies).to.have.lengthOf(5); - expect(stateAfter.replies[2].id).to.equal(messageToDelete.id); - expect(stateAfter.replies[2]).to.not.equal(messageToDelete); - expect(stateAfter.replies[2].deleted_at).to.be.a('date'); - expect(stateAfter.replies[2].deleted_at!.toISOString()).to.equal( - deletedAt.toISOString(), - ); - expect(stateAfter.replies[2].type).to.equal('deleted'); + // Soft delete routes through upsertReplyLocally, so the reply is retained (marked) in place. + const replies = repliesOf(thread); + expect(replies).to.have.lengthOf(5); + expect(replies[2].id).to.equal(messageToDelete.id); + expect(replies[2]).to.not.equal(messageToDelete); + expect(replies[2].deleted_at).to.be.a('date'); + expect(replies[2].deleted_at!.toISOString()).to.equal(deletedAt.toISOString()); + expect(replies[2].type).to.equal('deleted'); thread.unregisterSubscriptions(); }); From 3d2a56a40d185ebd0af674fd6de4c9d56e86645e Mon Sep 17 00:00:00 2001 From: MartinCupela <32706194+MartinCupela@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:27:37 +0200 Subject: [PATCH 42/48] refactor: replace legacy ChannelState message/thread/pinned storage with paginators (#1806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary Completes the `message-paginator` initiative on the LLC side: the channel's **messages, thread replies, and pinned messages are no longer stored on `channel.state`**. Each list now lives in a paginator that is the single source of truth (interval storage + a canonical `ItemIndex`): - **Main message list** → `channel.messagePaginator` - **Thread replies** → `thread.messagePaginator` (owned by the `Thread` object) - **Pinned messages** → `channel.pinnedMessagesPaginator` (new `PinnedMessagePaginator`) ### What changed - **`ChannelState` storage removed:** `messages`, `latestMessages`, `messageSets`, `messagePagination`, `threads`, `pinnedMessages`, and their mutators (`addMessageSorted`/`addMessagesSorted`, `removeMessage`, `findMessage`, `findMessageByTimestamp`, `filterErrorMessages`, `loadMessageIntoState`, `clearMessages`, `initMessages`, `pruneOldest`, `addReaction`/`removeReaction`, `updateUserMessages`, `deleteUserMessages`, `addPinnedMessages`/`addPinnedMessage`/`removePinnedMessage`, `removeQuotedMessageReferences`, + internal helpers). - **`PinnedMessagePaginator`** added and populated from channel events; pin/unpin falls out of `ingestItem` + a `{ pinned: true }` filter (no bespoke branching). - **`MessageIntervalPaginator`** extracted as the unread-free base of `MessagePaginator`; it tracks the latest message (`latestMessageId` + `latestMessage`), advanced on ingest and mirrored into reactive state. - **`channel.state.last_message_at`** is now a **read-only getter** derived from `messagePaginator.latestMessage` (setter + backing field removed). - **`channel.state.isUpToDate` / `setIsUpToDate` removed** — live-message routing (don't disrupt a scrolled-away view) is handled structurally by the paginator's interval model. - **`Channel._trackLatestMessage` removed.** `user.updated` / `user.deleted` propagation now scans active channels (`reflectUserUpdate` / `applyMessageDeletionForUser` self-filter by author id). A targeted user-reference index is planned (`specs/user-reference-index`). - **`utils` cleanup:** removed `addToMessageList`, `messageSetPagination`, `binarySearchByDateEqualOrNearestGreater`, `deleteUserMessages`, and the `MessageSet` / message-set pagination types. - **Reactive `headItems`** added to `PaginatorState` (newest-loaded window). - **Removed the unused `MessageReplyPaginator`** (dead sibling; the thread reply list uses `MessagePaginator`). ### Breaking changes Full list + before → after migration table in **`docs/breaking-changes-v14-v15.md`**. Highlights: | Before (v14) | After (v15) | | --- | --- | | `channel.state.messages` | `channel.messagePaginator.state.items` / `.items` | | `channel.state.threads[parentId]` | `thread.messagePaginator.state.items` | | `channel.state.pinnedMessages` | `channel.pinnedMessagesPaginator.state.items` | | `channel.state.addMessageSorted(m)` | `channel.messagePaginator.ingestItem(m)` | | `channel.state.removeMessage({ id })` | `channel.messagePaginator.removeItem({ id })` | | `channel.state.findMessage(id)` | `channel.messagePaginator.getItem(id)` | | `channel.state.isUpToDate` / `setIsUpToDate` | `messagePaginator.isActiveIntervalAtHead` / `hasMoreHead` / `jumpToTheLatestMessage()` | | `channel.state.last_message_at = …` | read-only (derived); ingest a message on the paginator | Behavioral note: `last_message_at` now advances on every incoming message regardless of the viewer's scroll position (the old `isUpToDate` suppression is gone) — it's a channel-level fact. ### Follow-ups (not in this PR) - **`specs/user-reference-index`** — replace the active-channel scan on `user.updated`/`user.deleted` with a `userId → message-reference` index (design-gated). - Unifying the remaining flat paginators on interval storage - Offline migration --------- Co-authored-by: Claude Opus 4.8 --- CLAUDE.md | 2 +- docs/breaking-changes-v14-v15.md | 164 + .../decisions.md | 41 + .../plan.md | 286 ++ .../spec.md | 72 + .../state.json | 82 + .../decisions.md | 62 + .../plan.md | 808 +++++ .../spec.md | 85 + .../state.json | 157 + specs/retained-items/spec.md | 174 + specs/user-reference-index/decisions.md | 56 + specs/user-reference-index/goal.md | 62 + specs/user-reference-index/plan.md | 158 + specs/user-reference-index/state.json | 25 + src/CooldownTimer.ts | 2 +- src/channel.ts | 285 +- src/channel_state.ts | 1050 +----- src/client.ts | 109 +- src/constants.ts | 4 - .../MessageDeliveryReporter.ts | 2 +- src/messageDelivery/MessageReceiptsTracker.ts | 2 +- src/offline-support/offline_support_api.ts | 3 +- src/pagination/paginators/BasePaginator.ts | 239 +- src/pagination/paginators/ChannelPaginator.ts | 11 +- .../paginators/MessageIntervalPaginator.ts | 1069 ++++++ src/pagination/paginators/MessagePaginator.ts | 977 ++---- .../paginators/MessageReplyPaginator.ts | 301 -- .../paginators/PinnedMessagePaginator.ts | 104 + .../paginators/ReminderPaginator.ts | 34 +- .../paginators/UserGroupPaginator.ts | 14 +- src/pagination/paginators/index.ts | 3 +- src/pagination/sortCompiler.ts | 34 +- src/pagination/utility.search.ts | 24 + src/thread.ts | 83 +- src/types.ts | 6 - src/utils.ts | 360 -- .../typescript/response-generators/channel.js | 2 +- .../typescript/response-generators/message.js | 5 +- test/unit/CooldownTimer.test.ts | 44 +- test/unit/channel.test.js | 2201 ++++++------ test/unit/channel_state.test.js | 2069 ----------- test/unit/client.test.js | 352 +- .../MessageDeliveryReporter.test.ts | 54 +- .../MessageReceiptsTracker.test.ts | 6 +- .../offline_support_api.test.ts | 9 +- .../paginators/BasePaginator.test.ts | 401 +-- .../paginators/ChannelPaginator.test.ts | 185 +- .../paginators/MessagePaginator.test.ts | 804 ++++- .../paginators/MessageReplyPaginator.test.ts | 114 - .../paginators/PinnedMessagePaginator.test.ts | 110 + .../paginators/ReminderPaginator.test.ts | 97 + .../paginators/UserGroupPaginator.test.ts | 121 + test/unit/pagination/sortCompiler.test.ts | 30 +- test/unit/pagination/utility.search.test.ts | 29 + test/unit/poll_manager.test.ts | 1 - test/unit/threads.test.ts | 99 +- test/unit/utils.test.js | 3081 ----------------- test/unit/utils.test.ts | 143 +- 59 files changed, 6967 insertions(+), 9940 deletions(-) create mode 100644 docs/breaking-changes-v14-v15.md create mode 100644 specs/migrate-offline-to-messagepaginator/decisions.md create mode 100644 specs/migrate-offline-to-messagepaginator/plan.md create mode 100644 specs/migrate-offline-to-messagepaginator/spec.md create mode 100644 specs/migrate-offline-to-messagepaginator/state.json create mode 100644 specs/remove-legacy-channelstate-messages/decisions.md create mode 100644 specs/remove-legacy-channelstate-messages/plan.md create mode 100644 specs/remove-legacy-channelstate-messages/spec.md create mode 100644 specs/remove-legacy-channelstate-messages/state.json create mode 100644 specs/retained-items/spec.md create mode 100644 specs/user-reference-index/decisions.md create mode 100644 specs/user-reference-index/goal.md create mode 100644 specs/user-reference-index/plan.md create mode 100644 specs/user-reference-index/state.json create mode 100644 src/pagination/paginators/MessageIntervalPaginator.ts delete mode 100644 src/pagination/paginators/MessageReplyPaginator.ts create mode 100644 src/pagination/paginators/PinnedMessagePaginator.ts delete mode 100644 test/unit/pagination/paginators/MessageReplyPaginator.test.ts create mode 100644 test/unit/pagination/paginators/PinnedMessagePaginator.test.ts create mode 100644 test/unit/pagination/paginators/ReminderPaginator.test.ts create mode 100644 test/unit/pagination/paginators/UserGroupPaginator.test.ts create mode 100644 test/unit/pagination/utility.search.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 29e93c200c..6823121978 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ 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). +- **`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`. - **`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/docs/breaking-changes-v14-v15.md b/docs/breaking-changes-v14-v15.md new file mode 100644 index 0000000000..ee80520816 --- /dev/null +++ b/docs/breaking-changes-v14-v15.md @@ -0,0 +1,164 @@ +# Breaking Changes: v14 → v15 (stream-chat JS) + +Consumer-facing **breaking changes** on the v15 line of the JS SDK — the `message-paginator` +initiative (`feat/message-paginator-master-merge` and the follow-on +`refactor/remove-legacy-channelstate-storage`), beyond the master ⇄ PR merge itself. This is the +source for the v14 → v15 migration guide / release notes. Append newest at the top. Keep each entry +self-contained: what changed, before → after, how to migrate, why. + +> Scope note: this file tracks **public API / observable behavior** changes. Internal refactors with +> identical output belong in `decisions.md`, not here. + +--- + +## `ChannelState` message / thread / pinned storage removed — paginators are the source of truth + +**Area:** `ChannelState`, `Channel`, `utils` · **Status:** implemented + +The channel's messages, thread replies, and pinned messages are no longer stored on `channel.state`. +Each list now lives in a paginator that is the single source of truth (interval storage + a canonical +`ItemIndex`): + +- **Main message list** → `channel.messagePaginator` +- **Thread replies** → `thread.messagePaginator` (via `client.threads` / the `Thread` object) +- **Pinned messages** → `channel.pinnedMessagesPaginator` + +### Removed from `ChannelState` + +Properties / getters: `messages`, `latestMessages`, `messageSets`, `messagePagination`, `threads`, +`pinnedMessages`. (Also `isUpToDate` and the `last_message_at` setter — see the dedicated entries +below.) + +Methods: `addMessageSorted`, `addMessagesSorted`, `removeMessage`, `findMessage`, +`findMessageByTimestamp`, `filterErrorMessages`, `loadMessageIntoState`, `clearMessages`, +`initMessages`, `pruneOldest`, `addReaction`, `removeReaction`, `updateUserMessages`, +`deleteUserMessages`, `addPinnedMessages`, `addPinnedMessage`, `removePinnedMessage`, +`removeQuotedMessageReferences` (plus the internal `_updateMessage` / `_updateQuotedMessageReferences` +/ `_add*`/`_remove*` reaction helpers). + +### Removed from `utils` (re-exported through the package root) + +`addToMessageList`, `messageSetPagination`, `binarySearchByDateEqualOrNearestGreater`, +`deleteUserMessages`, and the `MessageSet` / message-set pagination types. + +### Migrate + +| Before (v14) | After (v15) | +| ------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `channel.state.messages` | `channel.messagePaginator.state.items` (reactive) / `channel.messagePaginator.items` | +| `channel.state.latestMessages` | `channel.messagePaginator.headItems` / `.lastMessage` | +| `channel.state.messagePagination` | `channel.messagePaginator.state` (`hasMoreHead` / `hasMoreTail` / `cursor`) | +| `channel.state.threads[parentId]` | `thread.messagePaginator.state.items` (resolve the `Thread` via `client.threads`) | +| `channel.state.pinnedMessages` | `channel.pinnedMessagesPaginator.state.items` | +| `channel.state.addMessageSorted(m)` / `addMessagesSorted(ms)` | `channel.messagePaginator.ingestItem(m)` | +| `channel.state.removeMessage({ id })` | `channel.messagePaginator.removeItem({ id })` | +| `channel.state.findMessage(id)` | `channel.messagePaginator.getItem(id)` | + +Reactive reads use `useStateStore(channel.messagePaginator.state, …)` (or the paginator's `state` +store directly). Pin/unpin, reactions, user updates, and deletions are applied to the paginators by +the SDK's own event handlers — application code should not call the removed mutators. + +### Why + +Messages were previously stored twice — a flat `ChannelState` list plus message-set/pagination +bookkeeping — which had to be kept in sync with the paginators and could drift. Consolidating on the +paginators removes the dual-write, gives one API/behavior across the main list, threads, and pinned +messages (dedup-by-id, interval merge, `getItem`/`removeItem`, head-window semantics), and lets +`last_message_at` be derived rather than separately maintained. + +## `ChannelState.last_message_at` removed — use `channel.messagePaginator.lastMessageAt` + +**Area:** `ChannelState` · **Status:** implemented + +`channel.state.last_message_at` was **removed entirely** (it was briefly a read-only getter earlier +in v15; that getter is gone too). The channel's latest-message timestamp is now owned by the message +paginator as a whole-collection aggregate. + +- **Before:** `channel.state.last_message_at` (writable, then a derived getter). Internally + maintained by the now-removed `Channel._trackLatestMessage`. +- **After:** `channel.messagePaginator.lastMessageAt` — a `Date | null` **derived** getter over the + paginator's `aggregateState` store (`MessagePaginatorAggregateState = { lastMessage, +seededLastMessageAt }`). It returns `max(lastMessage?.created_at, seededLastMessageAt)`: + `lastMessage` is the newest loaded/received message (advanced on ingest), `seededLastMessageAt` is + the server floor **seeded from `ChannelResponse.last_message_at`** (for channels whose newest + message isn't loaded). Deriving the sort key from the two independent facts means it can never drift + from the display message. Subscribe to `channel.messagePaginator.aggregateState` for reactivity. +- **Migrate:** replace `channel.state.last_message_at` reads with + `channel.messagePaginator.lastMessageAt`. It is not writable; the value is derived from ingested + messages and the server seed. +- **Why:** the message paginator is the single source of truth for messages; `last_message_at` is an + aggregate over them (the dual of pagination). Deriving it through a `ChannelState` getter that + reached into the paginator's message index risked stale/mixed-basis sorting (a seeded-but-stale + paginator preferred over a fresher server value); a single seeded-then-advanced value on the + paginator removes that hazard. +- **Tracking relocated:** `MessageIntervalPaginator`'s `state.latestMessageId` and the `latestMessage` + getter (id resolved from the pagination `state`) were replaced. The tracked latest now lives on + `MessagePaginator.aggregateState.lastMessage`, advanced on every ingest. + `MessagePaginator.lastMessage` remains as a convenience getter but now reads `aggregateState`. + This matters for reactivity: pagination `state` only emits when the **active** interval is impacted, + so a WS message landing in the (non-active) head interval would not notify a `state`-derived + latest; `aggregateState` is written directly on each advance and emits regardless — subscribe to it + (e.g. for a channel/thread list item's latest-message display). `aggregateState.lastMessage` is a + LIVE reference: refreshed in place on edit/soft-delete/reaction of the current latest and recomputed + on hard-remove, and it honors `skip_last_msg_update_for_system_msgs` (system messages neither + reorder a channel nor become its displayed latest). A consumer that previously showed the unfiltered + newest message (`headmostItem`) as the channel-list preview will now skip system messages under that + config — a deliberate behavior change so the preview and the channel's sort position agree. + +## Newest-loaded window is exposed as computed getters `headItems` / `headmostItem` + +**Area:** `BasePaginator` · **Status:** implemented + +The newest-loaded window is exposed as **computed getters** on the paginator — `paginator.headItems` +(the window; `[]` before the first load) and `paginator.headmostItem` (its single newest item), +derived from the intervals on read. + +- **Migrate:** the v14 `channel.state.latestMessages` reactive array becomes + `channel.messagePaginator.headItems`. Because these are getters, **not** `PaginatorState` fields, + they are not subscribable via `useStateStore(paginator.state, (s) => s.headItems)` — read them + directly, or subscribe to `channel.messagePaginator.aggregateState` for the reactive last-message + signal. +- **Why:** the value is derivable from the intervals on demand, so it does not need to be materialized + (and re-emitted) into pagination state. + +## `Channel.lastMessage()` removed — use `channel.messagePaginator.headmostItem` + +**Area:** `Channel` · **Status:** implemented + +`channel.lastMessage()` was removed. It returned the newest loaded message (the head edge of the +message paginator's latest window); read `channel.messagePaginator.headmostItem` directly instead. + +- **Migrate:** `channel.lastMessage()` → `channel.messagePaginator.headmostItem`. +- **Note:** `headmostItem` is the newest _loaded_ message, **unfiltered** (includes system messages) — + distinct from `channel.messagePaginator.lastMessage`, the filtered chronological latest (honors + `skip_last_msg_update_for_system_msgs`) that backs `lastMessageAt`. Use `headmostItem` for "the newest + message on screen"; use `lastMessage` / `lastMessageAt` for channel-list ordering. +- **Why:** it was a thin wrapper over `headmostItem`, and sharing the name `lastMessage` with the + differently-filtered paginator getter was misleading. + +## `ChannelState.isUpToDate` / `setIsUpToDate` removed + +**Area:** `ChannelState` · **Status:** implemented + +The `isUpToDate` flag and its `setIsUpToDate(boolean)` setter were removed. They gated whether an +incoming `message.new` was appended to the visible message list when the user had scrolled to older +history. + +- **Before:** UI SDKs set `channel.state.setIsUpToDate(false)` when jumping to an older window so live + messages were not forced onto the visible list, and read `channel.state.isUpToDate` to decide + whether to show a "jump to latest" affordance. +- **After:** neither exists. Message routing is handled structurally by the message paginator: a live + message newer than the loaded head lands in the head (or logical-head) interval, which is not the + active window when the viewer has jumped away, so the visible window is preserved with no flag. +- **Migrate:** + - "Am I viewing the newest window?" → `channel.messagePaginator.isActiveIntervalAtHead` (getter). + - "Are there newer messages not yet loaded?" → `channel.messagePaginator.hasMoreHead` (reactive + via `channel.messagePaginator.state`). + - "Jump to the latest" → `channel.messagePaginator.jumpToTheLatestMessage()`. +- **Behavioral note:** `last_message_at` now advances on every incoming message regardless of the + viewer's scroll position (the old `isUpToDate` suppression is gone) — it is a channel-level fact, + independent of what the UI is currently viewing. +- **Why:** the flag duplicated state the paginator already models, and was only ever set `false` on + disconnect on this branch — its message-list responsibility had already moved to the paginator. + +--- diff --git a/specs/migrate-offline-to-messagepaginator/decisions.md b/specs/migrate-offline-to-messagepaginator/decisions.md new file mode 100644 index 0000000000..cee877ed7d --- /dev/null +++ b/specs/migrate-offline-to-messagepaginator/decisions.md @@ -0,0 +1,41 @@ +# Decisions — Offline migration + +## D-OFF-1 — Enhancement tranche (offline older-page pagination) + +**Status:** OPEN + +**Question:** Do we add the cursor-aware `AbstractOfflineDB.getChannelMessages` read + +`MessagePaginator.preloadFirstPageFromOfflineDb` (Task O7), enabling paginating older messages while +offline — or ship parity only (O1–O6)? + +**Trade-off:** O7 adds a new **abstract** method to the injection interface, which **breaks every +concrete offline DB implementation (RN/mobile) until they implement it** and must be released in +coordination with those SDKs. Parity (O1–O6) adds **no** abstract method and is safe for existing +implementers; it preserves today's behavior (offline shows the last-known latest page via channel +hydration; older-page loads need the network). + +**Recommendation:** **Ship parity (O1–O6) first**; schedule O7 as a follow-up with the RN/mobile +teams. Rationale: parity unblocks the parent plan's storage deletion without a cross-SDK breaking +change, and legacy offline didn't support older-page pagination either — so O7 is a net-new feature, +not a regression to avoid. + +## D-OFF-2 — `hydrateActiveChannels` paginator seed source (verify) + +**Status:** OPEN (verification) + +**Question:** Does the `hydrateActiveChannels` offline seed read the raw `ChannelAPIResponse.messages` +(the loop variable) or the just-populated `c.state.messages`? Mapping flagged this ambiguously. + +**Action:** Confirm by reading `client.ts:2324-2333` before Task O4/parent Task 10. If it reads +`c.state.messages`, O4 must repoint it to the response so removing legacy `_initializeState` message +population doesn't empty the offline seed. + +## D-OFF-3 — Persist paginated page loads (`upsertMessages`) now or later + +**Status:** OPEN + +**Question:** Include the `populateOfflineDbAfterQuery` → `upsertMessages` page-persist in O1, or defer? + +**Recommendation:** **Include in O1.** It closes a real gap (older pages fetched online are currently +not persisted for offline) using an existing abstract method (`upsertMessages`) — no interface break — +and makes O7 (offline read of those pages) actually useful later. diff --git a/specs/migrate-offline-to-messagepaginator/plan.md b/specs/migrate-offline-to-messagepaginator/plan.md new file mode 100644 index 0000000000..8705d5d3a1 --- /dev/null +++ b/specs/migrate-offline-to-messagepaginator/plan.md @@ -0,0 +1,286 @@ +# Plan — Migrate offline-support off legacy `ChannelState` message storage + +See [`spec.md`](spec.md) for findings, the C1–C6 coupling table, and tranche scope; +[`decisions.md`](decisions.md) for the enhancement-tranche gate. + +## Worktree + +**Shares the parent plan's worktree/branch** — +`../stream-chat-js-worktrees/remove-legacy-channelstate-messages`, branch +`feat/remove-legacy-channelstate-messages`, base `feat/message-paginator-master-merge`. This +sub-plan is **not** a separate concurrent worktree: tasks O2/O5 (`channel.ts`), O4 (`client.ts`), and +O1 (`MessagePaginator.ts`) edit files the parent plan's chains also edit, so they must serialize with +those chains on the same branch. Offline-only-file tasks (O3, O7, O8 on `offline_support_api.ts` / +offline types / `MockOfflineDB`) may run in parallel worktrees if desired. + +## Task overview + +Parity tranche = O1–O6, O8 (+O9). Enhancement tranche = O7 (gated by decision D-OFF-1). The whole +sub-plan is a **required predecessor of the parent plan's Task 11 (delete storage)** — specifically +O1, O3, O5 must land before the parent removes `addMessageSorted`, and O6 depends on the parent's +Task 2 (`countUnread` on the paginator). Cross-plan dependencies are called out per task. + +--- + +## Task O1: `MessagePaginator` offline enablement + page persistence + +**File(s) to create/modify:** `src/pagination/paginators/MessagePaginator.ts` + +**Dependencies:** parent Task 1 (paginator capabilities — same file, serialize after it) + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Override `isOfflineSupportEnabled` → `!!this.channel.getClient().offlineDb` (mirror + `ChannelPaginator.ts:249`). Note this also changes `runQueryRetryable` to prefer stale data / suppress + errors when items exist — desired offline; confirm it doesn't mask errors online. +- Override `populateOfflineDbAfterQuery` to persist the fetched page via + `offlineDb.upsertMessages({ messages })` (needs `LocalMessage`→`MessageResponse` conversion), keyed + implicitly by each message's `cid`/`parent_id` (closes the "no `upsertMessages` on page load" gap). +- Do **not** add `preloadFirstPageFromOfflineDb` here (that is enhancement O7); first-page offline read + stays via channel hydration. + +**Acceptance Criteria:** + +- [ ] `isOfflineSupportEnabled` true iff a DB is injected; unit test with `MockOfflineDB`. +- [ ] A message-page load calls `upsertMessages` with the page's messages. +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task O2: `channel.ts` — derive persistence `isLatestMessagesSet` from the paginator (C2) + +**File(s) to create/modify:** `src/channel.ts` + +**Dependencies:** parent Task 2 (channel.ts chain root — serialize within it) + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- In `Channel.query()` (`channel.ts:1890-1897`), replace `messageSet.isLatest` (legacy) with a + paginator-derived "is latest page" signal (paginator at head / no `headward` cursor) when calling + `offlineDb.upsertChannels({ channels:[state], isLatestMessagesSet })`. + +**Acceptance Criteria:** + +- [ ] `upsertChannels` receives the correct `isLatestMessagesSet` for latest vs. older/jumped pages, + independent of `messageSets` — unit test. +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task O3: Offline replay — send-message confirms via the paginator (C1) + +**File(s) to create/modify:** `src/offline-support/offline_support_api.ts` + +**Dependencies:** parent Task 1 (paginator ingest available); lands **before** parent Task 9/11 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- In `executeTask` send-message branch (`offline_support_api.ts:1296`), replace + `channel.state.addMessageSorted(newMessage, true)` with + `channel.messagePaginator.ingestItem(formatMessage(newMessage))` (thread branch already uses the + paginator via `upsertReplyLocally`). + +**Acceptance Criteria:** + +- [ ] Replaying a queued send-message surfaces the confirmed message in `messagePaginator`, not legacy + state — unit test with `MockOfflineDB`. +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task O4: Cold-start hydration seeds the paginator from the response (C5) + +**File(s) to create/modify:** `src/client.ts` (coordinate with parent Task 10) + +**Dependencies:** parent Task 10 (client.ts chain — serialize; both edit `hydrateActiveChannels`) + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Verify/ensure `hydrateActiveChannels` (offlineMode path) seeds `messagePaginator.postQueryReconcile` + from the raw offline `ChannelAPIResponse.messages`, with **no** dependency on the legacy + `_initializeState`/`clearMessages` message population removed by parent Task 10. + +**Acceptance Criteria:** + +- [ ] Cold-start offline hydration populates `messagePaginator` for each restored channel with legacy + message population removed — unit test with `MockOfflineDB`. +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task O5: Blocked/error-message DB cleanup off the paginator (C3) + +**File(s) to create/modify:** `src/channel.ts` (or a new small helper), consuming `messagePaginator` + +**Dependencies:** parent Task 2; lands **before** parent Task 11 (which deletes `filterErrorMessages`) + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Re-home the responsibility of `channel_state.filterErrorMessages()` (`channel_state.ts:988-998`): + find blocked/error messages via the paginator and call `offlineDb.hardDeleteMessage({ id })`, then + drop them from the paginator. Ensure the trigger points that called `filterErrorMessages` now call + the paginator-based cleanup. + +**Acceptance Criteria:** + +- [ ] Blocked/error messages are hard-deleted from the DB and removed from the paginator; no reliance + on `latestMessages` — unit test. +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task O6: Read-state persistence uses paginator-backed `countUnread` (C6) + +**File(s) to create/modify:** none of its own — verification task over `offline_support_api.ts` reads + +**Dependencies:** parent Task 2 (`countUnread`/`countUnreadMentions` migrated to the paginator) + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Confirm `handleNewMessage` (`:630/:634`) and `handleChannelTruncatedEvent` (`:891/:895`) still + compute correct `unread_messages` once `countUnread` reads the paginator; `channel.state.read` + stays. Add regression coverage. (No code change expected beyond the parent Task 2 migration; this + task exists to gate/verify the dependency.) + +**Acceptance Criteria:** + +- [ ] Offline read-count persistence unaffected by the storage removal — regression test. + +--- + +## Task O7 (GATED): Cursor-aware offline message read → true offline pagination + +**File(s) to create/modify:** `src/offline-support/types.ts` (+ `offline_support_api.ts` abstract +method), `src/pagination/paginators/MessagePaginator.ts` + +**Dependencies:** O1; **decision D-OFF-1** (coordinated breaking interface change) + +**Status:** pending (blocked on decision) + +**Owner:** unassigned + +**Scope:** + +- Add abstract `getChannelMessages({ cid, parent_id?, id_lt?, id_gt?, id_around?, limit }): +MessageResponse[] | null` to `OfflineDBApi`. +- Add `MessagePaginator.preloadFirstPageFromOfflineDb` (mirror `ChannelPaginator`), reading via the new + method keyed by `buildFilters()` (`cid`(+`parent_id`)) and the query cursor; hydrate through + `postQueryReconcile`. +- Coordinate the interface change with RN/mobile SDKs (see O9). + +**Acceptance Criteria:** + +- [ ] Older-page loads resolve from the DB while offline, via `MockOfflineDB` implementing the new + method — unit test. +- [ ] Interface change documented as breaking for offline implementers. + +--- + +## Task O8: Offline tests + +**File(s) to create/modify:** `test/unit/offline-support/offline_support_api.test.ts`, `MockOfflineDB` (in `test/unit/offline-support/`) + +**Dependencies:** O1–O6 (and O7 if in scope) + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Update `MockOfflineDB` for any new behavior; rewrite offline tests that assert against + `state.messages`/`addMessageSorted` to assert against `messagePaginator`; add the replay / + hydration / persistence / cleanup coverage from O1–O6. + +**Acceptance Criteria:** + +- [ ] `yarn test-unit test/unit/offline-support` green; no legacy-storage references. + +--- + +## Task O9: Coordination & docs for the interface change (only if O7 in scope) + +**File(s) to create/modify:** `CLAUDE.md`, `developers/*`, release notes/migration guide + +**Dependencies:** O7 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Document the new `AbstractOfflineDB.getChannelMessages` requirement and the `BREAKING CHANGE` for + offline implementers; open coordination tickets with RN/mobile SDK teams. + +**Acceptance Criteria:** + +- [ ] Migration note published; downstream tickets linked. + +--- + +## Execution Order + +``` +(interleaves with parent plan — same branch) + +After parent Task 1: +└── O1: MessagePaginator offline enablement + page persist + +After parent Task 2: +├── O2: channel.ts isLatestMessagesSet from paginator +├── O5: blocked/error DB cleanup off paginator +└── O6: read-count persistence verification (gated on parent Task 2) + +After parent Task 1 (offline-only file, parallel): +└── O3: replay via paginator ingest ── MUST precede parent Task 9/11 + +After parent Task 10: +└── O4: cold-start hydration seed from response + +Gated (decision D-OFF-1), after O1: +└── O7: cursor-aware offline read → O9 coordination/docs + +After O1–O6 (+O7): +└── O8: offline tests + +GATE: O1, O3, O5 complete ⇒ parent plan Task 11 (delete storage) may proceed. +``` + +## File Ownership Summary + +| Task | Creates/Modifies | Shared-file note | +| ---- | ------------------------------------------------------------------------------- | --------------------------------------------- | +| O1 | `src/pagination/paginators/MessagePaginator.ts` | after parent Task 1 | +| O2 | `src/channel.ts` | within parent channel.ts chain (after Task 2) | +| O3 | `src/offline-support/offline_support_api.ts` | offline-only | +| O4 | `src/client.ts` | within parent client.ts chain (with Task 10) | +| O5 | `src/channel.ts` (+ optional helper) | within parent channel.ts chain | +| O6 | (verification only) | depends on parent Task 2 | +| O7 | `src/offline-support/types.ts`, `offline_support_api.ts`, `MessagePaginator.ts` | gated; offline-only + paginator | +| O8 | `test/unit/offline-support/*` | offline-only | +| O9 | `CLAUDE.md`, `developers/*` | docs | diff --git a/specs/migrate-offline-to-messagepaginator/spec.md b/specs/migrate-offline-to-messagepaginator/spec.md new file mode 100644 index 0000000000..acd3445614 --- /dev/null +++ b/specs/migrate-offline-to-messagepaginator/spec.md @@ -0,0 +1,72 @@ +# Spec — Migrate offline-support off legacy `ChannelState` message storage + +Status: **planned**. Repo: `stream-chat` (JS SDK). **Sub-initiative of** +[`../remove-legacy-channelstate-messages`](../remove-legacy-channelstate-messages/spec.md) — this is +its Task 15, expanded. Resolves decision **D1** in that plan. + +## Goal + +Make offline-support work with `channel.messagePaginator` as the message source of truth, so the +legacy `ChannelState` message store (`messageSets`/`addMessageSorted`/`state.messages`/…) can be +deleted without breaking offline read/replay/persist. Offline-support has **no default implementation +in this package** (`AbstractOfflineDB` is injected by RN/mobile SDKs), so any change to the abstract +interface is a **coordinated, breaking change** for those SDKs. + +## Key findings (from subsystem + paginator mapping) + +1. **Offline message READS already reach the paginator without legacy state.** Cold start goes + `offlineDb.getChannelsForQuery()` → `client.hydrateActiveChannels(rows, {offlineMode:true})` → + `postQueryReconcile` seeds `messagePaginator` from the **raw `ChannelAPIResponse.messages`** + (`client.ts:2324-2333`). So the first/latest page works response-driven. _(Verify the + `hydrateActiveChannels` seed reads the response var, not `c.state.messages` — one mapping pass + flagged it ambiguously; confirm before relying on it.)_ +2. **Offline message WRITES already persist from the response, not `ChannelState`.** + `client.queryChannels` → `offlineDb.upsertChannels({channels, isLatestMessagesSet:true})` + (`client.ts:2199`); `Channel.query()` → `upsertChannels` (`channel.ts:1890`). **But** the + `isLatestMessagesSet` flag on the single-channel path is derived from legacy `messageSet.isLatest` + (`channel.ts:1890-1897`) — a coupling to sever. +3. **There is NO cursor-aware per-message read in `OfflineDBApi`** — only channel-bundled + `getChannels`/`getChannelsForQuery`. So offline _older-page pagination_ is not supported today + (legacy didn't support it either) and is an **enhancement**, not parity. +4. **`upsertMessages` is not called on paginated page loads** — only for WS `message.new` and inside + `upsertChannels`. Persisting older offline-fetched pages would be new. + +## Legacy couplings to sever (parity) + +| # | Site | Today | Target | +| --- | ---------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| C1 | `offline_support_api.ts:1296` | send-message replay → `channel.state.addMessageSorted(newMessage,true)` | `channel.messagePaginator.ingestItem(formatMessage(newMessage))` | +| C2 | `channel.ts:1890-1897` | `upsertChannels` `isLatestMessagesSet` from `messageSet.isLatest` | derive "is latest page" from the paginator (head / no headward cursor) | +| C3 | `channel_state.ts:988-998` | `filterErrorMessages()` scans `latestMessages`, drives `offlineDb.hardDeleteMessage` | paginator-driven blocked/error-message cleanup (this method is removed by the parent plan) | +| C4 | `channel.ts:729` | offline `deleteReaction` → `state.messages.find` | paginator `getItem` _(already covered by parent plan Task 2)_ | +| C5 | `client.ts:2288-2333`, `channel.ts:1836` | cold-start hydration populates legacy `messageSets` then seeds paginator from it | seed paginator directly from the offline `ChannelAPIResponse` _(coordinate with parent Task 10)_ | +| C6 | `offline_support_api.ts:630,634,891,895` | offline read persistence uses `channel.state.read` (stays) + `countUnread()` (legacy) | `state.read` stays; `countUnread` becomes paginator-backed _(parent plan Task 2 dependency)_ | + +## Scope + +**Parity tranche (default, no interface break):** sever C1–C6 so offline works after the storage +deletion, using the reads/writes that already flow through channel hydration + `upsertChannels`. +Optionally persist paginated page loads via `upsertMessages` (`populateOfflineDbAfterQuery`). + +**Enhancement tranche (gated — coordinated breaking change):** add a cursor-aware message read to +`AbstractOfflineDB` (e.g. `getChannelMessages({cid, parent_id?, id_lt/id_gt/limit})`) + a +`LocalMessage`↔`MessageResponse` conversion + `MessagePaginator.preloadFirstPageFromOfflineDb`, giving +true offline older-page pagination. Requires RN/mobile SDK implementations to add the method. + +## Blast radius + +- **Injection interface (`AbstractOfflineDB` / `OfflineDBApi`):** parity tranche adds no abstract + method (safe for existing RN/mobile impls). Enhancement tranche adds one abstract method → **breaks + every concrete impl until they implement it**; needs coordinated release with RN/mobile. +- **Shared files** with the parent plan: `channel.ts`, `client.ts`, `MessagePaginator.ts` — this + sub-plan interleaves with the parent's same-file chains (see plan.md), it is **not** a concurrent + worktree. +- **Tests:** `test/unit/offline-support/offline_support_api.test.ts` + `MockOfflineDB`. + +## Acceptance + +- After the parent plan deletes `ChannelState` message storage, offline replay, persistence, + cold-start hydration, reaction-delete, and read-count persistence all work off `messagePaginator` + (parity tranche), verified with a `MockOfflineDB` unit suite. +- No offline code references `channel.state.messages`/`latestMessages`/`addMessageSorted`/ + `findMessage`/`messageSet.isLatest`. diff --git a/specs/migrate-offline-to-messagepaginator/state.json b/specs/migrate-offline-to-messagepaginator/state.json new file mode 100644 index 0000000000..785a37ad91 --- /dev/null +++ b/specs/migrate-offline-to-messagepaginator/state.json @@ -0,0 +1,82 @@ +{ + "feature": "migrate-offline-to-messagepaginator", + "status": "planned", + "parent": "remove-legacy-channelstate-messages", + "worktree": { + "path": "../stream-chat-js-worktrees/remove-legacy-channelstate-messages", + "branch": "feat/remove-legacy-channelstate-messages", + "base": "feat/message-paginator-master-merge", + "note": "Shares the parent worktree/branch; not a concurrent worktree (shared files channel.ts/client.ts/MessagePaginator.ts)." + }, + "openDecisions": ["D-OFF-1", "D-OFF-2", "D-OFF-3"], + "gate": "O1, O3, O5 must complete before parent plan Task 11 (delete ChannelState storage).", + "tasks": [ + { + "id": "O1", + "name": "MessagePaginator offline enablement + page persist", + "status": "pending", + "owner": null, + "dependencies": ["parent:1"] + }, + { + "id": "O2", + "name": "channel.ts isLatestMessagesSet from paginator", + "status": "pending", + "owner": null, + "dependencies": ["parent:2"] + }, + { + "id": "O3", + "name": "Replay send-message via paginator ingest", + "status": "pending", + "owner": null, + "dependencies": ["parent:1"], + "note": "Must precede parent Task 9/11." + }, + { + "id": "O4", + "name": "Cold-start hydration seed from response", + "status": "pending", + "owner": null, + "dependencies": ["parent:10"] + }, + { + "id": "O5", + "name": "Blocked/error DB cleanup off paginator", + "status": "pending", + "owner": null, + "dependencies": ["parent:2"], + "note": "Must precede parent Task 11." + }, + { + "id": "O6", + "name": "Read-count persistence verification", + "status": "pending", + "owner": null, + "dependencies": ["parent:2"] + }, + { + "id": "O7", + "name": "Cursor-aware offline message read (enhancement)", + "status": "blocked", + "owner": null, + "dependencies": ["O1"], + "blockedBy": "D-OFF-1" + }, + { + "id": "O8", + "name": "Offline tests", + "status": "pending", + "owner": null, + "dependencies": ["O1", "O2", "O3", "O4", "O5", "O6"] + }, + { + "id": "O9", + "name": "Interface-change coordination & docs", + "status": "pending", + "owner": null, + "dependencies": ["O7"], + "blockedBy": "D-OFF-1" + } + ] +} diff --git a/specs/remove-legacy-channelstate-messages/decisions.md b/specs/remove-legacy-channelstate-messages/decisions.md new file mode 100644 index 0000000000..98c557d328 --- /dev/null +++ b/specs/remove-legacy-channelstate-messages/decisions.md @@ -0,0 +1,62 @@ +# Decisions — Remove legacy `ChannelState` message storage + +Open decisions that gate scope/sequencing. Update `Status` and record the resolution inline. + +## D1 — Offline-support scope (gates Task 11 / Task 15) + +**Status:** OPEN + +**Question:** Does this initiative migrate offline-support (Task 15), or defer it? + +**Constraint:** Deleting the store (Task 11) removes `ChannelState.addMessageSorted`, which the offline +replay path calls (`offline_support_api.ts:1296`). So there is **no "delete now, migrate offline +later"** option that also does a clean removal. Either: + +- (a) **Include Task 15** as a required predecessor of Task 11, or +- (b) **Descope offline** but then Task 11 must keep a compatibility write path for offline replay + (contradicts the hard-removal goal and D2), or +- (c) **Two-initiative split:** land Phases 1-4 (readers migrated, dual-write still on) now; do the + actual deletion (Tasks 9-14) + offline (Task 15) in a follow-up once offline is ready. + +**Recommendation:** (a) if offline can be scheduled now; otherwise (c) — ship the reader migration and +keep dual-write until offline is ready, so the codebase is never in a broken half-migrated state. +Note: offline has no default impl in this package (injected by RN/mobile SDKs), so its migration also +needs coordination with those SDKs. + +**Update:** offline is now planned in detail in +[`../migrate-offline-to-messagepaginator`](../migrate-offline-to-messagepaginator/plan.md). Key result: +the **parity tranche needs no breaking interface change** (offline reads/writes already flow through +channel hydration + `upsertChannels`), so option (a) is cheaper than feared — only the parity tranche +(sub-plan O1, O3, O5) must land before Task 11. The breaking piece (cursor-aware offline read for +older-page pagination) is isolated to the sub-plan's gated enhancement tranche, so it need not block +this removal. This makes **(a) with parity-only offline** the recommended path. + +## D2 — Deprecation shim vs. hard removal (public API) + +**Status:** OPEN + +**Question:** Keep `get messages()` / `get latestMessages()` as `@deprecated` delegates that read the +paginator for one major, or remove outright? + +**Recommendation:** **Hard removal.** This is already a breaking major on a WIP branch; a shim +re-introduces the second read path we are deleting and invites drift. (Reconsider only if external +Angular/RN consumers need a migration window.) + +## D3 — `pending_messages` + +**Status:** OPEN + +**Question:** Keep `ChannelState.pending_messages` (server pending list) or migrate/remove it? + +**Recommendation:** **Keep.** It is message-adjacent but not part of `messageSets`, has its own +lifecycle, and is out of scope for this removal. + +## D4 — Threads in first cut + +**Status:** OPEN + +**Question:** Include thread migration (Tasks 6, 7) now, or defer with the main-list-only cut? + +**Recommendation:** **Include.** `ChannelState.threads` has no external `src` readers and `Thread` +already owns a `messagePaginator`, so the thread migration is well-contained and blocks a clean +`ChannelState` deletion anyway. diff --git a/specs/remove-legacy-channelstate-messages/plan.md b/specs/remove-legacy-channelstate-messages/plan.md new file mode 100644 index 0000000000..e5dd8fa77f --- /dev/null +++ b/specs/remove-legacy-channelstate-messages/plan.md @@ -0,0 +1,808 @@ +# Plan — Remove legacy `ChannelState` message storage + +See [`spec.md`](spec.md) for goal, gaps, and blast radius; [`decisions.md`](decisions.md) for open +scope decisions (offline handling in particular gates the final tasks). + +## Worktree + +**Worktree path (JS SDK — primary):** `../stream-chat-js-worktrees/remove-legacy-channelstate-messages` +**Branch:** `feat/remove-legacy-channelstate-messages` +**Base branch:** `feat/message-paginator-master-merge` +**Preview branch:** `agent/feat/remove-legacy-channelstate-messages` + +React-side tasks (Task 8) run in a **separate** React worktree: +`../stream-chat-react-worktrees/remove-legacy-channelstate-messages`, branch +`feat/remove-legacy-channelstate-messages`, base `feat/message-paginator-master-merge`. Its +`node_modules/stream-chat` should point at the JS worktree above. + +All work MUST happen in these worktrees, not the main checkouts. Create/sync via the worktrees skill. + +## Task overview + +Tasks are self-contained and run in dedicated worktrees. **`src/channel.ts` and `src/client.ts` are +serialization chokepoints** — each is edited across several phases, so their tasks form dependency +chains (only one agent touches a file at a time). Reader migrations that live in their own files run +in parallel. The critical path is: paginator capabilities → migrate every legacy reader → stop +dual-writing → delete the store → types/exports → tests. + +--- + +## Task 1: Paginator capability parity + +**File(s) to create/modify:** `src/pagination/paginators/MessagePaginator.ts`, `src/pagination/paginators/BasePaginator.ts` + +**Dependencies:** None + +**Status:** done + +**Owner:** unassigned + +**Scope:** + +- Add a head-anchored **latest-window accessor** (e.g. `latestItems` / `lastItem`) that returns the + newest loaded messages regardless of the active interval (gap 2) — the foundation for `lastMessage` + and unread counting off the paginator. +- Add **partial truncation** (`truncated_at` cutoff clear) alongside the existing full + `clearStateAndCache()` (gap 3). +- Confirm/adjust `isHead`↔`isUpToDate` parity for `message.new` routing of out-of-range messages + (gap 7); document the mapping. + +**Acceptance Criteria:** + +- [ ] `latestItems`/`lastItem` returns head-window messages after a jump-to-message (unit test). +- [ ] Partial-truncate drops only messages older than `truncated_at`, keeps newer (unit test). +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task 2: `channel.ts` — seed paginator on all query paths + migrate derived readers + +**File(s) to create/modify:** `src/channel.ts` + +**Dependencies:** Task 1 + +**Status:** done + +**Owner:** unassigned + +**Scope:** + +- Seed/update `messagePaginator` from `channel.query()` (direct), `channel.search()`, and + `loadMessageIntoState` so the paginator always reflects fetched pages (gap 4). (`watch()` and + `hydrateActiveChannels` already seed.) +- Re-point onto the paginator: `lastMessage()` (L1389), `countUnread()`/`countUnreadMentions()` + (L1706/L1728) using Task 1's latest-window accessor, `_extendEventWithOwnReactions` `findMessage`→ + `messagePaginator.getItem` (L2895), and the `deleteReaction` offline read (L729). +- **Leave the legacy dual-writes in place** (removed later in Task 9) so this stays a safe, parity-only + change. + +**Acceptance Criteria:** + +- [ ] `lastMessage`, `countUnread`, `countUnreadMentions` return identical results reading the + paginator vs. the (still-present) legacy store — parity unit tests. +- [ ] A `query()`/`search()`/`loadMessageIntoState` call leaves `messagePaginator` populated. +- [ ] `yarn types` + `yarn lint` clean; `yarn test-unit` green vs. baseline. + +--- + +## Task 3: `CooldownTimer` reader migration + +**File(s) to create/modify:** `src/CooldownTimer.ts` + +**Dependencies:** Task 1 + +**Status:** done + +**Owner:** unassigned + +**Scope:** + +- `refresh()` (L105) reads `channel.state.latestMessages` for the own latest-message date — switch to + the paginator's latest-window accessor. + +**Acceptance Criteria:** + +- [ ] Cooldown refresh derives the same own-latest-message date from the paginator (unit test). +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task 4: `messageDelivery` reader migration (both branches) + +**File(s) to create/modify:** `src/messageDelivery/MessageDeliveryReporter.ts`, `src/messageDelivery/MessageReceiptsTracker.ts` + +**Dependencies:** Task 1, Task 6 + +**Scope:** + +- `MessageDeliveryReporter` channel branch (L137, `latestMessages`) → paginator latest-window; thread + branch (L143, `Thread.state...replies`) → `thread.messagePaginator` (needs Task 6). +- `MessageReceiptsTracker.findMessageByTimestamp` (L186) → paginator-based lookup. + +**Status:** done + +**Owner:** unassigned + +**Acceptance Criteria:** + +- [ ] Delivery-candidate selection and receipt mapping unchanged vs. legacy (unit tests). +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task 5: `client.ts` — `_updateUserReferences` reader migration + +**File(s) to create/modify:** `src/client.ts` + +**Dependencies:** Task 1 + +**Status:** done + +**Owner:** unassigned + +**Scope:** + +- `_updateUserReferences` (L1487) currently calls `state.updateUserMessages` across all active + channels. Provide a paginator-based equivalent that patches cached messages' user data (via + `messagePaginator` item index). Leave the legacy call until Task 10 (chokepoint chain on `client.ts`). + +**Acceptance Criteria:** + +- [ ] Updating a user propagates to cached paginator messages across active channels (unit test). +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task 6: `thread.ts` — make the paginator the reply source of truth + +**File(s) to create/modify:** `src/thread.ts` + +**Dependencies:** Task 1 + +**Status:** out-of-scope (threads excluded from this initiative) + +**Owner:** unassigned + +**Scope:** + +- Migrate `Thread.state.replies` consumers to `thread.messagePaginator.items`; retire + `upsertReplyLocally`/`deleteReplyLocally`/`failedRepliesMap` double-bookkeeping (thread.ts L494-705, + L776 NOTE), keeping WS reply sync on the paginator. +- Expose whatever accessor `MessageDeliveryReporter` (Task 4) needs for the thread branch. + +**Acceptance Criteria:** + +- [ ] Thread reply list, optimistic send, and failed-reply retry work off the paginator (unit tests). +- [ ] `yarn types` + `yarn lint` clean; thread tests green vs. baseline. + +--- + +## Task 7: `channel.ts` — decouple `getReplies` from `ChannelState.threads` + +**File(s) to create/modify:** `src/channel.ts` + +**Dependencies:** Task 2, Task 6 + +**Status:** out-of-scope (threads excluded from this initiative) + +**Owner:** unassigned + +**Scope:** + +- `Channel.getReplies` (L1600) writes `state.addMessagesSorted` (populating `ChannelState.threads` + + sets). Route replies to `thread.messagePaginator` (Task 6) instead; stop writing `ChannelState`. +- Same-file chain after Task 2. + +**Acceptance Criteria:** + +- [ ] `getReplies` populates the thread paginator, not `ChannelState.threads` (unit test). +- [ ] `yarn types` + `yarn lint` clean. + +--- + +## Task 8: React SDK — migrate readers to `messagePaginator` + +**File(s) to create/modify (React repo):** `src/context/MessageBounceContext.tsx`, `src/components/ChannelListItem/{utils.tsx,ChannelListItem.tsx,ChannelListItemUI.tsx}`, `src/components/MessageList/hooks/useMarkRead.ts` + +**Dependencies:** None (reads `channel.messagePaginator`, already populated for listed channels) + +**Status:** done + +**Owner:** unassigned + +**Scope:** + +- Replace `channel.state.removeMessage` (MessageBounceContext) with the paginator remove. +- Replace last-message/preview/title reads (`ChannelListItem` family) and `useMarkRead`'s + `latestMessages.slice(-1)` with `channel.messagePaginator` accessors. +- Update `stream-chat-react/CLAUDE.md` "DO NOT" guidance that references `addMessageSorted`/ + `state.messages` (also flag the JS `CLAUDE.md`; JS docs handled in Task 14). + +**Acceptance Criteria:** + +- [ ] No `state.messages`/`latestMessages`/`addMessageSorted`/`removeMessage` in `stream-chat-react/src`. +- [ ] React `yarn types` + `yarn lint-fix` clean; example builds; headless vite check renders channel + previews and mark-read correctly. + +--- + +## Task 9: `channel.ts` — stop dual-writing legacy in event handlers + `_initializeState` + +**File(s) to create/modify:** `src/channel.ts` + +**Dependencies:** Task 7, Task 3, Task 4, Task 5, Task 6, Task 8 (all readers migrated), Task 15 if offline in scope + +**Status:** done + +**Owner:** unassigned + +**Scope:** + +- Remove legacy twins from WS handlers (`message.new/updated/deleted`, reactions, `user.messages.deleted`, + `channel.truncated` → use Task 1 partial-truncate, `channel.hidden`) and from `_initializeState`, + keeping only the `messagePaginator` calls. Re-home non-message side effects (`last_message_at` + update, `pinnedMessages` handling stays via its own methods). + +**Acceptance Criteria:** + +- [ ] All message WS events reflected via the paginator only; pinned/read/members/typing unaffected. +- [ ] `yarn test-unit` green vs. baseline (channel event tests updated in Task 13). + +--- + +## Task 10: `client.ts` — stop dual-writing in `hydrateActiveChannels` + +**File(s) to create/modify:** `src/client.ts` + +**Dependencies:** Task 5, Task 9 + +**Status:** done + +**Owner:** unassigned + +**Scope:** + +- Drop `_initializeState`/`clearMessages`/`messageSetPagination` message seeding in + `hydrateActiveChannels` (L2292-2319), keeping only `postQueryReconcile` (paginator). Keep poll/reminder + hydration sourced from the raw API response rather than `channelState.messages`. + +**Acceptance Criteria:** + +- [ ] `queryChannels` seeds only the paginator; polls/reminders still hydrate. +- [ ] `yarn types` + `yarn lint` clean; `yarn test-unit` green vs. baseline. + +--- + +## Task 11: Delete the storage from `ChannelState` + `utils.messageSetPagination` + +**File(s) to create/modify:** `src/channel_state.ts`, `src/channel.ts`, `src/client.ts`, `src/thread.ts`, +`src/utils.ts`, `src/pagination/paginators/MessagePaginator.ts`, `src/offline-support/offline_support_api.ts` + +**Dependencies:** Task 9, Task 10 (and Task 15 if offline in scope) + +**Status:** DONE (committed). Reaction sub-steps 1-2 done; **step 3a** (main message-list storage +removed) committed `caba066b`/`c238323f`; **step 3b** (`channel.state.threads` removed, thread-only +methods deleted, thread reply `own_reactions` + user-deletion re-homed onto the `Thread` object) +committed `b9c4ed61`/`be4ac9dc`; both suites green. `addMessagesSorted`/`addMessageSorted` remain as a +slim channel-meta path (`last_message_at` + user-reference map) for full deletion after Task 13. + +**Owner:** claude + +**Scope:** + +- Remove `messageSets`, `messages`/`latestMessages` accessors, all pure message-set methods + private + geometry helpers, and the `threads` record (see `spec.md` scope list). Split `clearMessages` so the + `pinnedMessages` reset survives (e.g. `clearPinned`). Remove `messageSetPagination` from `utils.ts`. +- Resolve `pending_messages` per `decisions.md` #3. + +**`channel.state.threads` REMOVED (confirmed with owner):** the `Thread` object already owns reply +state (`Thread.state.replies` + `Thread.messagePaginator`, with its own message/reaction subscriptions), +so `channel.state.threads` is a redundant legacy shadow. Removing it lets the shared methods be DELETED +outright rather than kept for threads. Formerly-out-of-scope Tasks 6/7 fold in here. + +**Key coupling — `own_reactions`:** today `ChannelState.addReaction`/`removeReaction` stop a cross-user +reaction WS event from wiping the current user's `own_reactions` by reading the local cache +(`state.messages` / `state.threads`). With both caches gone this preservation is re-homed onto the +paginators (owner-approved). + +**Execution sequence (verified sub-steps):** + +1. **[DONE]** Add `MessagePaginator.reflectReaction({ message, reaction, removed, enforceUnique })` — read the + cached item via `getItem`, preserve the current user's `own_reactions`, apply the incoming reaction, ingest. + Reused by both the channel- and thread-level reaction handlers. Added 4 unit tests (cross-user preserve, + own-reaction add, delete-removal, other-user-not-added). Purely additive — JS 3538 pass/0 fail, types+lint clean. +2. **[DONE]** Rewire the **channel** reaction handlers (`reaction.new/deleted/updated`): the paginator path is + now `channel.messagePaginator.reflectReaction` (was `ingestItem(enriched event.message)` — behavior-equivalent). + `channelState.addReaction/removeReaction` are kept for now purely for their `pinnedMessages` side-effect + (dropped in step 3). **Thread reply reactions are NOT rewired here:** the thread UI reads + `Thread.state.replies` (not `Thread.messagePaginator`), and today reply `own_reactions` are preserved because + the channel's `addReaction` enriches the shared `event.message` before the Thread's handler runs. That + preservation must move onto `Thread.state.replies` — done in step 4/5 together with removing + `channel.state.threads` (when the channel stops enriching reply events). A paginator-level + `reflectReaction` on the Thread would not fix the `state.replies`-based UI. + The remaining removal is split into **3a** (main message list, threads retained) and **3b** + (threads) so each lands with a green suite. + +3. **[DONE] Step 3a — remove the main message-list storage; retain `channel.state.threads`.** + - Removed `messageSets` / `messages` / `latestMessages` accessors + all pure message-set methods and + geometry helpers; `addMessagesSorted`/`addMessageSorted` shrunk to shadow-skip + format + + `updateUserReference` + stale-thread-cleanup + `last_message_at` + thread population. + `removeMessage`/`findMessage`/`_updateQuotedMessageReferences` are thread-only; `_updateMessage`, + `deleteUserMessages`, `updateUserMessages` are thread+pinned; `clearMessages` clears pinned only. + - Removed `utils.messageSetPagination` (+ helpers/type/unused imports). + - Rewired writers/callers: `message.new/updated/deleted`, `channel.truncated`, `channel.hidden`, + `_initializeState` (no set param/return), `query`/`queryChannels` seeding via `seedFirstPageSync`, + `client._deleteUserMessageReference`. Channel + client reaction/deletion handlers act on + `channel.messagePaginator` (`reflectReaction` / `applyMessageDeletionForUser`). + - Full test surgery in both repos: deleted message-set machinery specs, re-scoped surviving + deleteUser/updateUser + #1736 self-quote coverage to threads + pinned, migrated event/query tests + to the paginator. JS 2605 pass, React 2517 pass; `yarn types` + `yarn lint` clean in both. + - Commits: JS `caba066b` (src) + `c238323f` (tests); React `a61e634d4` (readers) + `79e26c037` (test). +4. **[DONE] Step 3b — remove `channel.state.threads` + delete the thread-only methods.** + - Removed the `threads` property and every thread code path. Deleted `removeMessage`, `findMessage`, + `_updateQuotedMessageReferences`/`removeQuotedMessageReferences`, and the reaction-state helpers + `_addReactionToState`/`_removeReactionFromState`. `_updateMessage`, `updateUserMessages`, + `deleteUserMessages`, `addReaction`/`removeReaction` are now **pinned-only**. + - `addMessagesSorted`/`addMessageSorted` kept as a slim channel-meta path (records the user + reference + advances `last_message_at`, no thread population); `timestampChanged`/`initializing` + retained only for positional-call compatibility. Full deletion deferred to after Task 13. + - Re-homed onto the `Thread` object (owner-approved): reply reaction `own_reactions` via + `Thread.messagePaginator.reflectReaction`; reply `message.updated` own_reactions preserved from the + existing reply; and a new `user.messages.deleted`/`user.deleted` subscription that calls + `Thread.messagePaginator.applyMessageDeletionForUser`. The channel no longer enriches reply events + (`_extendEventWithOwnReactions` + reaction handlers are main-only via the paginator). + - `getReplies` no longer writes `channel.state.threads` (Thread owns replies). `offline_support_api` + reply upsert already targets the Thread; its `addMessageSorted` call is now channel-meta only. + - Test surgery: deleted the thread/`_addReactionToState`/`_removeReactionFromState`/`findMessage` + `ChannelState` specs and thread-scoped event tests; added Thread coverage for reply own_reactions + preservation and banned-user reply deletion. JS 2583 pass, React 2517 pass; `yarn types` + `yarn lint` + clean in both. (Uncommitted — pending review.) + +**Acceptance Criteria:** + +- [ ] `ChannelState` retains only non-message stores (read, members, watchers, typing, pinned, pending); file compiles. +- [ ] No `messageSets`/`messages`/`latestMessages`/`threads`/`addMessageSorted`/etc. remain in `stream-chat/src`. +- [ ] Cross-user reactions preserve the current user's `own_reactions` (channel + thread), verified by tests. +- [ ] Both suites green vs. baseline; types + lint clean. + +--- + +## Task 12: Types & public exports + +**File(s) to create/modify:** `src/types.ts`, `src/index.ts`, `src/channel_state.ts` (type anchor) + +**Dependencies:** Task 11 + +**Status:** DONE (committed `fea2407b` src + `8114a2cf` spec). Both suites green, `yarn types` + +`yarn lint` clean. Carries the `BREAKING CHANGE:` footer for the whole ChannelState storage removal. + +**Owner:** claude + +**Scope (as executed — deviates from the original assumptions below):** + +- Resolved the `ReturnType` anchor: retyped `pinnedMessages` and the + remaining `_updateMessage`/`_addToMessageList`/`removeMessageFromArray` signatures to `LocalMessage` + (equivalent — the `formatMessage` util returns `LocalMessage`). `ChannelState.formatMessage` stays. +- Removed the dead `MessageSet` object-shape type from `types.ts` (public via `export * from './types'` + → **breaking**) and the internal-only `DEFAULT_MESSAGE_SET_PAGINATION` from `constants.ts` + (not re-exported → non-breaking). +- **Kept** `MessageSetType` — still the type of `channel.query`'s `messageSetToAddToIfDoesNotExist` + param, which drives paginator seeding (`'latest'` seeds the first page; `'current'`/`'new'` do not). +- **Kept** `isLatestMessageSet` / `isLatestMessagesSet` — live flags on the `channels.queried` event + and the offline `upsertChannels` payload ("is this the latest page"), not the removed storage. +- **Kept** `binarySearchByDateEqualOrNearestGreater` (public util, now internally unused): removing it + is a gratuitous breaking change unrelated to the storage removal — flagged for the owner to decide. +- `BREAKING CHANGE:` footer to be applied on the commit for this task (documents the ChannelState + message-storage + `MessageSet` removal); triggers the major release for the whole 3a/3b/12 series. + +**Acceptance Criteria:** + +- [x] `yarn types` clean across `src`; dead `MessageSet`/`DEFAULT_MESSAGE_SET_PAGINATION` removed; + `BREAKING CHANGE` to be documented on commit. + +--- + +## Task 13: Tests — rewrite/remove legacy suites + add parity suites + +**File(s) to create/modify:** `test/unit/channel_state.test.js`, `test/unit/channel.test.js`, `test/unit/client.test.js`, `test/unit/CooldownTimer.test.ts`, `test/unit/offline-support/offline_support_api.test.ts`, `test/unit/poll_manager.test.ts`, `test/typescript/response-generators/message.js` + +**Dependencies:** Task 11, Task 12 + +**Status:** DONE (uncommitted — pending review). Most of the surgery landed incrementally during +Tasks 11/12/18 (message-set/thread/pinned suites removed or re-scoped to the paginators; +`channel_state`/`channel`/`client`/`utils` suites rewritten; `channel.ts _handleChannelEvent` +reorganized one-describe-per-WS-event; parity coverage added for paginators + pinned + user-deletion/ +update wiring). Close-out audit swept all of `test/` and found only two stale references, both fixed: +`test/typescript/response-generators/message.js` read `channel.state.messages` (→ use +`response.message.id` for the thread parent), and a `channel.test.js` `last_message_at` test passed a +stale `'new'` positional arg to `addMessagesSorted` (removed). Parity suites confirmed permanent: +`CooldownTimer.test.ts` (paginator `ingestPage`), `messageDelivery/*` (`latestItems`), plus the +paginator/channel/client/thread suites. `yarn test-unit` (2583) + `yarn types` + `yarn lint` green. + +**Owner:** claude + +**Scope:** + +- Rewrite/remove `channel_state.test.js` (message-set suites gone; keep non-message coverage). Update + `channel.test.js`/`client.test.js`/others to assert against the paginator. Fold the parity tests + authored in Tasks 1-7 into permanent coverage. + +**Acceptance Criteria:** + +- [x] `yarn test-unit` green; no test references removed state (`messageSets`/`state.messages`/ + `state.threads`/`state.pinnedMessages`/`addToMessageList`). NOTE: `addMessagesSorted`/`addMessageSorted` + survive as the channel-meta path and are still legitimately referenced; their full removal (and the + remaining vestigial test seeds) is the deferred post-Task-13 cleanup. + +--- + +## Task 14: Docs + +**File(s) to create/modify:** `CLAUDE.md` (JS), `stream-chat-react/CLAUDE.md`, `developers/*` as needed + +**Dependencies:** Task 12 + +**Status:** done (JS). React `CLAUDE.md` tracked separately on the React branch. + +**Owner:** claude + +**Scope:** + +- Remove/replace stale "use `channel.state.addMessageSorted()`/`removeMessage()`/`state.messages`" + guidance; document `messagePaginator` as the source of truth. + +**What landed (JS):** + +- Fixed the two stale JSDoc examples — `Channel.on` (`src/channel.ts`) and `StreamChat.on` + (`src/client.ts`) — that logged `channel.state.messages` → now `channel.messagePaginator.state.items`. +- Added a `CLAUDE.md` architecture note: messages / thread replies / pinned messages live in + `channel.messagePaginator` / `thread.messagePaginator` / `channel.pinnedMessagesPaginator` (single + source of truth), not `channel.state`; how to read/mutate; the read-only derived + `last_message_at`; pointer to `docs/breaking-changes-v14-v15.md`. +- `docs/breaking-changes-v14-v15.md` breaking-change ledger added earlier this effort. +- JS `CLAUDE.md` had no stale `addMessageSorted` guidance to remove. + +**Acceptance Criteria:** + +- [x] No stale legacy-storage guidance in the JS `CLAUDE.md`. +- [ ] React `CLAUDE.md` (`DO NOT mutate channel.state.messages` / `addMessageSorted()`, "threads must + exist in main channel state") — remains; tracked on the React branch, not this one. + +--- + +## Task 15 (GATED): Offline-support migration → see sub-plan + +**Expanded into its own spec:** +[`../migrate-offline-to-messagepaginator`](../migrate-offline-to-messagepaginator/plan.md). + +**Dependencies:** Task 1 — **decision-gated** (see `decisions.md` D1). If the storage is deleted +(Task 11), the sub-plan's **parity tranche (O1, O3, O5) is a required predecessor of Task 11**, because +Task 11 removes the `addMessageSorted` the offline replay path calls. If descoped, Task 11 must instead +retain a compatibility path (conflicts with hard removal). + +**Status:** planned (see sub-plan; enhancement tranche blocked on decision D-OFF-1) + +**Owner:** unassigned + +**Summary (full detail in the sub-plan):** offline message _reads_ and _writes_ already flow through +channel hydration + `upsertChannels` (response-driven), so **parity needs no interface change** — only +severing narrow legacy couplings (send-message replay, `isLatestMessagesSet` flag, `filterErrorMessages` +DB cleanup, reaction-delete read, cold-start seed). A cursor-aware offline read for older-page +pagination is a **gated, breaking** enhancement requiring RN/mobile coordination. + +**Acceptance Criteria:** + +- [ ] Sub-plan parity tranche (O1–O6, O8) complete; offline tests green with a `MockOfflineDB`. + +--- + +## Task 16 (OPTIMIZATION, non-blocking): author index for O(1) per-user updates + +**File(s) to create/modify:** `src/pagination/ItemIndex.ts` (or `src/pagination/paginators/MessagePaginator.ts`) + +**Dependencies:** Task 5. **Not** gating Task 11 — a performance follow-up that can land any time after Task 5. + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- `MessagePaginator.reflectUserUpdate(user)` (Task 5) — and later per-user operations such as + `applyMessageDeletionForUser` and the `deleteUserMessages` migration — currently scan + `_itemIndex.values()` (every cached message) to find a single user's messages: O(all cached). +- Maintain an **author secondary index** `userId → Set`, updated on ingest/remove, so + these become O(that user's cached messages). Options: (a) a message-aware secondary index owned by + `MessagePaginator`, or (b) generalize `ItemIndex` to accept optional secondary key extractors + (e.g. `getSecondaryKeys`). Prefer whichever keeps `ItemIndex` generic and the messages concern in + `MessagePaginator`. +- Re-point `reflectUserUpdate` (and any other per-user scans) at the author index. + +**Acceptance Criteria:** + +- [ ] `reflectUserUpdate` no longer iterates all cached messages; unit test asserts only the target + user's messages are visited (e.g. via a spy/counter) and behavior is unchanged. +- [ ] `yarn types` + `yarn lint` clean; full suite green. + +--- + +## Task 18 (FOLLOW-ON): `channel.state.pinnedMessages` → `channel.pinnedMessagesPaginator` + +**File(s):** `src/pagination/paginators/PinnedMessagePaginator.ts` (new), `src/channel.ts`, `src/channel_state.ts`, +`src/client.ts`, stream-chat-react pinned views. **Dependencies:** Task 11. **Owner:** claude +**Status:** DONE (uncommitted final step — pending review). Landed in sub-steps, each verified green: +(1) extract unread-free `MessageIntervalPaginator` base [committed `ca61f933`]; (2) `PinnedMessagePaginator` +extends it [committed `ecd84fa5`]; (3) wire `channel.pinnedMessagesPaginator` from events in parallel with +the legacy store [committed `031c5cac`]; (4a) React `usePinnedMessagesCount` reads the paginator +[committed React `2badfbb2d`]; (4b) delete `channel.state.pinnedMessages` + `addPinnedMessage(s)`/ +`removePinnedMessage` + the pinned-only methods (`addReaction`/`removeReaction`/`_updateMessage`/ +`updateUserMessages`/`deleteUserMessages`/`_addToMessageList`/`removeMessageFromArray`/`clearMessages`) + +the orphaned `utils.addToMessageList`/`utils.deleteUserMessages` helpers. Per-user pinned updates re-homed +to `pinnedMessagesPaginator.reflectUserUpdate`/`applyMessageDeletionForUser`. JS 2573 + React 2513 green. +The unread carve-out was achieved by the base-class extraction (unread absent by construction), not +suppression. **Follow-on still open:** thread replies + pinned both extend the clean base now, but +`MessageReplyPaginator` (unused) reconciliation and the final `addMessagesSorted` deletion remain +(post-Task-13, per the addMessagesSorted-removal note). + +**Why a dedicated paginator, not a bare `MessagePaginator` + filters:** pinned messages use a _different +endpoint_ (`channel.getPinnedMessages` → `/pinned_messages`) with `PinnedMessagePaginationOptions` (id-based +cursors) and `PinnedMessagesSort`, and sort by `pinned_at` — whereas `MessagePaginator.buildFilters()`, its +`created_at` sort, and its `query()` (→ `channel.query({messages})`) are baked for the main list. You can't pass +those in to a vanilla instance; they're methods to override. + +**Recommended design:** `class PinnedMessagePaginator extends MessagePaginator`, overriding: + +- `buildFilters()` → `{ cid, pinned: true }` and `shouldIncludeMessageInInterval()` → `!shadowed && !!pinned`. +- item comparator → `pinned_at` (desc), `query()`/`doRequest` → `channel.getPinnedMessages(options, sort)`, + `getNextQueryShape` + cursor derivation → id-based. +- Reuses the interval store + `ingestItem` + `reflect*` (`reflectReaction`/`reflectQuotedMessageUpdate`/ + `reflectUserUpdate`/`applyMessageDeletionForUser`). +- **Navigation IS kept:** `PinnedMessagePaginationOptions` supports `id_around`, so `jumpToMessage` (and + `jumpToTheLatestMessage`, i.e. most-recently-pinned) are meaningful for pinned and remain exposed. + +**MUST NOT expose or touch read/unread state.** Read/unread + delivery receipts belong to the channel and thread +message timelines — never to a _subset_ like pinned. Concretely, `PinnedMessagePaginator`: + +- overrides `postQueryReconcile` so the first-page reconcile does **not** call `seedUnreadSnapshot` (the base + seeds the unread snapshot from `channel.state.read` on the first page — wrong for pinned); +- does **not** surface `unreadStateSnapshot`, `seedUnreadSnapshot`/`setUnreadSnapshot`, `jumpToTheFirstUnreadMessage`, + or the `unreadReferencePolicy` option (unread-coupled — as opposed to the plain `id_around` `jumpToMessage`, which stays); +- is **not** wired into `MessageReceiptsTracker` (the tracker resolves read/delivered cursors via the _channel's_ + `messagePaginator.findItemByTimestamp`; the pinned paginator must never be a receipts source or target). + +**This read/unread carve-out is the strongest argument for the alternative factoring:** rather than subclassing +`MessagePaginator` and _suppressing_ its unread surface, extract the unread/read-snapshot concern out of +`MessagePaginator` into a separate composable piece (mixin or companion), leaving a clean message-interval base +(interval store + `ingestItem` + `reflect*` + `jumpToMessage`/`jumpToTheLatestMessage` navigation). Then the +channel/thread **main** paginators compose the unread concern, and `PinnedMessagePaginator` extends the clean base +and simply never gets it — no suppression needed, no accidental `channel.state.read` coupling. + +- _Recommended:_ the extraction (clean, prevents unread leaking into pinned by construction). +- _Lower-effort fallback:_ `extends MessagePaginator` + explicitly override/no-op the unread surface listed above + (works, but the unread API technically still exists on the pinned instance and must be kept inert). + +**Pin/unpin comes for free:** `matchesFilter({ pinned: true })` makes `ingestItem` auto-add on pin and +auto-remove on unpin — so the channel `message.new/updated/deleted` handlers just also feed +`pinnedMessagesPaginator.ingestItem(...)` (and `channel.truncated` prunes via `truncate`); reactions go through +its `reflectReaction`. No bespoke pin/unpin logic. + +**Follow-through:** seed from `ChannelAPIResponse.pinned_messages` on open; migrate the React pinned views +(`PinnedMessagesView`, `usePinnedMessagesSearch`, `usePinnedMessagesCount`, `slotBinding`) to the paginator; +delete `channel.state.pinnedMessages` + `addPinnedMessage(s)`/`removePinnedMessage`. This then lets Task 11's +"shrink to pinned-only" methods (`_updateMessage`/`updateUserMessages`/`deleteUserMessages`) be **deleted +outright**. Breaking public-API change → Task 12 (exports/semver) + Task 14 (docs). + +**Unblocks util cleanup:** `addPinnedMessage`/`ChannelState._addToMessageList` is the last caller of the +`utils.addToMessageList` helper. Once they are deleted here, remove `addToMessageList` from `src/utils.ts` +and its `describe('addToMessageList')` in `test/unit/utils.test.ts`. + +--- + +## Task 19 (ENHANCEMENT): reactive `headItems` on `PaginatorState` (generic base level) + +**File(s) to create/modify:** `src/pagination/paginators/BasePaginator.ts` (+ its unit test) + +**Dependencies:** none (independent base-paginator capability; unblocks reactive "latest message"/ +"latest messages" UI reads sourced from the paginator instead of `channel.state`) + +**Status:** DONE (uncommitted — pending review). JS suite green (+4 tests), types + lint clean; React +suite green. + +**Owner:** claude + +**Implementation notes:** `headItems?: T[]` added to `PaginatorState` (+ `initialState: undefined` + +a `get headItems()` accessor). Materialized via a `StateStore` preprocessor registered in the +`BasePaginator` constructor: interval mode derives from `latestItems` (reads intervals, already +mutated at emit time), flat mode reads `nextValue.items` (not `this.items`, which is the previous +emitted value inside a preprocessor). Reference-stable via per-index identity comparison against the +previous `headItems`. `latestItems`/`latestItem` getters were left unchanged (the preprocessor mirrors +their logic, so state and getters stay consistent without the riskier re-home). New tests in +`BasePaginator.test.ts`; `MessageSet`-free state fixtures in `BasePaginator`/`ChannelPaginator` tests +updated for the new field. + +**Motivation:** `latestItems`/`latestItem` are non-reactive getters computed on demand from +`itemIntervals`, so a UI subscribing via `useStateStore(paginator.state, selector)` cannot react to +changes in the newest-loaded window (new/edited/removed latest message, truncation, etc.). Expose the +head window as a reactive field so consumers can select it. Generic on `BasePaginator` so every +paginator (message, thread reply, pinned, channel-list, reminders) benefits. + +**Scope:** + +- Add `headItems?: T[] | undefined` to `PaginatorState` and default it to `undefined` in + `initialState` (mirrors `items`: `undefined` until the first load). +- Materialize it centrally. There is no single items-emission choke point (many `state.partialNext` + sites) and the `onBeforeItemsEmitted` plugin hook is declared but never invoked, so use a + `StateStore` **preprocessor** registered in the `BasePaginator` constructor: on each emission compute + the current head window (reuse the `latestItems` logic — head-most loaded interval in interval mode, + full list in flat mode) and assign it to `nextValue.headItems`. + - **Reference stability:** guard with a shallow (length + per-index `getItemId`) comparison against + the previous `headItems`; when unchanged, keep the previous array reference so selectors don't + re-fire on unrelated state changes (`isLoading`, `lastQueryError`, cursor). This matters because + the preprocessor runs on every `next()`, not only on `items` changes. + - Recompute from intervals (not gated on the active `items` reference) so the head window updates + even when the active window is a jumped-to older interval — the whole point for "latest message". +- Re-home `latestItems`/`latestItem` onto the field: `get latestItems()` returns + `state.headItems ?? []`; `latestItem` returns its head edge. Keeps a single source of truth and makes + both consistent with the reactive value. (Verify parity against the existing getter behavior first.) + +**Acceptance Criteria:** + +- [ ] `state.headItems` is `T[] | undefined`, `undefined` before first load, and updates reactively when + the newest-loaded window changes (ingest/remove/truncate/new-page), verified by a unit test that + subscribes and asserts emission. +- [ ] Selecting `headItems` does **not** re-fire on `isLoading`-only or other non-head state changes + (reference-stable when the window is unchanged). +- [ ] `latestItems`/`latestItem` remain behavior-equivalent; `yarn types` + `yarn lint` + `yarn test-unit` + green. + +--- + +## Task 20 (ENHANCEMENT): unify all paginators on itemIndex + interval storage + +**File(s) to create/modify:** `src/pagination/paginators/ChannelPaginator.ts`, +`src/pagination/paginators/ReminderPaginator.ts`, `src/pagination/paginators/UserGroupPaginator.ts` +(+ their unit tests) + +**Dependencies:** none (base capability already exists; independent of the ChannelState removal) + +**Status:** pending + +**Owner:** unassigned + +**Motivation:** `MessagePaginator` and `MessageReplyPaginator` already use interval storage (pass an +`itemIndex` to `super`); `ChannelPaginator`, `ReminderPaginator` and `UserGroupPaginator` are flat. +A single storage model gives one API/behavior across paginators (dedup-by-id, interval merge, +`headItems`/`latestItems` head-window semantics, `getItem`/`removeItem` by id), even where jump-to +(`*_around`) is not applicable (e.g. channels have no `id_around`). Unified code paths are easier to +reason about and test. + +**Scope:** + +- Pass an `itemIndex` (`new ItemIndex({ getId })`) to `super` for each flat paginator, with a correct + id: `ChannelPaginator` → `channel.cid`; `ReminderPaginator` → reminder id; `UserGroupPaginator` → + user id. Confirm each `sortComparator` is a total order suitable for interval placement. +- Reconcile behavior differences interval storage introduces: + - **dedup by id** on ingest (flat mode may currently allow/handle dupes differently); + - **item repositioning / reorder** — a channel whose `last_message_at` bumps it to the top is a + non-monotonic sort-key change; verify move = remove + re-ingest works (this is the main risk for + `ChannelPaginator`, driven by `ChannelManager`), and that `ChannelManager`'s add/move/remove paths + map onto `ingestItem`/`removeItem` cleanly; + - cursor/`hasMoreHead`/`hasMoreTail` derivation under the linear (non-around) derivator. +- Update tests: the flat-mode state fixtures (`headItems` mirrors `items`) become interval-derived; + add parity coverage that list order, dedup, pagination flags, and `headItems` match today's behavior. + +**Acceptance Criteria:** + +- [ ] All five paginators construct with an `itemIndex`; `usesItemIntervalStorage === true`. +- [ ] Channel list ordering (incl. reorder-to-top on new message), reminder list, and user-group list + behavior is unchanged vs. baseline (parity unit tests); `headItems`/`latestItems` correct. +- [ ] `yarn types` + `yarn lint` + `yarn test-unit` green; React suite green. + +**Risk:** medium — interval storage changes merge/dedup/reorder semantics for lists that mutate order +frequently (channels). Land per-paginator with parity tests rather than all at once. + +--- + +## Task 21 (INTEGRATION): land stream-chat-react PR #3245 "fix: unread indicators V10" + +**Repo:** stream-chat-react. **PR:** https://github.com/GetStream/stream-chat-react/pull/3245 +(base `feat/message-paginator-master-merge`, head `fix/unread-indicators`, by @isekovanic; goes with +LLC stream-chat-js PR #1803). **Dependencies:** none blocking — verified compatible with this +initiative. **Status:** planned. **Owner:** unassigned + +**What it is:** the React counterpart of the paginator unread model. Wires the "Unread messages" +separator / "N new" banner / focus-scroll entirely to `channel.messagePaginator`. Files (+93/-34): +`Channel.tsx` (seed unread snapshot on cached-channel reopen, before markRead), `MessageList.tsx` + +`VirtualizedMessageList.tsx` (focus-signal token lifecycle: `scheduleMessageFocusSignalClear` after +scroll, `clearMessageFocusSignal` on unmount), `UnreadMessagesNotification.tsx` (`clearUnreadSnapshot` +on mark-read), `useMarkRead.ts` (the bulk: `setViewingLive` gating, `resetUnreadSnapshot` on genuine +catch-up vs. persist-on-open, scrolled-back-to-bottom tracking via refs). + +**Compatibility with our LLC changes: verified clean.** Every API it uses survives our work — unread +surface (`seedUnreadSnapshot`, `unreadStateSnapshot`, `setViewingLive`/`isViewingLive`, +`clearUnreadSnapshot`) is retained on `MessagePaginator` through the Task-18 base extraction; focus +signal (`messageFocusSignal`, `scheduleMessageFocusSignalClear`, `clearMessageFocusSignal`) lives on +the `MessageIntervalPaginator` base and is inherited. It reads NO removed state (no +`channel.state.messages`/`threads`/`pinnedMessages`), only `channel.state.read` + `channel.messagePaginator.*`. +It is design-consistent: manually seeds on cached reopen precisely because our auto-seed only fires on +a first-page query, and drives `setViewingLive` to feed the exact gate our `message.new` handler reads. + +**Scope to land:** + +- **Add the missing tests (gating condition).** The PR ships 5 `src/` files with ZERO test changes for + an intricate `useMarkRead` state machine (viewing-live gate, catch-up-vs-open snapshot handling, + scrolled-back-to-bottom refs). Add unit tests: open/reopen keeps the separator where the user left + off; genuine scroll-back-to-bottom clears the snapshot; `visibilitychange` + at-bottom + no + `hasMoreNewer` toggles `setViewingLive`; focus-signal is cleared after scroll and on unmount. +- **Reconcile with our React branch.** Both this PR and our `checkpoint/channelstate-removal-wip` + React branch touch `Channel.tsx` — DIFFERENT hunks (its reopen `seedUnreadSnapshot` vs. our + `user.deleted` `channel.state.messages`→`channel.messagePaginator.items` fix), so a trivial, + no-semantic-conflict merge. The PR does not touch our other migrated readers. +- **Sequencing.** Both are parallel off `feat/message-paginator-master-merge`; whichever lands second + takes the trivial `Channel.tsx` merge. Runtime correctness needs the LLC unread APIs shipped — they + are in our branch, so no extra LLC work is required beyond what this initiative already lands. + +**Acceptance Criteria:** + +- [ ] PR #3245 integrated onto our React branch (cherry-pick/merge), `Channel.tsx` reconciled. +- [ ] `useMarkRead` unread state-machine unit tests added and green. +- [ ] React `yarn types` + `yarn lint` + `yarn test` green against our stream-chat-js dist. + +--- + +## Execution Order + +``` +Phase 1 (Parallel): +├── Task 1: Paginator capabilities +└── Task 8: React readers + +Phase 2 (After Task 1): +├── Task 2: channel.ts seeding + readers +├── Task 3: CooldownTimer +├── Task 5: client.ts _updateUserReferences +├── Task 6: thread.ts reply source +└── Task 15: Offline (only if in scope — decisions.md #1) + +Phase 3 (After Task 2 + Task 6): +├── Task 4: messageDelivery (needs Task 1 + Task 6) +└── Task 7: channel.ts getReplies decouple (needs Task 2 + Task 6) + +Phase 4 (After all readers: Tasks 3,4,5,7,8 [+15 if in scope]): +├── Task 9: channel.ts stop dual-write +└── Task 10: client.ts stop dual-write (needs Task 5 + Task 9) + +Phase 5 (After Tasks 9,10 [+15]): +└── Task 11: Delete ChannelState storage + utils + +Phase 6 (After Task 11): +└── Task 12: Types & exports + +Phase 7 (After Task 12): +├── Task 13: Tests +└── Task 14: Docs +``` + +## File Ownership Summary + +| Task | Creates/Modifies | +| ---- | ------------------------------------------------------------------------------------------------- | +| 1 | `src/pagination/paginators/MessagePaginator.ts`, `src/pagination/paginators/BasePaginator.ts` | +| 2 | `src/channel.ts` (chain 1/3) | +| 3 | `src/CooldownTimer.ts` | +| 4 | `src/messageDelivery/MessageDeliveryReporter.ts`, `src/messageDelivery/MessageReceiptsTracker.ts` | +| 5 | `src/client.ts` (chain 1/2) | +| 6 | `src/thread.ts` | +| 7 | `src/channel.ts` (chain 2/3) | +| 8 | React repo: `MessageBounceContext.tsx`, `ChannelListItem/*`, `useMarkRead.ts`, `CLAUDE.md` | +| 9 | `src/channel.ts` (chain 3/3) | +| 10 | `src/client.ts` (chain 2/2) | +| 11 | `src/channel_state.ts`, `src/utils.ts` | +| 12 | `src/types.ts`, `src/index.ts`, `src/channel_state.ts` (types) | +| 13 | `test/unit/*`, `test/typescript/response-generators/message.js` | +| 14 | `CLAUDE.md`, `stream-chat-react/CLAUDE.md`, `developers/*` | +| 15 | `src/offline-support/offline_support_api.ts`, `src/pagination/paginators/BasePaginator.ts` | + +> Note: `src/channel.ts` (Tasks 2→7→9) and `src/client.ts` (Tasks 5→10) are serialized chains — one +> agent at a time per the make-plans same-file rule. `BasePaginator.ts` is touched by Task 1 and (if +> in scope) Task 15 → order 15 after 1. diff --git a/specs/remove-legacy-channelstate-messages/spec.md b/specs/remove-legacy-channelstate-messages/spec.md new file mode 100644 index 0000000000..034fbdb8eb --- /dev/null +++ b/specs/remove-legacy-channelstate-messages/spec.md @@ -0,0 +1,85 @@ +# Spec — Remove legacy `ChannelState` message storage + +Status: **planned**. Repo: `stream-chat` (JS SDK), with coordinated `stream-chat-react` changes. + +## Goal + +Make `channel.messagePaginator` / `thread.messagePaginator` the **single source of truth** for loaded +messages, and delete the legacy in-memory message store on `ChannelState` (`src/channel_state.ts`): +the `messageSets` array, its `messages` / `latestMessages` accessors, the per-parent `threads` reply +record, and the whole family of set mutators/finders. Today both stores are maintained **in lockstep** +(dual-write) for the main list — every legacy writer already has a paginator twin — so the work is +mostly _removing the legacy half_ once the remaining read paths and a few paginator capability gaps +are migrated. + +## Motivation + +Two parallel message stores means double bookkeeping, drift risk, and confusion about the source of +truth (the bug that motivated this: a `watch()`-opened channel had populated `channel.state.messages` +but an empty `messagePaginator`, so the UI — which reads the paginator — showed no messages). One +store removes the class of bug entirely. + +## Scope + +> **OUT OF SCOPE (narrowed 2026-07): threads.** `thread.ts` / `Thread.state.replies` and the +> per-parent `ChannelState.threads` reply record are **excluded** from this initiative. The target is +> the **main channel message list** (`channel.state.messages` / `messageSets` / `latestMessages`) +> only. Consequently the shared `ChannelState` methods that serve both the message list _and_ threads +> (`addMessagesSorted`, `findMessage`, `updateUserMessages`, `deleteUserMessages`) are **refactored to +> keep their thread/pinned handling**, not deleted outright. + +**Removed** (main message-list concerns in `src/channel_state.ts` unless noted): +`messageSets` (L125); `get/set messages` (L164/168); `get/set latestMessages` (L177/181); +`get messagePagination` (L330); `pruneOldest`, the message-list portions of `addMessageSorted`/ +`addMessagesSorted`, `addReaction`/`removeReaction` (+ `_addReactionToState`/`_removeReactionFromState`/ +`_add|_removeOwnReaction*`), `_updateQuotedMessageReferences`/`removeQuotedMessageReferences`, +`_updateMessage`, `_addToMessageList`, `removeMessage`/`removeMessageFromArray`, the message-list +portions of `updateUserMessages`/`deleteUserMessages`, `filterErrorMessages`, `initMessages`, +`loadMessageIntoState`, the message-list portion of `findMessage`/`findMessageByTimestamp`, +`switchToMessageSet` + private set-geometry helpers (L62-92); `src/utils.ts` `messageSetPagination` +(L1069+); `type MessageSet`, `type MessageSetType`, `isLatestMessageSet` (`src/types.ts`). + +**Stays** (non-message-list data on `ChannelState`): watchers, typing (+ the typing-only `clean()`), +read state, members/`member_count`, own capabilities, muted users, **`pinnedMessages`**, the +**`threads` reply record (L108)** and its mutations (threads out of scope), `membership`, +`unreadCount`, `last_message_at`, `isUpToDate`. Undecided: `pending_messages` (see `decisions.md`). + +## Blocking gaps (paginator is not yet an authoritative superset) + +1. Derived reads still on legacy: `channel.lastMessage()` (L1389), `countUnread`/`countUnreadMentions` + (L1706/L1728), `_extendEventWithOwnReactions` `findMessage` (L2895), `deleteReaction` offline read + (L729), `CooldownTimer.refresh` (L105), `MessageReceiptsTracker` (L186), `MessageDeliveryReporter` + channel branch (L137), `client._updateUserReferences` (L1487). +2. **Latest-window accessor:** the paginator's _active_ interval is not always the head/latest window + (after a jump/search), so a paginator-based `lastMessage`/unread read needs a head-anchored accessor. +3. **Partial truncation:** `channel.truncated` with `truncated_at` prunes per-set by timestamp; the + paginator only supports full `clearStateAndCache()`. +4. **Seeding coverage:** `channel.query()` (direct), `channel.search()`, `loadMessageIntoState` don't + seed the paginator (only `watch()` and `hydrateActiveChannels` do). +5. **Threads:** `ChannelState.threads` (no external `src` reader) + `Thread.state.replies` (still the + maintained UI source, `thread.ts` L776 NOTE) + `Channel.getReplies` writing `state.addMessagesSorted` + (L1600). +6. **Offline DB** (`offline-support/offline_support_api.ts` L1296): replay path writes + `channel.state.addMessageSorted`; `BasePaginator.isOfflineSupportEnabled` is hardcoded `false`. + Because the storage deletion removes `addMessageSorted`, offline **must** be migrated before the + final delete (it is not optional if we delete that method) — see `decisions.md`. +7. **`isUpToDate` parity:** legacy gates `message.new` insertion on `isUpToDate` (L2428); confirm the + paginator's `isHead` routing matches. + +## Blast radius + +- **Public API (semver-major):** `src/index.ts` re-exports `channel_state` + `types`. `ChannelState` + message members, `MessageSet`, `MessageSetType`, `isLatestMessageSet` disappear/change. Encode via + Conventional Commit `feat!:` / `BREAKING CHANGE:` footer — never bump the version manually. +- **React SDK (must migrate before the JS delete):** 7 sites — `MessageBounceContext.tsx:53`, + `ChannelListItem/{utils.tsx:47,ChannelListItem.tsx:96/158,ChannelListItemUI.tsx:44}`, + `MessageList/hooks/useMarkRead.ts:8` — plus stale `CLAUDE.md` guidance in both repos. +- **Tests (JS):** ~450 refs across 7 files (`channel_state.test.js` 355, `channel.test.js` 56, + `client.test.js` 25, `CooldownTimer.test.ts` 9, offline 3, `poll_manager.test.ts` 1, response-gen 1). + +## Acceptance (whole initiative) + +- No reference to `messageSets` / `state.messages` / `state.latestMessages` / `addMessageSorted` etc. + remains in `stream-chat/src` or `stream-chat-react/src`. +- `yarn types` + `yarn lint` clean; `yarn test-unit` green vs. branch baseline. +- Deep-linked channel, jumped/searched message, and threads all render (headless vite check). diff --git a/specs/remove-legacy-channelstate-messages/state.json b/specs/remove-legacy-channelstate-messages/state.json new file mode 100644 index 0000000000..49c681f619 --- /dev/null +++ b/specs/remove-legacy-channelstate-messages/state.json @@ -0,0 +1,157 @@ +{ + "feature": "remove-legacy-channelstate-messages", + "status": "planned", + "worktree": { + "path": "../stream-chat-js-worktrees/remove-legacy-channelstate-messages", + "branch": "feat/remove-legacy-channelstate-messages", + "base": "feat/message-paginator-master-merge" + }, + "openDecisions": ["D1", "D2", "D3", "D4"], + "tasks": [ + { + "id": 1, + "name": "Paginator capability parity", + "status": "done", + "owner": "claude", + "dependencies": [], + "note": "Added BasePaginator.latestItems/latestItem + protected headInterval, MessagePaginator.truncate({truncatedAt}); isUpToDate<->isHead parity confirmed via tests. Full unit suite 3514 pass (+7 new), 0 fail." + }, + { + "id": 2, + "name": "channel.ts seeding + derived readers", + "status": "done", + "owner": "claude", + "dependencies": [1], + "note": "Migrated lastMessage/countUnread/countUnreadMentions to messagePaginator.latestItem(s) and deleteReaction offline read to messagePaginator.getItem; legacy dual-writes left in place. _extendEventWithOwnReactions deferred to Task 6 (thread-reply lookup needs the thread paginator). Seeding finding: gap 4 already covered by watch()+hydrateActiveChannels — search() never populated ChannelState messages, loadMessageIntoState has no callers (to be deleted), and direct channel.query() must NOT be blanket-seeded (it is the MessagePaginator's own pagination transport → re-entrancy). channel.test.js updated to seed the paginator. Full suite 3542 pass." + }, + { + "id": 3, + "name": "CooldownTimer reader", + "status": "done", + "owner": "claude", + "dependencies": [1], + "note": "CooldownTimer.refresh() reads messagePaginator.latestItems; moved messagePaginator creation before cooldownTimer in the Channel ctor (refresh() runs at construction). Fixed a Task 1 gap found here: latestItems/latestItem now fall back to the live head (logical) interval so WS message.new on a fresh (unqueried) channel is seen. CooldownTimer.test.ts seeds the paginator. Full suite 3542 pass." + }, + { + "id": 4, + "name": "messageDelivery readers (channel branch only)", + "status": "done", + "owner": "claude", + "dependencies": [1], + "note": "MessageDeliveryReporter channel branch -> messagePaginator.latestItems; added MessagePaginator.findItemByTimestamp (lowerBound over latest window) and re-pointed MessageReceiptsTracker's default locator to it. Thread branch left as-is (threads out of scope; it early-returns anyway). Moved messagePaginator creation above messageReceiptsTracker in the Channel ctor. Tests seed the paginator; message.new test messages need cid to ingest. Full suite 3543 pass (lone 301 WS error is environmental)." + }, + { + "id": 5, + "name": "client.ts _updateUserReferences reader", + "status": "done", + "owner": "claude", + "dependencies": [1], + "note": "Added MessagePaginator.reflectUserUpdate(user) (batched: patches item index + single active-window re-emit) and called it alongside legacy state.updateUserMessages in client._updateUserMessageReferences (dual-write; legacy removed in Task 10). Thread replies (thread paginators) + pinnedMessages user-ref updates deferred to Task 6 / pinned handling. Full suite 3543 pass; the lone 301 WS error is environmental." + }, + { + "id": 6, + "name": "thread.ts reply source of truth", + "status": "in-scope (folded into Task 11)", + "owner": "claude", + "dependencies": [1], + "note": "SCOPE REVERSED (user directive): channel.state.threads is to be REMOVED, not kept. Verified the Thread object already owns reply state: Thread.state.replies + its own Thread.messagePaginator, with independent subscriptions to message.new/updated/deleted + reaction.* (thread.ts:517/617/639). So channel.state.threads is a redundant legacy shadow for RENDERING. The only thing riding on it is own_reactions preservation on cross-user reactions (channelState.addReaction reads the local reply copy so another user's reaction event doesn't wipe our own_reactions). That preservation must be re-homed onto the paginators (see Task 11 note). Removal folded into Task 11." + }, + { + "id": 7, + "name": "channel.ts getReplies decouple", + "status": "in-scope (folded into Task 11)", + "owner": "claude", + "dependencies": [2], + "note": "SCOPE REVERSED (user directive): channel.getReplies (channel.ts:1578) currently calls this.state.addMessagesSorted(data.messages) to populate channel.state.threads. With channel.state.threads removed, that call is dropped — the Thread object populates its own reply store from the getReplies response. Folded into Task 11." + }, + { + "id": 8, + "name": "React readers migration", + "status": "done", + "owner": "claude", + "dependencies": [], + "note": "React readers (ChannelListItem lastMessage/preview, useMessageDeliveryStatus, useMarkRead, MessageBounceContext, useActionHandler) now read channel.messagePaginator. Fixed the receipts-ordering bug (option B): the paginator is now seeded SYNCHRONOUSLY before read-state hydration. Split BasePaginator.postQueryReconcile into an async wrapper + sync applyQueryReconcile core; added MessagePaginator.seedFirstPageSync used by channel.query() ('latest' path, before _initializeState) and client.hydrateActiveChannels() (before _initializeState, keeping the isInitialized/isActiveIntervalAtHead guard); removed the late async seeds from watch()/hydrate. Changed findItemByTimestamp from ceil to floor (last message <= target) — correct for read/delivered cursors; its only prod caller is the receipts tracker. Wired client._deleteUserMessageReference to messagePaginator.applyMessageDeletionForUser (global-ban user.messages.deleted). Fixed initClientWithChannels concurrent-setup race (Promise.all -> sequential) that cross-seeded paginators via the shared axios mock. Full suites green: JS 3544 pass/0 fail; React 2517 pass/0 fail; both types+lint clean." + }, + { + "id": 9, + "name": "channel.ts stop dual-write", + "status": "done", + "owner": "claude", + "dependencies": [3, 4, 5, 8], + "note": "SCOPE RE-SEQUENCED by the threads-stay decision (Task 6/7 out-of-scope): the shared ChannelState methods (addMessageSorted/removeMessage/deleteUserMessages/addReaction/removeReaction/_updateQuotedMessageReferences) still feed state.threads, so their legacy main-list WRITE can only be removed when messageSets is deleted and the methods are split — that is Task 11. Removing them here would break threads and be redone in Task 11. What Task 9 delivered (making the paginator authoritative for all remaining channel.ts read/behavior logic so Task 11 can delete the storage cleanly): (1) channel.truncated now uses messagePaginator.truncate({truncatedAt}) for the partial case (previously clearStateAndCache wrongly wiped the whole paginator on a partial truncate, blanking migrated readers) + clearUnreadSnapshot, keeps clearStateAndCache for the full case, and ingests the truncate system message into the paginator (Part A gap: it previously had no paginator twin). (2) _extendEventWithOwnReactions now resolves main (non-reply) messages via messagePaginator.getItem, keeping state.findMessage only for thread replies — removing the last channel.ts main-list reader. Remaining legacy main-list readers are only last_message_at (fed by addMessagesSorted, which stays until Task 11) and channel.truncated's own messageSet scan (thread/pinned pruning). Tests updated (channel.test.js truncate + own_reactions). Full suites green: JS 3544 pass/0 fail (types+lint clean); React 2517 pass/0 fail." + }, + { + "id": 10, + "name": "client.ts stop dual-write (hydrate)", + "status": "done", + "owner": "claude", + "dependencies": [5, 9], + "note": "No standalone code change — same threads-stay coupling as Task 9, plus a green-cadence constraint. (a) The paginator is already seeded in hydrateActiveChannels via seedFirstPageSync BEFORE _initializeState (added in Task 8). (b) Poll/reminder hydration already sources from the RAW ChannelAPIResponse (client.ts ~2354-2355 use the loop var channelState.messages = the API response, not channel.state.messages) — the plan's independently-feasible criterion, already satisfied. The plan's other directive ('drop _initializeState/clearMessages/messageSetPagination seeding') is NOT independently doable: _initializeState also hydrates read/members/watchers/membership/pinned + stale-thread cleanup (all needed); clearMessages + the messageSetPagination block feed the still-PUBLIC getters channel.state.messages and channel.state.messagePagination (channel_state.ts:330), which tests still read. Stopping the legacy write now (before Task 11 deletes the storage/getters and Task 13 updates those tests) would blank those getters and turn the suite red between Task 10 and Task 13, breaking the green-vs-baseline cadence. So the hydrate legacy message-list seeding removal is folded into Task 11 (with its _initializeState split) + Task 13 (test updates). Suites remain green from Task 9 (JS 3544/0, React 2517/0)." + }, + { + "id": 11, + "name": "Delete ChannelState message-list storage + utils", + "status": "in progress", + "owner": "claude", + "dependencies": [9, 10], + "subSteps": "Running in verified sub-steps. [DONE] Sub-step 1 — dead code + last React straggler: migrated stream-chat-react SearchResultItem off channel.state.loadMessageIntoState to the paginator path (it just sets searchController.focusedMessage; already performs messagePaginator.jumpToMessage — a Task 8 React-reader gap the Explore map missed because it only scanned JS src); then deleted ChannelState.loadMessageIntoState, filterErrorMessages, findMessageByTimestamp, and the now-dead private switchToMessageSet + unused isBlockedMessage import, plus their channel_state.test.js blocks. Verified green: JS 3532 pass/0 fail (types+lint clean), React 2517 pass/0 fail. [DONE] Sub-step 2 — migrated the CONSUMER assertions (message-list-as-proxy) to the paginator while storage still dual-writes. JS: channel.test.js 8 sites (truncate tests now seed via seedLatestWindow + assert latestItems; message.delete quoted-refs + 'reload doesn't wipe state' read messagePaginator.getItem/latestItems). React: mock-builders/api/markRead.ts (latestItem?.id), ChannelListItem/ScrollToLatestMessageButton/MessageList/Channel test readers -> messagePaginator.latestItem(s)/getItem. Deliberately LEFT the messageSets/messagePagination MACHINERY tests (channel.test.js ~1036-1250 & 2746-2768; ALL 21 client.test.js message-set-merge assertions) untouched — they test code being deleted, so they get removed WITH the code in sub-step 3/4 (they stay green now via dual-write). Verified green: JS 3532/0 (lint clean), React 2517/0 (types+lint clean). SCOPE EXPANDED (user directive): channel.state.threads is ALSO removed now (not kept) — so the shared methods lose BOTH their main-list and thread branches and are DELETED outright (addMessagesSorted/addMessageSorted, removeMessage, deleteUserMessages, findMessage), or stripped to pinned-only where a pinned branch exists (_updateMessage, _updateQuotedMessageReferences). Threads' reply state is fully owned by the Thread object (Thread.state.replies + Thread.messagePaginator, verified). KEY COUPLING: own_reactions preservation on cross-user reactions currently rides on channelState.addReaction reading the local copy (channel.state.messages for main, channel.state.threads for replies); with both caches gone it must be re-homed onto the paginators — the channel reaction handler preserves via channel.messagePaginator.getItem before ingest (main); the Thread reaction handler preserves via Thread.messagePaginator.getItem (reply). [PENDING] Sub-step 3 — re-home reaction own_reactions preservation onto the paginators (add a paginator reaction-merge path); delete/strip the shared ChannelState methods; remove legacy writes in channel.ts handlers (incl. getReplies thread population) + hydrate + split _initializeState; re-home last_message_at; drop channel.state.threads. [PENDING] Sub-step 4 — delete messageSets/messages/latestMessages/threads storage + getters + geometry helpers + utils.messageSetPagination; delete the machinery + thread ChannelState tests. --- ORIGINAL SCOPE: Scope narrowed to the MAIN message list: remove messageSets / messages / latestMessages / message-set pagination + geometry helpers. KEEP ChannelState.threads (per-parent replies), pinnedMessages, and pending_messages. Shared methods that serve both sets and threads (addMessagesSorted, findMessage, updateUserMessages, deleteUserMessages, addReaction, removeReaction, _updateMessage, _updateQuotedMessageReferences, removeMessage) are REFACTORED to keep their thread/pinned handling, not fully deleted. PULLED IN FROM TASK 9 (threads-stay re-sequencing): the removal of the legacy main-list WRITES in channel.ts event handlers (message.new/updated/deleted, reactions, user.messages.deleted) happens HERE, as the shared methods are split — Task 9 could not remove them without breaking threads. PULLED IN FROM TASK 10: the hydrateActiveChannels legacy message seeding removal (clearMessages + _initializeState's addMessagesSorted message branch + the messageSetPagination block) happens HERE — it feeds the still-public getters channel.state.messages / channel.state.messagePagination (channel_state.ts:330) that tests read, so removing it before the storage/getters are deleted and tests updated (Task 13) would turn the suite red. Split _initializeState so its read/members/watchers/pinned/thread-cleanup stays while the main-list write drops. Also re-home last_message_at (currently maintained by addMessagesSorted) and delete confirmed-dead code found in Part B: ChannelState.loadMessageIntoState, filterErrorMessages, findMessageByTimestamp (no src callers). NOTE: because removing the writes blanks channel.state.messages/messagePagination, Task 11 must land TOGETHER with Task 13's test updates to keep the suite green vs baseline." + }, + { + "id": 12, + "name": "Types & exports", + "status": "pending", + "owner": null, + "dependencies": [11] + }, + { + "id": 13, + "name": "Tests", + "status": "pending", + "owner": null, + "dependencies": [11, 12] + }, + { + "id": 14, + "name": "Docs", + "status": "done", + "owner": "claude", + "dependencies": [12], + "note": "JS side done. Fixed the two stale JSDoc examples (Channel.on / StreamChat.on) that logged channel.state.messages -> channel.messagePaginator.state.items; added a CLAUDE.md architecture note that messages/threads/pinned live in the paginators (channel.messagePaginator / thread.messagePaginator / channel.pinnedMessagesPaginator) as the single source of truth, not channel.state, incl. the read-only derived last_message_at, pointing at docs/breaking-changes-v14-v15.md (ledger added earlier). JS CLAUDE.md had no stale addMessageSorted guidance to remove. The React CLAUDE.md stale guidance ('DO NOT mutate channel.state.messages / use addMessageSorted()' + 'threads must exist in main channel state') remains and is tracked on the React branch, not here." + }, + { + "id": 15, + "name": "Offline-support migration (gated by D1)", + "status": "planned", + "owner": null, + "dependencies": [1], + "blockedBy": "D1", + "subPlan": "migrate-offline-to-messagepaginator", + "note": "Expanded into its own spec. Parity tranche (O1,O3,O5) is a required predecessor of Task 11; enhancement tranche gated on D-OFF-1." + }, + { + "id": 16, + "name": "Author (userId -> message ids) secondary index for O(1) per-user updates", + "status": "pending", + "owner": null, + "dependencies": [5], + "blocking": false, + "note": "Optimization follow-up, NOT gating Task 11. reflectUserUpdate (and later per-user ops like applyMessageDeletionForUser / deleteUserMessages migration) currently scan _itemIndex.values() (all cached messages). Maintain an author index (userId -> Set) updated on ingest/remove so these become O(user's messages) not O(all)." + }, + { + "id": 17, + "name": "Complete removal of addMessagesSorted (migrate channel.state.threads off ChannelState)", + "status": "superseded — folded into Task 11 (user directive to remove channel.state.threads now, not after Task 13)", + "owner": null, + "dependencies": [11, 13], + "note": "SUPERSEDED: the user directed removing channel.state.threads as part of Task 11 rather than deferring. See Task 6/7 (now in-scope) and Task 11. Original deferred plan retained below for reference. Sequenced AFTER Task 13. Task 11 only STRIPS addMessagesSorted/addMessageSorted (and removeMessage/deleteUserMessages/_updateMessage/_updateQuotedMessageReferences/findMessage) to their thread(+pinned) branch — it cannot delete them because they are the sole writers/consumers of channel.state.threads, the per-parent reply cache that threads-out-of-scope keeps. channel.state.threads is never read for RENDERING (thread UI reads threadManager.state / Thread.state.replies), only INTERNALLY to enrich thread-reply WS events (findMessage(id,parentId) in _extendEventWithOwnReactions; addReaction/removeReaction reply merge; deleteUserMessages/_updateMessage/_updateQuotedMessageReferences reply branches). To DELETE addMessagesSorted (and channel.state.threads) entirely: (1) move thread-reply own_reactions/reaction/quoted/delete enrichment onto the Thread object's own reply store (Thread.state.replies) — this is the deferred Task 6/7 threads migration; (2) drop the thread branches from the shared ChannelState methods and delete channel.state.threads + addMessagesSorted/addMessageSorted; (3) re-point the remaining callers (channel.getReplies at channel.ts:1578, offline_support_api) at the Thread store; (4) update/remove the ChannelState thread tests. Breaking public-API change (ChannelState is export *-ed) — fold into the Task 12 semver/exports pass or a follow-up major. Gated on reviving the Task 6/7 threads decision." + }, + { + "id": 18, + "name": "Migrate channel.state.pinnedMessages -> channel.pinnedMessagesPaginator (PinnedMessagePaginator)", + "status": "planned", + "owner": null, + "dependencies": [11], + "note": "GOAL: replace the channel.state.pinnedMessages array (+ addPinnedMessages/addPinnedMessage/removePinnedMessage + the pinned branches of _updateMessage/updateUserMessages/deleteUserMessages that Task 11 KEEPS) with a channel.pinnedMessagesPaginator. Unlike channel.state.threads, pinnedMessages is a LIVE store with real React consumers (PinnedMessagesView, usePinnedMessagesSearch, usePinnedMessagesCount, slotBinding), so this is a genuine migration, not a shadow removal.\n\nDESIGN — a dedicated PinnedMessagePaginator, NOT a bare MessagePaginator instance. Passing filters to a vanilla MessagePaginator is insufficient: buildFilters(), the sort (DEFAULT_BACKEND_SORT=created_at), and query() (channel.query({messages}) / getReplies) are methods baked with main-list/created_at assumptions, and pinned uses a DIFFERENT endpoint (channel.getPinnedMessages -> /pinned_messages) with PinnedMessagePaginationOptions (id-based cursors: id_around/id_gt(e)/id_lt(e)) and PinnedMessagesSort. Recommended: `class PinnedMessagePaginator extends MessagePaginator` overriding: buildFilters() -> { cid, pinned: true }; shouldIncludeMessageInInterval() -> !shadowed && !!pinned; item sort/comparator -> pinned_at (desc, matching legacy addPinnedMessage sortBy 'pinned_at'); query()/config.doRequest -> channel.getPinnedMessages(options, sort); getNextQueryShape + cursor derivation -> id-based (PinnedMessagePaginationOptions). NAVIGATION IS KEPT: PinnedMessagePaginationOptions supports id_around, so jumpToMessage (and jumpToTheLatestMessage = most-recently-pinned) are meaningful for pinned and stay exposed. MUST NOT expose/touch READ/UNREAD state — read/unread + delivery receipts belong to the channel + thread message timelines, never to a subset like pinned. So PinnedMessagePaginator: (a) overrides postQueryReconcile so the first-page reconcile does NOT call seedUnreadSnapshot (the base seeds from channel.state.read on first page — wrong for pinned); (b) does NOT surface unreadStateSnapshot / seedUnreadSnapshot / setUnreadSnapshot / jumpToTheFirstUnreadMessage / the unreadReferencePolicy option (unread-coupled — the plain id_around jumpToMessage stays); (c) is NOT wired into MessageReceiptsTracker (the tracker resolves cursors via the CHANNEL's messagePaginator.findItemByTimestamp; pinned must never be a receipts source/target). This read/unread carve-out is the strongest argument for the ALTERNATIVE factoring: instead of subclassing MessagePaginator and suppressing its unread surface, EXTRACT the unread/read-snapshot concern out of MessagePaginator into a composable mixin/companion, leaving a clean message-interval base (interval + ingestItem + reflect* + jumpToMessage/jumpToTheLatestMessage navigation). The channel/thread MAIN paginators compose the unread concern; PinnedMessagePaginator extends the clean base and never gets it (no suppression, no accidental channel.state.read coupling). Recommended: the extraction. Lower-effort fallback: extends MessagePaginator + override/no-op the unread surface (works, but the unread API still exists on the pinned instance and must be kept inert). The reflect* helpers must be reachable by both paginators either way (they live on the shared base if extracted, or are inherited if subclassing).\n\nPIN/UNPIN — the elegant part the user intuited: matchesFilter({ pinned: true }) means ingestItem AUTO-ADDS a message when it becomes pinned and AUTO-REMOVES it when unpinned (ingestItem commits the removal when matchesFilter fails). So the channel message.new/updated/deleted handlers simply also call pinnedMessagesPaginator.ingestItem(...) (and channel.truncated prunes via truncate); reactions on pinned messages go through pinnedMessagesPaginator.reflectReaction. No bespoke pin/unpin branching.\n\nSEEDING: seed from ChannelAPIResponse.pinned_messages on channel open (mirrors the main paginator seedFirstPageSync), replacing state.addPinnedMessages(state.pinned_messages).\n\nFOLLOW-THROUGH: migrate the React consumers to read the paginator state; delete channel.state.pinnedMessages + its methods; this then lets Task 11's 'shrink to pinned-only' methods (_updateMessage/updateUserMessages/deleteUserMessages) be DELETED outright rather than kept. Breaking public-API change (ChannelState.pinnedMessages + methods are export *-ed) -> Task 12 semver/exports + Task 14 docs. Sequenced after Task 11 (independent of the message-list removal, but shares the reflect* infrastructure)." + } + ] +} diff --git a/specs/retained-items/spec.md b/specs/retained-items/spec.md new file mode 100644 index 0000000000..65c7fc663c --- /dev/null +++ b/specs/retained-items/spec.md @@ -0,0 +1,174 @@ +# Retained items — list members not delivered by pagination + +Status: **implemented** (2026-07). Scope: `stream-chat-js` (`BasePaginator` + a thin +`ChannelPaginatorsOrchestrator` pass-through) and `stream-chat-react` (hooks + example). + +> **Shipped design = SEPARATE STORE, not a merge.** The body below (Ordering / Invariants / +> Entity-storage sections) was written for an earlier "merge retained items into the paginated +> `state.items`" model that was **abandoned**. Retained items are NOT merged into the paginated +> list; they live in their own reactive store and the UI renders them wherever it likes. This +> avoids provenance ledgers, offset-corruption, and dedup entirely. + +Shipped: + +- **`stream-chat-js` — `BasePaginator` (flat mode):** single `_itemIndex` owns entities; the + paginated `state.items` is unchanged (pure server list). Retained membership is a separate + reactive `retainedState: StateStore<{ itemIds: string[] }>` (ids only, entities resolved via + `getItem`). API: `retainItem(item, { onDuplicateRetrieved? })` / `releaseItem(id)` / + `isRetained(id)`; `PaginatorOptions.onDuplicateRetrieved` (`keepRetained` default). Lifecycle: + a retained item that stops matching the filter (or is removed) drops from the store. Pagination + is untouched (retained items are never in `state.items`). +- **`ChannelPaginatorsOrchestrator`:** `retainChannel(channel, opts?)` / `releaseChannel(cid)` — + data-semantic, delegate to the owning paginator(s) (ownership resolver picks the owner). +- **`stream-chat-react`:** `useChannelPaginatorState(paginator)` (reactive paginated view) and + `useRetainedChannels(paginator)` (retained ids → entities, sorted by `effectiveComparator`). +- **Example:** `WorkspaceUrlSync` restore calls `orchestrator.retainChannel`; the channel nav + renders a pinned retained section per paginator via `useRetainedChannels`. + +Verified: full JS unit suite green (+7 retained-items tests); deep-linking a past-page-1 channel +shows it in the pinned section and opens it, with the paginated list/offset untouched. + +Related SDK fix (message-paginator gap, not retained-items specific): `Channel.watch()` now seeds the +channel's `messagePaginator` with the first (latest) page it fetches, mirroring +`client.hydrateActiveChannels()` on the `queryChannels()` path. Previously only list-queried channels +had a seeded message paginator; a channel opened via `watch()` alone (deep-link restore, search +result, new DM) rendered an empty `MessageList` until a later channel-list query re-seeded it. See +`channel.ts` `watch()`. + +Ownership at `retainChannel` time depends on `matchesFilter`, which reads channel state +(`state.members`, membership, mute status). A channel absent from every loaded page is an unwatched +stub whose state is empty, so retaining it right away would match only the empty-filter fallback +(`channels:opened`), never a data-dependent list like `channels:default`. The example therefore +**watches the channel before retaining** (`WorkspaceUrlSync.resolveBinding`) — the same single watch +`` would issue, moved earlier — so ownership resolves into the real owning list. The retained +section then renders for the **active** paginator only (`RetainedChannels paginator={activePaginator}`). + +## Problem + +An item can enter a list by a route **other than pagination** — a `?channel=` deep-link restore, a +search result, a freshly created DM, or a WS-ingested channel. Such an item may live past the +loaded page, so a first-page (re)query replaces the list and drops it. We need a first-class, +data-layer way to say "this item belongs in the list regardless of whether pagination delivered it" +— without corrupting pagination and without UI concepts bleeding into the SDK. + +## Two orthogonal axes (core idea) + +Presence and order are **separate concerns** and must not be conflated (that was the "pin" mistake): + +- **Membership** (new) — _is the item in the list?_ Presence for items not delivered by pagination. +- **Ordering** (`boost` + `sortComparator`, unchanged) — _where does it rank among items that are in + the list?_ Boost never creates presence; it only reorders items already present. + +"Deep-linked channel shown at the top" is therefore **membership + boost**, not a third concept. +`boost` is not "retain without TTL" — TTL is incidental; the real difference is the axis (rank vs +presence). + +## Membership API (data-semantic, on `BasePaginator`) + +No UI verbs in the client. The paginator exposes: + +```ts +retainItem(item: T, opts?: { onDuplicateRetrieved?: 'keepRetained' | 'dropRetained' }): void; +releaseItem(id: string): void; +isRetained(id: string): boolean; +// config default: +// PaginatorOptions.onDuplicateRetrieved?: 'keepRetained' | 'dropRetained' (default 'keepRetained') +``` + +"The user opened a channel (URL / search / DM)" is an **app** intent that _maps to_ `retainItem`. +That mapping lives in the app (and, optionally, a thin data-semantic pass-through on the +orchestrator that routes to the owning paginator — e.g. `retainChannel`/`releaseChannel`, still not +`open`/`close`). The SDK stays UI-agnostic. + +## Provenance: an item can hold two memberships at once + +An item may be **paginated** (delivered by a server page) and/or **retained** (declared via +`retainItem`). The paginator tracks which ids arrived via a server page. This ledger is what lets +`releaseItem` know whether an item survives on its own. + +## `onDuplicateRetrieved` — dedup resolution (the only real policy) + +When the _same_ item is both retained and paginated, that's a duplicate. Display is **always** +deduped (shown once) and offset/cursor **always** ignore the retained set — automatic, not policy. +The single choice is what happens to the **retained record**: + +- **`keepRetained`** (default) — retention stands. If a later re-query's first page no longer + includes the item, retention still keeps it. The library does not silently undo an explicit + declaration. +- **`dropRetained`** — retention was a _bridge_ until pagination caught up; once a page covers the + item, drop it from the retained set so it behaves like any ordinary paginated member thereafter. + +Both produce the **identical list right now**. They diverge only on a _future_ re-query that no +longer returns the item — so the policy is purely "how durable is this retention." (The earlier name +`onPaginationOverlap` was misleading — it named the trigger, not the decision; this is a +deduplication resolution, hence `onDuplicateRetrieved`.) + +## Ordering of retained items + +- Retained items are ordered by the **paginator's own sort** — `effectiveComparator` = `boost` + first, then `sortComparator` (built from the paginator's `sort` param). With nothing boosted, + `effectiveComparator` collapses to `sortComparator`, so retained items sort exactly like paginated + ones. +- Each retained item is **inserted into the displayed list at its comparator position** (the same + boost-aware binary-search insert `ingestItem` uses) over the server-ordered items, deduped — the + paginated items are **not** globally re-sorted, so we never diverge from server order. +- **Boost is the explicit override** to lift a specific retained item to the top (the deep-link + "active channel at top" case), independent of its sort key. + +### Honest caveat (inherent, not a bug) + +A retained item that isn't paginated yet has an **unknown true position** — only the loaded window +is known. So by sort key it can only be placed _among the loaded items_; if it truly belongs below +the loaded window it lands at the bottom of that window (best effort). Boost overrides this when a +deterministic top position is wanted. (This is why the `MessagePaginator` logical-head/tail model +does **not** transfer to channels: channel order is UI-state-dependent, so a sort-derived slot is +both wrong and useless — boost-to-top is the channel-appropriate answer.) + +## Invariants + +- **Display** = union(paginated, retained), deduped, ordered by `effectiveComparator` (retained + items merged into the server-ordered list at their comparator position). +- **Pagination** — offset/cursor derive from the **paginated set only**. Already true in + `BasePaginator`: `postQueryReconcile` advances `offset` by the _raw server page_ length, not the + displayed array length — so retained items never shift where the next page starts. +- **Automatic removal happens only on filter/lifecycle** — a retained item is shown iff it still + matches the paginator's filter (archived/muted out, deleted → drops). It is **never** auto-removed + merely because pagination reached it — that is exactly what `dropRetained` opts into, explicitly. +- **Scope:** flat-list mode (`ChannelPaginator`). Interval-storage paginators (`MessagePaginator`) + model out-of-range items via logical intervals and are out of scope for retention. + +## Entity storage — reuse `_itemIndex` (flat mode must populate it) + +Entities live in **one** place: the paginator's `_itemIndex` (id → entity). `retainedItems` is then +just an **ordered array of ids** (kept in `effectiveComparator` order), not a second copy of the +entities. `releaseItem`/`isRetained` operate on ids; display resolves ids → entities via +`_itemIndex`. + +Today this store is **not** available to `ChannelPaginator`: `_itemIndex` is allocated in every +paginator (BasePaginator ctor) but only written in **interval mode** (`ingestPage` and the +interval branch of `ingestItem`). So this feature requires: + +- **Flat mode populates `_itemIndex`** — write entities on query reconcile and `ingestItem`, remove + on removal — so `getItem(id)` works for channels. +- **Decouple entity-store from interval-storage.** `_usesItemIntervalStorage = !!itemIndex` conflates + "an index exists" with "use interval storage," but they're separable: the fallback index is + created regardless, so flat mode can use it as a plain id map **without** enabling intervals. + Channels stay flat; retention gets an entity store. + +## Implementation sketch (for when we build it) + +- `BasePaginator`: + - Maintain `_itemIndex` in flat mode (see above) as the single entity store. + - `retainedItemIds: string[]` (or a Set kept sorted for display) — ids only, ordered by + `effectiveComparator`; entities resolved via `_itemIndex`. + - a server-provenance id set, `retainItem` / `releaseItem` / `isRetained`, and a display-assembly + step that merges retained ids into the server-ordered list by `effectiveComparator`, deduped. + Re-applied on every reconcile so retention survives a first-page replace. `onDuplicateRetrieved` config + + per-call override drives whether a paginated duplicate clears the retained record. +- `ChannelPaginatorsOrchestrator`: optional thin `retainChannel(channel)` / `releaseChannel(cid)` + that resolve the owning paginator(s) and delegate — data-semantic, no UI verbs. +- Consumer (stream-chat-react example `Sync.tsx`): map URL/thread restore intent → `retainChannel`; + boost the restored channel if "top" placement is desired. +- Tests: retention survives first-page reconcile; offset unaffected by retained count; `keepRetained` + vs `dropRetained` divergence only across a re-query; filter-mismatch drops a retained item; + retained ordering follows `sortComparator` (and boost override); no-op in interval-storage mode. diff --git a/specs/user-reference-index/decisions.md b/specs/user-reference-index/decisions.md new file mode 100644 index 0000000000..30ad9f22b6 --- /dev/null +++ b/specs/user-reference-index/decisions.md @@ -0,0 +1,56 @@ +# User Reference Index — Decisions + +Open scope/design decisions. Resolve **D1** and **D2** before implementation (they set the shape the +tasks build against). + +## D1 — Where does the index live? (blocks Task 2+) + +- **Option A — per-`ItemIndex`:** each paginator's `ItemIndex` maintains a `userId → Set` + map as items are `setOne`/`remove`d. The client asks each active channel's paginators "do you + reference this user?" — still iterates channels, but each lookup is O(1) instead of O(items). + Cheapest to keep consistent (single choke point: `ItemIndex.setOne`/`remove`), but still O(active + channels) to find affected ones. +- **Option B — client-level aggregate:** a `userId → Map>` on `client.state`, + updated as paginators ingest/remove. `user.updated` looks up the exact channels + messages. Fully + targeted (no channel iteration), but requires paginators to report ref changes up to the client + (a subscription or callback), which reintroduces some coupling. +- **Recommendation:** start with **A** (contained, single choke point) and measure; escalate to **B** + only if the O(active channels) lookup is still a hotspot. Decide up front so tasks target one shape. + +## D2 — Author only, or author + quoted author? (blocks Task 3/4) + +The update path (`reflectUserUpdate`) only touches `message.user`. The delete path +(`applyMessageDeletionForUser`) touches `message.user` **and** `message.quoted_message.user`. Options: + +- Index **both** relationships under the same user key (a message referenced twice — as author and as + quoted author of another message — is fine; lookups dedupe by message id). +- Index **author only**, and keep a separate targeted pass for quoted authors (or accept a scan just + for the quoted case). +- **Recommendation:** index both; it is the only way to make the delete path fully targeted, and the + maintenance choke point already sees the whole message (so `quoted_message.user?.id` is available). + +## D3 — Index granularity: channel-set vs message-set + +- `userId → Set`: enough to call the existing `reflectUserUpdate(user)` / `applyMessageDeletionForUser({userId})` + on only the right channels (those methods still self-filter internally, but over a much smaller set). + Minimal change to the paginator methods. +- `userId → message ids`: lets the paginator update exactly the referenced messages (no per-channel + re-scan at all), but requires new paginator entry points that take explicit ids. +- **Recommendation:** channel-set first (smallest delta, reuses existing methods); revisit message-set + if profiling shows the in-channel self-filter is still significant. + +## D4 — Consistency on author replacement + +`user.id` does not change on `user.updated` (name/image only), so the index key is stable for updates. +But `ItemIndex.setOne` can replace a cached message with a **different author** (e.g. an edit event +carrying a corrected `user`, or an optimistic→confirmed swap). The maintenance logic must, on +`setOne`, diff the previous cached item's `user.id` / `quoted_message.user.id` against the new one and +move the reference. `remove`, `truncate`, and `clearStateAndCache` must drop references. This is the +main correctness surface — Task 2's tests must cover it. + +## D5 — Interaction with `userChannelReferences` + +Leave `client.state.userChannelReferences` (members/watchers/read) as-is. The new index is message- +scoped and separate. Do **not** try to merge them — they have different maintenance points and +lifetimes. Confirm no code path expects message authors to appear in `userChannelReferences` after +this change (the message-paginator-master-merge work already stopped registering them there). diff --git a/specs/user-reference-index/goal.md b/specs/user-reference-index/goal.md new file mode 100644 index 0000000000..506d0caabc --- /dev/null +++ b/specs/user-reference-index/goal.md @@ -0,0 +1,62 @@ +# User Reference Index — Goal + +## Background + +`Channel._trackLatestMessage` was removed (see `specs/message-paginator-master-merge`). It used to +register each message author into `client.state.userChannelReferences` (a `userId → { cid: true }` +map). With that registration gone, `user.updated` / `user.deleted` propagation to message content can +no longer rely on the map, so the client now **scans every active channel**: + +```ts +// client._updateUserMessageReferences / _deleteUserMessageReference +for (const channel of Object.values(this.activeChannels)) { + channel.pinnedMessagesPaginator.reflectUserUpdate(user); + channel.messagePaginator.reflectUserUpdate(user); // iterates the whole item index, filters by author id +} +``` + +`reflectUserUpdate` / `applyMessageDeletionForUser` are author-id-filtered no-ops on channels that +don't reference the user, so this is **correct** but does **O(active channels × loaded items)** work on +every `user.updated` / `user.deleted` — most of it wasted (a name change on one user walks every +message of every open channel). + +## Objective + +Introduce a **user → message-reference index** so that `user.updated` / `user.deleted` propagation +touches only the channels (ideally only the messages) that actually reference the user, restoring the +targeted behavior the old `userChannelReferences` author registration gave us — but maintained +automatically by the message stores rather than by an imperative per-message call in `channel.ts`. + +The index must cover **both** relationships the current handlers act on: + +- `message.user` (author) — used by `reflectUserUpdate` and `applyMessageDeletionForUser`. +- `message.quoted_message.user` (quoted author) — used by `applyMessageDeletionForUser` and the + quoted-message deletion path. + +## Success criteria + +- `user.updated` / `user.deleted` propagation visits only channels/messages that reference the user; + no full scan of unrelated active channels. +- Behavior parity with the current scan-based implementation: the same messages end up updated / + deleted (author and quoted-author), across `messagePaginator` and `pinnedMessagesPaginator`. +- The index is maintained automatically as messages are ingested / updated / removed / truncated / + cleared — no reintroduction of a per-message call in `channel.ts` (keep the channel↔paginator + layering clean). +- `yarn types`, `yarn lint` (0 warnings), `yarn test` all pass; new unit tests cover index + maintenance and targeted propagation; a benchmark or complexity note demonstrates the reduction. + +## Constraints + +- Build on branch `feat/message-paginator-master-merge` (this depends on the `_trackLatestMessage` + removal and the active-channel-scan handlers). +- Do not change the observable semantics of `user.updated` / `user.deleted` handling. +- Keep `client.state.userChannelReferences` for members / watchers / read references — this spec is + about **message** references only. +- Work in a dedicated worktree; do not push to remote. + +## Non-goals + +- Changing which stores participate (thread reply paginators are still handled by `Thread`'s own + subscriptions, not by the client-level message-reference propagation — unchanged here). +- Member / watcher / read reference handling. +- Any change to `last_message_at` / latest-message tracking (separate, already shipped). diff --git a/specs/user-reference-index/plan.md b/specs/user-reference-index/plan.md new file mode 100644 index 0000000000..d574f491d0 --- /dev/null +++ b/specs/user-reference-index/plan.md @@ -0,0 +1,158 @@ +# Plan — User Reference Index + +See [`goal.md`](goal.md) for objective and success criteria; [`decisions.md`](decisions.md) for the +open design decisions (**D1** index location and **D2** author-vs-quoted must be resolved before +Task 2). + +## Worktree + +**Worktree path (JS SDK):** `../stream-chat-js-worktrees/user-reference-index` +**Branch:** `feat/user-reference-index` +**Base branch:** `feat/message-paginator-master-merge` + +All work MUST happen in this worktree, not the main checkout. Create/sync via the worktrees skill. +This feature has no React-side tasks (the change is internal to the JS SDK's event propagation). + +## Task overview + +Tasks are self-contained. The **critical path** is: resolve design (D1/D2) → build the index + +maintenance at the single choke point → expose lookup → rewrite the two client handlers → tests. The +index-maintenance task owns `ItemIndex` (a serialization chokepoint), so anything touching it chains +behind Task 2. + +--- + +## Task 1: Resolve design decisions (D1, D2, D3) + +**File(s) to create/modify:** `specs/user-reference-index/decisions.md` + +**Dependencies:** None + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Pick index location (D1: per-`ItemIndex` vs client-aggregate), relationship coverage (D2: author + + quoted author), and granularity (D3: channel-set vs message-set). Record the choice + rationale. +- Write the concrete type the rest of the tasks target, e.g. `UserReferenceIndex` with + `add(message)`, `remove(message)`, `channelsFor(userId)` / `messageIdsFor(userId)`. + +**Acceptance Criteria:** + +- [ ] D1, D2, D3 marked resolved with a one-line rationale each. +- [ ] The chosen index interface is written down (signatures) for Tasks 2–4 to implement against. + +## Task 2: Index + maintenance at the ingest/remove choke point + +**File(s) to create/modify:** `src/pagination/ItemIndex.ts` (+ a new `UserReferenceIndex` if D1=per-index), unit test alongside + +**Dependencies:** Task 1 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Maintain the index from the single choke point that all ingestion funnels through + (`ItemIndex.setOne` / `remove`). On `setOne`, diff the previous cached item's referenced user id(s) + against the new item's and move references (D4). On `remove`, drop them. +- Cover both `message.user?.id` and `message.quoted_message?.user?.id` (per D2). +- Ensure `truncate` (bulk `remove`) and `clearStateAndCache` (index `clear`) drop references. + +**Acceptance Criteria:** + +- [ ] Index reflects author + quoted-author references after `setOne`/`remove`/`clear`. +- [ ] Author-replacement on `setOne` moves the reference (old id no longer maps, new id does). +- [ ] Unit tests for add/replace/remove/clear/truncate maintenance. + +## Task 3: Expose targeted lookup from Channel/paginators + +**File(s) to create/modify:** `src/pagination/paginators/BasePaginator.ts` or `MessageIntervalPaginator.ts` (lookup accessor), `src/channel.ts` (aggregate if needed) + +**Dependencies:** Task 2 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Expose a way for the client to ask "which channels / messages reference `userId`?" per the chosen + shape (D1/D3). If D1=client-aggregate, wire paginator ref changes up to `client.state`; if + D1=per-index, expose `referencesUser(userId)` / `messageIdsForUser(userId)` on the paginator. + +**Acceptance Criteria:** + +- [ ] Client can resolve affected channels (and message ids if D3=message-set) in better than + O(loaded items) per channel. +- [ ] No new imperative maintenance call added to `channel.ts` event handlers. + +## Task 4: Rewrite client user-event handlers to use the index + +**File(s) to create/modify:** `src/client.ts` (`_updateUserMessageReferences`, `_deleteUserMessageReference`) + +**Dependencies:** Task 3 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Replace the `Object.values(this.activeChannels)` scan with an index lookup that yields only the + channels (and messages, if D3=message-set) referencing the user, then apply `reflectUserUpdate` / + `applyMessageDeletionForUser` to those only. Keep the quoted-author deletion behavior identical. + +**Acceptance Criteria:** + +- [ ] `user.updated` updates exactly the previously-affected messages (author) — no others visited. +- [ ] `user.deleted` (soft + hard) affects author and quoted-author messages identically to today. +- [ ] Existing `client.test.js` user-event suites pass unchanged (behavior parity). + +## Task 5: Tests + complexity verification + +**File(s) to create/modify:** `test/unit/client.test.js`, `test/unit/pagination/*` as needed + +**Dependencies:** Task 4 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Add tests: multi-channel setup where only some channels reference the user; assert only those are + touched (spy on `reflectUserUpdate` / `applyMessageDeletionForUser`, or on index lookup). +- Add a note / micro-benchmark demonstrating the visited-set is proportional to references, not to + total active channels × loaded items. + +**Acceptance Criteria:** + +- [ ] Test proves unrelated channels' paginators are not walked on a user event. +- [ ] `yarn types`, `yarn lint`, `yarn test` all green. + +--- + +## Execution order + +- **Phase 0 (design):** Task 1. +- **Phase 1 (core):** Task 2 (after Task 1). +- **Phase 2 (wire-up):** Task 3 (after Task 2), then Task 4 (after Task 3) — serialized because they + chain `ItemIndex` → paginator → client. +- **Phase 3 (verify):** Task 5 (after Task 4). + +Little parallelism here — it's a short dependency chain through the ingest choke point. The main +value of the plan is sequencing and the design gate. + +## File ownership summary + +| Task | Creates/Modifies | +| ---- | ------------------------------------------------------------------------- | +| 1 | `specs/user-reference-index/decisions.md` | +| 2 | `src/pagination/ItemIndex.ts` (+ optional `UserReferenceIndex.ts`) + test | +| 3 | `src/pagination/paginators/*.ts`, `src/channel.ts` (aggregate, if D1=B) | +| 4 | `src/client.ts` | +| 5 | `test/unit/client.test.js`, `test/unit/pagination/*` | diff --git a/specs/user-reference-index/state.json b/specs/user-reference-index/state.json new file mode 100644 index 0000000000..52f7b50453 --- /dev/null +++ b/specs/user-reference-index/state.json @@ -0,0 +1,25 @@ +{ + "tasks": { + "task-1-resolve-design-decisions": "pending", + "task-2-index-and-maintenance": "pending", + "task-3-expose-targeted-lookup": "pending", + "task-4-rewrite-client-handlers": "pending", + "task-5-tests-and-complexity": "pending" + }, + "flags": { + "blocked": false, + "blocked_reason": "", + "needs-review": false, + "decisions_resolved": "D1=open, D2=open, D3=open, D4=noted, D5=leave-userChannelReferences-as-is", + "ready_to_execute": false, + "ready_to_execute_reason": "Design gate: resolve D1/D2/D3 (Task 1) before implementation." + }, + "meta": { + "last_updated": "2026-07-21", + "active_task": null, + "worktree": "/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/user-reference-index", + "branch": "feat/user-reference-index", + "base_branch": "feat/message-paginator-master-merge", + "motivation": "Replace the O(active channels x loaded items) all-channel scan in client._updateUserMessageReferences / _deleteUserMessageReference (introduced when Channel._trackLatestMessage / per-author userChannelReferences registration was removed) with a targeted user->message-reference index." + } +} diff --git a/src/CooldownTimer.ts b/src/CooldownTimer.ts index 87374a71b7..1421461de3 100644 --- a/src/CooldownTimer.ts +++ b/src/CooldownTimer.ts @@ -102,7 +102,7 @@ export class CooldownTimer extends WithSubscriptions { const canSkipCooldown = (own_capabilities ?? []).includes('skip-slow-mode'); const ownLatestMessageDate = this.findOwnLatestMessageDate({ - messages: this.channel.state.latestMessages, + messages: this.channel.messagePaginator.headItems, }); if ( diff --git a/src/channel.ts b/src/channel.ts index 33d6866477..ac81a095da 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -4,14 +4,13 @@ import { CooldownTimer } from './CooldownTimer'; import { MessageComposer } from './messageComposer'; import { MessageReceiptsTracker } from './messageDelivery'; import type { ReadStoreReconcileMeta } from './messageDelivery'; -import { MessagePaginator } from './pagination/paginators'; +import { MessagePaginator, PinnedMessagePaginator } from './pagination/paginators'; import { MessageOperations } from './messageOperations'; import { channelHasReadEvents, formatMessage, generateChannelTempCid, logChatPromiseExecution, - messageSetPagination, normalizeQuerySort, } from './utils'; import type { StreamChat } from './client'; @@ -190,6 +189,7 @@ export class Channel { public readonly messageComposer: MessageComposer; public readonly messageReceiptsTracker: MessageReceiptsTracker; public readonly messagePaginator: MessagePaginator; + public readonly pinnedMessagesPaginator: PinnedMessagePaginator; public readonly messageOperations: MessageOperations; public readonly cooldownTimer: CooldownTimer; @@ -241,13 +241,17 @@ export class Channel { compositionContext: this, }); + // 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.messageReceiptsTracker = new MessageReceiptsTracker({ channel: this }); this.messageReceiptsTracker.registerSubscriptions(); this.cooldownTimer = new CooldownTimer({ channel: this }); - this.messagePaginator = new MessagePaginator({ channel: this }); - this.messageOperations = new MessageOperations({ ingest: (m) => this.messagePaginator.ingestItem(m), get: (id) => this.messagePaginator.getItem(id), @@ -726,7 +730,7 @@ export class Channel { try { const offlineDb = this.getClient().offlineDb; if (offlineDb) { - const message = this.state.messages.find(({ id }) => id === messageID); + const message = this.messagePaginator.getItem(messageID); const reaction = { created_at: '', updated_at: '', @@ -1378,27 +1382,6 @@ export class Channel { return this.getClient().user?.privacy_settings?.typing_indicators?.enabled ?? true; } - /** - * lastMessage - return the last message, takes into account that last few messages might not be perfectly sorted - * - * @return {ReturnType | undefined} Description - */ - lastMessage(): LocalMessage | undefined { - // get last 5 messages, sort, return the latest - // get a slice of the last 5 - let min = this.state.latestMessages.length - 5; - if (min < 0) { - min = 0; - } - const max = this.state.latestMessages.length + 1; - const messageSlice = this.state.latestMessages.slice(min, max); - - // sort by pk desc - messageSlice.sort((a, b) => b.created_at.getTime() - a.created_at.getTime()); - - return messageSlice[0]; - } - /** * markRead - Send the mark read event for this user, only works if the `read_events` setting is enabled. Syncs the message delivery report candidates local state. * @@ -1465,7 +1448,7 @@ export class Channel { channel_type: this.type, cid: this.cid, created_at: new Date().toISOString(), - last_read_message_id: this.lastMessage()?.id, + last_read_message_id: this.messagePaginator.headmostItem?.id, team: this.data?.team, type: 'message.read_locally', user: client.user, @@ -1518,6 +1501,10 @@ export class Channel { this.data = state.channel; this._syncStateFromChannelData(this.data, previousData); + // The message paginator is seeded synchronously inside query() (before read-state hydration), + // so a channel opened via watch() alone — a deep-link restore, a search result, a freshly + // created DM — already has its latest page loaded here. + this._client.logger( 'info', `channel:watch() - started watching channel ${this.cid}`, @@ -1576,11 +1563,8 @@ export class Channel { }, ); - // add any messages to our thread state - if (data.messages) { - this.state.addMessagesSorted(data.messages); - } - + // Thread reply state is owned by the Thread object (Thread.messagePaginator); the returned + // replies are consumed there. The channel message list is owned by channel.messagePaginator. return data; } @@ -1685,10 +1669,10 @@ export class Channel { */ countUnread(lastRead?: Date | null) { if (!lastRead) return this.state.unreadCount; - // todo: prevent finding the latest message set on each iteration let count = 0; - for (let i = 0; i < this.state.latestMessages.length; i += 1) { - const message = this.state.latestMessages[i]; + const latestMessages = this.messagePaginator.headItems; + for (let i = 0; i < latestMessages.length; i += 1) { + const message = latestMessages[i]; if (message.created_at > lastRead && this._countMessageAsUnread(message)) { count++; } @@ -1706,8 +1690,9 @@ export class Channel { const userID = this.getClient().userID; let count = 0; - for (let i = 0; i < this.state.latestMessages.length; i += 1) { - const message = this.state.latestMessages[i]; + const latestMessages = this.messagePaginator.headItems; + for (let i = 0; i < latestMessages.length; i += 1) { + const message = latestMessages[i]; if ( this._countMessageAsUnread(message) && (!lastRead || message.created_at > lastRead) && @@ -1813,25 +1798,32 @@ export class Channel { }); } - // add any messages to our channel state - const { messageSet, filteredMessageIds } = this._initializeState( - state, - messageSetToAddToIfDoesNotExist, - ); - messageSet.pagination = { - ...messageSet.pagination, - ...messageSetPagination({ - parentSet: messageSet, - messagePaginationOptions: options?.messages, - requestedPageSize: - options?.messages?.limit ?? DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE, - returnedPage: state.messages, - filteredReturnedPage: state.messages.filter( - (m) => !filteredMessageIds.includes(m.id), - ), - logger: this.getClient().logger, - }), - }; + // Seed the message paginator with the first (latest) page BEFORE _initializeState, which + // hydrates the read state and (via MessageReceiptsTracker) resolves read/delivered cursors + // against this paginator. Seeding first guarantees the tracker sees a populated timeline; a + // later async seed would run after the reconcile and mislabel delivery status. Only the + // latest-page open paths (watch/create) pass 'latest' — the paginator's own pagination queries + // use 'current' and must not be reseeded as a first page here. + if (messageSetToAddToIfDoesNotExist === 'latest' && Array.isArray(state.messages)) { + const requestedPageSize = + options?.messages?.limit ?? DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE; + // Pass the query's message pagination options through: a channel can be opened AROUND a + // message (id_around / created_at_around), in which case the fetched page is a jump window, + // not the latest page — the paginator must reconcile it with jump semantics. + this.messagePaginator.seedFirstPageSync( + state.messages.map(formatMessage), + requestedPageSize, + options?.messages, + ); + } + + // Seed read/members/pinned/thread-cleanup state; the message list is in the paginator. + this._initializeState(state); + // The queried page is the latest set unless this was a jump/around query. + const isLatestMessageSet = + messageSetToAddToIfDoesNotExist === 'latest' && + !options?.messages?.id_around && + !(options?.messages as MessagePaginationOptions | undefined)?.created_at_around; this.getClient().polls.hydratePollCache(state.messages, true); this.getClient().reminders.hydrateState(state.messages); @@ -1865,14 +1857,14 @@ export class Channel { type: 'channels.queried', queriedChannels: { channels: [state], - isLatestMessageSet: messageSet.isLatest, + isLatestMessageSet, }, }); this.getClient().offlineDb?.executeQuerySafely( (db) => db.upsertChannels?.({ channels: [state], - isLatestMessagesSet: messageSet.isLatest, + isLatestMessagesSet: isLatestMessageSet, }), { method: 'upsertChannels' }, ); @@ -2102,7 +2094,7 @@ export class Channel { /** * on - Listen to events on this channel. * - * channel.on('message.new', event => {console.log("my new message", event, channel.state.messages)}) + * channel.on('message.new', event => {console.log("my new message", event, channel.messagePaginator.state.items)}) * or * channel.on(event => {console.log(event.type)}) * @@ -2371,24 +2363,18 @@ export class Channel { const formattedMessage = formatMessage(event.message); const isThreadReply = !!event.message.parent_id && !event.message.show_in_channel; - if (event.hard_delete) { - channelState.removeMessage(event.message); - if (!isThreadReply) { + // Thread-only replies are handled by the Thread object; the channel owns the main list. + if (!isThreadReply) { + if (event.hard_delete) { this.messagePaginator.removeItem({ id: event.message.id }); - } - } else { - channelState.addMessageSorted(event.message, false, false); - if (!isThreadReply) { + this.pinnedMessagesPaginator.removeItem({ id: event.message.id }); + } else { this.messagePaginator.ingestItem(formattedMessage); + this.pinnedMessagesPaginator.ingestItem(formattedMessage); } } this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage); - - channelState.removeQuotedMessageReferences(event.message); - - if (event.message.pinned) { - channelState.removePinnedMessage(event.message); - } + this.pinnedMessagesPaginator.reflectQuotedMessageUpdate(formattedMessage); } break; case 'user.messages.deleted': @@ -2400,8 +2386,11 @@ export class Channel { hardDelete, deletedAt, }); - - this.state.deleteUserMessages(event.user, hardDelete, deletedAt); + this.pinnedMessagesPaginator.applyMessageDeletionForUser({ + userId: event.user.id, + hardDelete, + deletedAt, + }); } break; case 'message.new': @@ -2412,16 +2401,13 @@ export class Channel { const isThreadMessage = event.message.parent_id && !event.message.show_in_channel; - if (this.state.isUpToDate || isThreadMessage) { - channelState.addMessageSorted(event.message, ownMessage); - } - - if (event.message.pinned) { - channelState.addPinnedMessage(event.message); - } - if (!isThreadMessage) { + // ingestItem advances the paginator's tracked latest message (→ last_message_at). A + // message that arrives while the viewer has scrolled to an older window lands in the + // head interval, not the active one, so the view is preserved without an isUpToDate flag. this.messagePaginator.ingestItem(formatMessage(event.message)); + // ingestItem auto-adds when pinned (matchesFilter { pinned: true }). + this.pinnedMessagesPaginator.ingestItem(formatMessage(event.message)); } // do not increase the unread count - the back-end does not increase the count neither in the following cases: @@ -2492,52 +2478,39 @@ export class Channel { if (event.message) { this._extendEventWithOwnReactions(event); const formattedMessage = formatMessage(event.message); - channelState.addMessageSorted(event.message, false, false); if (!event.message.parent_id) { this.messagePaginator.ingestItem(formattedMessage); this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage); - } - channelState._updateQuotedMessageReferences({ message: event.message }); - if (event.message.pinned) { - channelState.addPinnedMessage(event.message); - } else { - channelState.removePinnedMessage(event.message); + // ingestItem auto-adds on pin / auto-removes on unpin (matchesFilter { pinned: true }). + this.pinnedMessagesPaginator.ingestItem(formattedMessage); + this.pinnedMessagesPaginator.reflectQuotedMessageUpdate(formattedMessage); } } break; case 'channel.truncated': if (event.channel?.truncated_at) { - const truncatedAt = +new Date(event.channel.truncated_at); - - channelState.messageSets.forEach((messageSet, messageSetIndex) => { - messageSet.messages.forEach(({ created_at: createdAt, id }) => { - if (truncatedAt > +createdAt) - channelState.removeMessage({ id, messageSetIndex }); - }); - }); - - channelState.pinnedMessages.forEach(({ id, created_at: createdAt }) => { - if (truncatedAt > +createdAt) - channelState.removePinnedMessage({ id } as MessageResponse); - }); - channelState.unreadCount = this.countUnread( - new Date(event.channel.truncated_at), - ); + const truncatedAtDate = new Date(event.channel.truncated_at); + + channelState.unreadCount = this.countUnread(truncatedAtDate); + // Partial truncation: keep messages newer than the cutoff. clearStateAndCache would wipe + // the whole paginator (readers now source from it), so use the partial truncate. The + // channel-wide read/unread context is reset by the truncation, so drop the unread snapshot + // too (clearStateAndCache did this for the full-truncate branch). + this.messagePaginator.truncate({ truncatedAt: truncatedAtDate }); + this.messagePaginator.clearUnreadSnapshot(); + this.pinnedMessagesPaginator.truncate({ truncatedAt: truncatedAtDate }); } else { - channelState.clearMessages(); channelState.unreadCount = 0; + this.messagePaginator.clearStateAndCache(); + this.pinnedMessagesPaginator.clearStateAndCache(); } // system messages don't increment unread counts if (event.message) { - channelState.addMessageSorted(event.message); - if (event.message.pinned) { - channelState.addPinnedMessage(event.message); - } + this.messagePaginator.ingestItem(formatMessage(event.message)); + this.pinnedMessagesPaginator.ingestItem(formatMessage(event.message)); } - this.messagePaginator.clearStateAndCache(); - break; case 'member.added': case 'member.updated': { @@ -2637,33 +2610,48 @@ export class Channel { break; case 'reaction.new': if (event.message && event.reaction) { - const { message, reaction } = event; - event.message = channelState.addReaction(reaction, message) as MessageResponse; + const { reaction } = event; if (!event.message?.parent_id) { - this.messagePaginator.ingestItem(formatMessage(event.message)); + this.messagePaginator.reflectReaction({ message: event.message, reaction }); + this.pinnedMessagesPaginator.reflectReaction({ + message: event.message, + reaction, + }); } } break; case 'reaction.deleted': if (event.message && event.reaction) { - const { message, reaction } = event; - event.message = channelState.removeReaction(reaction, message); + const { reaction } = event; if (event.message && !event.message.parent_id) { - this.messagePaginator.ingestItem(formatMessage(event.message)); + this.messagePaginator.reflectReaction({ + message: event.message, + reaction, + removed: true, + }); + this.pinnedMessagesPaginator.reflectReaction({ + message: event.message, + reaction, + removed: true, + }); } } break; case 'reaction.updated': if (event.message && event.reaction) { - const { message, reaction } = event; + const { reaction } = event; // assuming reaction.updated is only called if enforce_unique is true - event.message = channelState.addReaction( - reaction, - message, - true, - ) as MessageResponse; if (!event.message?.parent_id) { - this.messagePaginator.ingestItem(formatMessage(event.message)); + this.messagePaginator.reflectReaction({ + enforceUnique: true, + message: event.message, + reaction, + }); + this.pinnedMessagesPaginator.reflectReaction({ + enforceUnique: true, + message: event.message, + reaction, + }); } } break; @@ -2676,7 +2664,8 @@ export class Channel { }; channel._syncStateFromChannelData(channel.data, previousChannelData); if (event.clear_history) { - channelState.clearMessages(); + this.messagePaginator.clearStateAndCache(); + this.pinnedMessagesPaginator.clearStateAndCache(); } break; } @@ -2772,10 +2761,7 @@ export class Channel { this.state.syncMemberCountFromChannelData(data, fallbackData); } - _initializeState( - state: ChannelAPIResponse, - messageSetToAddToIfDoesNotExist: MessageSetType = 'latest', - ) { + _initializeState(state: ChannelAPIResponse) { const { state: clientState, user, userID } = this.getClient(); // add the members and users @@ -2791,22 +2777,18 @@ export class Channel { this.state.membership = state.membership || {}; - const messages = state.messages || []; - if (!this.state.messages) { - this.state.initMessages(); - } - const { messageSet, filteredMessageIds } = this.state.addMessagesSorted( - messages, - false, - true, - true, - messageSetToAddToIfDoesNotExist, + // Seed the message paginator's `lastMessageAt` aggregate from the server's authoritative + // `last_message_at`. The first-page seed (Channel.query / client.hydrateActiveChannels) also + // advances it from ingested messages; both feed the same monotonic max, so this additionally + // covers the path where the paginator seed is skipped (an already-loaded channel the viewer has + // jumped away from, where re-seeding would clobber their window). + this.messagePaginator.seedLastMessageAt(state.channel?.last_message_at); + + // Seed the pinned-messages paginator from the same response. + this.pinnedMessagesPaginator.seedFirstPageSync( + (state.pinned_messages || []).map(formatMessage), + this.pinnedMessagesPaginator.pageSize, ); - - if (!this.state.pinnedMessages) { - this.state.pinnedMessages = []; - } - this.state.addPinnedMessages(state.pinned_messages || []); if (state.pending_messages) { this.state.pending_messages = state.pending_messages; } @@ -2828,7 +2810,7 @@ export class Channel { // that everything up to this point is not marked as unread const readUpdates: ChannelState['read'] = {}; if (userID != null) { - const last_read = this.state.last_message_at || new Date(); + const last_read = this.messagePaginator.lastMessageAt || new Date(); if (user) { readUpdates[user.id] = { user, @@ -2876,18 +2858,16 @@ export class Channel { { changedUserIds: entries.map(([userId]) => userId) }, ); } - - return { - messageSet, - filteredMessageIds, - }; } _extendEventWithOwnReactions(event: Event) { if (!event.message) { return; } - const message = this.state.findMessage(event.message.id, event.message.parent_id); + // The channel message list is owned by the paginator; enrich from it. Thread-only replies are + // not in the paginator (getItem returns undefined) — the Thread object preserves their + // own_reactions on its own reply store. + const message = this.messagePaginator.getItem(event.message.id); if (message) { event.message.own_reactions = message.own_reactions; } @@ -2939,6 +2919,5 @@ export class Channel { this.disconnected = true; this.messageReceiptsTracker.unregisterSubscriptions(); this.cooldownTimer.clearTimeout(); - this.state.setIsUpToDate(false); } } diff --git a/src/channel_state.ts b/src/channel_state.ts index 5845635463..e61a4bff5f 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -5,19 +5,10 @@ import type { LocalMessage, MessageResponse, MessageResponseBase, - MessageSet, - MessageSetType, PendingMessageResponse, - ReactionResponse, UserResponse, } from './types'; -import { - deleteUserMessages as _deleteUserMessages, - addToMessageList, - formatMessage, - isBlockedMessage, -} from './utils'; -import { DEFAULT_MESSAGE_SET_PAGINATION } from './constants'; +import { formatMessage } from './utils'; import { StateStore } from './store'; type ChannelReadStatus = Record< @@ -59,38 +50,6 @@ export type OwnCapabilitiesState = { ownCapabilities: string[]; }; -const messageSetBounds = ( - a: LocalMessage[] | MessageResponse[], - b: LocalMessage[] | MessageResponse[], -) => ({ - newestMessageA: new Date(a[0]?.created_at ?? 0), - oldestMessageA: new Date(a.slice(-1)[0]?.created_at ?? 0), - newestMessageB: new Date(b[0]?.created_at ?? 0), - oldestMessageB: new Date(b.slice(-1)[0]?.created_at ?? 0), -}); - -const aContainsOrEqualsB = (a: LocalMessage[], b: LocalMessage[]) => { - const { newestMessageA, newestMessageB, oldestMessageA, oldestMessageB } = - messageSetBounds(a, b); - return newestMessageA >= newestMessageB && oldestMessageB >= oldestMessageA; -}; - -const aOverlapsB = (a: LocalMessage[], b: LocalMessage[]) => { - const { newestMessageA, newestMessageB, oldestMessageA, oldestMessageB } = - messageSetBounds(a, b); - return ( - oldestMessageA < oldestMessageB && - oldestMessageB < newestMessageA && - newestMessageA < newestMessageB - ); -}; - -const messageSetsOverlapByTimestamp = (a: LocalMessage[], b: LocalMessage[]) => - aContainsOrEqualsB(a, b) || - aContainsOrEqualsB(b, a) || - aOverlapsB(a, b) || - aOverlapsB(b, a); - /** * ChannelState - A container class for the channel state. */ @@ -103,26 +62,9 @@ export class ChannelState { readonly ownCapabilitiesStore: StateStore; // todo: is this actually used somewhere? readonly mutedUsersStore: StateStore; - pinnedMessages: Array>; pending_messages: Array; - threads: Record>>; unreadCount: number; membership: ChannelMemberResponse; - last_message_at: Date | null; - /** - * Flag which indicates if channel state contain latest/recent messages or no. - * This flag should be managed by UI sdks using a setter - setIsUpToDate. - * When false, any new message (received by websocket event - message.new) will not - * be pushed on to message list. - */ - isUpToDate: boolean; - /** - * Disjoint lists of messages - * Users can jump in the message list (with searching) and this can result in disjoint lists of messages - * The state manages these lists and merges them when lists overlap - * The messages array contains the currently active set - */ - messageSets: MessageSet[] = []; constructor(channel: Channel) { this._channel = channel; @@ -142,45 +84,9 @@ export class ChannelState { }); this.syncMemberCountFromChannelData(channel?.data); this.syncOwnCapabilitiesFromChannelData(channel?.data); - this.initMessages(); - this.pinnedMessages = []; this.pending_messages = []; - this.threads = {}; this.membership = {}; this.unreadCount = 0; - /** - * Flag which indicates if channel state contain latest/recent messages or no. - * This flag should be managed by UI sdks using a setter - setIsUpToDate. - * When false, any new message (received by websocket event - message.new) will not - * be pushed on to message list. - */ - this.isUpToDate = true; - this.last_message_at = - channel?.state?.last_message_at != null - ? new Date(channel.state.last_message_at) - : null; - } - - get messages() { - return this.messageSets.find((s) => s.isCurrent)?.messages || []; - } - - set messages(messages: Array>) { - const index = this.messageSets.findIndex((s) => s.isCurrent); - this.messageSets[index].messages = messages; - } - - /** - * The list of latest messages - * The messages array not always contains the latest messages (for example if a user searched for an earlier message, that is in a different message set) - */ - get latestMessages() { - return this.messageSets.find((s) => s.isLatest)?.messages || []; - } - - set latestMessages(messages: Array>) { - const index = this.messageSets.findIndex((s) => s.isLatest); - this.messageSets[index].messages = messages; } get members() { @@ -327,45 +233,6 @@ export class ChannelState { this.watcherStore.partialNext({ watcherCount }); } - get messagePagination() { - return ( - this.messageSets.find((s) => s.isCurrent)?.pagination || - DEFAULT_MESSAGE_SET_PAGINATION - ); - } - - pruneOldest(maxMessages: number) { - const currentIndex = this.messageSets.findIndex((s) => s.isCurrent); - if (this.messageSets[currentIndex].isLatest) { - const newMessages = this.messageSets[currentIndex].messages; - this.messageSets[currentIndex].messages = newMessages.slice(-maxMessages); - this.messageSets[currentIndex].pagination.hasPrev = true; - } - } - - /** - * addMessageSorted - Add a message to the state - * - * @param {MessageResponse} newMessage A new message - * @param {boolean} timestampChanged Whether updating a message with changed created_at value. - * @param {boolean} addIfDoesNotExist Add message if it is not in the list, used to prevent out of order updated messages from being added. - * @param {MessageSetType} messageSetToAddToIfDoesNotExist Which message set to add to if message is not in the list (only used if addIfDoesNotExist is true) - */ - addMessageSorted( - newMessage: MessageResponse | LocalMessage, - timestampChanged = false, - addIfDoesNotExist = true, - messageSetToAddToIfDoesNotExist: MessageSetType = 'latest', - ) { - return this.addMessagesSorted( - [newMessage], - timestampChanged, - false, - addIfDoesNotExist, - messageSetToAddToIfDoesNotExist, - ); - } - /** * Takes the message object, parses the dates, sets `__html` * and sets the status to `received` if missing; returns a new message object. @@ -375,629 +242,6 @@ export class ChannelState { formatMessage = (message: MessageResponse | MessageResponseBase | LocalMessage) => formatMessage(message); - /** - * addMessagesSorted - Add the list of messages to state and resorts the messages - * - * @param {Array} newMessages A list of messages - * @param {boolean} timestampChanged Whether updating messages with changed created_at value. - * @param {boolean} initializing Whether channel is being initialized. - * @param {boolean} addIfDoesNotExist Add message if it is not in the list, used to prevent out of order updated messages from being added. - * @param {MessageSetType} messageSetToAddToIfDoesNotExist Which message set to add to if messages are not in the list (only used if addIfDoesNotExist is true) - * - */ - addMessagesSorted( - newMessages: (MessageResponse | LocalMessage)[], - timestampChanged = false, - initializing = false, - addIfDoesNotExist = true, - messageSetToAddToIfDoesNotExist: MessageSetType = 'current', - ) { - const { messagesToAdd, targetMessageSetIndex } = this.findTargetMessageSet( - newMessages, - addIfDoesNotExist, - messageSetToAddToIfDoesNotExist, - ); - - const filteredMessageIds: string[] = []; - - for (let i = 0; i < messagesToAdd.length; i += 1) { - const isFromShadowBannedUser = messagesToAdd[i].shadowed; - if (isFromShadowBannedUser && addIfDoesNotExist) { - filteredMessageIds.push(messagesToAdd[i].id); - continue; - } - // If message is already formatted we can skip the tasks below - // This will be true for messages that are already present at the state -> this happens when we perform merging of message sets - // This will be also true for message previews used by some SDKs - const isMessageFormatted = messagesToAdd[i].created_at instanceof Date; - let message: ReturnType; - if (isMessageFormatted) { - message = messagesToAdd[i] as ReturnType; - } else { - message = this.formatMessage(messagesToAdd[i]); - - if (message.user && this._channel?.cid) { - /** - * Store the reference to user for this channel, so that when we have to - * handle updates to user, we can use the reference map, to determine which - * channels need to be updated with updated user object. - */ - this._channel - .getClient() - .state.updateUserReference(message.user, this._channel.cid); - } - - if ( - initializing && - message.id && - this.threads[message.id] && - !this._channel.getClient().preventThreadCleanup - ) { - // If we are initializing the state of channel (e.g., in case of connection recovery), - // then in that case we remove thread related to this message from threads object. - // This way we can ensure that we don't have any stale data in thread object - // and consumer can refetch the replies. - delete this.threads[message.id]; - } - - const shouldSkipLastMessageAtUpdate = - this._channel.getConfig()?.skip_last_msg_update_for_system_msgs && - message.type === 'system'; - - if ( - !shouldSkipLastMessageAtUpdate && - (!this.last_message_at || - message.created_at.getTime() > this.last_message_at.getTime()) - ) { - this.last_message_at = new Date(message.created_at.getTime()); - } - } - - // update or append the messages... - const parentID = message.parent_id; - - // add to the given message set - if ((!parentID || message.show_in_channel) && targetMessageSetIndex !== -1) { - this.messageSets[targetMessageSetIndex].messages = this._addToMessageList( - this.messageSets[targetMessageSetIndex].messages, - message, - timestampChanged, - 'created_at', - addIfDoesNotExist, - ); - } - - /** - * Add message to thread if applicable and the message - * was added when querying for replies, or the thread already exits. - * This is to prevent the thread state from getting out of sync if - * a thread message is shown in channel but older than the newest thread - * message. This situation can result in a thread state where a random - * message is "oldest" message, and newer messages are therefore not loaded. - * This can also occur if an old thread message is updated. - */ - if (parentID && !initializing) { - const thread = this.threads[parentID] || []; - this.threads[parentID] = this._addToMessageList( - thread, - message, - timestampChanged, - 'created_at', - addIfDoesNotExist, - ); - } - } - - return { - messageSet: this.messageSets[targetMessageSetIndex], - filteredMessageIds, - }; - } - - /** - * addPinnedMessages - adds messages in pinnedMessages property - * - * @param {Array} pinnedMessages A list of pinned messages - * - */ - addPinnedMessages(pinnedMessages: MessageResponse[]) { - for (let i = 0; i < pinnedMessages.length; i += 1) { - this.addPinnedMessage(pinnedMessages[i]); - } - } - - /** - * addPinnedMessage - adds message in pinnedMessages - * - * @param {MessageResponse} pinnedMessage message to update - * - */ - addPinnedMessage(pinnedMessage: MessageResponse) { - this.pinnedMessages = this._addToMessageList( - this.pinnedMessages, - this.formatMessage(pinnedMessage), - false, - 'pinned_at', - ); - } - - /** - * removePinnedMessage - removes pinned message from pinnedMessages - * - * @param {MessageResponse} message message to remove - * - */ - removePinnedMessage(message: MessageResponse) { - const { result } = this.removeMessageFromArray(this.pinnedMessages, message); - this.pinnedMessages = result; - } - - addReaction( - reaction: ReactionResponse, - message?: MessageResponse, - enforce_unique?: boolean, - ) { - const messageWithReaction = message; - let messageFromState: LocalMessage | undefined; - if (!messageWithReaction) { - messageFromState = this.findMessage(reaction.message_id); - } - - if (!messageWithReaction && !messageFromState) { - return; - } - - const messageToUpdate = messageWithReaction ?? messageFromState; - const updateData = { - id: messageToUpdate?.id, - parent_id: messageToUpdate?.parent_id, - pinned: messageToUpdate?.pinned, - show_in_channel: messageToUpdate?.show_in_channel, - }; - - this._updateMessage(updateData, (msg) => { - if (messageWithReaction) { - const updatedMessage = { ...messageWithReaction }; - // This part will remove own_reactions from what is essentially - // a copy of event.message; we do not want to return that as someone - // else reaction would remove our own_reactions needlessly. This - // only happens when we are not the sender of the reaction. We need - // the variable itself so that the event can be properly enriched - // later on. - messageWithReaction.own_reactions = this._addOwnReactionToMessage( - msg.own_reactions, - reaction, - enforce_unique, - ); - // Whenever we are the ones sending the reaction, the helper enriches - // own_reactions as normal so we can use that, otherwise we fallback - // to whatever state we had. - updatedMessage.own_reactions = - this._channel.getClient().userID === reaction.user_id - ? messageWithReaction.own_reactions - : msg.own_reactions; - return this.formatMessage(updatedMessage); - } - - if (messageFromState) { - return this._addReactionToState(messageFromState, reaction, enforce_unique); - } - - return msg; - }); - return messageWithReaction ?? messageFromState; - } - - _addReactionToState( - messageFromState: LocalMessage, - reaction: ReactionResponse, - enforce_unique?: boolean, - ) { - if (!messageFromState.reaction_groups) { - messageFromState.reaction_groups = {}; - } - - // 1. Firstly, get rid of all of our own reactions from the reaction_groups - // if enforce_unique is enabled. - if (enforce_unique) { - for (const ownReaction of messageFromState.own_reactions ?? []) { - const oldOwnReactionTypeData = messageFromState.reaction_groups[ownReaction.type]; - messageFromState.reaction_groups[ownReaction.type] = { - ...oldOwnReactionTypeData, - count: oldOwnReactionTypeData.count - 1, - sum_scores: oldOwnReactionTypeData.sum_scores - (ownReaction.score ?? 1), - }; - // If there are no reactions left in this group, simply remove it. - if (messageFromState.reaction_groups[ownReaction.type].count < 1) { - delete messageFromState.reaction_groups[ownReaction.type]; - } - } - } - - const newReactionGroups = messageFromState.reaction_groups; - const oldReactionTypeData = newReactionGroups[reaction.type]; - const score = reaction.score ?? 1; - - // 2. Next, update the reaction_groups with the new reaction. - messageFromState.reaction_groups[reaction.type] = oldReactionTypeData - ? { - ...oldReactionTypeData, - count: oldReactionTypeData.count + 1, - sum_scores: oldReactionTypeData.sum_scores + score, - last_reaction_at: reaction.created_at, - } - : { - count: 1, - first_reaction_at: reaction.created_at, - last_reaction_at: reaction.created_at, - sum_scores: score, - }; - - // 3. Update the own_reactions with the new reaction. - messageFromState.own_reactions = this._addOwnReactionToMessage( - messageFromState.own_reactions, - reaction, - enforce_unique, - ); - - // 4. Finally, update the latest_reactions with the new reaction, - // while respecting enforce_unique. - const userId = this._channel.getClient().userID; - messageFromState.latest_reactions = enforce_unique - ? [ - ...(messageFromState.latest_reactions || []).filter( - (r) => r.user_id !== userId, - ), - reaction, - ] - : [...(messageFromState.latest_reactions || []), reaction]; - - return messageFromState; - } - - _addOwnReactionToMessage( - ownReactions: ReactionResponse[] | null | undefined, - reaction: ReactionResponse, - enforce_unique?: boolean, - ) { - if (enforce_unique) { - ownReactions = []; - } else { - ownReactions = this._removeOwnReactionFromMessage(ownReactions, reaction); - } - - ownReactions = ownReactions || []; - if (this._channel.getClient().userID === reaction.user_id) { - ownReactions.push(reaction); - } - - return ownReactions; - } - - _removeOwnReactionFromMessage( - ownReactions: ReactionResponse[] | null | undefined, - reaction: ReactionResponse, - ) { - if (ownReactions) { - return ownReactions.filter( - (item) => item.user_id !== reaction.user_id || item.type !== reaction.type, - ); - } - return ownReactions; - } - - removeReaction(reaction: ReactionResponse, message?: MessageResponse) { - const messageWithRemovedReaction = message; - let messageFromState: LocalMessage | undefined; - if (!messageWithRemovedReaction) { - messageFromState = this.findMessage(reaction.message_id); - } - - if (!messageWithRemovedReaction && !messageFromState) { - return; - } - - const messageToUpdate = messageWithRemovedReaction ?? messageFromState; - const updateData = { - id: messageToUpdate?.id, - parent_id: messageToUpdate?.parent_id, - pinned: messageToUpdate?.pinned, - show_in_channel: messageToUpdate?.show_in_channel, - }; - this._updateMessage(updateData, (msg) => { - if (messageWithRemovedReaction) { - messageWithRemovedReaction.own_reactions = this._removeOwnReactionFromMessage( - msg.own_reactions, - reaction, - ); - return this.formatMessage(messageWithRemovedReaction); - } - - if (messageFromState) { - return this._removeReactionFromState(messageFromState, reaction); - } - - return msg; - }); - return messageWithRemovedReaction; - } - - _removeReactionFromState(messageFromState: LocalMessage, reaction: ReactionResponse) { - const reactionToRemove = messageFromState.own_reactions?.find( - (r) => r.type === reaction.type, - ); - if (reactionToRemove && messageFromState.reaction_groups?.[reactionToRemove.type]) { - const newReactionGroup = messageFromState.reaction_groups[reactionToRemove.type]; - messageFromState.reaction_groups[reactionToRemove.type] = { - ...newReactionGroup, - count: newReactionGroup.count - 1, - sum_scores: newReactionGroup.sum_scores - (reactionToRemove.score ?? 1), - }; - // If there are no reactions left in this group, simply remove it. - if (messageFromState.reaction_groups[reactionToRemove.type].count < 1) { - delete messageFromState.reaction_groups[reactionToRemove.type]; - } - } - messageFromState.own_reactions = messageFromState.own_reactions?.filter( - (r) => r.type !== reaction.type, - ); - const userId = this._channel.getClient().userID; - messageFromState.latest_reactions = messageFromState.latest_reactions?.filter( - (r) => !(r.user_id === userId && r.type === reaction.type), - ); - return messageFromState; - } - - _updateQuotedMessageReferences({ - message, - remove, - }: { - message: MessageResponse; - remove?: boolean; - }) { - const parseMessage = (m: ReturnType) => - ({ - ...m, - created_at: m.created_at.toISOString(), - pinned_at: m.pinned_at?.toISOString(), - updated_at: m.updated_at?.toISOString(), - }) as unknown as MessageResponse; - - const update = (messages: LocalMessage[]) => { - const updatedMessages = messages.reduce((acc, msg) => { - if (msg.quoted_message_id === message.id) { - acc.push({ - ...parseMessage(msg), - quoted_message: remove ? { ...message, attachments: [] } : message, - }); - } - return acc; - }, []); - this.addMessagesSorted(updatedMessages, true); - }; - - if (!message.parent_id) { - this.messageSets.forEach((set) => update(set.messages)); - } else if (message.parent_id && this.threads[message.parent_id]) { - // prevent going through all the threads even though it is possible to quote a message from another thread - update(this.threads[message.parent_id]); - } - } - - removeQuotedMessageReferences(message: MessageResponse) { - this._updateQuotedMessageReferences({ message, remove: true }); - } - - /** - * Updates all instances of given message in channel state - * @param message - * @param updateFunc - */ - _updateMessage( - message: { - id?: string; - parent_id?: string; - pinned?: boolean; - show_in_channel?: boolean; - }, - updateFunc: ( - msg: ReturnType, - ) => ReturnType, - ) { - const { parent_id, show_in_channel, pinned } = message; - - if (parent_id && this.threads[parent_id]) { - const thread = this.threads[parent_id]; - const msgIndex = thread.findIndex((msg) => msg.id === message.id); - if (msgIndex !== -1) { - thread[msgIndex] = updateFunc(thread[msgIndex]); - this.threads[parent_id] = thread; - } - } - - if ((!show_in_channel && !parent_id) || show_in_channel) { - const messageSetIndex = this.findMessageSetIndex(message); - if (messageSetIndex !== -1) { - const msgIndex = this.messageSets[messageSetIndex].messages.findIndex( - (msg) => msg.id === message.id, - ); - if (msgIndex !== -1) { - const upMsg = updateFunc(this.messageSets[messageSetIndex].messages[msgIndex]); - this.messageSets[messageSetIndex].messages[msgIndex] = upMsg; - } - } - } - - if (pinned) { - const msgIndex = this.pinnedMessages.findIndex((msg) => msg.id === message.id); - if (msgIndex !== -1) { - this.pinnedMessages[msgIndex] = updateFunc(this.pinnedMessages[msgIndex]); - } - } - } - - /** - * Setter for isUpToDate. - * - * @param isUpToDate Flag which indicates if channel state contain latest/recent messages or no. - * This flag should be managed by UI sdks using a setter - setIsUpToDate. - * When false, any new message (received by websocket event - message.new) will not - * be pushed on to message list. - */ - setIsUpToDate = (isUpToDate: boolean) => { - this.isUpToDate = isUpToDate; - }; - - /** - * _addToMessageList - Adds a message to a list of messages, tries to update first, appends if message isn't found - * - * @param {Array>} messages A list of messages - * @param message - * @param {boolean} timestampChanged Whether updating a message with changed created_at value. - * @param {string} sortBy field name to use to sort the messages by - * @param {boolean} addIfDoesNotExist Add message if it is not in the list, used to prevent out of order updated messages from being added. - */ - _addToMessageList( - messages: Array>, - message: ReturnType, - timestampChanged = false, - sortBy: 'pinned_at' | 'created_at' = 'created_at', - addIfDoesNotExist = true, - ) { - return addToMessageList( - messages, - message, - timestampChanged, - sortBy, - addIfDoesNotExist, - ); - } - - /** - * removeMessage - Description - * - * @param {{ id: string; parent_id?: string }} messageToRemove Object of the message to remove. Needs to have at id specified. - * - * @return {boolean} Returns if the message was removed - */ - removeMessage(messageToRemove: { - id: string; - messageSetIndex?: number; - parent_id?: string; - }) { - let isRemoved = false; - if (messageToRemove.parent_id && this.threads[messageToRemove.parent_id]) { - const { removed, result: threadMessages } = this.removeMessageFromArray( - this.threads[messageToRemove.parent_id], - messageToRemove, - ); - - this.threads[messageToRemove.parent_id] = threadMessages; - isRemoved = removed; - } else { - const messageSetIndex = - messageToRemove.messageSetIndex ?? this.findMessageSetIndex(messageToRemove); - if (messageSetIndex !== -1) { - const { removed, result: messages } = this.removeMessageFromArray( - this.messageSets[messageSetIndex].messages, - messageToRemove, - ); - this.messageSets[messageSetIndex].messages = messages; - isRemoved = removed; - } - } - - return isRemoved; - } - - removeMessageFromArray = ( - msgArray: Array>, - msg: { id: string; parent_id?: string }, - ) => { - const result = msgArray.filter( - (message) => !(!!message.id && !!msg.id && message.id === msg.id), - ); - - return { removed: result.length < msgArray.length, result }; - }; - - /** - * Updates the message.user property with updated user object, for messages. - * - * @param {UserResponse} user - */ - updateUserMessages = (user: UserResponse) => { - const _updateUserMessages = ( - messages: Array>, - user: UserResponse, - ) => { - for (let i = 0; i < messages.length; i++) { - const m = messages[i]; - if (m.user?.id === user.id) { - messages[i] = { ...m, user }; - } - } - }; - - this.messageSets.forEach((set) => _updateUserMessages(set.messages, user)); - - for (const parentId in this.threads) { - _updateUserMessages(this.threads[parentId], user); - } - - _updateUserMessages(this.pinnedMessages, user); - }; - - /** - * Marks the messages as deleted, from deleted user. - * - * @param {UserResponse} user - * @param {boolean} hardDelete - */ - deleteUserMessages = ( - user: UserResponse, - hardDelete = false, - deletedAt?: LocalMessage['deleted_at'], - ) => { - this.messageSets.forEach(({ messages }) => - _deleteUserMessages({ messages, user, hardDelete, deletedAt: deletedAt ?? null }), - ); - - for (const parentId in this.threads) { - _deleteUserMessages({ - messages: this.threads[parentId], - user, - hardDelete, - deletedAt: deletedAt ?? null, - }); - } - - _deleteUserMessages({ - messages: this.pinnedMessages, - user, - hardDelete, - deletedAt: deletedAt ?? null, - }); - }; - - /** - * filterErrorMessages - Removes error messages from the channel state. - * - */ - filterErrorMessages() { - const filteredMessages = this.latestMessages.filter( - (message) => message.type !== 'error', - ); - - const blockedMessages = this.latestMessages.filter(isBlockedMessage); - // We need to hard delete the blocked messages from the offline database. - for (const message of blockedMessages) { - this._channel.getClient().offlineDb?.hardDeleteMessage({ id: message.id }); - } - - this.latestMessages = filteredMessages; - } - /** * clean - Remove stale data such as users that stayed in typing state for more than 5 seconds */ @@ -1019,296 +263,4 @@ export class ChannelState { } } } - - clearMessages() { - this.initMessages(); - this.pinnedMessages = []; - } - - initMessages() { - this.messageSets = [ - { - messages: [], - isLatest: true, - isCurrent: true, - pagination: { ...DEFAULT_MESSAGE_SET_PAGINATION }, - }, - ]; - } - - /** - * loadMessageIntoState - Loads a given message (and messages around it) into the state - * - * @param {string} messageId The id of the message, or 'latest' to indicate switching to the latest messages - * @param {string} parentMessageId The id of the parent message, if we want load a thread reply - * @param {number} limit The page size if the message has to be queried from the server - */ - async loadMessageIntoState( - messageId: string | 'latest', - parentMessageId?: string, - limit = 25, - ) { - let messageSetIndex: number; - let switchedToMessageSet = false; - let loadedMessageThread = false; - const messageIdToFind = parentMessageId || messageId; - if (messageId === 'latest') { - if (this.messages === this.latestMessages) { - return; - } - messageSetIndex = this.messageSets.findIndex((s) => s.isLatest); - } else { - messageSetIndex = this.findMessageSetIndex({ id: messageIdToFind }); - } - if (messageSetIndex !== -1) { - this.switchToMessageSet(messageSetIndex); - switchedToMessageSet = true; - } - loadedMessageThread = - !parentMessageId || - !!this.threads[parentMessageId]?.find((m) => m.id === messageId); - if (switchedToMessageSet && loadedMessageThread) { - return; - } - if (!switchedToMessageSet) { - await this._channel.query( - { messages: { id_around: messageIdToFind, limit } }, - 'new', - ); - } - if (!loadedMessageThread && parentMessageId) { - await this._channel.getReplies(parentMessageId, { id_around: messageId, limit }); - } - messageSetIndex = this.findMessageSetIndex({ id: messageIdToFind }); - if (messageSetIndex !== -1) { - this.switchToMessageSet(messageSetIndex); - } - } - - /** - * findMessage - Finds a message inside the state - * - * @param {string} messageId The id of the message - * @param {string} parentMessageId The id of the parent message, if we want load a thread reply - * - * @return {ReturnType} Returns the message, or undefined if the message wasn't found - */ - findMessage(messageId: string, parentMessageId?: string) { - if (parentMessageId) { - const messages = this.threads[parentMessageId]; - if (!messages) { - return undefined; - } - return messages.find((m) => m.id === messageId); - } - - const messageSetIndex = this.findMessageSetIndex({ id: messageId }); - if (messageSetIndex === -1) { - return undefined; - } - return this.messageSets[messageSetIndex].messages.find((m) => m.id === messageId); - } - - findMessageByTimestamp( - timestampMs: number, - parentMessageId?: string, - exactTsMatch: boolean = false, - ): LocalMessage | null { - if ( - (parentMessageId && !this.threads[parentMessageId]) || - this.messageSets.length === 0 - ) - return null; - const setIndex = this.findMessageSetByOldestTimestamp(timestampMs); - const targetMsgSet = this.messageSets[setIndex]?.messages; - if (!targetMsgSet?.length) return null; - const firstMsgTimestamp = targetMsgSet[0].created_at.getTime(); - const lastMsgTimestamp = targetMsgSet.slice(-1)[0].created_at.getTime(); - const isOutOfBound = - timestampMs < firstMsgTimestamp || lastMsgTimestamp < timestampMs; - if (isOutOfBound && exactTsMatch) return null; - - let msgIndex = 0, - hi = targetMsgSet.length - 1; - while (msgIndex < hi) { - const mid = (msgIndex + hi) >>> 1; - if (timestampMs <= targetMsgSet[mid].created_at.getTime()) hi = mid; - else msgIndex = mid + 1; - } - - const foundMessage = targetMsgSet[msgIndex]; - return !exactTsMatch - ? foundMessage - : foundMessage.created_at.getTime() === timestampMs - ? foundMessage - : null; - } - - private switchToMessageSet(index: number) { - const currentMessages = this.messageSets.find((s) => s.isCurrent); - if (!currentMessages) { - return; - } - currentMessages.isCurrent = false; - this.messageSets[index].isCurrent = true; - } - - private areMessageSetsOverlap( - messages1: Array<{ id: string }>, - messages2: Array<{ id: string }>, - ) { - return messages1.some((m1) => messages2.find((m2) => m1.id === m2.id)); - } - - private findMessageSetIndex(message: { id?: string }) { - return this.messageSets.findIndex( - (set) => !!set.messages.find((m) => m.id === message.id), - ); - } - - /** - * Identifies the set index into which a message set would pertain if its first item's creation date corresponded to oldestTimestampMs. - * @param oldestTimestampMs - */ - private findMessageSetByOldestTimestamp = (oldestTimestampMs: number): number => { - let lo = 0, - hi = this.messageSets.length; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - const msgSet = this.messageSets[mid]; - // should not happen - if (msgSet.messages.length === 0) return -1; - - const oldestMessageTimestampInSet = msgSet.messages[0].created_at.getTime(); - if (oldestMessageTimestampInSet <= oldestTimestampMs) hi = mid; - else lo = mid + 1; - } - return lo; - }; - - private findTargetMessageSet( - newMessages: (MessageResponse | LocalMessage)[], - addIfDoesNotExist = true, - messageSetToAddToIfDoesNotExist: MessageSetType = 'current', - ) { - let messagesToAdd: (MessageResponse | LocalMessage)[] = newMessages; - let targetMessageSetIndex!: number; - if (newMessages.length === 0) - return { targetMessageSetIndex: 0, messagesToAdd: newMessages }; - if (addIfDoesNotExist) { - const overlappingMessageSetIndicesByMsgIds = this.messageSets - .map((_, i) => i) - .filter((i) => - this.areMessageSetsOverlap(this.messageSets[i].messages, newMessages), - ); - const overlappingMessageSetIndicesByTimestamp = this.messageSets - .map((_, i) => i) - .filter((i) => - messageSetsOverlapByTimestamp( - this.messageSets[i].messages, - newMessages.map(formatMessage), - ), - ); - switch (messageSetToAddToIfDoesNotExist) { - case 'new': - if (overlappingMessageSetIndicesByMsgIds.length > 0) { - targetMessageSetIndex = overlappingMessageSetIndicesByMsgIds[0]; - } else if (overlappingMessageSetIndicesByTimestamp.length > 0) { - targetMessageSetIndex = overlappingMessageSetIndicesByTimestamp[0]; - // No new message set is created if newMessages only contains thread replies - } else if (newMessages.some((m) => !m.parent_id)) { - // find the index to insert the set - const setIngestIndex = this.findMessageSetByOldestTimestamp( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - new Date(newMessages[0].created_at!).getTime(), - ); - if (setIngestIndex === -1) { - this.messageSets.push({ - messages: [], - isCurrent: false, - isLatest: false, - pagination: { ...DEFAULT_MESSAGE_SET_PAGINATION }, - }); - targetMessageSetIndex = this.messageSets.length - 1; - } else { - const isLatest = setIngestIndex === 0; - this.messageSets.splice(setIngestIndex, 0, { - messages: [], - isCurrent: false, - isLatest, - pagination: { ...DEFAULT_MESSAGE_SET_PAGINATION }, // fixme: it is problematic decide about pagination without having data - }); - if (isLatest) { - this.messageSets.slice(1).forEach((set) => { - set.isLatest = false; - }); - } - targetMessageSetIndex = setIngestIndex; - } - } - break; - case 'current': - // determine if there is another set to which it would match taken into consideration the timestamp - if (overlappingMessageSetIndicesByTimestamp.length > 0) { - targetMessageSetIndex = overlappingMessageSetIndicesByTimestamp[0]; - } else { - targetMessageSetIndex = this.messageSets.findIndex((s) => s.isCurrent); - } - break; - case 'latest': - // determine if there is another set to which it would match taken into consideration the timestamp - if (overlappingMessageSetIndicesByTimestamp.length > 0) { - targetMessageSetIndex = overlappingMessageSetIndicesByTimestamp[0]; - } else { - targetMessageSetIndex = this.messageSets.findIndex((s) => s.isLatest); - } - break; - default: - targetMessageSetIndex = -1; - } - // when merging the target set will be the first one from the overlapping message sets - const mergeTargetMessageSetIndex = overlappingMessageSetIndicesByMsgIds.splice( - 0, - 1, - )[0]; - const mergeSourceMessageSetIndices = [...overlappingMessageSetIndicesByMsgIds]; - if ( - mergeTargetMessageSetIndex !== undefined && - mergeTargetMessageSetIndex !== targetMessageSetIndex - ) { - mergeSourceMessageSetIndices.push(targetMessageSetIndex); - } - // merge message sets - if (mergeSourceMessageSetIndices.length > 0) { - const target = this.messageSets[mergeTargetMessageSetIndex]; - const sources = this.messageSets.filter( - (_, i) => mergeSourceMessageSetIndices.indexOf(i) !== -1, - ); - sources.forEach((messageSet) => { - target.isLatest = target.isLatest || messageSet.isLatest; - target.isCurrent = target.isCurrent || messageSet.isCurrent; - target.pagination.hasPrev = - messageSet.messages[0].created_at < target.messages[0].created_at - ? messageSet.pagination.hasPrev - : target.pagination.hasPrev; - target.pagination.hasNext = - target.messages.slice(-1)[0].created_at < - messageSet.messages.slice(-1)[0].created_at - ? messageSet.pagination.hasNext - : target.pagination.hasNext; - messagesToAdd = [...messagesToAdd, ...messageSet.messages]; - }); - sources.forEach((s) => this.messageSets.splice(this.messageSets.indexOf(s), 1)); - const overlappingMessageSetIndex = this.messageSets.findIndex((s) => - this.areMessageSetsOverlap(s.messages, newMessages), - ); - targetMessageSetIndex = overlappingMessageSetIndex; - } - } else { - // assumes that all new messages belong to the same set - targetMessageSetIndex = this.findMessageSetIndex(newMessages[0]); - } - - return { targetMessageSetIndex, messagesToAdd }; - } } diff --git a/src/client.ts b/src/client.ts index 20189ca74f..456bf5edaa 100644 --- a/src/client.ts +++ b/src/client.ts @@ -33,7 +33,6 @@ import { isFunction, isOnline, isOwnUserBaseProperty, - messageSetPagination, normalizeQuerySort, randomId, retryInterval, @@ -1189,7 +1188,7 @@ export class StreamChat { /** * on - Listen to events on all channels and users your watching * - * client.on('message.new', event => {console.log("my new message", event, channel.state.messages)}) + * client.on('message.new', event => {console.log("my new message", event, channel.messagePaginator.state.items)}) * or * client.on(event => {console.log(event.type)}) * @@ -1474,17 +1473,17 @@ export class StreamChat { * @param {UserResponse} user */ _updateUserMessageReferences = (user: UserResponse) => { - const refMap = this.state.userChannelReferences[user.id] || {}; - - for (const channelID in refMap) { - const channel = this.activeChannels[channelID]; - + // Scan all active channels rather than a user->channel reference map. Message authors are no + // longer registered as channel references (that registration was removed along with + // `Channel._trackLatestMessage`); `reflectUserUpdate` filters by author id internally, so it is + // a no-op on channels without this user's messages. + // The next step is to have user ItemIndex, where the update would be O(1) complexity + for (const channel of Object.values(this.activeChannels)) { if (!channel) continue; - const state = channel.state; - /** update the messages from this user. */ - state?.updateUserMessages(user); + channel.pinnedMessagesPaginator.reflectUserUpdate(user); + channel.messagePaginator.reflectUserUpdate(user); } }; @@ -1504,15 +1503,21 @@ export class StreamChat { hardDelete = false, deletedAt?: LocalMessage['deleted_at'], ) => { - const refMap = this.state.userChannelReferences[user.id] || {}; - - for (const channelID in refMap) { - const channel = this.activeChannels[channelID]; + // Scan all active channels rather than a user->channel reference map (see + // `_updateUserMessageReferences`); `applyMessageDeletionForUser` filters by author id internally. + for (const channel of Object.values(this.activeChannels)) { if (channel) { - const state = channel.state; - /** deleted the messages from this user. */ - state?.deleteUserMessages(user, hardDelete, deletedAt); + channel.messagePaginator.applyMessageDeletionForUser({ + userId: user.id, + hardDelete, + deletedAt: deletedAt ?? new Date(), + }); + channel.pinnedMessagesPaginator.applyMessageDeletionForUser({ + userId: user.id, + hardDelete, + deletedAt: deletedAt ?? new Date(), + }); } } }; @@ -2285,61 +2290,43 @@ export class StreamChat { c.initialized = !offlineMode; c.push_preferences = channelState.push_preferences; - let updatedMessagesSet; - let filteredMessageIds: string[] = []; - if (skipInitialization === undefined) { - const { messageSet, filteredMessageIds: _filteredMessageIds } = - c._initializeState(channelState, 'latest'); - filteredMessageIds = _filteredMessageIds; - updatedMessagesSet = messageSet; - } else if (!skipInitialization.includes(channelState.channel.id)) { - c.state.clearMessages(); - const { messageSet, filteredMessageIds: _filteredMessageIds } = - c._initializeState(channelState, 'latest'); - filteredMessageIds = _filteredMessageIds; - updatedMessagesSet = messageSet; - } - - if (updatedMessagesSet) { - updatedMessagesSet.pagination = { - ...updatedMessagesSet.pagination, - ...messageSetPagination({ - parentSet: updatedMessagesSet, - requestedPageSize: - queryChannelsOptions?.message_limit || - DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE, - returnedPage: channelState.messages, - filteredReturnedPage: channelState.messages.filter( - (m) => !filteredMessageIds.includes(m.id), - ), - logger: this.logger, - }), - }; - this.polls.hydratePollCache(channelState.messages, true); - this.reminders.hydrateState(channelState.messages); - } + const willInitialize = + skipInitialization === undefined || + !skipInitialization.includes(channelState.channel.id); const requestedPageSize = queryChannelsOptions?.message_limit ?? DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE; + + // Seed the paginator BEFORE _initializeState, which hydrates read state and (via + // MessageReceiptsTracker) resolves read/delivered cursors against this paginator. Seeding + // first guarantees the tracker sees a populated timeline; a later async seed would run after + // the reconcile and mislabel delivery status. + // // Skip the re-seed when this (shared) channel's paginator is already loaded AND the user has // jumped to an older window (active interval is not the head): a first-page re-seed forces the // newest page to merge into that jumped interval across the gap (missing messages in the // middle). A cold paginator, or one still at the head (offline/at-latest), re-seeds normally so // cursors/hasMoreTail get (re)derived and pagination keeps working. if ( - !c.messagePaginator.isInitialized || - c.messagePaginator.isActiveIntervalAtHead + willInitialize && + (!c.messagePaginator.isInitialized || c.messagePaginator.isActiveIntervalAtHead) ) { - c.messagePaginator.postQueryReconcile({ - direction: 'tailward', - isFirstPage: true, - queryShape: { limit: requestedPageSize }, + c.messagePaginator.seedFirstPageSync( + channelState.messages.map(formatMessage), requestedPageSize, - results: { - items: channelState.messages.map(formatMessage), - tailward: channelState.messages[0]?.id, - }, - }); + ); + } + + if (skipInitialization === undefined) { + c._initializeState(channelState); + } else if (!skipInitialization.includes(channelState.channel.id)) { + // The paginators are (re)seeded above and via _initializeState → seedFirstPageSync. + c._initializeState(channelState); + } + + if (willInitialize) { + this.polls.hydratePollCache(channelState.messages, true); + this.reminders.hydrateState(channelState.messages); } c.messageComposer.initStateFromChannelResponse(channelState); c.cooldownTimer.refresh(); diff --git a/src/constants.ts b/src/constants.ts index 1762a89752..a503997efb 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,9 +1,5 @@ export const DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE = 25; export const DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE = 100; -export const DEFAULT_MESSAGE_SET_PAGINATION = Object.freeze({ - hasNext: false, - hasPrev: false, -}); export const DEFAULT_UPLOAD_SIZE_LIMIT_BYTES = 100 * 1024 * 1024; // 100 MB export const API_MAX_FILES_ALLOWED_PER_MESSAGE = 10; export const MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY = 100; diff --git a/src/messageDelivery/MessageDeliveryReporter.ts b/src/messageDelivery/MessageDeliveryReporter.ts index 2317b4dddd..2140551845 100644 --- a/src/messageDelivery/MessageDeliveryReporter.ts +++ b/src/messageDelivery/MessageDeliveryReporter.ts @@ -134,7 +134,7 @@ export class MessageDeliveryReporter { let key: string | undefined = undefined; if (isChannel(collection)) { - latestMessages = collection.state.latestMessages; + latestMessages = collection.messagePaginator.headItems; const ownReadState = collection.state.read[ownUserId] ?? {}; lastReadAt = ownReadState?.last_read; lastDeliveredAt = ownReadState?.last_delivered_at; diff --git a/src/messageDelivery/MessageReceiptsTracker.ts b/src/messageDelivery/MessageReceiptsTracker.ts index 1ea3a60ae8..ea9787528b 100644 --- a/src/messageDelivery/MessageReceiptsTracker.ts +++ b/src/messageDelivery/MessageReceiptsTracker.ts @@ -183,7 +183,7 @@ export class MessageReceiptsTracker extends WithSubscriptions { this.locateMessage = locateMessage ?? ((timestampMs: number) => { - const message = this.channel.state.findMessageByTimestamp(timestampMs); + const message = this.channel.messagePaginator.findItemByTimestamp(timestampMs); return message ? { timestampMs, msgId: message.id } : null; }); } diff --git a/src/offline-support/offline_support_api.ts b/src/offline-support/offline_support_api.ts index 6e658f50a3..8ee0511070 100644 --- a/src/offline-support/offline_support_api.ts +++ b/src/offline-support/offline_support_api.ts @@ -21,6 +21,7 @@ import { StateStore } from '../store'; import { channelHasReadEvents, channelTracksReadLocally, + formatMessage, localMessageToNewMessagePayload, runDetached, } from '../utils'; @@ -1293,7 +1294,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { timestampChanged: true, }); } - channel.state.addMessageSorted(newMessage, true); + channel.messagePaginator.trackLastMessage(formatMessage(newMessage)); } return newMessageResponse; } diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 2214bba28c..05e1d7e331 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -377,14 +377,6 @@ export abstract class BasePaginator { * outside the paginator. */ protected _itemIndex: ItemIndex; - /** - * Whether the paginator should maintain interval storage. - * - * Intervals are populated only when a caller provides an `itemIndex` instance. - * Otherwise the paginator behaves as a classic list paginator and mutates - * only `state.items`. - */ - protected _usesItemIntervalStorage: boolean; protected _executeQueryDebounced!: DebouncedExecQueryFunction; /** Last effective query shape produced by subclass for the most recent request. */ @@ -455,7 +447,6 @@ export abstract class BasePaginator { this.setDebounceOptions({ debounceMs }); this.sortComparator = noOrderChange; this._filterFieldToDataResolvers = []; - this._usesItemIntervalStorage = !!itemIndex; this._itemIndex = itemIndex ?? new ItemIndex({ getId: this.getItemId.bind(this) }); } @@ -526,6 +517,33 @@ export abstract class BasePaginator { return this.state.getLatestValue().items; } + /** + * The newest loaded window of items, independent of which window is currently *active* + * (`items` follows the active interval, which may point at a jumped-to / searched window). This is + * the head-most loaded interval under the paginator's ordering (anchored or the live-head logical + * interval). + * + * NOTE: this deliberately uses the head-*most loaded* interval rather than requiring the + * `isHead` flag — the query/hydration seed does not reliably mark a freshly loaded latest page + * as `isHead`, so an isHead-only check would miss channel-list channels entirely. The trade-off + * is that after jumping to an older window with the latest window not loaded, this reports that + * older window as "latest" (best effort). Use for "latest"-derived reads: last message, unread + * counting, delivery candidates, channel-list previews. + */ + get headItems(): T[] { + const head = this.getHeadIntervalFromSortedIntervals(this.itemIntervals); + return head ? this.intervalToItems(head) : []; + } + + /** + * The item on the head edge of the head pagination interval (of {@link BasePaginator.headItems}). + * `undefined` when nothing is loaded. + */ + get headmostItem(): T | undefined { + const head = this.getHeadIntervalFromSortedIntervals(this.itemIntervals); + return head ? (this.getIntervalPaginationEdges(head)?.head ?? undefined) : undefined; + } + get cursor() { return this.state.getLatestValue().cursor; } @@ -578,10 +596,6 @@ export abstract class BasePaginator { return Array.from(this._itemIntervals.values()); } - protected get usesItemIntervalStorage(): boolean { - return this._usesItemIntervalStorage; - } - protected get liveHeadLogical(): LogicalInterval | undefined { const itv = this._itemIntervals.get(LOGICAL_HEAD_INTERVAL_ID); return itv && isLiveHeadInterval(itv) ? itv : undefined; @@ -600,7 +614,7 @@ export abstract class BasePaginator { params: PaginationQueryParams, ): Promise>; - abstract filterQueryResults(items: T[]): T[] | Promise; + abstract filterQueryResults(items: T[]): T[]; /** * Subclasses must return the query shape. @@ -824,7 +838,6 @@ export abstract class BasePaginator { protected getIntervalSortBounds( interval: Interval | LogicalInterval, ): IntervalSortBounds | null { - if (!this.usesItemIntervalStorage) return null; const ids = interval.itemIds; if (!this._itemIndex || ids.length === 0) return null; const start = this._itemIndex?.get?.(ids[0]); @@ -845,7 +858,6 @@ export abstract class BasePaginator { protected getIntervalPaginationEdges( interval: Interval | LogicalInterval, ): IntervalPaginationEdges | null { - if (!this.usesItemIntervalStorage) return null; const bounds = this.getIntervalSortBounds(interval); if (!bounds) return null; return this.intervalItemIdsAreHeadFirst @@ -1384,7 +1396,6 @@ export abstract class BasePaginator { targetIntervalId?: string; setActive?: boolean; }): Interval | null { - if (!this.usesItemIntervalStorage) return null; if (!page?.length) return null; const pageInterval = this.makeInterval({ @@ -1521,53 +1532,13 @@ export abstract class BasePaginator { } /** - * Ingests a single item on live update. - * - * If intervals + itemIndex exist, tries to: + * Ingests a single item on live update: * - update the ItemIndex * - find an anchored interval whose sort bounds contain the item * - insert the item into that interval using locate+plateau logic * - if this is the active interval, re-emit state.items from interval - * - * If no intervals or no itemIndex exist, falls back to the legacy list-based ingestion. */ ingestItem(ingestedItem: T): boolean { - if (!this.usesItemIntervalStorage) { - const items = this.items ?? []; - const id = this.getItemId(ingestedItem); - const existingIndex = items.findIndex((i) => this.getItemId(i) === id); - const hadItem = existingIndex > -1; - - const nextItems = items.slice(); - if (hadItem) nextItems.splice(existingIndex, 1); - - // If it no longer matches the filter, we only commit the removal (if any). - if (!this.matchesFilter(ingestedItem)) { - if (hadItem) this.state.partialNext({ items: nextItems }); - return hadItem; - } - - // Determine insertion index against the list without the old snapshot. - const insertionIndex = - binarySearch({ - needle: ingestedItem, - length: nextItems.length, - getItemAt: (index: number) => nextItems[index], - itemIdentityEquals: (item1, item2) => - this.getItemId(item1) === this.getItemId(item2), - compare: this.effectiveComparator.bind(this), - plateauScan: true, - }).insertionIndex ?? -1; - - const keepOrderInState = this.config.lockItemOrder && hadItem; - const insertAt = keepOrderInState ? existingIndex : insertionIndex; - if (insertAt < 0) return false; - - nextItems.splice(insertAt, 0, ingestedItem); - this.state.partialNext({ items: nextItems }); - return true; - } - const id = this.getItemId(ingestedItem); const previousItem = this._itemIndex.get(id); @@ -1595,24 +1566,6 @@ export abstract class BasePaginator { return itemHasBeenRemoved; } - // If we don't have itemIndex, manipulate only items array in paginator state and not intervals - // as intervals do not store the whole items and have to rely on _itemIndex - // if (!this.usesItemIntervalStorage) { - // const items = this.items ?? []; - // const newItems = items.slice(); - // - // // Recompute insertionIndex for the *new* snapshot against the updated list (original removed). - // const insertionIndex = this.locateItemInState(ingestedItem)?.insertionIndex ?? -1; - // - // const insertAt = keepOrderInState ? originalIndexInState : insertionIndex; - // - // if (insertAt < 0) return false; // corruption guard - // - // newItems.splice(insertAt, 0, ingestedItem); - // this.state.partialNext({ items: newItems }); - // return true; - // } - const previousInterval = previousCoords?.interval?.interval; const onlyLogicalIntervals = @@ -1811,20 +1764,10 @@ export abstract class BasePaginator { return this.removeItemAtCoordinates(coords); } - // Fallback for state-only mode (sequential scan in state.items) - if (!this.usesItemIntervalStorage) { - const index = this.items?.findIndex((i) => this.getItemId(i) === id) ?? -1; - if (index === -1) return noAction; - const newItems = [...(this.items ?? [])]; - newItems.splice(index, 1); - this.state.partialNext({ items: newItems }); - return { state: { currentIndex: index, insertionIndex: -1 } }; - } - return noAction; } - /** Sets the items in the state. If intervals are kept, the active interval will be updated */ + /** Sets the items in the state, ingesting them so the active interval is updated. */ setItems({ valueOrFactory, cursor, @@ -1850,17 +1793,15 @@ export abstract class BasePaginator { newState.offset = newItems.length; } - if (this.usesItemIntervalStorage) { - const interval = this.ingestPage({ - page: newItems, - isHead: isFirstPage, - isTail: isLastPage, - }); - if (interval) { - this.setActiveInterval(interval, { updateState: false }); - newState.hasMoreHead = interval.hasMoreHead; - newState.hasMoreTail = interval.hasMoreTail; - } + const interval = this.ingestPage({ + page: newItems, + isHead: isFirstPage, + isTail: isLastPage, + }); + if (interval) { + this.setActiveInterval(interval, { updateState: false }); + newState.hasMoreHead = interval.hasMoreHead; + newState.hasMoreTail = interval.hasMoreTail; } return newState; @@ -1918,10 +1859,20 @@ export abstract class BasePaginator { }; protected getStateBeforeFirstQuery(): PaginatorState { - return { + const state: PaginatorState = { ...this.initialState, isLoading: true, }; + // This is the one moment the loaded window is (re)established from its start offset. For offset + // pagination the head (beginning) is loaded exactly when that window starts at offset 0, so + // hasMoreHead is a constant known before the query runs — anchor it here, once. It must NOT be + // re-derived per page in postQueryReconcile, because the offset only grows tailward from here and + // would then read as "more headward" even for a list that started at the head. Cursor pagination + // learns hasMoreHead from the query response, so leave the optimistic default for it. + if (!this.isCursorPagination) { + state.hasMoreHead = (this.config.initialOffset ?? 0) > 0; + } + return state; } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -1987,9 +1938,11 @@ export abstract class BasePaginator { /** * Falsy return value means query was not successful. * @param direction + * @param keepPreviousItems * @param forcedQueryShape * @param reset * @param retryCount + * @param silent * @param updateState */ async executeQuery({ @@ -2007,6 +1960,20 @@ export abstract class BasePaginator { const isFirstPage = this.isFirstPageQuery({ queryShape, reset }); if (isFirstPage && !keepPreviousItems) { const state = this.getStateBeforeFirstQuery(); + if (reset === 'yes') { + // A forced reset / reload starts from a clean slate: drop the previously loaded interval + // storage and canonical index so the incoming page cannot merge into stale intervals. + // Without this, a reload would blank only `state.items`, leaving the old interval behind for + // `ingestPage` to merge the fresh page into. + // + // Only a forced reset clears the cache. A first page reached through ordinary shape-change + // detection (e.g. cursor pagination, whose per-page cursor makes every page look like a new + // shape) must PRESERVE the cache so adjacent/overlapping pages merge. Genuine filter/sort + // changes clear the cache separately via `resetState()` in the paginator's own setters. + this.setIntervals([]); + this.setActiveInterval(undefined); + this._itemIndex.clear(); + } let items: T[] | undefined = undefined; if (!this.isInitialized) { items = @@ -2032,7 +1999,7 @@ export abstract class BasePaginator { retryCount, }); - return await this.postQueryReconcile({ + return this.postQueryReconcile({ direction, isFirstPage, keepPreviousItems, @@ -2043,7 +2010,7 @@ export abstract class BasePaginator { }); } - async postQueryReconcile({ + postQueryReconcile({ direction, isFirstPage, keepPreviousItems, @@ -2051,7 +2018,7 @@ export abstract class BasePaginator { requestedPageSize, results, updateState = true, - }: PostQueryReconcileParams): Promise> { + }: PostQueryReconcileParams): ExecuteQueryReturnValue { this._lastQueryShape = queryShape; this._nextQueryShape = undefined; @@ -2075,54 +2042,40 @@ export abstract class BasePaginator { const resolvedTailward = tailward ?? next; stateUpdate.lastQueryError = undefined; - const filteredItems = await this.filterQueryResults(items); + // Filtering is a synchronous local predicate (see filterQueryResults), so the whole + // reconciliation runs in a single tick. The channel-open seed relies on this to populate the + // paginator synchronously (MessagePaginator.seedFirstPageSync) before read-state hydration. + const filteredItems = this.filterQueryResults(items); stateUpdate.items = filteredItems; - // State-only mode: merge pages into a single list. - if (!this.usesItemIntervalStorage) { - const currentItems = this.items ?? []; - if (!isFirstPage) { - // In state-only mode we treat pagination as a growing list. - // Both directions extend the same list (cursor semantics are expressed by the cursor, not by list "side"). - stateUpdate.items = [...currentItems, ...filteredItems]; - } - } - const isJumpQuery = !!queryShape && this.isJumpQueryShape(queryShape); - const interval = this.usesItemIntervalStorage - ? this.ingestPage({ - page: stateUpdate.items, - policy: isJumpQuery ? 'strict-overlap-only' : 'auto', - // the first page should be always marked as head - isHead: isJumpQuery - ? undefined //head/tail doesn't apply / is unknown for this ingestion - : isFirstPage || - (direction === 'headward' ? requestedPageSize > items.length : undefined), - // even though the page is first, we have to compare the requested vs returned page size - isTail: isJumpQuery - ? undefined //head/tail doesn't apply / is unknown for this ingestion - : isFirstPage || direction === 'tailward' - ? requestedPageSize > items.length - : undefined, - targetIntervalId: isJumpQuery ? undefined : this._activeIntervalId, - }) - : null; + const interval = this.ingestPage({ + page: stateUpdate.items, + policy: isJumpQuery ? 'strict-overlap-only' : 'auto', + // the first page should be always marked as head + isHead: isJumpQuery + ? undefined //head/tail doesn't apply / is unknown for this ingestion + : isFirstPage || + (direction === 'headward' ? requestedPageSize > items.length : undefined), + // even though the page is first, we have to compare the requested vs returned page size + isTail: isJumpQuery + ? undefined //head/tail doesn't apply / is unknown for this ingestion + : isFirstPage || direction === 'tailward' + ? requestedPageSize > items.length + : undefined, + targetIntervalId: isJumpQuery ? undefined : this._activeIntervalId, + }); if (interval && updateState) { this.setActiveInterval(interval, { updateState: false }); stateUpdate.items = this.intervalToItems(interval); - } else if ( - updateState && - this.usesItemIntervalStorage && - !items.length && - (keepPreviousItems || !isFirstPage) - ) { + } else if (updateState && !items.length && (keepPreviousItems || !isFirstPage)) { // An empty page must NOT wipe the loaded items on a non-destructive refresh // (keepPreviousItems) or an incremental query. `ingestPage` returns null for an empty page // (leaving the active interval untouched), so `stateUpdate.items` still holds the empty // `filteredItems` here and committing that would blank the list. This happens when a refresh - // finds nothing, or when a paginate hits the dataset edge. Preserve the current view instead - // (mirrors state only mode, whose concat of an empty page is a noop). A genuine reset - // (isFirstPage without keepPreviousItems) still blanks, so an emptied dataset shows empty. + // finds nothing, or when a paginate hits the dataset edge. Preserve the current view instead. + // A genuine reset (isFirstPage without keepPreviousItems) still blanks, so an emptied dataset + // shows empty. stateUpdate.items = this.items; } @@ -2164,7 +2117,11 @@ export abstract class BasePaginator { } } else { // todo: we could keep the offset in two directions (initial tailward offset would be taken from config.initialOffset) - stateUpdate.offset = (this.offset ?? 0) + items.length; + const startOffset = this.offset ?? 0; + stateUpdate.offset = startOffset + items.length; + // Only hasMoreTail depends on the page result. hasMoreHead is fixed by where the loaded window + // starts (offset 0 => head loaded) and was anchored once at the reset (getStateBeforeFirstQuery); + // the offset only grows tailward from here, so leave hasMoreHead untouched. stateUpdate.hasMoreTail = items.length === this.pageSize; } diff --git a/src/pagination/paginators/ChannelPaginator.ts b/src/pagination/paginators/ChannelPaginator.ts index 603ffdf23c..97d1796b0b 100644 --- a/src/pagination/paginators/ChannelPaginator.ts +++ b/src/pagination/paginators/ChannelPaginator.ts @@ -10,6 +10,7 @@ import { BasePaginator } from './BasePaginator'; import type { FilterBuilderOptions } from '../FilterBuilder'; import { FilterBuilder } from '../FilterBuilder'; import { makeComparator } from '../sortCompiler'; +import { ItemIndex } from '../ItemIndex'; import { generateUUIDv4 } from '../../utils'; import type { StreamChat } from '../../client'; import type { Channel } from '../../channel'; @@ -111,7 +112,7 @@ const lastUpdatedFilterResolver: FieldToDataResolver = { matchesField: (field) => field === 'last_updated', resolve: (channel) => { // combination of last_message_at and updated_at - const lastMessageAt = channel.state.last_message_at?.getTime() ?? null; + const lastMessageAt = channel.messagePaginator.lastMessageAt?.getTime() ?? null; const updatedAt = channel.data?.updated_at ? new Date(channel.data?.updated_at).getTime() : undefined; @@ -170,7 +171,7 @@ const dataFieldFilterResolver: FieldToDataResolver = { const channelSortPathResolver: PathResolver = (channel, path) => { switch (path) { case 'last_message_at': - return channel.state.last_message_at; + return channel.messagePaginator.lastMessageAt; case 'has_unread': { return hasUnreadFilterResolver.resolve(channel, path); } @@ -211,7 +212,11 @@ export class ChannelPaginator extends BasePaginator requestOptions, sort, }: ChannelPaginatorOptions) { - super({ hasPaginationQueryShapeChanged, ...paginatorOptions }); + super({ + hasPaginationQueryShapeChanged, + itemIndex: new ItemIndex({ getId: (channel) => channel.cid }), + ...paginatorOptions, + }); const definedSort = sort ?? DEFAULT_BACKEND_SORT; this.client = client; this._id = id ?? `channel-paginator-${generateUUIDv4()}`; diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts new file mode 100644 index 0000000000..56e2199466 --- /dev/null +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -0,0 +1,1069 @@ +import type { + AnyInterval, + CursorDerivator, + CursorDeriveResult, + Interval, + PaginationDirection, + PaginationQueryParams, + PaginatorCursor, + PaginatorState, +} from './BasePaginator'; +import { + BasePaginator, + isLogicalInterval, + type PaginationQueryReturnValue, + type PaginationQueryShapeChangeIdentifier, + type PaginatorOptions, + ZERO_PAGE_CURSOR, +} from './BasePaginator'; +import type { + AscDesc, + LocalMessage, + MessagePaginationOptions, + MessageResponse, + PinnedMessagePaginationOptions, + ReactionResponse, + UserResponse, +} from '../../types'; +import type { Channel } from '../../channel'; +import { StateStore } from '../../store'; +import { formatMessage, generateUUIDv4, toDeletedMessage } from '../../utils'; +import { makeComparator } from '../sortCompiler'; +import type { FieldToDataResolver } from '../types.normalization'; +import { resolveDotPathValue } from '../utility.normalization'; +import { lowerBound } from '../utility.search'; +import { ItemIndex } from '../ItemIndex'; +import { deriveCreatedAtAroundPaginationFlags } from '../cursorDerivation'; +import { deriveIdAroundPaginationFlags } from '../cursorDerivation/idAroundPaginationFlags'; +import { deriveLinearPaginationFlags } from '../cursorDerivation/linearPaginationFlags'; + +export type MessageFocusReason = + | 'jump-to-message' + | 'jump-to-first-unread' + | 'jump-to-latest'; + +export type MessageFocusSignal = { + messageId: string; + reason: MessageFocusReason; + token: number; + createdAt: number; + ttlMs: number; +}; + +export type MessageFocusSignalState = { + signal: MessageFocusSignal | null; +}; + +export type JumpToMessageOptions = { + pageSize?: number; + /** + * Optional reason attached to emitted focus signal. + * Defaults to `jump-to-message`. + */ + focusReason?: MessageFocusReason; + /** + * TTL for the emitted focus signal in milliseconds. + * Defaults to `3000`. + */ + focusSignalTtlMs?: number; + /** + * If true, suppresses focus signal emission after a successful jump. + */ + suppressFocusSignal?: boolean; +}; + +export type MessagePaginatorSort = { created_at: AscDesc } | { created_at: AscDesc }[]; + +export type MessagePaginatorFilter = { + cid: string; + parent_id?: string; +}; + +const DEFAULT_BACKEND_SORT: MessagePaginatorSort = { + created_at: 1, +}; + +// server's default size is 100 +const DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE = 100; + +export type MessagePaginatorState = PaginatorState; +export type MessageQueryShape = MessagePaginationOptions | PinnedMessagePaginationOptions; + +/** + * At the moment all the pagination parameters are just different types of cursors, e.g. + * id_lt, id_gt, ... + * But we always paginate within the same list without changing the sorting params. + * It is currently not possible to change the sorting params. + */ +const hasPaginationQueryShapeChanged: PaginationQueryShapeChangeIdentifier< + MessageQueryShape +> = () => false; + +const dataFieldFilterResolver: FieldToDataResolver = { + matchesField: () => true, + resolve: (message, path) => resolveDotPathValue(message, path), +}; + +export const getMessageCreatedAtTimestamp = (message: LocalMessage): number | null => { + if (!(message.created_at instanceof Date)) return null; + const timestamp = message.created_at.getTime(); + return Number.isFinite(timestamp) ? timestamp : null; +}; + +export type MessagePaginatorOptions = { + channel: Channel; + id?: string; + itemIndex?: ItemIndex; + parentMessageId?: string; + /** + * Sort passed to backend message/replies query. + * Does not affect in-memory item ordering. + */ + requestSort?: MessagePaginatorSort; + /** + * @deprecated Use `requestSort` instead. + */ + sort?: MessagePaginatorSort; + /** + * In-memory ordering for items exposed by paginator state. + */ + itemOrder?: MessagePaginatorSort; + paginatorOptions?: PaginatorOptions; +}; + +/** + * MessageIntervalPaginator allows configuring backend request sort, while keeping internal item ordering stable. + * Filtering of ingested items is still limited to local predicates (`filterQueryResults`). + */ +export class MessageIntervalPaginator extends BasePaginator< + LocalMessage, + MessageQueryShape +> { + declare state: StateStore; + private readonly _id: string; + protected channel: Channel; + protected parentMessageId?: string; + readonly messageFocusSignal: StateStore; + private clearMessageFocusSignalTimeoutId: ReturnType | null = null; + private messageFocusSignalToken = 0; + protected _requestSort = DEFAULT_BACKEND_SORT; + protected _itemOrder: MessagePaginatorSort = DEFAULT_BACKEND_SORT; + protected _nextQueryShape: MessageQueryShape | undefined; + sortComparator: (a: LocalMessage, b: LocalMessage) => number; + /** + * Single source of truth for whether a message should be included in paginator intervals/state. + * Keep this consistent with `filterQueryResults` AND cursor flag derivation. + */ + shouldIncludeMessageInInterval(message: LocalMessage): boolean { + return !message.shadowed; + } + + protected get intervalItemIdsAreHeadFirst(): boolean { + // Messages are stored in chronological order (created_at asc) within an interval. + // Pagination "head" (newest side) is therefore at the END of the `itemIds` array. + return false; + } + + protected get intervalSortDirection(): 'asc' | 'desc' { + // Head edge is newest, but sortComparator is created_at asc => newer head edges + // should come first => reverse interval ordering. + return 'desc'; + } + + constructor({ + channel, + id, + itemIndex = new ItemIndex({ getId: (item) => item.id }), + parentMessageId, + requestSort, + sort, + itemOrder, + paginatorOptions, + }: MessagePaginatorOptions) { + const resolvedRequestSort = requestSort ?? sort ?? DEFAULT_BACKEND_SORT; + const resolvedItemOrder = itemOrder ?? resolvedRequestSort; + super({ + hasPaginationQueryShapeChanged, + initialCursor: ZERO_PAGE_CURSOR, + itemIndex, + ...paginatorOptions, + 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()}`; + this._requestSort = resolvedRequestSort; + this._itemOrder = resolvedItemOrder; + this.messageFocusSignal = new StateStore({ + signal: null, + }); + this.sortComparator = makeComparator({ + sort: this._requestSort, + resolvePathValue: resolveDotPathValue, + tiebreaker: (l, r) => { + const leftId = this.getItemId(l); + const rightId = this.getItemId(r); + 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.setFilterResolvers([dataFieldFilterResolver]); + } + + get id() { + return this._id; + } + + get sort() { + return this._requestSort ?? DEFAULT_BACKEND_SORT; + } + + get requestSort() { + return this._requestSort ?? DEFAULT_BACKEND_SORT; + } + + get itemOrder() { + return this._itemOrder ?? this._requestSort ?? DEFAULT_BACKEND_SORT; + } + + /** + * Even though we do not send filters object to the server, we need to have filters for client-side item ingestion logic. + */ + buildFilters = (): MessagePaginatorFilter => ({ + cid: this.channel.cid, + ...(this.parentMessageId ? { parent_id: this.parentMessageId } : {}), + }); + + // invoked inside BasePaginator.executeQuery() to keep it as a query descriptor; + protected getNextQueryShape({ + direction, + }: Omit< + PaginationQueryParams, + 'isFirstPageQuery' + >): MessageQueryShape { + return { + limit: this.pageSize, + [direction === 'tailward' ? 'id_lt' : 'id_gt']: + direction && this.cursor?.[direction], + }; + } + + getCursorFromQueryResults = ({ + direction, + items, + }: { + direction?: PaginationDirection; + items: LocalMessage[]; + }) => { + if (!items.length) { + return { + tailward: undefined, + headward: undefined, + }; + } + + const start = items[0]; + const end = items[items.length - 1]; + + // Newer side is the pagination head for messages. Which bound is considered "head" + // is determined by intervalItemIdsAreHeadFirst (see BasePaginator.getIntervalPaginationEdges). + const head = this.intervalItemIdsAreHeadFirst ? start : end; + const tail = this.intervalItemIdsAreHeadFirst ? end : start; + + // if there is no direction, then we are jumping, and we want to set both directions in the cursor + return { + tailward: !direction || direction === 'tailward' ? this.getItemId(tail) : undefined, + headward: !direction || direction === 'headward' ? this.getItemId(head) : undefined, + }; + }; + + query = async ({ + direction, + }: PaginationQueryParams): Promise< + PaginationQueryReturnValue + > => { + // get the params only if they were not generated previously + if (!this._nextQueryShape) { + this._nextQueryShape = this.getNextQueryShape({ direction }); + } + + const options = this._nextQueryShape; + let items: LocalMessage[]; + let tailward: string | undefined; + let headward: string | undefined; + if (this.config.doRequest) { + const result = await this.config.doRequest(options); + items = this.getCanonicalQueryItems(result?.items ?? []); + // if there is no direction, then we are jumping, and we want to set both directions in the cursor + tailward = + !direction || direction === 'tailward' + ? (result.cursor?.tailward ?? undefined) + : undefined; + headward = + !direction || direction === 'headward' + ? (result.cursor?.headward ?? undefined) + : undefined; + } else { + const { messages } = this.parentMessageId + ? await this.channel.getReplies( + this.parentMessageId, + options, + Array.isArray(this.requestSort) ? this.requestSort : [this.requestSort], + ) + : await this.channel.query({ + messages: options, + // todo: why do we query for watchers? + // watchers: { limit: this.pageSize }, + }); + items = this.getCanonicalQueryItems(messages.map(formatMessage)); + const cursor = this.getCursorFromQueryResults({ direction, items }); + tailward = cursor.tailward; + headward = cursor.headward; + } + + return { items, headward, tailward }; + }; + + /** + * Seed the paginator with the page a channel-open query just fetched (`Channel.query` for + * `watch`/`create`, and `client.hydrateActiveChannels`). + * + * These paths hydrate the channel read state in the SAME synchronous tick they add messages + * (`Channel._initializeState`), and the read patch drives `MessageReceiptsTracker`, which resolves + * read/delivered cursors against this paginator (`findItemByTimestamp`) whenever the server omits + * the `last_read_message_id` / `last_delivered_message_id`. `postQueryReconcile` is fully + * synchronous (filtering is a local predicate), so seeding here guarantees the paginator is + * populated before the reconcile runs. First-page reconciliation also takes the unread snapshot. + * + * The fetched page is NOT always the latest window: a channel can be opened AROUND a message + * (`messages: { id_around }` / `{ created_at_around }`), so the original pagination options are + * threaded through as the query shape. For an around/jump open this lets `postQueryReconcile` + * apply jump semantics (no forced head/tail; cursor flags derived from the around position) + * instead of wrongly flagging the window as the head (newest) page. + */ + seedFirstPageSync( + messages: LocalMessage[], + requestedPageSize: number, + messagePaginationOptions?: MessagePaginationOptions, + ) { + const queryShape: MessageQueryShape = { + ...messagePaginationOptions, + limit: requestedPageSize, + }; + const isJump = this.isJumpQueryShape(queryShape); + this.postQueryReconcile({ + // A jump/around page spans both directions; a plain latest page paginates tailward (older). + direction: isJump ? undefined : 'tailward', + isFirstPage: true, + queryShape, + requestedPageSize, + results: { + items: messages, + headward: isJump ? messages[messages.length - 1]?.id : undefined, + tailward: messages[0]?.id, + }, + }); + } + + isJumpQueryShape(queryShape: MessageQueryShape): boolean { + return ( + !!queryShape?.id_around || + !!(queryShape as MessagePaginationOptions)?.created_at_around + ); + } + + jumpToMessage = async ( + messageId: string, + { + focusReason, + focusSignalTtlMs, + pageSize, + suppressFocusSignal, + }: JumpToMessageOptions = {}, + ): Promise => { + let localMessage = this.getItem(messageId); + let interval: AnyInterval | undefined; + let state: Partial> | undefined; + if (localMessage) { + interval = this.locateIntervalForItem(localMessage); + if ( + interval && + !isLogicalInterval(interval) && + !interval.itemIds.includes(messageId) + ) { + // locateIntervalForItem can match by created_at RANGE and return an interval whose range + // spans the target while its loaded itemIds do NOT contain it (e.g. a neighbouring interval + // grew across the target's position without merging). Prefer the interval that actually + // holds the id so the jump activates the window the message really lives in - otherwise, if + // the range-matched interval happens to be active, the jump becomes a no-op. + interval = this.itemIntervals.find( + (candidate) => + !isLogicalInterval(candidate) && candidate.itemIds.includes(messageId), + ); + } + } + + if (localMessage && interval && !isLogicalInterval(interval)) { + state = { + hasMoreHead: interval.hasMoreHead, + hasMoreTail: interval.hasMoreTail, + cursor: this.getCursorFromInterval(interval), + items: this.intervalToItems(interval), + }; + } else if (!localMessage || !interval || isLogicalInterval(interval)) { + const result = await this.executeQuery({ + queryShape: { id_around: messageId, limit: pageSize }, + updateState: false, + }); + localMessage = this.getItem(messageId); + if (!localMessage || !result || !result.targetInterval) { + this.channel.getClient().notifications.addError({ + message: 'Jump to message unsuccessful', + origin: { emitter: 'MessagePaginator.jumpToMessage', context: { messageId } }, + options: { type: 'api:messages:query:failed' }, + }); + return false; + } + interval = result.targetInterval; + state = isLogicalInterval(interval) + ? result.stateCandidate + : { + ...result.stateCandidate, + hasMoreHead: interval.hasMoreHead, + hasMoreTail: interval.hasMoreTail, + // Prefer the cursor derived during postQueryReconcile, but fall back to + // interval-derived cursor to keep jumps consistent if the stateCandidate + // is partial. + cursor: result.stateCandidate.cursor ?? this.getCursorFromInterval(interval), + items: this.intervalToItems(interval), + }; + } + + if (!this.isActiveInterval(interval)) { + this.setActiveInterval(interval, { updateState: false }); + } + if (state) this.state.partialNext(state); + if (!suppressFocusSignal) { + this.emitMessageFocusSignal({ + messageId, + reason: focusReason ?? 'jump-to-message', + ttlMs: focusSignalTtlMs, + }); + } + return true; + }; + + jumpToTheLatestMessage = async (options?: JumpToMessageOptions): Promise => { + let latestMessageId: string | undefined; + if (!(this.itemIntervals[0] as Interval)?.isHead) { + // load the newest page in case pagination is currently on an older window (an empty/partial + // headward response marks the interval as the head) + await this.executeQuery({ direction: 'headward', updateState: false }); + } + + // Re-read itemIntervals AFTER the query: the getter returns a fresh array each call, so a + // reference captured before executeQuery would be stale and miss the head we just loaded. + const headInterval = this.itemIntervals[0] as Interval | undefined; + if (headInterval?.isHead) { + latestMessageId = headInterval.itemIds.slice(-1)[0]; + } + + if (!latestMessageId) { + this.channel.getClient().notifications.addError({ + message: 'Jump to latest message unsuccessful', + origin: { emitter: 'MessagePaginator.jumpToTheLatestMessage' }, + options: { type: 'api:message:query:failed' }, + }); + return false; + } + + return await this.jumpToMessage(latestMessageId, { + suppressFocusSignal: true, + ...options, + focusReason: 'jump-to-latest', + }); + }; + + /** + * Fold an already fetched newest (head) page into the currently loaded items without issuing a + * query. The caller supplies a page it obtained on its own and this reconciles it against what is + * loaded, instead of rerunning the first page query, so the loaded set is updated in place rather + * than blanked and reloaded. Two cases, decided by whether the incoming page overlaps the loaded + * head: + * + * 1. OVERLAP - the incoming page shares at least one id with the loaded head (fewer than a full + * page is new). Merge in place: existing items are reconciled by id (edits, soft deletes), new + * items are appended and every already loaded item (including older pages already paged in) is + * kept. `hasMoreTail`/`cursor.tailward` are left as-is so the page can be any size, so deriving + * "has older items" from its length would wrongly clear it while older items remain. + * + * 2. DISJOINT - the incoming page shares no id with the loaded head (at least a full page is new). + * Merging would weld the two across the gap (the interval merge treats two head intervals as + * overlapping when one reaches further headward), hiding the items in between with no way to + * reach them. Instead the loaded set is discarded and rebuilt from the incoming page as a fresh + * contiguous head (`hasMoreTail: true`, cursor reanchored to the page's oldest item) so the + * gap and older history load again when paginating older. + * + * Both paths emit exactly once and never blank the loaded set. Noop unless the page is non empty + * and the newest slice is both loaded AND the interval currently in view (the head interval is + * anchored at the head and active); when the caller has jumped to a separate older window the + * merge is skipped so their position is preserved, and the incoming page is picked up on a later + * load. + */ + mergeNewestPage = (page: LocalMessage[]) => { + if (!page?.length) return; + const headInterval = this.itemIntervals[0] as Interval | undefined; + if (!headInterval?.isHead) return; + // Only reconcile when the head is the interval currently in view. If the caller jumped to a + // separate (older) window, that window is active and the head is merely still-loaded underneath; + // reconciling would switch the view to the head and yank them to the newest. Skip to preserve + // their position (the newest page is picked up on scroll / a later load). + if (!this.isActiveInterval(headInterval)) return; + + const loadedIds = new Set(headInterval.itemIds); + const overlapsLoadedHead = page.some((item) => loadedIds.has(this.getItemId(item))); + + if (!overlapsLoadedHead) { + // Disjoint window: rebuild from the fetched page as a fresh newest slice. Clearing + // the stale intervals first ensures `ingestPage` builds a single head interval instead of + // merging across the gap so reanchoring the cursor to this page's oldest item keeps the next + // "load older" contiguous. + this.setIntervals([]); + this.setActiveInterval(undefined); + const resetInterval = this.ingestPage({ + page, + isHead: true, + // Disjoint means the previously loaded head was entirely OLDER than this newest window, so + // there is always older data to load (the gap + the prior history) - keep hasMoreTail true. + isTail: false, + setActive: false, + }); + if (!resetInterval) return; + this.setActiveInterval(resetInterval, { updateState: false }); + this.state.partialNext({ + items: this.intervalToItems(resetInterval), + cursor: this.getCursorFromInterval(resetInterval), + hasMoreHead: resetInterval.hasMoreHead, + hasMoreTail: resetInterval.hasMoreTail, + }); + return; + } + + // Overlapping window: merge in place, preserving the older boundary. + const interval = this.ingestPage({ page, isHead: true, setActive: false }); + if (!interval) return; + + this.setActiveInterval(interval, { updateState: false }); + this.state.partialNext({ + items: this.intervalToItems(interval), + // The newest slice is loaded (head anchored), so after merging the head window there is + // nothing newer to load. hasMoreTail / cursor are deliberately preserved (see above). + hasMoreHead: false, + }); + }; + + protected resolveUnreadBoundaryIdsByTimestamp = ({ + lastReadAt, + messages, + }: { + lastReadAt: Date; + messages: LocalMessage[]; + }): { firstUnreadMessageId: string | null; lastReadMessageId: string | null } => { + // Messages are expected in chronological order. We find: + // - lastReadMessageId: newest message with created_at <= lastReadAt + // - firstUnreadMessageId: first message with created_at > lastReadAt + // + // If the page starts after lastReadAt, the entire page is unread and the first message is + // used as unread anchor (legacy "whole channel is unread" behavior for this queried window). + const lastReadTimestamp = lastReadAt.getTime(); + if (!Number.isFinite(lastReadTimestamp) || !messages.length) { + return { firstUnreadMessageId: null, lastReadMessageId: null }; + } + + let firstUnreadMessageId: string | null = null; + let lastReadMessageId: string | null = null; + + for (const message of messages) { + const messageTimestamp = getMessageCreatedAtTimestamp(message); + if (messageTimestamp === null) continue; + + if (messageTimestamp <= lastReadTimestamp) { + lastReadMessageId = message.id; + } else if (!firstUnreadMessageId) { + firstUnreadMessageId = message.id; + } + } + + const firstMessageWithTimestamp = messages.find( + (message) => getMessageCreatedAtTimestamp(message) !== null, + ); + const firstMessageTimestamp = + firstMessageWithTimestamp && + getMessageCreatedAtTimestamp(firstMessageWithTimestamp); + if ( + firstMessageWithTimestamp && + typeof firstMessageTimestamp === 'number' && + lastReadTimestamp < firstMessageTimestamp + ) { + return { + firstUnreadMessageId: firstMessageWithTimestamp.id, + lastReadMessageId, + }; + } + + return { firstUnreadMessageId, lastReadMessageId }; + }; + + emitMessageFocusSignal = ({ + messageId, + reason, + ttlMs = 3000, + }: { + messageId: string; + reason: MessageFocusReason; + ttlMs?: number; + }): MessageFocusSignal => { + this.messageFocusSignalToken += 1; + const signal: MessageFocusSignal = { + messageId, + reason, + token: this.messageFocusSignalToken, + createdAt: Date.now(), + ttlMs, + }; + + if (this.clearMessageFocusSignalTimeoutId) { + clearTimeout(this.clearMessageFocusSignalTimeoutId); + this.clearMessageFocusSignalTimeoutId = null; + } + + this.messageFocusSignal.next({ signal }); + + // NOTE: the auto-dismissal countdown is intentionally NOT started here. A focused message may + // be emitted while its message list is not yet visible (e.g. the channel is covered by a thread + // panel when a "view in channel" jump resolves), so measuring the highlight lifetime from the + // moment the jump resolved would burn it while the message is still off-screen. The consumer + // starts the countdown via `scheduleMessageFocusSignalClear` once the message is actually + // viewed. + return signal; + }; + + /** + * Starts the auto-dismissal countdown for the currently active focus signal. Call this once the + * focused message has been viewed (rendered and visible), so the highlight's lifetime is measured + * from when the user could actually see it rather than from when the jump resolved. No-op if the + * signal has already been cleared or superseded (guarded by `token`). + */ + scheduleMessageFocusSignalClear = ({ + token, + ttlMs, + }: { token?: number; ttlMs?: number } = {}) => { + const current = this.messageFocusSignal.getLatestValue().signal; + if (!current) return; + if (typeof token !== 'undefined' && current.token !== token) return; + + if (this.clearMessageFocusSignalTimeoutId) { + clearTimeout(this.clearMessageFocusSignalTimeoutId); + this.clearMessageFocusSignalTimeoutId = null; + } + + this.clearMessageFocusSignalTimeoutId = setTimeout(() => { + this.clearMessageFocusSignal({ token: current.token }); + }, ttlMs ?? current.ttlMs); + }; + + clearMessageFocusSignal = ({ token }: { token?: number } = {}) => { + const current = this.messageFocusSignal.getLatestValue().signal; + if (!current) return; + if (typeof token !== 'undefined' && current.token !== token) return; + + if (this.clearMessageFocusSignalTimeoutId) { + clearTimeout(this.clearMessageFocusSignalTimeoutId); + this.clearMessageFocusSignalTimeoutId = null; + } + + this.messageFocusSignal.next({ signal: null }); + }; + + clearStateAndCache() { + this.resetState(); + this._itemIndex.clear(); + this.clearMessageFocusSignal(); + } + + /** + * Partial truncation for `channel.truncated` carrying a `truncated_at`: drop every loaded + * message strictly older than the cutoff (keeping newer ones) across all loaded windows, + * mirroring the legacy per-message-set pruning. For a full truncation (no `truncated_at`) use + * {@link MessageIntervalPaginator.clearStateAndCache} instead. + * + * Batched and edge-classified: because messages are chronological, each interval is classified by + * its `tail` (oldest) and `head` (newest) edges, so only the single interval that *straddles* the + * cutoff is scanned member-by-member — the rest are kept or dropped wholesale. The straddling + * interval becomes the new global tail (nothing older than the cutoff exists anymore), so its + * `isTail`/`hasMoreTail` are set; intervals entirely newer keep their flags (unloaded older + * messages may still sit between them and the cutoff). The active window is re-emitted once. + */ + truncate = ({ truncatedAt }: { truncatedAt: Date }) => { + const cutoff = truncatedAt.getTime(); + if (Number.isNaN(cutoff)) return; + + const isOld = (item: LocalMessage | undefined) => { + const time = item?.created_at ? new Date(item.created_at).getTime() : undefined; + return typeof time === 'number' && time < cutoff; + }; + + const removedIds: string[] = []; + const survivingIntervals: AnyInterval[] = []; + // iterate from head to tail + for (const interval of this.itemIntervals) { + const edges = this.getIntervalPaginationEdges(interval); + if (!edges || !isOld(edges.tail)) { + survivingIntervals.push(interval); // oldest edge >= cutoff → nothing to drop + } else if (isOld(edges.head)) { + removedIds.push(...interval.itemIds); // newest edge < cutoff → whole interval is older + } else { + // determines the cutoff. Items are chronological, so the old ones are at the beginning of the array, + // binary-search rather than scanning every member. + const ids = interval.itemIds; + const splitIndex = lowerBound( + ids.length, + (index) => !isOld(this._itemIndex.get(ids[index])), + ); + removedIds.push(...ids.slice(0, splitIndex)); + const kept = ids.slice(splitIndex); + survivingIntervals.push( + isLogicalInterval(interval) + ? { ...interval, itemIds: kept } + : { ...interval, itemIds: kept, isTail: true, hasMoreTail: false }, + ); + } + } + + if (!removedIds.length) return; + for (const id of removedIds) this._itemIndex.remove(id); + + // No re-sort needed: `survivingIntervals` preserves the order of the already-sorted + // `itemIntervals` (we only keep/prune/drop, never reorder), and truncation removes only + // tailward items — an interval's head edge (the intervalComparator sort key) never changes. + this.setIntervals(survivingIntervals); + + // Single re-emit of the active window (setIntervals does not emit). + const active = this._activeIntervalId + ? this._itemIntervals.get(this._activeIntervalId) + : undefined; + if (active && !isLogicalInterval(active)) { + this.setActiveInterval(active); + return; + } + + // The active window was truncated away entirely. It sat below the cutoff, so it was older + // than every survivor — activate the nearest surviving window (the tail-most, i.e. oldest, + // anchored interval) rather than emitting an empty page, which would blank the message list. + const anchoredSurvivors = this.itemIntervals.filter( + (itv): itv is Interval => !isLogicalInterval(itv), + ); + + // If the active interval was truncated, we move to the neareast interval - which is the tail now + const fallback = this.getTailIntervalFromSortedIntervals(anchoredSurvivors); + if (fallback) { + this.setActiveInterval(fallback); + } else { + // Nothing loaded survived the truncation. + this.state.partialNext({ items: [] }); + } + }; + + applyMessageDeletionForUser = ({ + userId, + hardDelete = false, + deletedAt, + }: { + userId: string; + hardDelete?: boolean; + deletedAt: Date; + }) => { + const loadedMessages = this.items ?? []; + + for (const message of loadedMessages) { + if (message.user?.id === userId) { + if (hardDelete) { + this.removeItem({ id: message.id }); + } else { + this.ingestItem( + toDeletedMessage({ + message, + hardDelete, + deletedAt, + }) as LocalMessage, + ); + } + continue; + } + + if ( + message.quoted_message?.user?.id === userId && + message.quoted_message.type !== 'deleted' + ) { + this.ingestItem({ + ...message, + quoted_message: toDeletedMessage({ + message: message.quoted_message, + hardDelete, + deletedAt, + }) as LocalMessage, + }); + } + } + }; + + /** + * Ensures quoted-message snapshots across loaded paginator cache are in sync + * with the provided message. + * + * Scans cached messages and updates any item where `quoted_message_id` + * matches `message.id`. + */ + reflectQuotedMessageUpdate = (message: LocalMessage) => { + const cachedMessages = this._itemIndex.values(); + + for (const cachedMessage of cachedMessages) { + if (cachedMessage.quoted_message_id !== message.id) continue; + + this.ingestItem({ + ...cachedMessage, + quoted_message: message, + }); + } + }; + + /** + * Reflect an updated `user` object onto every cached message authored by that user, mirroring + * the legacy `ChannelState.updateUserMessages` for the main message list (does not touch + * `quoted_message.user` — that is not part of the legacy behavior). + * + * Batched: a user rename can affect many messages, so this patches the shared item index and + * re-emits the active window a single time (if it held an affected message) rather than one + * `ingestItem` re-emit per message. + */ + reflectUserUpdate = (user: UserResponse) => { + const activeIds = new Set((this.items ?? []).map((m) => this.getItemId(m))); + let activeAffected = false; + for (const message of this._itemIndex.values()) { + if (message.user?.id !== user.id) continue; + this._itemIndex.setOne({ ...message, user }); + if (activeIds.has(this.getItemId(message))) activeAffected = true; + } + if (activeAffected) { + this.state.partialNext({ + items: (this.items ?? []).map((m) => this.getItem(this.getItemId(m)) ?? m), + }); + } + }; + + /** + * Apply a reaction WS event (`reaction.new` / `reaction.updated` / `reaction.deleted`) to the + * cached message. The event's `message` already carries the server-updated + * `reaction_groups` / `latest_reactions`; only `own_reactions` needs local preservation so a + * cross-user reaction does not wipe the current user's reactions. This re-homes what + * `ChannelState.addReaction` / `removeReaction` used to do off the now-removed + * `channel.state.messages` / `channel.state.threads` caches (the same logic backs the thread + * paginator via `Thread.messagePaginator`). + * + * `own_reactions` is seeded from the currently cached item (so another user's reaction keeps ours), + * falling back to the event's own_reactions when the message is not loaded — matching the legacy + * behavior where `_updateMessage` only mutated a message that existed locally. + * + * @param params + * @param {MessageResponse | LocalMessage} params.message The reaction event's message, carrying the + * server-computed `reaction_groups` / `latest_reactions`. Ingested as-is except for `own_reactions`. + * @param {ReactionResponse} params.reaction The reaction from the event. Only added to/removed from + * `own_reactions` when its `user_id` is the current user; otherwise the current user's + * `own_reactions` are left untouched. + * @param {boolean} [params.removed=false] `true` for `reaction.deleted` (remove the reaction from + * `own_reactions`); `false` for `reaction.new` / `reaction.updated` (add it). + * @param {boolean} [params.enforceUnique=false] When adding, first clear the current user's existing + * `own_reactions` so only the incoming one remains (used by `reaction.updated`, where a user's + * reaction replaces their previous one). + */ + reflectReaction = ({ + enforceUnique = false, + message, + reaction, + removed = false, + }: { + message: MessageResponse | LocalMessage; + reaction: ReactionResponse; + enforceUnique?: boolean; + removed?: boolean; + }) => { + const formatted = formatMessage(message); + const existing = this.getItem(formatted.id); + const baseOwnReactions = existing?.own_reactions ?? formatted.own_reactions ?? []; + const own_reactions = removed + ? this.removeOwnReactionOfType(baseOwnReactions, reaction) + : this.addOwnReaction(baseOwnReactions, reaction, enforceUnique); + this.ingestItem({ ...formatted, own_reactions }); + }; + + private removeOwnReactionOfType( + ownReactions: ReactionResponse[], + reaction: ReactionResponse, + ): ReactionResponse[] { + return ownReactions.filter( + (r) => r.user_id !== reaction.user_id || r.type !== reaction.type, + ); + } + + private addOwnReaction( + ownReactions: ReactionResponse[], + reaction: ReactionResponse, + enforceUnique: boolean, + ): ReactionResponse[] { + const base = enforceUnique + ? [] + : this.removeOwnReactionOfType(ownReactions, reaction); + if (this.channel.getClient().userID === reaction.user_id) { + return [...base, reaction]; + } + return base; + } + + /** + * Map a timestamp to a loaded message — the first message in the latest (head) window whose + * `created_at` is >= `timestampMs` (mirrors the legacy `ChannelState.findMessageByTimestamp` + * lower-bound search), or the newest loaded message when the timestamp is beyond it. Used by the + * receipts tracker to resolve read/delivered cursors. Searches the newest loaded window — where + * read cursors live — which is already sorted, so this is O(log n) with no re-sort. + */ + findItemByTimestamp = ( + timestampMs: number, + exactTsMatch = false, + ): LocalMessage | null => { + const items = this.headItems; // ascending by created_at + if (!items.length) return null; + // Resolve the last message created AT OR BEFORE `timestampMs` (floor). The sole caller is + // read/delivered cursor resolution (MessageReceiptsTracker): the cursor carries the timestamp of + // the last message a participant reached, so a message created strictly after the cursor has NOT + // been reached. A ceil match (first message >= target) would over-count it — e.g. a participant + // whose read cursor predates every loaded message would be reported as having read the oldest one. + // `lowerBound` returns the first index whose created_at is strictly greater than the target, so + // the floor is the item immediately before it. + const firstAfter = lowerBound(items.length, (i) => { + const t = getMessageCreatedAtTimestamp(items[i]); + return t === null || t > timestampMs; + }); + if (firstAfter === 0) return null; // target precedes every loaded message + const found = items[firstAfter - 1]; + const foundTimestamp = getMessageCreatedAtTimestamp(found); + // A message without a resolvable created_at (e.g. an optimistic message still missing its + // server timestamp) cannot be located by timestamp. + if (foundTimestamp === null) return null; + if (!exactTsMatch) return found; + return foundTimestamp === timestampMs ? found : null; + }; + + filterQueryResults = (items: LocalMessage[]) => + items.filter(this.shouldIncludeMessageInInterval.bind(this)); + + private getCanonicalQueryItems(items: LocalMessage[]): LocalMessage[] { + return [...items].sort(this.itemOrderComparator); + } +} + +const makeDeriveCursor = + ( + paginator: MessageIntervalPaginator, + ): CursorDerivator => + (ctx) => { + // Not included in the interval (filtered out by MessageIntervalPaginator.filterQueryResults). + // + // IMPORTANT: We must keep cursor derivation consistent with the ingested interval. + // The interval is built from the filtered page, but ctx.page contains the raw response. + // Around/linear derivators compare page edges and lengths against interval.itemIds. If we + // pass a page that includes locally filtered messages (e.g. shadowed), those comparisons + // can incorrectly conclude that the page is not at the dataset bounds. + const pageWithPermittedMessages: LocalMessage[] = []; + let filteredLocallyCount = 0; + for (const message of ctx.page) { + if (!paginator.shouldIncludeMessageInInterval(message)) { + filteredLocallyCount++; + } else { + pageWithPermittedMessages.push(message); + } + } + + const requestedPageSizeAfterAdjustment = Math.max( + 0, + ctx.requestedPageSize - filteredLocallyCount, + ); + + if ( + ctx.interval && + ctx.interval.itemIds.length + filteredLocallyCount < ctx.page.length + ) { + console.error( + 'error', + 'Corrupted message set state: parent set size < returned page size', + ); + return { + cursor: ctx.cursor, + hasMoreHead: ctx.hasMoreHead, + hasMoreTail: ctx.hasMoreTail, + }; + } + + const injectCursor = ({ + hasMoreHead, + hasMoreTail, + }: { + hasMoreHead: boolean; + hasMoreTail: boolean; + }): CursorDeriveResult => { + const cursor: PaginatorCursor = { + headward: !hasMoreHead ? null : (ctx.interval?.itemIds.slice(-1)[0] ?? null), + tailward: !hasMoreTail ? null : (ctx.interval?.itemIds[0] ?? null), + }; + return { cursor, hasMoreHead, hasMoreTail }; + }; + + if ((ctx.queryShape as MessagePaginationOptions)?.created_at_around) { + return injectCursor( + deriveCreatedAtAroundPaginationFlags< + LocalMessage, + MessagePaginationOptions, + MessageIntervalPaginator + >({ + ...ctx, + paginator, + page: pageWithPermittedMessages, + requestedPageSize: requestedPageSizeAfterAdjustment, + }), + ); + } else if (ctx.queryShape?.id_around) { + return injectCursor( + deriveIdAroundPaginationFlags({ + ...ctx, + page: pageWithPermittedMessages, + requestedPageSize: requestedPageSizeAfterAdjustment, + }), + ); + } else { + return injectCursor( + deriveLinearPaginationFlags({ + ...ctx, + page: pageWithPermittedMessages, + requestedPageSize: requestedPageSizeAfterAdjustment, + }), + ); + } + }; diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 13beba1fae..76e470f58f 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -1,132 +1,63 @@ import type { - AnyInterval, - CursorDerivator, - CursorDeriveResult, ExecuteQueryReturnValue, Interval, - PaginationDirection, - PaginationQueryParams, - PaginatorCursor, - PaginatorState, PostQueryReconcileParams, } from './BasePaginator'; import { - BasePaginator, - isLogicalInterval, - type PaginationQueryReturnValue, - type PaginationQueryShapeChangeIdentifier, - type PaginatorOptions, - ZERO_PAGE_CURSOR, -} from './BasePaginator'; -import type { - AscDesc, - LocalMessage, - MessagePaginationOptions, - PinnedMessagePaginationOptions, -} from '../../types'; -import type { Channel } from '../../channel'; + type MessagePaginatorOptions as BaseMessagePaginatorOptions, + getMessageCreatedAtTimestamp, + type JumpToMessageOptions, + MessageIntervalPaginator, + type MessageQueryShape, +} from './MessageIntervalPaginator'; +import type { LocalMessage } from '../../types'; import { StateStore } from '../../store'; -import { formatMessage, generateUUIDv4, toDeletedMessage } from '../../utils'; -import { makeComparator } from '../sortCompiler'; -import type { FieldToDataResolver } from '../types.normalization'; -import { resolveDotPathValue } from '../utility.normalization'; -import { ItemIndex } from '../ItemIndex'; -import { deriveCreatedAtAroundPaginationFlags } from '../cursorDerivation'; -import { deriveIdAroundPaginationFlags } from '../cursorDerivation/idAroundPaginationFlags'; -import { deriveLinearPaginationFlags } from '../cursorDerivation/linearPaginationFlags'; - -export type MessageFocusReason = - | 'jump-to-message' - | 'jump-to-first-unread' - | 'jump-to-latest'; - -export type MessageFocusSignal = { - messageId: string; - reason: MessageFocusReason; - token: number; - createdAt: number; - ttlMs: number; -}; - -export type MessageFocusSignalState = { - signal: MessageFocusSignal | null; -}; - -export type JumpToMessageOptions = { - pageSize?: number; - /** - * Optional reason attached to emitted focus signal. - * Defaults to `jump-to-message`. - */ - focusReason?: MessageFocusReason; - /** - * TTL for the emitted focus signal in milliseconds. - * Defaults to `3000`. - */ - focusSignalTtlMs?: number; - /** - * If true, suppresses focus signal emission after a successful jump. - */ - suppressFocusSignal?: boolean; -}; - -export type MessagePaginatorSort = { created_at: AscDesc } | { created_at: AscDesc }[]; -export type MessagePaginatorFilter = { - cid: string; - parent_id?: string; -}; - -const DEFAULT_BACKEND_SORT: MessagePaginatorSort = { - created_at: 1, -}; - -// server's default size is 100 -const DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE = 100; - -export type MessagePaginatorState = PaginatorState; -export type MessageQueryShape = MessagePaginationOptions | PinnedMessagePaginationOptions; +export type { + JumpToMessageOptions, + MessageFocusReason, + MessageFocusSignal, + MessageFocusSignalState, + MessagePaginatorFilter, + MessagePaginatorSort, + MessagePaginatorState, + MessageQueryShape, +} from './MessageIntervalPaginator'; +export { MessageIntervalPaginator } from './MessageIntervalPaginator'; /** - * At the moment all the pagination parameters are just different types of cursors, e.g. - * id_lt, id_gt, ... - * But we always paginate within the same list without changing the sorting params. - * It is currently not possible to change the sorting params. + * Auxiliary (non-pagination) state for the message paginator: whole-collection aggregates that are + * independent of the active pagination window (the dual of pagination — values over the entire set, + * not a page of it). `lastMessageAt` is effectively `MAX(created_at)` over the channel-relevant + * messages and is the source of truth for channel-list ordering + * (`channel.messagePaginator.lastMessageAt`): seeded from `ChannelResponse.last_message_at`, then + * advanced monotonically as newer messages are ingested. */ -const hasPaginationQueryShapeChanged: PaginationQueryShapeChangeIdentifier< - MessageQueryShape -> = () => false; - -const dataFieldFilterResolver: FieldToDataResolver = { - matchesField: () => true, - resolve: (message, path) => resolveDotPathValue(message, path), -}; - -const getMessageCreatedAtTimestamp = (message: LocalMessage): number | null => { - if (!(message.created_at instanceof Date)) return null; - const timestamp = message.created_at.getTime(); - return Number.isFinite(timestamp) ? timestamp : null; -}; - -export type MessagePaginatorOptions = { - channel: Channel; - id?: string; - itemIndex?: ItemIndex; - parentMessageId?: string; - /** - * Sort passed to backend message/replies query. - * Does not affect in-memory item ordering. - */ - requestSort?: MessagePaginatorSort; +export type MessagePaginatorAggregateState = { /** - * @deprecated Use `requestSort` instead. + * The newest channel-relevant message, for display (e.g. a channel/thread list item's last-message + * preview or latest-reply avatar). A LIVE reference: it advances to a strictly newer message, is + * refreshed in place when that message is edited/soft-deleted/reacted-to, and is recomputed to the + * next newest when it is hard-removed. Respects `shouldAdvanceLastMessage` (skips system messages + * per `skip_last_msg_update_for_system_msgs`, and thread-only replies). `null` until one is ingested. + * + * Lives here, NOT derived from pagination `state`, so it stays reactive when a WS message lands in + * the head interval while an older window is active — the pagination store only emits when the + * *active* interval is impacted (see `BasePaginator.ingestItem`), so a `state`-derived latest would + * go stale in that case. */ - sort?: MessagePaginatorSort; + lastMessage: LocalMessage | null; /** - * In-memory ordering for items exposed by paginator state. + * Server-provided `ChannelResponse.last_message_at` floor, for channels whose newest message is not + * loaded (e.g. surfaced by a channel-list query). Kept SEPARATE from {@link lastMessage} so it can + * outrank a stale/absent loaded message for sorting without overwriting the display message. The + * sort key {@link MessagePaginator.lastMessageAt} is derived as the max of the two, so the two can + * never drift out of sync. */ - itemOrder?: MessagePaginatorSort; - paginatorOptions?: PaginatorOptions; + seededLastMessageAt: Date | null; +}; + +export type MessagePaginatorOptions = BaseMessagePaginatorOptions & { /** * Controls whether `jumpToTheFirstUnreadMessage()` should prefer the `unreadStateSnapshot` * state over `channel.state.read[...]`. @@ -166,13 +97,11 @@ export type LiveViewState = { }; /** - * MessagePaginator allows configuring backend request sort, while keeping internal item ordering stable. - * Filtering of ingested items is still limited to local predicates (`filterQueryResults`). + * MessagePaginator extends {@link MessageIntervalPaginator} with the unread/live-view concern: + * an independent unread reference snapshot, the UI-driven "viewing the latest messages" signal, and + * the "jump to first unread" navigation built on top of them. */ -export class MessagePaginator extends BasePaginator { - private readonly _id: string; - private channel: Channel; - private parentMessageId?: string; +export class MessagePaginator extends MessageIntervalPaginator { private unreadReferencePolicy: 'snapshot' | 'read-state-only'; /** * Independent unread reference state (not tied to `channel.state.read`). @@ -185,59 +114,19 @@ export class MessagePaginator extends BasePaginator; - readonly messageFocusSignal: StateStore; - private clearMessageFocusSignalTimeoutId: ReturnType | null = null; - private messageFocusSignalToken = 0; - protected _requestSort = DEFAULT_BACKEND_SORT; - protected _itemOrder: MessagePaginatorSort = DEFAULT_BACKEND_SORT; - protected _nextQueryShape: MessageQueryShape | undefined; - sortComparator: (a: LocalMessage, b: LocalMessage) => number; /** - * Single source of truth for whether a message should be included in paginator intervals/state. - * Keep this consistent with `filterQueryResults` AND cursor flag derivation. + * Auxiliary (non-pagination) state — see {@link MessagePaginatorAggregateState}. A store separate + * from `state` so `lastMessageAt` can be advanced from inside a `state.next` updater + * (`ingestPage`) without being clobbered, and so consumers subscribe to a quiet signal that only + * emits when the aggregate actually changes (not on every scroll/pagination emission). */ - shouldIncludeMessageInInterval(message: LocalMessage): boolean { - return !message.shadowed; - } - - protected get intervalItemIdsAreHeadFirst(): boolean { - // Messages are stored in chronological order (created_at asc) within an interval. - // Pagination "head" (newest side) is therefore at the END of the `itemIds` array. - return false; - } - - protected get intervalSortDirection(): 'asc' | 'desc' { - // Head edge is newest, but sortComparator is created_at asc => newer head edges - // should come first => reverse interval ordering. - return 'desc'; - } + readonly aggregateState: StateStore; constructor({ - channel, - id, - itemIndex = new ItemIndex({ getId: (item) => item.id }), - parentMessageId, - requestSort, - sort, - itemOrder, - paginatorOptions, unreadReferencePolicy = 'snapshot', + ...options }: MessagePaginatorOptions) { - const resolvedRequestSort = requestSort ?? sort ?? DEFAULT_BACKEND_SORT; - const resolvedItemOrder = itemOrder ?? resolvedRequestSort; - super({ - hasPaginationQueryShapeChanged, - initialCursor: ZERO_PAGE_CURSOR, - itemIndex, - ...paginatorOptions, - 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()}`; - this._requestSort = resolvedRequestSort; - this._itemOrder = resolvedItemOrder; + super(options); this.unreadReferencePolicy = unreadReferencePolicy; this.unreadStateSnapshot = new StateStore({ lastReadAt: null, @@ -248,143 +137,165 @@ export class MessagePaginator extends BasePaginator({ isViewingLive: false, }); - this.messageFocusSignal = new StateStore({ - signal: null, + this.aggregateState = new StateStore({ + lastMessage: null, + seededLastMessageAt: null, }); - this.sortComparator = makeComparator({ - sort: this._requestSort, - resolvePathValue: resolveDotPathValue, - tiebreaker: (l, r) => { - const leftId = this.getItemId(l); - const rightId = this.getItemId(r); - 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.setFilterResolvers([dataFieldFilterResolver]); } - get id() { - return this._id; + /** + * 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 + * message is ingested. + */ + get lastMessageAt(): Date | null { + const { lastMessage, seededLastMessageAt } = this.aggregateState.getLatestValue(); + const fromMessage = + lastMessage?.created_at instanceof Date ? lastMessage.created_at : null; + if (fromMessage && seededLastMessageAt) { + return fromMessage >= seededLastMessageAt ? fromMessage : seededLastMessageAt; + } + return fromMessage ?? seededLastMessageAt; } - get sort() { - return this._requestSort ?? DEFAULT_BACKEND_SORT; + /** + * The newest channel-relevant message (the monotonic tracked latest), for display. Convenience read + * of {@link aggregateState}; subscribe to `aggregateState` for reactivity. `null` until a message is + * ingested (a server-only seed advances {@link lastMessageAt} but leaves this `null`). + */ + get lastMessage(): LocalMessage | null { + return this.aggregateState.getLatestValue().lastMessage; } - get requestSort() { - return this._requestSort ?? DEFAULT_BACKEND_SORT; + /** + * The main channel list's notion of "latest message" for `channel.state.last_message_at`: on top of + * the base rule (not shadowed) this excludes thread-only replies (a reply with a `parent_id` that is + * not shown in the channel is not part of the channel list) and, when the channel is configured with + * `skip_last_msg_update_for_system_msgs`, system messages. Mirrors the legacy + * `Channel._trackLastMessage` skip logic. + * + * These exclusions are specific to the MAIN channel list. A reply list (thread paginator, which has + * a `parentMessageId`) is made ENTIRELY of thread replies — there the newest reply is exactly the + * "latest message", so the exclusions are skipped and only the base rule applies. + */ + protected shouldAdvanceLastMessage(message: LocalMessage): boolean { + if (message.shadowed) return false; + if (this.parentMessageId) return true; + 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 && + message.type === 'system'; + return !skipSystemMessage; } - get itemOrder() { - return this._itemOrder ?? this._requestSort ?? DEFAULT_BACKEND_SORT; + /** + * Monotonically advance {@link lastMessageAt} to `message.created_at` when it is newer than the + * current value and {@link shouldAdvanceLastMessage} permits it. Writes the dedicated + * {@link aggregateState} store (not `state`), so it is safe to call from inside a `state.next` + * updater (`ingestPage`). Returns `true` when the value moved. + * + * Called internally on ingest; also public for callers that advance the aggregate without a normal + * in-window ingest — e.g. offline pending-message replay, where the sent message has not yet been + * ingested via the `message.new` event. + */ + trackLastMessage(message: LocalMessage): boolean { + if (!this.shouldAdvanceLastMessage(message)) return false; + const incoming = getMessageCreatedAtTimestamp(message); + if (incoming === null) return false; + const current = this.aggregateState.getLatestValue().lastMessage; + // Refresh in place when this IS the current latest being updated — an edit, soft-delete, + // reaction, or quoted-message update all re-ingest the same id (same `created_at`). This keeps + // `lastMessage` a LIVE reference (so a preview shows "deleted"/edited text), and because + // `aggregateState` emits regardless of the active interval, it stays reactive off-window. + if (current && current.id === message.id) { + this.aggregateState.partialNext({ lastMessage: message }); + return true; + } + // Otherwise only advance to a strictly newer message. Guard against the current message's own + // timestamp — NOT `lastMessageAt` (which the server seed can inflate); otherwise a seed newer than + // the loaded window would reject the very message it was derived from. + const currentTs = current ? getMessageCreatedAtTimestamp(current) : null; + if (currentTs !== null && incoming <= currentTs) return false; + this.aggregateState.partialNext({ lastMessage: message }); + return true; } /** - * Even though we do not send filters object to the server, we need to have filters for client-side item ingestion logic. + * Seed {@link lastMessageAt} from the server-provided `ChannelResponse.last_message_at`, the + * authoritative whole-channel aggregate. Monotonic: a no-op when the paginator already advanced + * past it (e.g. from ingested messages), so seed order does not matter. */ - buildFilters = (): MessagePaginatorFilter => ({ - cid: this.channel.cid, - ...(this.parentMessageId ? { parent_id: this.parentMessageId } : {}), - }); - - // invoked inside BasePaginator.executeQuery() to keep it as a query descriptor; - protected getNextQueryShape({ - direction, - }: Omit< - PaginationQueryParams, - 'isFirstPageQuery' - >): MessageQueryShape { - return { - limit: this.pageSize, - [direction === 'tailward' ? 'id_lt' : 'id_gt']: - direction && this.cursor?.[direction], - }; + seedLastMessageAt(value: string | Date | null | undefined) { + if (!value) return; + const date = value instanceof Date ? value : new Date(value); + const timestamp = date.getTime(); + if (!Number.isFinite(timestamp)) return; + const current = this.aggregateState.getLatestValue().seededLastMessageAt; + if (current && timestamp <= current.getTime()) return; + this.aggregateState.partialNext({ seededLastMessageAt: date }); } - getCursorFromQueryResults = ({ - direction, - items, - }: { - direction?: PaginationDirection; - items: LocalMessage[]; - }) => { - if (!items.length) { - return { - tailward: undefined, - headward: undefined, - }; + ingestItem(item: LocalMessage): boolean { + // Only items that survive the filter advance the aggregate (`ingestItem` also handles removals + // of items that no longer match). The advance writes the separate `aggregateState` store, so it + // is independent of `super`'s `state`/index mutations. + if (this.matchesFilter(item)) { + this.trackLastMessage(item); } + return super.ingestItem(item); + } - const start = items[0]; - const end = items[items.length - 1]; - - // Newer side is the pagination head for messages. Which bound is considered "head" - // is determined by intervalItemIdsAreHeadFirst (see BasePaginator.getIntervalPaginationEdges). - const head = this.intervalItemIdsAreHeadFirst ? start : end; - const tail = this.intervalItemIdsAreHeadFirst ? end : start; - - // if there is no direction, then we are jumping, and we want to set both directions in the cursor - return { - tailward: !direction || direction === 'tailward' ? this.getItemId(tail) : undefined, - headward: !direction || direction === 'headward' ? this.getItemId(head) : undefined, - }; - }; - - query = async ({ - direction, - }: PaginationQueryParams): Promise< - PaginationQueryReturnValue - > => { - // get the params only if they were not generated previously - if (!this._nextQueryShape) { - this._nextQueryShape = this.getNextQueryShape({ direction }); + ingestPage( + params: Parameters[0], + ): Interval | null { + const interval = super.ingestPage(params); + // Advance from each page item; the monotonic max over the page yields the newest. Comparison is + // by `created_at` against `aggregateState` (no index lookup), so order vs `super` is irrelevant. + if (params.page?.length) { + for (const item of params.page) this.trackLastMessage(item); } + return interval; + } - const options = this._nextQueryShape; - let items: LocalMessage[]; - let tailward: string | undefined; - let headward: string | undefined; - if (this.config.doRequest) { - const result = await this.config.doRequest(options); - items = this.getCanonicalQueryItems(result?.items ?? []); - // if there is no direction, then we are jumping, and we want to set both directions in the cursor - tailward = - !direction || direction === 'tailward' - ? (result.cursor?.tailward ?? undefined) - : undefined; - headward = - !direction || direction === 'headward' - ? (result.cursor?.headward ?? undefined) - : undefined; - } else { - const { messages } = this.parentMessageId - ? await this.channel.getReplies( - this.parentMessageId, - options, - Array.isArray(this.requestSort) ? this.requestSort : [this.requestSort], - ) - : await this.channel.query({ - messages: options, - // todo: why do we query for watchers? - // watchers: { limit: this.pageSize }, - }); - items = this.getCanonicalQueryItems(messages.map(formatMessage)); - const cursor = this.getCursorFromQueryResults({ direction, items }); - tailward = cursor.tailward; - headward = cursor.headward; + removeItem( + params: Parameters[0], + ): ReturnType { + const removedId = + params.id ?? (params.item ? this.getItemId(params.item) : undefined); + const wasLatest = + !!removedId && this.aggregateState.getLatestValue().lastMessage?.id === removedId; + const result = super.removeItem(params); + if (wasLatest) { + // The tracked latest was hard-removed; fall back to the newest still-loaded message that + // passes the filter. `trackLastMessage` cannot do this (it only advances), so recompute here. + this.aggregateState.partialNext({ lastMessage: this.recomputeLastMessage() }); } + return result; + } - return { items, headward, tailward }; - }; + /** + * The still-loaded message with the greatest `created_at` that passes + * {@link shouldAdvanceLastMessage} (skips system / thread-only per config), from the newest-loaded + * window. Used to recompute the tracked latest after the current one is removed. `null` when nothing + * loaded qualifies. + * + * "Latest" is defined by `created_at` (matching {@link trackLastMessage}), NOT by the paginator's + * display order — so this compares timestamps rather than assuming a position, and stays correct + * whatever `itemOrder` / `requestSort` are configured to. + */ + private recomputeLastMessage(): LocalMessage | null { + let latest: LocalMessage | null = null; + let latestTimestamp = -Infinity; + for (const item of this.headItems) { + if (!this.shouldAdvanceLastMessage(item)) continue; + const timestamp = getMessageCreatedAtTimestamp(item); + if (timestamp === null || timestamp <= latestTimestamp) continue; + latest = item; + latestTimestamp = timestamp; + } + return latest; + } /** * (Re)seed the unread state snapshot from the current own read state. @@ -400,6 +311,11 @@ export class MessagePaginator extends BasePaginator { + // A paginator query (BasePaginator.executeQuery) awaits the network before running its + // synchronous postQueryReconcile, which calls this on the first page. If the channel was + // disconnected while that request was in flight, reading the client below throws ("You can't + // use a channel after client.disconnect()"), so guard against that. + if (this.channel.disconnected) return; const ownUserId = this.channel.getClient().user?.id; const ownReadState = ownUserId ? this.channel.state.read[ownUserId] : undefined; if (!ownReadState) return; @@ -412,14 +328,14 @@ export class MessagePaginator extends BasePaginator, - ): Promise> { - const result = await super.postQueryReconcile(params); + ): ExecuteQueryReturnValue { + const result = super.postQueryReconcile(params); if (params.isFirstPage) { this.seedUnreadSnapshot(); @@ -427,203 +343,6 @@ export class MessagePaginator extends BasePaginator => { - let localMessage = this.getItem(messageId); - let interval: AnyInterval | undefined; - let state: Partial> | undefined; - if (localMessage) { - interval = this.locateIntervalForItem(localMessage); - if ( - interval && - !isLogicalInterval(interval) && - !interval.itemIds.includes(messageId) - ) { - // locateIntervalForItem can match by created_at RANGE and return an interval whose range - // spans the target while its loaded itemIds do NOT contain it (e.g. a neighbouring interval - // grew across the target's position without merging). Prefer the interval that actually - // holds the id so the jump activates the window the message really lives in - otherwise, if - // the range-matched interval happens to be active, the jump becomes a no-op. - interval = this.itemIntervals.find( - (candidate) => - !isLogicalInterval(candidate) && candidate.itemIds.includes(messageId), - ); - } - } - - if (localMessage && interval && !isLogicalInterval(interval)) { - state = { - hasMoreHead: interval.hasMoreHead, - hasMoreTail: interval.hasMoreTail, - cursor: this.getCursorFromInterval(interval), - items: this.intervalToItems(interval), - }; - } else if (!localMessage || !interval || isLogicalInterval(interval)) { - const result = await this.executeQuery({ - queryShape: { id_around: messageId, limit: pageSize }, - updateState: false, - }); - localMessage = this.getItem(messageId); - if (!localMessage || !result || !result.targetInterval) { - this.channel.getClient().notifications.addError({ - message: 'Jump to message unsuccessful', - origin: { emitter: 'MessagePaginator.jumpToMessage', context: { messageId } }, - options: { type: 'api:messages:query:failed' }, - }); - return false; - } - interval = result.targetInterval; - state = isLogicalInterval(interval) - ? result.stateCandidate - : { - ...result.stateCandidate, - hasMoreHead: interval.hasMoreHead, - hasMoreTail: interval.hasMoreTail, - // Prefer the cursor derived during postQueryReconcile, but fall back to - // interval-derived cursor to keep jumps consistent if the stateCandidate - // is partial. - cursor: result.stateCandidate.cursor ?? this.getCursorFromInterval(interval), - items: this.intervalToItems(interval), - }; - } - - if (!this.isActiveInterval(interval)) { - this.setActiveInterval(interval, { updateState: false }); - } - if (state) this.state.partialNext(state); - if (!suppressFocusSignal) { - this.emitMessageFocusSignal({ - messageId, - reason: focusReason ?? 'jump-to-message', - ttlMs: focusSignalTtlMs, - }); - } - return true; - }; - - jumpToTheLatestMessage = async (options?: JumpToMessageOptions): Promise => { - let latestMessageId: string | undefined; - if (!(this.itemIntervals[0] as Interval)?.isHead) { - // load the newest page in case pagination is currently on an older window (an empty/partial - // headward response marks the interval as the head) - await this.executeQuery({ direction: 'headward', updateState: false }); - } - - // Re-read itemIntervals AFTER the query: the getter returns a fresh array each call, so a - // reference captured before executeQuery would be stale and miss the head we just loaded. - const headInterval = this.itemIntervals[0] as Interval | undefined; - if (headInterval?.isHead) { - latestMessageId = headInterval.itemIds.slice(-1)[0]; - } - - if (!latestMessageId) { - this.channel.getClient().notifications.addError({ - message: 'Jump to latest message unsuccessful', - origin: { emitter: 'MessagePaginator.jumpToTheLatestMessage' }, - options: { type: 'api:message:query:failed' }, - }); - return false; - } - - return await this.jumpToMessage(latestMessageId, { - suppressFocusSignal: true, - ...options, - focusReason: 'jump-to-latest', - }); - }; - - /** - * Fold an already fetched newest (head) page into the currently loaded items without issuing a - * query. The caller supplies a page it obtained on its own and this reconciles it against what is - * loaded, instead of rerunning the first page query, so the loaded set is updated in place rather - * than blanked and reloaded. Two cases, decided by whether the incoming page overlaps the loaded - * head: - * - * 1. OVERLAP - the incoming page shares at least one id with the loaded head (fewer than a full - * page is new). Merge in place: existing items are reconciled by id (edits, soft deletes), new - * items are appended and every already loaded item (including older pages already paged in) is - * kept. `hasMoreTail`/`cursor.tailward` are left as-is so the page can be any size, so deriving - * "has older items" from its length would wrongly clear it while older items remain. - * - * 2. DISJOINT - the incoming page shares no id with the loaded head (at least a full page is new). - * Merging would weld the two across the gap (the interval merge treats two head intervals as - * overlapping when one reaches further headward), hiding the items in between with no way to - * reach them. Instead the loaded set is discarded and rebuilt from the incoming page as a fresh - * contiguous head (`hasMoreTail: true`, cursor reanchored to the page's oldest item) so the - * gap and older history load again when paginating older. - * - * Both paths emit exactly once and never blank the loaded set. Noop unless the page is non empty - * and the newest slice is both loaded AND the interval currently in view (the head interval is - * anchored at the head and active); when the caller has jumped to a separate older window the - * merge is skipped so their position is preserved, and the incoming page is picked up on a later - * load. - */ - mergeNewestPage = (page: LocalMessage[]) => { - if (!page?.length) return; - const headInterval = this.itemIntervals[0] as Interval | undefined; - if (!headInterval?.isHead) return; - // Only reconcile when the head is the interval currently in view. If the caller jumped to a - // separate (older) window, that window is active and the head is merely still-loaded underneath; - // reconciling would switch the view to the head and yank them to the newest. Skip to preserve - // their position (the newest page is picked up on scroll / a later load). - if (!this.isActiveInterval(headInterval)) return; - - const loadedIds = new Set(headInterval.itemIds); - const overlapsLoadedHead = page.some((item) => loadedIds.has(this.getItemId(item))); - - if (!overlapsLoadedHead) { - // Disjoint window: rebuild from the fetched page as a fresh newest slice. Clearing - // the stale intervals first ensures `ingestPage` builds a single head interval instead of - // merging across the gap so reanchoring the cursor to this page's oldest item keeps the next - // "load older" contiguous. - this.setIntervals([]); - this.setActiveInterval(undefined); - const resetInterval = this.ingestPage({ - page, - isHead: true, - // Disjoint means the previously loaded head was entirely OLDER than this newest window, so - // there is always older data to load (the gap + the prior history) - keep hasMoreTail true. - isTail: false, - setActive: false, - }); - if (!resetInterval) return; - this.setActiveInterval(resetInterval, { updateState: false }); - this.state.partialNext({ - items: this.intervalToItems(resetInterval), - cursor: this.getCursorFromInterval(resetInterval), - hasMoreHead: resetInterval.hasMoreHead, - hasMoreTail: resetInterval.hasMoreTail, - }); - return; - } - - // Overlapping window: merge in place, preserving the older boundary. - const interval = this.ingestPage({ page, isHead: true, setActive: false }); - if (!interval) return; - - this.setActiveInterval(interval, { updateState: false }); - this.state.partialNext({ - items: this.intervalToItems(interval), - // The newest slice is loaded (head anchored), so after merging the head window there is - // nothing newer to load. hasMoreTail / cursor are deliberately preserved (see above). - hasMoreHead: false, - }); - }; - /** * Jumps to the unread reference message. * @@ -737,129 +456,6 @@ export class MessagePaginator extends BasePaginator { - // Messages are expected in chronological order. We find: - // - lastReadMessageId: newest message with created_at <= lastReadAt - // - firstUnreadMessageId: first message with created_at > lastReadAt - // - // If the page starts after lastReadAt, the entire page is unread and the first message is - // used as unread anchor (legacy "whole channel is unread" behavior for this queried window). - const lastReadTimestamp = lastReadAt.getTime(); - if (!Number.isFinite(lastReadTimestamp) || !messages.length) { - return { firstUnreadMessageId: null, lastReadMessageId: null }; - } - - let firstUnreadMessageId: string | null = null; - let lastReadMessageId: string | null = null; - - for (const message of messages) { - const messageTimestamp = getMessageCreatedAtTimestamp(message); - if (messageTimestamp === null) continue; - - if (messageTimestamp <= lastReadTimestamp) { - lastReadMessageId = message.id; - } else if (!firstUnreadMessageId) { - firstUnreadMessageId = message.id; - } - } - - const firstMessageWithTimestamp = messages.find( - (message) => getMessageCreatedAtTimestamp(message) !== null, - ); - const firstMessageTimestamp = - firstMessageWithTimestamp && - getMessageCreatedAtTimestamp(firstMessageWithTimestamp); - if ( - firstMessageWithTimestamp && - typeof firstMessageTimestamp === 'number' && - lastReadTimestamp < firstMessageTimestamp - ) { - return { - firstUnreadMessageId: firstMessageWithTimestamp.id, - lastReadMessageId, - }; - } - - return { firstUnreadMessageId, lastReadMessageId }; - }; - - emitMessageFocusSignal = ({ - messageId, - reason, - ttlMs = 3000, - }: { - messageId: string; - reason: MessageFocusReason; - ttlMs?: number; - }): MessageFocusSignal => { - this.messageFocusSignalToken += 1; - const signal: MessageFocusSignal = { - messageId, - reason, - token: this.messageFocusSignalToken, - createdAt: Date.now(), - ttlMs, - }; - - if (this.clearMessageFocusSignalTimeoutId) { - clearTimeout(this.clearMessageFocusSignalTimeoutId); - this.clearMessageFocusSignalTimeoutId = null; - } - - this.messageFocusSignal.next({ signal }); - - // NOTE: the auto-dismissal countdown is intentionally NOT started here. A focused message may - // be emitted while its message list is not yet visible (e.g. the channel is covered by a thread - // panel when a "view in channel" jump resolves), so measuring the highlight lifetime from the - // moment the jump resolved would burn it while the message is still off-screen. The consumer - // starts the countdown via `scheduleMessageFocusSignalClear` once the message is actually - // viewed. - return signal; - }; - - /** - * Starts the auto-dismissal countdown for the currently active focus signal. Call this once the - * focused message has been viewed (rendered and visible), so the highlight's lifetime is measured - * from when the user could actually see it rather than from when the jump resolved. No-op if the - * signal has already been cleared or superseded (guarded by `token`). - */ - scheduleMessageFocusSignalClear = ({ - token, - ttlMs, - }: { token?: number; ttlMs?: number } = {}) => { - const current = this.messageFocusSignal.getLatestValue().signal; - if (!current) return; - if (typeof token !== 'undefined' && current.token !== token) return; - - if (this.clearMessageFocusSignalTimeoutId) { - clearTimeout(this.clearMessageFocusSignalTimeoutId); - this.clearMessageFocusSignalTimeoutId = null; - } - - this.clearMessageFocusSignalTimeoutId = setTimeout(() => { - this.clearMessageFocusSignal({ token: current.token }); - }, ttlMs ?? current.ttlMs); - }; - - clearMessageFocusSignal = ({ token }: { token?: number } = {}) => { - const current = this.messageFocusSignal.getLatestValue().signal; - if (!current) return; - if (typeof token !== 'undefined' && current.token !== token) return; - - if (this.clearMessageFocusSignalTimeoutId) { - clearTimeout(this.clearMessageFocusSignalTimeoutId); - this.clearMessageFocusSignalTimeoutId = null; - } - - this.messageFocusSignal.next({ signal: null }); - }; - setUnreadSnapshot = (next: Partial): UnreadSnapshotState => { this.unreadStateSnapshot.partialNext(next); return this.unreadStateSnapshot.getLatestValue(); @@ -891,166 +487,9 @@ export class MessagePaginator extends BasePaginator { - this.resetState(); - this._itemIndex.clear(); + clearStateAndCache() { + super.clearStateAndCache(); this.clearUnreadSnapshot(); - this.clearMessageFocusSignal(); - }; - - applyMessageDeletionForUser = ({ - userId, - hardDelete = false, - deletedAt, - }: { - userId: string; - hardDelete?: boolean; - deletedAt: Date; - }) => { - const loadedMessages = this.items ?? []; - - for (const message of loadedMessages) { - if (message.user?.id === userId) { - if (hardDelete) { - this.removeItem({ id: message.id }); - } else { - this.ingestItem( - toDeletedMessage({ - message, - hardDelete, - deletedAt, - }) as LocalMessage, - ); - } - continue; - } - - if ( - message.quoted_message?.user?.id === userId && - message.quoted_message.type !== 'deleted' - ) { - this.ingestItem({ - ...message, - quoted_message: toDeletedMessage({ - message: message.quoted_message, - hardDelete, - deletedAt, - }) as LocalMessage, - }); - } - } - }; - - /** - * Ensures quoted-message snapshots across loaded paginator cache are in sync - * with the provided message. - * - * Scans cached messages and updates any item where `quoted_message_id` - * matches `message.id`. - */ - reflectQuotedMessageUpdate = (message: LocalMessage) => { - const cachedMessages = this._itemIndex.values(); - - for (const cachedMessage of cachedMessages) { - if (cachedMessage.quoted_message_id !== message.id) continue; - - this.ingestItem({ - ...cachedMessage, - quoted_message: message, - }); - } - }; - - filterQueryResults = (items: LocalMessage[]) => - items.filter(this.shouldIncludeMessageInInterval.bind(this)); - - private getCanonicalQueryItems(items: LocalMessage[]): LocalMessage[] { - return [...items].sort(this.itemOrderComparator); + this.aggregateState.next({ lastMessage: null, seededLastMessageAt: null }); } } - -const makeDeriveCursor = - (paginator: MessagePaginator): CursorDerivator => - (ctx) => { - // Not included in the interval (filtered out by MessagePaginator.filterQueryResults). - // - // IMPORTANT: We must keep cursor derivation consistent with the ingested interval. - // The interval is built from the filtered page, but ctx.page contains the raw response. - // Around/linear derivators compare page edges and lengths against interval.itemIds. If we - // pass a page that includes locally filtered messages (e.g. shadowed), those comparisons - // can incorrectly conclude that the page is not at the dataset bounds. - const pageWithPermittedMessages: LocalMessage[] = []; - let filteredLocallyCount = 0; - for (const message of ctx.page) { - if (!paginator.shouldIncludeMessageInInterval(message)) { - filteredLocallyCount++; - } else { - pageWithPermittedMessages.push(message); - } - } - - const requestedPageSizeAfterAdjustment = Math.max( - 0, - ctx.requestedPageSize - filteredLocallyCount, - ); - - if ( - ctx.interval && - ctx.interval.itemIds.length + filteredLocallyCount < ctx.page.length - ) { - console.error( - 'error', - 'Corrupted message set state: parent set size < returned page size', - ); - return { - cursor: ctx.cursor, - hasMoreHead: ctx.hasMoreHead, - hasMoreTail: ctx.hasMoreTail, - }; - } - - const injectCursor = ({ - hasMoreHead, - hasMoreTail, - }: { - hasMoreHead: boolean; - hasMoreTail: boolean; - }): CursorDeriveResult => { - const cursor: PaginatorCursor = { - headward: !hasMoreHead ? null : (ctx.interval?.itemIds.slice(-1)[0] ?? null), - tailward: !hasMoreTail ? null : (ctx.interval?.itemIds[0] ?? null), - }; - return { cursor, hasMoreHead, hasMoreTail }; - }; - - if ((ctx.queryShape as MessagePaginationOptions)?.created_at_around) { - return injectCursor( - deriveCreatedAtAroundPaginationFlags< - LocalMessage, - MessagePaginationOptions, - MessagePaginator - >({ - ...ctx, - paginator, - page: pageWithPermittedMessages, - requestedPageSize: requestedPageSizeAfterAdjustment, - }), - ); - } else if (ctx.queryShape?.id_around) { - return injectCursor( - deriveIdAroundPaginationFlags({ - ...ctx, - page: pageWithPermittedMessages, - requestedPageSize: requestedPageSizeAfterAdjustment, - }), - ); - } else { - return injectCursor( - deriveLinearPaginationFlags({ - ...ctx, - page: pageWithPermittedMessages, - requestedPageSize: requestedPageSizeAfterAdjustment, - }), - ); - } - }; diff --git a/src/pagination/paginators/MessageReplyPaginator.ts b/src/pagination/paginators/MessageReplyPaginator.ts deleted file mode 100644 index 415be87d53..0000000000 --- a/src/pagination/paginators/MessageReplyPaginator.ts +++ /dev/null @@ -1,301 +0,0 @@ -import type { - AnyInterval, - Interval, - PaginationQueryParams, - PaginatorState, -} from './BasePaginator'; -import { isLogicalInterval, ZERO_PAGE_CURSOR } from './BasePaginator'; -import { - BasePaginator, - type PaginationQueryReturnValue, - type PaginationQueryShapeChangeIdentifier, - type PaginatorOptions, -} from './BasePaginator'; -import type { - LocalMessage, - MessagePaginationOptions, - PinnedMessagePaginationOptions, -} from '../../types'; -import type { Channel } from '../../channel'; -import { formatMessage, generateUUIDv4 } from '../../utils'; -import { makeComparator } from '../sortCompiler'; -import { isEqual } from '../../utils/mergeWith/mergeWithCore'; -import type { FieldToDataResolver } from '../types.normalization'; -import { resolveDotPathValue } from '../utility.normalization'; -import type { - JumpToMessageOptions, - MessagePaginatorOptions, - MessagePaginatorSort, -} from './MessagePaginator'; -import { ItemIndex } from '../ItemIndex'; - -export type MessageReplyPaginatorFilter = { - cid: string; - parent_id: string; -}; - -const DEFAULT_PAGE_SIZE = 50; - -const DEFAULT_BACKEND_SORT: MessagePaginatorSort = { - created_at: 1, -}; - -export type MessageReplyQueryShape = { - options: MessagePaginationOptions | PinnedMessagePaginationOptions; - sort: MessagePaginatorSort; -}; - -const getQueryShapeRelevantMessageOptions = ( - options: MessagePaginationOptions, -): Omit => { - const { - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - limit: _, - ...relevantOptions - } = options; - return relevantOptions; -}; - -const hasPaginationQueryShapeChanged: PaginationQueryShapeChangeIdentifier< - MessageReplyQueryShape -> = (prevQueryShape, nextQueryShape) => - !isEqual( - { - ...prevQueryShape, - options: getQueryShapeRelevantMessageOptions(prevQueryShape?.options ?? {}), - }, - { - ...nextQueryShape, - options: getQueryShapeRelevantMessageOptions(nextQueryShape?.options ?? {}), - }, - ); - -const dataFieldFilterResolver: FieldToDataResolver = { - matchesField: () => true, - resolve: (message, path) => resolveDotPathValue(message, path), -}; - -export type MessageReplyPaginatorOptions = Omit< - MessagePaginatorOptions, - 'paginatorOptions' -> & { - parentMessageId: string; - paginatorOptions?: PaginatorOptions; -}; - -export class MessageReplyPaginator extends BasePaginator< - LocalMessage, - MessageReplyQueryShape -> { - private readonly _id: string; - private channel: Channel; - protected _parentMessageId: string; - protected _sort = DEFAULT_BACKEND_SORT; - protected _nextQueryShape: MessageReplyQueryShape | undefined; - sortComparator: (a: LocalMessage, b: LocalMessage) => number; - - protected get intervalItemIdsAreHeadFirst(): boolean { - // Replies are stored in chronological order (created_at asc) within an interval. - // Pagination "head" (newest side) is therefore at the END of the `itemIds` array. - return false; - } - - protected get intervalSortDirection(): 'asc' | 'desc' { - // Head edge is newest, but sortComparator is created_at asc => newer head edges - // should come first => reverse interval ordering. - return 'desc'; - } - - constructor({ - channel, - id, - itemIndex = new ItemIndex({ getId: (item) => item.id }), - paginatorOptions, - parentMessageId, - }: MessageReplyPaginatorOptions) { - super({ - hasPaginationQueryShapeChanged, - initialCursor: ZERO_PAGE_CURSOR, - itemIndex, - ...paginatorOptions, - pageSize: paginatorOptions?.pageSize ?? DEFAULT_PAGE_SIZE, - }); - const definedSort = DEFAULT_BACKEND_SORT; - this.channel = channel; - this._parentMessageId = parentMessageId; - this._id = id ?? `message-reply-paginator-${generateUUIDv4()}`; - this._sort = definedSort; - this.sortComparator = makeComparator({ - sort: this._sort, - resolvePathValue: resolveDotPathValue, - tiebreaker: (l, r) => { - const leftId = this.getItemId(l); - const rightId = this.getItemId(r); - return leftId < rightId ? -1 : leftId > rightId ? 1 : 0; - }, - }); - this.setFilterResolvers([dataFieldFilterResolver]); - } - - get id() { - return this._id; - } - - get sort() { - return this._sort ?? DEFAULT_BACKEND_SORT; - } - - /** - * Even though we do not send filters object to the server, we need to have filters for client-side item ingestion logic. - */ - buildFilters = (): MessageReplyPaginatorFilter => ({ - cid: this.channel.cid, - parent_id: this._parentMessageId, - }); - - // invoked inside BasePaginator.executeQuery() to keep it as a query descriptor; - protected getNextQueryShape({ - direction, - }: PaginationQueryParams): MessageReplyQueryShape { - return { - options: { - limit: this.pageSize, - [direction === 'tailward' ? 'id_lt' : 'id_gt']: - direction && this.cursor?.[direction], - }, - sort: this._sort, - }; - } - - query = async ({ - direction, - queryShape, - }: PaginationQueryParams): Promise< - PaginationQueryReturnValue - > => { - if (!queryShape) { - queryShape = this.getNextQueryShape({ direction }); - } - const { sort, options } = queryShape; - let items: LocalMessage[]; - let tailward: string | undefined; - let headward: string | undefined; - if (this.config.doRequest) { - const result = await this.config.doRequest({ - options, - sort: Array.isArray(sort) ? sort : [sort], - }); - items = result?.items ?? []; - // if there is no direction, then we are jumping, and we want to set both directions in the cursor - tailward = - !direction || direction === 'tailward' - ? (result.cursor?.tailward ?? undefined) - : undefined; - headward = - !direction || direction === 'headward' - ? (result.cursor?.headward ?? undefined) - : undefined; - } else { - const { messages } = await this.channel.getReplies( - this._parentMessageId, - options, - Array.isArray(sort) ? sort : [sort], - ); - items = messages.map(formatMessage); - // if there is no direction, then we are jumping, and we want to set both directions in the cursor - tailward = !direction || direction === 'tailward' ? messages[0].id : undefined; - headward = - !direction || direction === 'headward' ? messages.slice(-1)[0].id : undefined; - } - - return { items, headward, tailward }; - }; - - isJumpQueryShape(queryShape: MessageReplyQueryShape): boolean { - return ( - !!queryShape?.options?.id_around || - !!(queryShape.options as MessagePaginationOptions)?.created_at_around - ); - } - - /** - * Jump to a message inside thread replies. - * - * Mirrors `MessagePaginator.jumpToMessage` behavior: - * - If the message is already present in the item index and belongs to an existing interval, - * it activates that interval without querying. - * - Otherwise, performs an `id_around` query and ensures the item is present. - */ - jumpToMessage = async ( - messageId: string, - { pageSize }: JumpToMessageOptions = {}, - ): Promise => { - let localMessage = this.getItem(messageId); - let interval: AnyInterval | undefined; - let state: Partial> | undefined; - - if (localMessage) { - interval = this.locateIntervalForItem(localMessage); - } - - if (!localMessage || !interval || isLogicalInterval(interval)) { - const result = await this.executeQuery({ - queryShape: { - options: { id_around: messageId, limit: pageSize }, - sort: this.sort, - }, - updateState: false, - }); - - localMessage = this.getItem(messageId); - if (!localMessage || !result || !result.targetInterval) { - this.channel.getClient().notifications.addError({ - message: 'Jump to message unsuccessful', - origin: { - emitter: 'MessageReplyPaginator.jumpToMessage', - context: { messageId, parentMessageId: this._parentMessageId }, - }, - options: { type: 'api:replies:query:failed' }, - }); - return false; - } - interval = result.targetInterval; - state = result.stateCandidate; - } - - if (!this.isActiveInterval(interval)) { - this.setActiveInterval(interval); - if (state) this.state.partialNext(state); - } - - return true; - }; - - jumpToTheLatestMessage = async (options?: JumpToMessageOptions): Promise => { - let latestMessageId: string | undefined; - const intervals = this.itemIntervals; - - if (!(intervals[0] as Interval)?.isHead) { - // get the first page (in case the pagination has not started at the head) - await this.executeQuery({ updateState: false }); - } - - const headInterval = intervals[0]; - if ((intervals[0] as Interval)?.isHead) { - latestMessageId = headInterval.itemIds.slice(-1)[0]; - } - - if (!latestMessageId) { - this.channel.getClient().notifications.addError({ - message: 'Jump to latest message unsuccessful', - origin: { emitter: 'MessageReplyPaginator.jumpToTheLatestMessage' }, - options: { type: 'api:message:replies:query:failed' }, - }); - return false; - } - - return await this.jumpToMessage(latestMessageId, options); - }; - - filterQueryResults = (items: LocalMessage[]) => items; -} diff --git a/src/pagination/paginators/PinnedMessagePaginator.ts b/src/pagination/paginators/PinnedMessagePaginator.ts new file mode 100644 index 0000000000..484521b735 --- /dev/null +++ b/src/pagination/paginators/PinnedMessagePaginator.ts @@ -0,0 +1,104 @@ +import type { PaginatorCursor, PaginatorOptions } from './BasePaginator'; +import { + MessageIntervalPaginator, + type MessageQueryShape, +} from './MessageIntervalPaginator'; +import type { AscDesc, LocalMessage, PinnedMessagePaginationOptions } from '../../types'; +import type { Channel } from '../../channel'; +import { formatMessage, generateUUIDv4 } from '../../utils'; +import { makeComparator } from '../sortCompiler'; +import { resolveDotPathValue } from '../utility.normalization'; +import { ItemIndex } from '../ItemIndex'; + +export type PinnedMessagePaginatorFilter = { + cid: string; + pinned: boolean; +}; + +export type PinnedMessagePaginatorOptions = { + channel: Channel; + id?: string; + itemIndex?: ItemIndex; + paginatorOptions?: PaginatorOptions; +}; + +/** + * Pinned-message list paginator. + * + * Extends the unread-free {@link MessageIntervalPaginator} base — pinned messages are a subset of + * the channel and MUST NOT participate in read/unread or delivery-receipt tracking (that belongs to + * the channel and thread message timelines). By extending the base rather than {@link MessagePaginator} + * it simply never gets the unread surface. + * + * Differences from the main list: + * - fetches from the `/pinned_messages` endpoint (`channel.getPinnedMessages`) rather than + * `channel.query({ messages })`; + * - includes only pinned, non-shadowed messages (`shouldIncludeMessageInInterval`), and filters on + * `{ cid, pinned: true }` so `ingestItem` auto-adds on pin and auto-removes on unpin; + * - orders by `pinned_at` ascending (oldest-pinned first), matching the legacy + * `channel.state.pinnedMessages` order. + * + * Navigation (`jumpToMessage` / `jumpToTheLatestMessage`) is inherited and meaningful — the endpoint + * supports `id_around` — but no unread-coupled navigation exists. + */ +export class PinnedMessagePaginator extends MessageIntervalPaginator { + constructor({ + channel, + id, + itemIndex, + paginatorOptions, + }: PinnedMessagePaginatorOptions) { + super({ + channel, + id: id ?? `pinned-message-paginator-${generateUUIDv4()}`, + itemIndex: itemIndex ?? new ItemIndex({ getId: (item) => item.id }), + paginatorOptions, + }); + + // 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: { pinned_at: AscDesc } = { pinned_at: 1 }; + this.sortComparator = makeComparator({ + sort: pinnedAtSort, + resolvePathValue: resolveDotPathValue, + tiebreaker, + }); + this.config.itemOrderComparator = makeComparator< + LocalMessage, + { pinned_at: AscDesc } + >({ + sort: pinnedAtSort, + resolvePathValue: resolveDotPathValue, + tiebreaker, + }); + + // Fetch from the pinned-messages endpoint. The base `query` feeds the resolved query shape + // (including `id_around` jumps) here as `options`; we return both cursors and let the base gate + // them by direction. + this.config.doRequest = async ( + options: MessageQueryShape, + ): Promise<{ cursor?: PaginatorCursor; items: LocalMessage[] }> => { + const { messages } = await this.channel.getPinnedMessages( + options as PinnedMessagePaginationOptions, + [{ pinned_at: 1 }], + ); + const items = messages.map(formatMessage); + return { cursor: this.getCursorFromQueryResults({ items }), items }; + }; + } + + buildFilters = (): PinnedMessagePaginatorFilter => ({ + cid: this.channel.cid, + pinned: true, + }); + + shouldIncludeMessageInInterval(message: LocalMessage): boolean { + return !message.shadowed && !!message.pinned; + } +} diff --git a/src/pagination/paginators/ReminderPaginator.ts b/src/pagination/paginators/ReminderPaginator.ts index 8c50224523..7a5480ee8c 100644 --- a/src/pagination/paginators/ReminderPaginator.ts +++ b/src/pagination/paginators/ReminderPaginator.ts @@ -11,6 +11,17 @@ import type { ReminderSort, } from '../../types'; import type { StreamChat } from '../../client'; +import { ItemIndex } from '../ItemIndex'; +import { makeComparator } from '../sortCompiler'; +import { resolveDotPathValue } from '../utility.normalization'; + +// Reminders are keyed by the message they belong to; used for interval dedup and index addressing. +const getReminderId = (reminder: ReminderResponse) => reminder.message_id; + +// Fallback order for interval placement when no explicit sort is set. Order is not a pinned contract +// (ReminderManager stores reminders in a message_id-keyed Map), but interval storage needs a total +// order, so default to a deterministic one. +const DEFAULT_SORT: ReminderSort = { created_at: 1 }; export class ReminderPaginator extends BasePaginator< ReminderResponse, @@ -35,6 +46,7 @@ export class ReminderPaginator extends BasePaginator< set sort(sort: ReminderSort | undefined) { this._sort = sort; + this.sortComparator = this.buildSortComparator(); this.resetState(); } @@ -42,8 +54,28 @@ export class ReminderPaginator extends BasePaginator< client: StreamChat, options?: PaginatorOptions, ) { - super({ initialCursor: ZERO_PAGE_CURSOR, ...options }); + super({ + initialCursor: ZERO_PAGE_CURSOR, + itemIndex: new ItemIndex({ getId: getReminderId }), + ...options, + }); this.client = client; + this.sortComparator = this.buildSortComparator(); + } + + getItemId(item: ReminderResponse): string { + return getReminderId(item); + } + + // Interval storage needs a total order. Derive it from the requested sort (rebuilt when `sort` + // changes, which also resets the accumulated pages), with a message_id tiebreaker. + private buildSortComparator() { + return makeComparator({ + sort: this._sort ?? DEFAULT_SORT, + resolvePathValue: resolveDotPathValue, + tiebreaker: (l, r) => + l.message_id < r.message_id ? -1 : l.message_id > r.message_id ? 1 : 0, + }); } protected getNextQueryShape({ diff --git a/src/pagination/paginators/UserGroupPaginator.ts b/src/pagination/paginators/UserGroupPaginator.ts index 5ee5ba0d15..bc19ee1f76 100644 --- a/src/pagination/paginators/UserGroupPaginator.ts +++ b/src/pagination/paginators/UserGroupPaginator.ts @@ -7,6 +7,7 @@ import type { } from './BasePaginator'; import type { QueryUserGroupsOptions, UserGroupResponse } from '../../types'; import type { StreamChat } from '../../client'; +import { ItemIndex } from '../ItemIndex'; type UserGroupListCursor = { created_at_gt: string; @@ -46,8 +47,19 @@ export class UserGroupPaginator extends BasePaginator< client: StreamChat, options?: PaginatorOptions, ) { - super({ initialCursor: { ...ZERO_PAGE_CURSOR, headward: null }, ...options }); + super({ + initialCursor: { ...ZERO_PAGE_CURSOR, headward: null }, + itemIndex: new ItemIndex({ getId: (group) => group.id }), + ...options, + }); this.client = client; + // Interval storage needs a total order for its placement/merge math. The listing is ordered by + // the forward cursor (`created_at_gt`, `id_gt`), i.e. ascending `created_at` then `id` — mirror + // that here so the visible order matches the server's. + this.sortComparator = (a, b) => { + if (a.created_at !== b.created_at) return a.created_at < b.created_at ? -1 : 1; + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }; } get initialState(): PaginatorState { diff --git a/src/pagination/paginators/index.ts b/src/pagination/paginators/index.ts index 8a6b8ff394..aeaee4f4bc 100644 --- a/src/pagination/paginators/index.ts +++ b/src/pagination/paginators/index.ts @@ -1,6 +1,7 @@ export * from './BasePaginator'; export * from './ChannelPaginator'; +export { MessageIntervalPaginator } from './MessageIntervalPaginator'; export * from './MessagePaginator'; -export * from './MessageReplyPaginator'; +export * from './PinnedMessagePaginator'; export * from './ReminderPaginator'; export * from './UserGroupPaginator'; diff --git a/src/pagination/sortCompiler.ts b/src/pagination/sortCompiler.ts index b4b05aaf92..e775dbcb19 100644 --- a/src/pagination/sortCompiler.ts +++ b/src/pagination/sortCompiler.ts @@ -155,21 +155,27 @@ export function makeComparator< case 'boolean': comparison = compare(normalized.a, normalized.b); break; - default: - // deterministic fallback: null/undefined last; else string compare - if (leftValue == null && rightValue == null) comparison = 0; - else if (leftValue == null) comparison = 1; - else if (rightValue == null) comparison = -1; - else { - const stringLeftValue = String(leftValue), - stringRightValue = String(rightValue); - comparison = - stringLeftValue === stringRightValue - ? 0 - : stringLeftValue < stringRightValue - ? -1 - : 1; + default: { + // Null/undefined always sort to the tail, INDEPENDENT of `direction`. Return here so the + // result bypasses the direction flip below — otherwise a descending sort (e.g. + // `{ last_message_at: -1 }`) negates "null last" into "null first" and floats value-less + // items (e.g. channels with no last message) to the head of the list. + if (leftValue == null && rightValue == null) { + comparison = 0; // tie on this term; fall through to the next term / tiebreaker + break; } + if (leftValue == null) return 1; // a (null) sorts after b + if (rightValue == null) return -1; // b (null) sorts after a + // Both non-null but not normalizable: deterministic string compare (respects direction). + const stringLeftValue = String(leftValue), + stringRightValue = String(rightValue); + comparison = + stringLeftValue === stringRightValue + ? 0 + : stringLeftValue < stringRightValue + ? -1 + : 1; + } } if (comparison !== 0) return direction === 1 ? comparison : -comparison; } diff --git a/src/pagination/utility.search.ts b/src/pagination/utility.search.ts index 4d1cec4195..6f855013ec 100644 --- a/src/pagination/utility.search.ts +++ b/src/pagination/utility.search.ts @@ -1,3 +1,27 @@ +/** + * Partition-point ("lower bound") binary search over the index range `[0, length)`. + * + * `predicate` must be **monotonic** over the range — `false` for a (possibly empty) prefix, then + * `true` for the remaining suffix. Returns the first index at which `predicate(index)` holds, or + * `length` if it never does. O(log length). + * + * Unlike `binarySearch`, this locates a *boundary* defined by a predicate rather than a specific + * item by identity — e.g. "first message at/after a timestamp" over a chronologically sorted array. + */ +export function lowerBound( + length: number, + predicate: (index: number) => boolean, +): number { + let lo = 0; + let hi = length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (predicate(mid)) hi = mid; + else lo = mid + 1; + } + return lo; +} + export function locateOnPlateauAlternating( items: readonly T[], needle: T, diff --git a/src/thread.ts b/src/thread.ts index 0302c2463c..a1461da346 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -241,6 +241,11 @@ export class Thread extends WithSubscriptions { }); } + // Seed the reply paginator's lastMessageAt floor from the thread's server-provided + // `last_message_at` (analogous to the channel seed in Channel._initializeState), so a thread whose + // newest reply is not among `latest_replies` still reports the correct latest-activity timestamp. + this.messagePaginator.seedLastMessageAt(threadData?.last_message_at); + this.messageComposer = new MessageComposer({ client, composition: threadData?.draft ?? draft, @@ -396,6 +401,9 @@ export class Thread extends WithSubscriptions { thread.messagePaginator.state.getLatestValue().items ?? [], ); pendingReplies.forEach((reply) => this.messagePaginator.ingestItem(reply)); + // Carry the re-queried thread's last-activity floor so lastMessageAt stays fresh even when the + // merged page does not include the newest reply. Monotonic, so an older value is a no-op. + this.messagePaginator.seedLastMessageAt(thread.messagePaginator.lastMessageAt); }; public registerSubscriptions = () => { @@ -413,6 +421,7 @@ export class Thread extends WithSubscriptions { this.addUnsubscribeFunction(this.subscribeRepliesUnread()); this.addUnsubscribeFunction(this.subscribeMessageDeleted()); this.addUnsubscribeFunction(this.subscribeMessageUpdated()); + this.addUnsubscribeFunction(this.subscribeUserMessagesDeleted()); }; private subscribeThreadUpdated = () => @@ -612,23 +621,79 @@ export class Thread extends WithSubscriptions { }).unsubscribe; private subscribeMessageUpdated = () => { - const eventTypes: EventTypes[] = [ - 'message.updated', - 'message.undeleted', + const messageUpdateTypes: EventTypes[] = ['message.updated', 'message.undeleted']; + const reactionTypes: EventTypes[] = [ 'reaction.new', 'reaction.deleted', 'reaction.updated', ]; - const unsubscribeFunctions = eventTypes.map( + const unsubscribeMessageUpdated = messageUpdateTypes.map( (eventType) => this.client.on(eventType, (event) => { - if (event.message) { - this.updateParentMessageOrReplyLocally(event.message); - this.messagePaginator.reflectQuotedMessageUpdate( - formatMessage(event.message), - ); + if (!event.message) return; + // A `message.updated` WS event carries `own_reactions: []`; upserting it verbatim would + // wipe the current user's reactions on a reply edit. The reply paginator is this thread's + // own source of truth (the channel no longer enriches reply events), so preserve the + // existing reply's `own_reactions`. + const message = + event.message.parent_id === this.id + ? { + ...event.message, + own_reactions: + this.messagePaginator.getItem(event.message.id)?.own_reactions ?? + event.message.own_reactions, + } + : event.message; + this.updateParentMessageOrReplyLocally(message); + this.messagePaginator.reflectQuotedMessageUpdate(formatMessage(event.message)); + }).unsubscribe, + ); + + const unsubscribeReactions = reactionTypes.map( + (eventType) => + this.client.on(eventType, (event) => { + if (!event.message || !event.reaction) return; + const { message, reaction } = event; + if (message.parent_id === this.id) { + // Preserve/apply the current user's `own_reactions` off the reply paginator itself, + // independently of the channel (mirrors the channel's main-list reflectReaction). + this.messagePaginator.reflectReaction({ + enforceUnique: eventType === 'reaction.updated', + message, + reaction, + removed: eventType === 'reaction.deleted', + }); + } else if (!message.parent_id && message.id === this.id) { + this.updateParentMessageLocally({ message }); } + this.messagePaginator.reflectQuotedMessageUpdate(formatMessage(message)); + }).unsubscribe, + ); + + const unsubscribeFunctions = [...unsubscribeMessageUpdated, ...unsubscribeReactions]; + + return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe()); + }; + + private subscribeUserMessagesDeleted = () => { + // Apply a user ban / deletion to this thread's own reply list. Previously + // channel.state.deleteUserMessages marked banned-user replies deleted in the (now removed) + // channel.state.threads shadow; the reply paginator is the thread's source of truth now. + const eventTypes: EventTypes[] = ['user.messages.deleted', 'user.deleted']; + + const unsubscribeFunctions = eventTypes.map( + (eventType) => + this.client.on(eventType, (event) => { + if (!event.user) return; + // user.deleted carries the deletion time on the user; user.messages.deleted on the event. + const deletedAtSource = + eventType === 'user.deleted' ? event.user.deleted_at : event.created_at; + this.messagePaginator.applyMessageDeletionForUser({ + userId: event.user.id, + hardDelete: !!event.hard_delete, + deletedAt: deletedAtSource ? new Date(deletedAtSource) : new Date(), + }); }).unsubscribe, ); diff --git a/src/types.ts b/src/types.ts index 489d3bdf5e..5b77f5448f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3509,12 +3509,6 @@ export type ImportTask = { }; export type MessageSetType = 'latest' | 'current' | 'new'; -export type MessageSet = { - isCurrent: boolean; - isLatest: boolean; - messages: LocalMessage[]; - pagination: { hasNext: boolean; hasPrev: boolean }; -}; export type PushProviderUpsertResponse = { push_provider: PushProvider; diff --git a/src/utils.ts b/src/utils.ts index 3176dfe228..d50551d9f9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,12 +7,9 @@ import type { ChannelSortBase, LocalMessage, LocalMessageBase, - Logger, Message, - MessagePaginationOptions, MessageResponse, MessageResponseBase, - MessageSet, OwnUserBase, OwnUserResponse, PromoteChannelParams, @@ -482,39 +479,6 @@ export const toDeletedMessage = ({ } }; -export const deleteUserMessages = ({ - messages, - user, - hardDelete = false, - deletedAt, -}: { - messages: Array; - user: UserResponse; - hardDelete: boolean; - deletedAt: LocalMessage['deleted_at']; -}) => { - for (let i = 0; i < messages.length; i++) { - const message = messages[i]; - if (message.user?.id === user.id) { - messages[i] = - message.type === 'deleted' - ? message - : (toDeletedMessage({ message, hardDelete, deletedAt }) as LocalMessage); - } - - if (messages[i].quoted_message && message.quoted_message?.user?.id === user.id) { - messages[i].quoted_message = - message.quoted_message.type === 'deleted' - ? message.quoted_message - : (toDeletedMessage({ - message: messages[i].quoted_message as LocalMessageBase, - hardDelete, - deletedAt, - }) as LocalMessage); - } - } -}; - export const findIndexInSortedArray = ({ needle, sortedArray, @@ -604,72 +568,6 @@ export const findIndexInSortedArray = ({ return left; }; -export function addToMessageList( - messages: readonly T[], - newMessage: T, - timestampChanged = false, - sortBy: 'pinned_at' | 'created_at' = 'created_at', - addIfDoesNotExist = true, -) { - const addMessageToList = addIfDoesNotExist || timestampChanged; - let newMessages = [...messages]; - - // if created_at has changed, message should be filtered and re-inserted in correct order - // slow op but usually this only happens for a message inserted to state before actual response with correct timestamp - if (timestampChanged) { - newMessages = newMessages.filter( - (message) => !(message.id && newMessage.id === message.id), - ); - } - - // for empty list just concat and return unless it's an update or deletion - if (newMessages.length === 0 && addMessageToList) { - return newMessages.concat(newMessage); - } else if (newMessages.length === 0) { - return newMessages; - } - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const messageTime = newMessage[sortBy]!.getTime(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const messageIsNewest = newMessages.at(-1)![sortBy]!.getTime() < messageTime; - - // if message is newer than last item in the list concat and return unless it's an update or deletion - if (messageIsNewest && addMessageToList) { - return newMessages.concat(newMessage); - } else if (messageIsNewest) { - return newMessages; - } - - // find the closest index to push the new message - const insertionIndex = findIndexInSortedArray({ - needle: newMessage, - sortedArray: newMessages, - sortDirection: 'ascending', - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - selectValueToCompare: (m) => m[sortBy]!.getTime(), - selectKey: (m) => m.id, - }); - - // message already exists and not filtered with timestampChanged, update and return - if ( - !timestampChanged && - newMessage.id && - newMessages[insertionIndex] && - newMessage.id === newMessages[insertionIndex].id - ) { - newMessages[insertionIndex] = newMessage; - return newMessages; - } - - // do not add updated or deleted messages to the list if they already exist or come with a timestamp change - if (addMessageToList) { - newMessages.splice(insertionIndex, 0, newMessage); - } - - return newMessages; -} - function maybeGetReactionGroupsFallback( groups: { [key: string]: ReactionGroupResponse } | null | undefined, counts: { [key: string]: number } | null | undefined, @@ -834,264 +732,6 @@ export const uniqBy = ( }); }; -type MessagePaginationUpdatedParams = { - parentSet: MessageSet; - requestedPageSize: number; - returnedPage: MessageResponse[]; - filteredReturnedPage: MessageResponse[]; - logger?: Logger; - messagePaginationOptions?: MessagePaginationOptions; -}; - -export function binarySearchByDateEqualOrNearestGreater( - array: { - created_at?: string; - }[], - targetDate: Date, -): number { - let left = 0; - let right = array.length - 1; - - while (left <= right) { - const mid = Math.floor((left + right) / 2); - const midCreatedAt = array[mid].created_at; - if (!midCreatedAt) { - left += 1; - continue; - } - const midDate = new Date(midCreatedAt); - - if (midDate.getTime() === targetDate.getTime()) { - return mid; - } else if (midDate.getTime() < targetDate.getTime()) { - left = mid + 1; - } else { - right = mid - 1; - } - } - - return left; -} - -const messagePaginationCreatedAtAround = ({ - parentSet, - requestedPageSize, - returnedPage, - filteredReturnedPage, - messagePaginationOptions, -}: MessagePaginationUpdatedParams) => { - const newPagination = { ...parentSet.pagination }; - if (!messagePaginationOptions?.created_at_around) return newPagination; - let hasPrev; - let hasNext; - let updateHasPrev; - let updateHasNext; - const createdAtAroundDate = new Date(messagePaginationOptions.created_at_around); - const [firstPageMsg, lastPageMsg] = [returnedPage[0], returnedPage.slice(-1)[0]]; - - // expect ASC order (from oldest to newest) - const wholePageHasNewerMessages = - !!firstPageMsg?.created_at && new Date(firstPageMsg.created_at) > createdAtAroundDate; - const wholePageHasOlderMessages = - !!lastPageMsg?.created_at && new Date(lastPageMsg.created_at) < createdAtAroundDate; - - const requestedPageSizeNotMet = - requestedPageSize > parentSet.messages.length && - requestedPageSize > returnedPage.length; - const noMoreMessages = - (requestedPageSize > parentSet.messages.length || - parentSet.messages.length >= returnedPage.length) && - requestedPageSize > returnedPage.length; - - if (wholePageHasNewerMessages) { - hasPrev = false; - updateHasPrev = true; - if (requestedPageSizeNotMet) { - hasNext = false; - updateHasNext = true; - } - } else if (wholePageHasOlderMessages) { - hasNext = false; - updateHasNext = true; - if (requestedPageSizeNotMet) { - hasPrev = false; - updateHasPrev = true; - } - } else if (noMoreMessages) { - hasNext = hasPrev = false; - updateHasPrev = updateHasNext = true; - } else { - const [firstFilteredPageMsg, lastFilteredPageMsg] = [ - filteredReturnedPage[0], - filteredReturnedPage.slice(-1)[0], - ]; - const [firstPageMsgIsFirstInSet, lastPageMsgIsLastInSet] = [ - firstFilteredPageMsg?.id && firstFilteredPageMsg.id === parentSet.messages[0]?.id, - lastFilteredPageMsg?.id && - lastFilteredPageMsg.id === parentSet.messages.slice(-1)[0]?.id, - ]; - updateHasPrev = firstPageMsgIsFirstInSet; - updateHasNext = lastPageMsgIsLastInSet; - const midPointByCount = Math.floor(returnedPage.length / 2); - const midPointByCreationDate = binarySearchByDateEqualOrNearestGreater( - returnedPage, - createdAtAroundDate, - ); - - if (midPointByCreationDate !== -1) { - hasPrev = midPointByCount <= midPointByCreationDate; - hasNext = midPointByCount >= midPointByCreationDate; - } - } - - if (updateHasPrev && typeof hasPrev !== 'undefined') newPagination.hasPrev = hasPrev; - if (updateHasNext && typeof hasNext !== 'undefined') newPagination.hasNext = hasNext; - - return newPagination; -}; - -const messagePaginationIdAround = ({ - parentSet, - requestedPageSize, - returnedPage, - filteredReturnedPage, - messagePaginationOptions, -}: MessagePaginationUpdatedParams) => { - const newPagination = { ...parentSet.pagination }; - const { id_around } = messagePaginationOptions || {}; - if (!id_around) return newPagination; - let hasPrev; - let hasNext; - - const [firstFilteredPageMsg, lastFilteredPageMsg] = [ - filteredReturnedPage[0], - filteredReturnedPage.slice(-1)[0], - ]; - const [firstPageMsgIsFirstInSet, lastPageMsgIsLastInSet] = [ - firstFilteredPageMsg?.id === parentSet.messages[0]?.id, - lastFilteredPageMsg?.id === parentSet.messages.slice(-1)[0]?.id, - ]; - let updateHasPrev = firstPageMsgIsFirstInSet; - let updateHasNext = lastPageMsgIsLastInSet; - - const midPoint = Math.floor(returnedPage.length / 2); - const noMoreMessages = - (requestedPageSize > parentSet.messages.length || - parentSet.messages.length >= returnedPage.length) && - requestedPageSize > returnedPage.length; - - if (noMoreMessages) { - hasNext = hasPrev = false; - updateHasPrev = updateHasNext = true; - } else if (!returnedPage[midPoint]) { - return newPagination; - } else if (returnedPage[midPoint].id === id_around) { - hasPrev = hasNext = true; - } else { - let targetMsg; - const halves = [returnedPage.slice(0, midPoint), returnedPage.slice(midPoint)]; - hasPrev = hasNext = true; - for (let i = 0; i < halves.length; i++) { - targetMsg = halves[i].find((message) => message.id === id_around); - if (targetMsg && i === 0) { - hasPrev = false; - } - if (targetMsg && i === 1) { - hasNext = false; - } - } - } - - if (updateHasPrev && typeof hasPrev !== 'undefined') newPagination.hasPrev = hasPrev; - if (updateHasNext && typeof hasNext !== 'undefined') newPagination.hasNext = hasNext; - - return newPagination; -}; - -const messagePaginationLinear = ({ - parentSet, - requestedPageSize, - returnedPage, - filteredReturnedPage, - messagePaginationOptions, -}: MessagePaginationUpdatedParams) => { - const newPagination = { ...parentSet.pagination }; - - let hasPrev; - let hasNext; - - const [firstFilteredPageMsg, lastFilteredPageMsg] = [ - filteredReturnedPage[0], - filteredReturnedPage.slice(-1)[0], - ]; - const [firstPageMsgIsFirstInSet, lastPageMsgIsLastInSet] = [ - firstFilteredPageMsg?.id && firstFilteredPageMsg.id === parentSet.messages[0]?.id, - lastFilteredPageMsg?.id && - lastFilteredPageMsg.id === parentSet.messages.slice(-1)[0]?.id, - ]; - - const queriedNextMessages = - messagePaginationOptions && - (messagePaginationOptions.created_at_after_or_equal || - messagePaginationOptions.created_at_after || - messagePaginationOptions.id_gt || - messagePaginationOptions.id_gte); - - const queriedPrevMessages = - typeof messagePaginationOptions === 'undefined' - ? true - : messagePaginationOptions.created_at_before_or_equal || - messagePaginationOptions.created_at_before || - messagePaginationOptions.id_lt || - messagePaginationOptions.id_lte || - messagePaginationOptions.offset; - - const containsUnrecognizedOptionsOnly = - !queriedNextMessages && - !queriedPrevMessages && - !messagePaginationOptions?.id_around && - !messagePaginationOptions?.created_at_around; - - const hasMore = returnedPage.length >= requestedPageSize; - - if (typeof queriedPrevMessages !== 'undefined' || containsUnrecognizedOptionsOnly) { - hasPrev = hasMore; - } - if (typeof queriedNextMessages !== 'undefined') { - hasNext = hasMore; - } - const returnedPageIsEmpty = returnedPage.length === 0; - - if ((firstPageMsgIsFirstInSet || returnedPageIsEmpty) && typeof hasPrev !== 'undefined') - newPagination.hasPrev = hasPrev; - if ((lastPageMsgIsLastInSet || returnedPageIsEmpty) && typeof hasNext !== 'undefined') - newPagination.hasNext = hasNext; - - return newPagination; -}; - -export const messageSetPagination = (params: MessagePaginationUpdatedParams) => { - if ( - params.parentSet.messages.length + - (params.returnedPage.length - params.filteredReturnedPage.length) < - params.returnedPage.length - ) { - params.logger?.( - 'error', - 'Corrupted message set state: parent set size < returned page size', - ); - return params.parentSet.pagination; - } - - if (params.messagePaginationOptions?.created_at_around) { - return messagePaginationCreatedAtAround(params); - } else if (params.messagePaginationOptions?.id_around) { - return messagePaginationIdAround(params); - } else { - return messagePaginationLinear(params); - } -}; - /** * 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. diff --git a/test/typescript/response-generators/channel.js b/test/typescript/response-generators/channel.js index 9296bf4e48..588636fcde 100644 --- a/test/typescript/response-generators/channel.js +++ b/test/typescript/response-generators/channel.js @@ -141,7 +141,7 @@ async function lastMessage() { await channel.sendMessage({ text: 'Hello World' }); await channel.sendMessage({ text: 'Hello World...again' }); - const message = await channel.lastMessage(); + const message = await channel.messagePaginator.headmostItem; delete message.__html; // __html is deprecated and removed from the types return message; } diff --git a/test/typescript/response-generators/message.js b/test/typescript/response-generators/message.js index 1be1cbd7bf..8049ccecb8 100644 --- a/test/typescript/response-generators/message.js +++ b/test/typescript/response-generators/message.js @@ -73,9 +73,8 @@ async function getReplies() { parent_id: response.message.id, }); await channel.query(); - const parent = channel.state.messages[channel.state.messages.length - 1]; - - return await channel.getReplies(parent.id); + // The second sendMessage above is a reply to response.message, so that is the thread parent. + return await channel.getReplies(response.message.id); } async function sendAction() { diff --git a/test/unit/CooldownTimer.test.ts b/test/unit/CooldownTimer.test.ts index b976e06f59..4909cbe3c4 100644 --- a/test/unit/CooldownTimer.test.ts +++ b/test/unit/CooldownTimer.test.ts @@ -2,7 +2,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { getClientWithUser } from './test-utils/getClient'; import { generateMsg } from './test-utils/generateMessage'; -import type { ChannelResponse, Event } from '../../src'; +import { formatMessage } from '../../src'; +import type { Channel, ChannelResponse, Event } from '../../src'; + +// CooldownTimer.refresh() derives the current user's latest message from the message paginator's +// latest (head) window, so tests seed the paginator (formatted) rather than legacy channel state. +const seedLatestWindow = ( + channel: Channel, + ...messages: ReturnType[] +) => + channel.messagePaginator.ingestPage({ + page: messages.map((m) => formatMessage(m)), + isHead: true, + isTail: true, + setActive: true, + }); describe('CooldownTimer', () => { afterEach(() => { @@ -23,7 +37,8 @@ describe('CooldownTimer', () => { }; const lastOwnMessageAt = new Date('2026-01-01T00:00:00.000Z'); - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: lastOwnMessageAt.toISOString(), updated_at: lastOwnMessageAt.toISOString(), @@ -59,7 +74,8 @@ describe('CooldownTimer', () => { own_capabilities: [], }; - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: now.toISOString(), updated_at: now.toISOString(), @@ -73,7 +89,8 @@ describe('CooldownTimer', () => { channel.data.cooldown = 0; - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: now.toISOString(), updated_at: now.toISOString(), @@ -87,7 +104,8 @@ describe('CooldownTimer', () => { channel.data.cooldown = 10; - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: now.toISOString(), updated_at: now.toISOString(), @@ -119,7 +137,8 @@ describe('CooldownTimer', () => { }; const lastOwnMessageAt = new Date('2026-01-01T00:00:00.000Z'); // 10s ago - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: lastOwnMessageAt.toISOString(), updated_at: lastOwnMessageAt.toISOString(), @@ -145,7 +164,8 @@ describe('CooldownTimer', () => { own_capabilities: ['skip-slow-mode'], }; - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: now.toISOString(), updated_at: now.toISOString(), @@ -168,7 +188,8 @@ describe('CooldownTimer', () => { // timeSince = 2s const lastOwnMessageAt = new Date('2026-01-01T00:00:08.000Z'); - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: lastOwnMessageAt.toISOString(), updated_at: lastOwnMessageAt.toISOString(), @@ -202,7 +223,8 @@ describe('CooldownTimer', () => { // timeSince = 2s const lastOwnMessageAt = new Date('2026-01-01T00:00:08.000Z'); - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: lastOwnMessageAt.toISOString(), updated_at: lastOwnMessageAt.toISOString(), @@ -236,7 +258,8 @@ describe('CooldownTimer', () => { // timeSince = 2s const lastOwnMessageAt = new Date('2026-01-01T00:00:08.000Z'); - channel.state.addMessageSorted( + seedLatestWindow( + channel, generateMsg({ created_at: lastOwnMessageAt.toISOString(), updated_at: lastOwnMessageAt.toISOString(), @@ -275,6 +298,7 @@ describe('CooldownTimer', () => { type: 'message.new', user: { id: client.userID as string }, message: generateMsg({ + cid: channel.cid, // must match the paginator filter so message.new ingests into an interval created_at: now.toISOString(), updated_at: now.toISOString(), user: { id: client.userID as string }, diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 12e7400a7b..d8bfb36d5a 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -10,12 +10,24 @@ import { mockChannelQueryResponse } from './test-utils/mockChannelQueryResponse' import { ChannelState, StreamChat } from '../../src'; import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from '../../src/constants'; import { MockOfflineDB } from './offline-support/MockOfflineDB'; -import { generateUUIDv4 as uuidv4 } from '../../src/utils'; +import { formatMessage, generateUUIDv4 as uuidv4 } from '../../src/utils'; import { describe, beforeEach, afterEach, it, expect, vi } from 'vitest'; +// Seed the channel's messagePaginator "latest" (head) window from raw generated messages. +// The unread/last-message readers now source from `messagePaginator.headItems`/`headmostItem`, +// so tests populate the paginator (formatted) rather than the legacy `state.addMessagesSorted`. +const seedLatestWindow = (channel, messages) => + channel.messagePaginator.ingestPage({ + page: messages.map((m) => formatMessage(m)), + isHead: true, + isTail: true, + setActive: true, + }); + describe('Channel count unread', function () { let lastRead; + let ignoredMessages; let user; let channel; let client; @@ -34,7 +46,7 @@ describe('Channel count unread', function () { channel.lastRead = () => lastRead; channel.data.own_capabilities = ['read-events']; - const ignoredMessages = [ + ignoredMessages = [ generateMsg({ date: '2018-01-01T00:00:00', mentioned_users: [user] }), generateMsg({ date: '2019-01-01T00:00:00' }), generateMsg({ date: '2020-01-01T00:00:00' }), @@ -54,7 +66,6 @@ describe('Channel count unread', function () { mentioned_users: [user], }), ]; - channel.state.addMessagesSorted(ignoredMessages); }); it('_countMessageAsUnread should return false shadowed or silent messages', function () { @@ -115,35 +126,40 @@ describe('Channel count unread', function () { it('countUnread should return correct count', function () { expect(channel.countUnread(lastRead)).to.be.equal(0); - channel.state.addMessagesSorted([ + // ignoredMessages (shadowed/silent/muted/at-or-before lastRead) must not be counted + seedLatestWindow(channel, [ + ...ignoredMessages, generateMsg({ date: '2021-01-01T00:00:00' }), generateMsg({ date: '2022-01-01T00:00:00' }), ]); expect(channel.countUnread(lastRead)).to.be.equal(2); }); - it('countUnread should return correct count when multiple message sets are loaded into state', () => { + it('countUnread should read the latest window, not the active one', () => { expect(channel.countUnread(lastRead)).to.be.equal(0); - channel.state.addMessagesSorted([ - generateMsg({ date: '2026-01-01T00:00:00' }), - generateMsg({ date: '2026-02-01T00:00:00' }), - ]); - channel.state.addMessagesSorted( - [generateMsg({ date: '2006-01-01T00:00:00' })], - false, - true, - true, - 'new', - ); - channel.state.messageSets[0].isCurrent = false; - channel.state.messageSets[1].isCurrent = true; + // latest (head) window + channel.messagePaginator.ingestPage({ + page: [ + ...ignoredMessages, + generateMsg({ date: '2026-01-01T00:00:00' }), + generateMsg({ date: '2026-02-01T00:00:00' }), + ].map((m) => formatMessage(m)), + isHead: true, + setActive: false, + }); + // a separate, older window becomes the active (current) one + channel.messagePaginator.ingestPage({ + page: [formatMessage(generateMsg({ date: '2006-01-01T00:00:00' }))], + setActive: true, + }); expect(channel.countUnread(lastRead)).to.be.equal(2); }); it('countUnreadMentions should return correct count', function () { expect(channel.countUnreadMentions()).to.be.equal(0); - channel.state.addMessageSorted( + seedLatestWindow(channel, [ + ...ignoredMessages, generateMsg({ date: '2021-01-01T00:00:00', mentioned_users: [user, { id: 'random' }], @@ -152,28 +168,30 @@ describe('Channel count unread', function () { date: '2022-01-01T00:00:00', mentioned_users: [{ id: 'random' }], }), - ); + ]); expect(channel.countUnreadMentions()).to.be.equal(1); }); - it('countUnreadMentions should return correct count when multiple message sets are loaded into state', () => { + it('countUnreadMentions should read the latest window, not the active one', () => { expect(channel.countUnreadMentions()).to.be.equal(0); - channel.state.addMessagesSorted([ - generateMsg({ - date: '2021-01-01T00:00:00', - mentioned_users: [user, { id: 'random' }], - }), - generateMsg({ date: '2022-01-01T00:00:00' }), - ]); - channel.state.addMessagesSorted( - [generateMsg({ date: '2010-01-01T00:00:00' })], - false, - true, - true, - 'new', - ); - channel.state.messageSets[0].isCurrent = false; - channel.state.messageSets[1].isCurrent = true; + // latest (head) window contains the mention + channel.messagePaginator.ingestPage({ + page: [ + ...ignoredMessages, + generateMsg({ + date: '2021-01-01T00:00:00', + mentioned_users: [user, { id: 'random' }], + }), + generateMsg({ date: '2022-01-01T00:00:00' }), + ].map((m) => formatMessage(m)), + isHead: true, + setActive: false, + }); + // a separate, older window becomes the active (current) one + channel.messagePaginator.ingestPage({ + page: [formatMessage(generateMsg({ date: '2010-01-01T00:00:00' }))], + setActive: true, + }); expect(channel.countUnreadMentions()).to.be.equal(1); }); @@ -199,7 +217,6 @@ describe('Channel count unread', function () { user: user, unread_messages: 0, }; - channel.state.addMessagesSorted(messages); expect(channel.lastRead()).to.eq(last_read); }); @@ -334,7 +351,7 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function const { client, channel } = setupChannel({ isLocalUnreadCountEnabled: true }); const post = vi.spyOn(client, 'post').mockResolvedValue({}); const lastMsg = generateMsg({ user: otherUser }); - channel.state.addMessagesSorted([lastMsg]); + seedLatestWindow(channel, [lastMsg]); channel.state.unreadCount = 5; channel.state.read[user.id] = { last_read: new Date('2020-01-01T00:00:00'), @@ -386,7 +403,7 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function const { client, channel } = setupChannel({ isLocalUnreadCountEnabled: true }); const post = vi.spyOn(client, 'post').mockResolvedValue({}); const lastMsg = generateMsg({ user: otherUser }); - channel.state.addMessagesSorted([lastMsg]); + seedLatestWindow(channel, [lastMsg]); channel.state.unreadCount = 3; delete channel.state.read[user.id]; @@ -416,595 +433,765 @@ describe('Channel _handleChannelEvent', function () { channel.initialized = true; }); - it('member.updated/member.added are being handled properly (ChannelState.membership & ChannelState.members)', () => { - expect(channel.state.members).to.be.empty; - expect(channel.state.membership).to.be.empty; - - const currentMember = generateMember({ - user, - pinned_at: new Date().toISOString(), - archived_at: new Date().toISOString(), + const makePinned = (id, dateISO, overrides = {}) => + generateMsg({ + id, + cid: channel.cid, + pinned: true, + pinned_at: dateISO, + date: dateISO, + ...overrides, }); - const otherMember = generateMember({ - user: { id: 'user-other' }, + const seedPinned = (messages) => + channel.pinnedMessagesPaginator.ingestPage({ + page: messages.map(formatMessage), + isHead: true, + isTail: true, + setActive: true, }); - channel._handleChannelEvent({ - type: 'member.added', - user, - member: currentMember, - }); + const pinnedIds = () => channel.pinnedMessagesPaginator.items?.map((m) => m.id) ?? []; - expect(channel.state.members).to.have.property(user.id); - expect(channel.state.members[user.id]).to.deep.equal(currentMember); - expect(channel.state.membership).to.deep.equal(currentMember); + describe('member.added / member.updated / member.removed', () => { + it('member.updated/member.added are being handled properly (ChannelState.membership & ChannelState.members)', () => { + expect(channel.state.members).to.be.empty; + expect(channel.state.membership).to.be.empty; - channel._handleChannelEvent({ - type: 'member.added', - user, - member: otherMember, - }); + const currentMember = generateMember({ + user, + pinned_at: new Date().toISOString(), + archived_at: new Date().toISOString(), + }); - expect(channel.state.members).to.have.keys([user.id, otherMember.user.id]); - expect(channel.state.members[otherMember.user.id]).to.deep.equal(otherMember); - expect(channel.state.members[user.id]).to.deep.equal(currentMember); - expect(channel.state.membership).to.deep.equal(currentMember); + const otherMember = generateMember({ + user: { id: 'user-other' }, + }); - const currentMemberUpdated = generateMember({ - user, - pinned_at: null, - archived_at: null, - }); + channel._handleChannelEvent({ + type: 'member.added', + user, + member: currentMember, + }); - channel._handleChannelEvent({ - type: 'member.updated', - user, - member: currentMemberUpdated, - }); + expect(channel.state.members).to.have.property(user.id); + expect(channel.state.members[user.id]).to.deep.equal(currentMember); + expect(channel.state.membership).to.deep.equal(currentMember); - expect(channel.state.membership).to.not.have.keys(['pinned_at', 'archived_at']); - expect(channel.state.membership).to.equal(channel.state.members[user.id]); - }); + channel._handleChannelEvent({ + type: 'member.added', + user, + member: otherMember, + }); - it('does not change channel.data.member_count on member.added or member.removed', () => { - channel.data = { member_count: 5 }; + expect(channel.state.members).to.have.keys([user.id, otherMember.user.id]); + expect(channel.state.members[otherMember.user.id]).to.deep.equal(otherMember); + expect(channel.state.members[user.id]).to.deep.equal(currentMember); + expect(channel.state.membership).to.deep.equal(currentMember); - const newMember = generateMember({ user: { id: 'user-new' } }); + const currentMemberUpdated = generateMember({ + user, + pinned_at: null, + archived_at: null, + }); - channel._handleChannelEvent({ - type: 'member.added', - user: newMember.user, - member: newMember, + channel._handleChannelEvent({ + type: 'member.updated', + user, + member: currentMemberUpdated, + }); + + expect(channel.state.membership).to.not.have.keys(['pinned_at', 'archived_at']); + expect(channel.state.membership).to.equal(channel.state.members[user.id]); }); - expect(channel.data.member_count).to.equal(5); + it('does not change channel.data.member_count on member.added or member.removed', () => { + channel.data = { member_count: 5 }; - channel._handleChannelEvent({ - type: 'member.removed', - user: newMember.user, - member: newMember, - }); + const newMember = generateMember({ user: { id: 'user-new' } }); - expect(channel.data.member_count).to.equal(5); - }); + channel._handleChannelEvent({ + type: 'member.added', + user: newMember.user, + member: newMember, + }); - it('message.new does not reset the unreadCount for current user messages', function () { - channel.state.unreadCount = 100; - channel._handleChannelEvent({ - type: 'message.new', - user, - message: generateMsg(), - }); + expect(channel.data.member_count).to.equal(5); - expect(channel.state.unreadCount).to.be.equal(100); + channel._handleChannelEvent({ + type: 'member.removed', + user: newMember.user, + member: newMember, + }); + + expect(channel.data.member_count).to.equal(5); + }); }); - it('message.new does not reset the unreadCount for own thread replies', function () { - channel.state.unreadCount = 100; - channel._handleChannelEvent({ - type: 'message.new', - user, - message: generateMsg({ - parent_id: 'parentId', - type: 'reply', + describe('message.new', () => { + it('message.new does not reset the unreadCount for current user messages', function () { + channel.state.unreadCount = 100; + channel._handleChannelEvent({ + type: 'message.new', user, - }), + message: generateMsg(), + }); + + expect(channel.state.unreadCount).to.be.equal(100); }); - expect(channel.state.unreadCount).to.be.equal(100); - }); + it('message.new does not reset the unreadCount for own thread replies', function () { + channel.state.unreadCount = 100; + channel._handleChannelEvent({ + type: 'message.new', + user, + message: generateMsg({ + parent_id: 'parentId', + type: 'reply', + user, + }), + }); - it('message.new does not reset the unreadCount for others thread replies', function () { - channel.state.unreadCount = 100; - channel._handleChannelEvent({ - type: 'message.new', - user: { id: 'id' }, - message: generateMsg({ - parent_id: 'parentId', - type: 'reply', + expect(channel.state.unreadCount).to.be.equal(100); + }); + + it('message.new does not reset the unreadCount for others thread replies', function () { + channel.state.unreadCount = 100; + channel._handleChannelEvent({ + type: 'message.new', user: { id: 'id' }, - }), + message: generateMsg({ + parent_id: 'parentId', + type: 'reply', + user: { id: 'id' }, + }), + }); + + expect(channel.state.unreadCount).to.be.equal(100); }); - expect(channel.state.unreadCount).to.be.equal(100); - }); + it('message.new ingests message into messagePaginator even for own messages', function () { + const message = generateMsg({ id: 'own-message-id', user }); - it('message.new ingests message into messagePaginator even for own messages', function () { - const message = generateMsg({ id: 'own-message-id', user }); + channel._handleChannelEvent({ + type: 'message.new', + user, + message, + }); - channel._handleChannelEvent({ - type: 'message.new', - user, - message, + expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); }); - expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); - }); + it('message.new ignores thread replies in messagePaginator', function () { + const message = generateMsg({ + id: 'thread-reply-message-id', + parent_id: 'parent-message-id', + user: { id: 'another-user' }, + }); - it('message.new ignores thread replies in messagePaginator', function () { - const message = generateMsg({ - id: 'thread-reply-message-id', - parent_id: 'parent-message-id', - user: { id: 'another-user' }, - }); + channel._handleChannelEvent({ + type: 'message.new', + user: message.user, + message, + }); - channel._handleChannelEvent({ - type: 'message.new', - user: message.user, - message, + expect(channel.messagePaginator.getItem(message.id)).to.be.undefined; }); - expect(channel.messagePaginator.getItem(message.id)).to.be.undefined; - }); - - it('message.new increment unreadCount properly', function () { - channel.state.unreadCount = 20; - channel._handleChannelEvent({ - type: 'message.new', - user: { id: 'id' }, - message: generateMsg({ user: { id: 'id' } }), + it('message.new increment unreadCount properly', function () { + channel.state.unreadCount = 20; + channel._handleChannelEvent({ + type: 'message.new', + user: { id: 'id' }, + message: generateMsg({ user: { id: 'id' } }), + }); + expect(channel.state.unreadCount).to.be.equal(21); + channel._handleChannelEvent({ + type: 'message.new', + user: { id: 'id2' }, + message: generateMsg({ user: { id: 'id2' } }), + }); + expect(channel.state.unreadCount).to.be.equal(22); }); - expect(channel.state.unreadCount).to.be.equal(21); - channel._handleChannelEvent({ - type: 'message.new', - user: { id: 'id2' }, - message: generateMsg({ user: { id: 'id2' } }), + + it('message.new skip increment for silent/shadowed/muted messages', function () { + channel.state.unreadCount = 30; + channel._handleChannelEvent({ + type: 'message.new', + user: { id: 'id' }, + message: generateMsg({ silent: true }), + }); + expect(channel.state.unreadCount).to.be.equal(30); + channel._handleChannelEvent({ + type: 'message.new', + user: { id: 'id2' }, + message: generateMsg({ shadowed: true }), + }); + expect(channel.state.unreadCount).to.be.equal(30); + channel._handleChannelEvent({ + type: 'message.new', + user: { id: 'mute1' }, + message: generateMsg({ user: { id: 'mute1' } }), + }); + expect(channel.state.unreadCount).to.be.equal(30); }); - expect(channel.state.unreadCount).to.be.equal(22); - }); - it('message.new skip increment for silent/shadowed/muted messages', function () { - channel.state.unreadCount = 30; - channel._handleChannelEvent({ - type: 'message.new', - user: { id: 'id' }, - message: generateMsg({ silent: true }), + it('should include unread_messages for message events from another user', () => { + channel.state.read['id'] = { + unread_messages: 2, + }; + + const message = generateMsg(); + + const events = [ + 'message.read', + 'message.deleted', + 'message.new', + 'message.updated', + 'member.added', + 'member.updated', + 'member.removed', + ]; + + for (const event of events) { + channel.state.read['id'].unread_messages = 2; + channel._handleChannelEvent({ + type: event, + user: { id: 'id' }, + message, + }); + expect( + channel.state.read['id'].unread_messages, + `${event} should not be undefined`, + ).not.to.be.undefined; + } }); - expect(channel.state.unreadCount).to.be.equal(30); - channel._handleChannelEvent({ - type: 'message.new', - user: { id: 'id2' }, - message: generateMsg({ shadowed: true }), + + it('should include unread_messages for message events from the current user', () => { + channel.state.read[client.user.id] = { + unread_messages: 2, + }; + + const message = generateMsg({ user: { id: client.userID } }); + + const events = [ + 'message.read', + 'message.deleted', + 'message.new', + 'message.updated', + 'member.added', + 'member.updated', + 'member.removed', + ]; + + for (const event of events) { + channel.state.read['id'] = { + unread_messages: 2, + }; + + channel._handleChannelEvent({ + type: event, + user: { id: client.user.id }, + message, + }); + expect( + channel.state.read[client.user.id].unread_messages, + `${event} should not be undefined`, + ).not.to.be.undefined; + } }); - expect(channel.state.unreadCount).to.be.equal(30); - channel._handleChannelEvent({ - type: 'message.new', - user: { id: 'mute1' }, - message: generateMsg({ user: { id: 'mute1' } }), + + // Also covers the message.updated unpin path: a pinned message unpinned via + // message.updated is removed from the pinnedMessagesPaginator. + it('feeds the pinnedMessagesPaginator on pin and unpin events', () => { + const existing = generateMsg({ + id: 'pinned-existing', + cid: channel.cid, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.001Z').toISOString(), + }); + channel.pinnedMessagesPaginator.ingestPage({ + page: [formatMessage(existing)], + isHead: true, + isTail: true, + setActive: true, + }); + expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.eql([ + 'pinned-existing', + ]); + + // A newly pinned message arrives → auto-added. + const newlyPinned = generateMsg({ + id: 'pinned-new', + cid: channel.cid, + pinned: true, + pinned_at: new Date('2020-01-01T00:00:00.002Z').toISOString(), + }); + channel._handleChannelEvent({ type: 'message.new', message: newlyPinned, user }); + expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.include( + 'pinned-new', + ); + + // The existing message is unpinned via message.updated → auto-removed. + channel._handleChannelEvent({ + type: 'message.updated', + message: { ...existing, pinned: false, pinned_at: null }, + }); + expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.not.include( + 'pinned-existing', + ); }); - expect(channel.state.unreadCount).to.be.equal(30); }); - it('message.updated syncs reply metadata into messagePaginator', function () { - const parentMessage = generateMsg({ - id: 'parent-message-id', - reply_count: 1, - thread_participants: [{ id: 'user-1' }], - }); + describe('message.updated', () => { + it('message.updated syncs reply metadata into messagePaginator', function () { + const parentMessage = generateMsg({ + id: 'parent-message-id', + reply_count: 1, + thread_participants: [{ id: 'user-1' }], + }); - channel.messagePaginator.ingestItem(parentMessage); + channel.messagePaginator.ingestItem(parentMessage); - channel._handleChannelEvent({ - type: 'message.updated', - message: { - ...parentMessage, - reply_count: 29, - thread_participants: [{ id: 'user-1' }, { id: 'user-2' }], - }, + channel._handleChannelEvent({ + type: 'message.updated', + message: { + ...parentMessage, + reply_count: 29, + thread_participants: [{ id: 'user-1' }, { id: 'user-2' }], + }, + }); + + const parentFromPaginator = channel.messagePaginator.getItem(parentMessage.id); + expect(parentFromPaginator?.reply_count).to.be.equal(29); + expect(parentFromPaginator?.thread_participants).to.have.length(2); }); - const parentFromPaginator = channel.messagePaginator.getItem(parentMessage.id); - expect(parentFromPaginator?.reply_count).to.be.equal(29); - expect(parentFromPaginator?.thread_participants).to.have.length(2); - }); + it('message.updated ignores thread replies in messagePaginator', function () { + const parentMessage = generateMsg({ id: 'thread-parent-id' }); + const threadReply = generateMsg({ + id: 'thread-reply-id', + parent_id: parentMessage.id, + text: 'before update', + }); - it('message.updated ignores thread replies in messagePaginator', function () { - const parentMessage = generateMsg({ id: 'thread-parent-id' }); - const threadReply = generateMsg({ - id: 'thread-reply-id', - parent_id: parentMessage.id, - text: 'before update', - }); + channel.messagePaginator.ingestItem(parentMessage); + channel._handleChannelEvent({ + type: 'message.updated', + message: { ...threadReply, text: 'after update' }, + }); - channel.messagePaginator.ingestItem(parentMessage); - channel._handleChannelEvent({ - type: 'message.updated', - message: { ...threadReply, text: 'after update' }, + expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; }); - expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; - }); + it('message.updated syncs quoted_message references in messagePaginator', function () { + const quotedMessage = generateMsg({ + id: 'quoted-message-id', + text: 'before update', + }); + const quoteCarrier = generateMsg({ + id: 'quote-carrier-id', + quoted_message_id: quotedMessage.id, + quoted_message: quotedMessage, + }); - it('message.updated syncs quoted_message references in messagePaginator', function () { - const quotedMessage = generateMsg({ - id: 'quoted-message-id', - text: 'before update', - }); - const quoteCarrier = generateMsg({ - id: 'quote-carrier-id', - quoted_message_id: quotedMessage.id, - quoted_message: quotedMessage, - }); + channel.messagePaginator.setItems({ + valueOrFactory: [quotedMessage, quoteCarrier], + isFirstPage: true, + isLastPage: true, + }); - channel.messagePaginator.setItems({ - valueOrFactory: [quotedMessage, quoteCarrier], - isFirstPage: true, - isLastPage: true, - }); + channel._handleChannelEvent({ + type: 'message.updated', + message: { + ...quotedMessage, + text: 'after update', + }, + }); - channel._handleChannelEvent({ - type: 'message.updated', - message: { - ...quotedMessage, - text: 'after update', - }, + expect( + channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.text, + ).to.equal('after update'); }); - expect( - channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.text, - ).to.equal('after update'); - }); + // Also covers message.deleted (both event payloads are enriched with own_reactions). + it('should extend "message.updated" and "message.deleted" event payloads with "own_reactions"', () => { + const own_reactions = [ + { + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + type: 'wow', + }, + ]; + // Thread-reply own_reactions preservation is owned by the Thread object (covered in + // threads.test.ts); at the channel level only the paginator-backed message list is enriched. + const message = generateMsg({ own_reactions }); + seedLatestWindow(channel, [message]); - it('message.undeleted ignores thread replies in messagePaginator', function () { - const parentMessage = generateMsg({ id: 'thread-parent-id-2' }); - const threadReply = generateMsg({ - id: 'thread-reply-id-2', - parent_id: parentMessage.id, - text: 'undeleted reply', - }); + ['message.updated', 'message.deleted'].forEach((eventType) => { + let receivedEvent; + channel.on(eventType, (e) => (receivedEvent = e)); - channel.messagePaginator.ingestItem(parentMessage); - channel._handleChannelEvent({ - type: 'message.undeleted', - message: threadReply, + const event = { + type: eventType, + // own_reactions is always [] in WS events + message: { ...message, own_reactions: [] }, + }; + channel._handleChannelEvent(event); + channel._callChannelListeners(event); + + const stored = channel.messagePaginator.getItem(message.id); + expect(stored.own_reactions.length).to.equal(own_reactions.length); + expect(receivedEvent.message.own_reactions.length).to.equal(own_reactions.length); + }); }); - expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; + // Also covers message.deleted (quoted_message references update on both events). + it('should update quoted_message references on "message.updated" and "message.deleted" event', () => { + // Thread-reply quoted-message updates are owned by the Thread object (Thread.messagePaginator + // .reflectQuotedMessageUpdate); this exercises the channel's paginator-backed message list. + const originalText = 'XX'; + const updatedText = 'YY'; + const quoted_message = generateMsg({ + date: new Date(2).toISOString(), + id: 'quoted-message', + text: originalText, + }); + const quotingMessage = generateMsg({ + date: new Date(3).toISOString(), + id: 'quoting-message', + quoted_message, + quoted_message_id: quoted_message.id, + }); + const updatedQuotedMessage = { ...quoted_message, text: updatedText }; + ['message.updated', 'message.deleted'].forEach((eventType) => { + seedLatestWindow(channel, [quoted_message, quotingMessage]); + const event = { type: eventType, message: updatedQuotedMessage }; + channel._handleChannelEvent(event); + const stored = channel.messagePaginator.getItem(quotingMessage.id); + expect(stored.quoted_message.text).to.equal(updatedQuotedMessage.text); + channel.messagePaginator.clearStateAndCache(); + }); + }); }); - it('message.undeleted syncs quoted_message references in messagePaginator', function () { - const quotedMessage = generateMsg({ - id: 'quoted-message-id-undeleted', - type: 'deleted', - text: 'before undelete', - }); - const quoteCarrier = generateMsg({ - id: 'quote-carrier-id-undeleted', - quoted_message_id: quotedMessage.id, - quoted_message: quotedMessage, - }); + describe('message.undeleted', () => { + it('message.undeleted ignores thread replies in messagePaginator', function () { + const parentMessage = generateMsg({ id: 'thread-parent-id-2' }); + const threadReply = generateMsg({ + id: 'thread-reply-id-2', + parent_id: parentMessage.id, + text: 'undeleted reply', + }); - channel.messagePaginator.setItems({ - valueOrFactory: [quotedMessage, quoteCarrier], - isFirstPage: true, - isLastPage: true, - }); + channel.messagePaginator.ingestItem(parentMessage); + channel._handleChannelEvent({ + type: 'message.undeleted', + message: threadReply, + }); - channel._handleChannelEvent({ - type: 'message.undeleted', - message: { - ...quotedMessage, - type: 'regular', - text: 'after undelete', - }, + expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; }); - expect( - channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.text, - ).to.equal('after undelete'); - expect( - channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.type, - ).to.equal('regular'); - }); - - it('does not override the delivery information in the read status', () => {}); + it('message.undeleted syncs quoted_message references in messagePaginator', function () { + const quotedMessage = generateMsg({ + id: 'quoted-message-id-undeleted', + type: 'deleted', + text: 'before undelete', + }); + const quoteCarrier = generateMsg({ + id: 'quote-carrier-id-undeleted', + quoted_message_id: quotedMessage.id, + quoted_message: quotedMessage, + }); - it('message.truncate removes all messages if "truncated_at" is "now"', function () { - const messages = [ - { created_at: '2021-01-01T00:01:00' }, - { created_at: '2021-01-01T00:02:00' }, - { created_at: '2021-01-01T00:03:00' }, - ].map(generateMsg); + channel.messagePaginator.setItems({ + valueOrFactory: [quotedMessage, quoteCarrier], + isFirstPage: true, + isLastPage: true, + }); - channel.state.addMessagesSorted(messages); - expect(channel.state.messages.length).to.be.equal(3); + channel._handleChannelEvent({ + type: 'message.undeleted', + message: { + ...quotedMessage, + type: 'regular', + text: 'after undelete', + }, + }); - channel._handleChannelEvent({ - type: 'channel.truncated', - user: { id: 'id' }, - channel: { - truncated_at: new Date().toISOString(), - }, + expect( + channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.text, + ).to.equal('after undelete'); + expect( + channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.type, + ).to.equal('regular'); }); - - expect(channel.state.messages.length).to.be.equal(0); }); - it('message.truncate clears messagePaginator unread snapshot', function () { - const cachedMessage = generateMsg({ id: 'truncate-cached-message-id' }); - channel.messagePaginator.setItems({ - valueOrFactory: [cachedMessage], - isFirstPage: true, - isLastPage: true, - }); - channel.messagePaginator.setUnreadSnapshot({ - firstUnreadMessageId: 'm-1', - lastReadAt: new Date('2021-01-01T00:00:00.000Z'), - lastReadMessageId: 'm-0', - unreadCount: 7, - }); + describe('channel.truncated', () => { + it('message.truncate removes all messages if "truncated_at" is "now"', function () { + const messages = [ + { created_at: '2021-01-01T00:01:00' }, + { created_at: '2021-01-01T00:02:00' }, + { created_at: '2021-01-01T00:03:00' }, + ].map(generateMsg); - channel._handleChannelEvent({ - type: 'channel.truncated', - user: { id: 'id' }, - channel: { - truncated_at: new Date().toISOString(), - }, - }); + seedLatestWindow(channel, messages); + expect(channel.messagePaginator.headItems.length).to.be.equal(3); - expect(channel.messagePaginator.unreadStateSnapshot.getLatestValue()).toEqual({ - firstUnreadMessageId: null, - lastReadAt: null, - lastReadMessageId: null, - unreadCount: 0, + channel._handleChannelEvent({ + type: 'channel.truncated', + user: { id: 'id' }, + channel: { + truncated_at: new Date().toISOString(), + }, + }); + + expect(channel.messagePaginator.headItems.length).to.be.equal(0); }); - expect(channel.messagePaginator.items).toBeUndefined(); - expect(channel.messagePaginator.getItem(cachedMessage.id)).toBeUndefined(); - }); - it('message.truncate removes messages up to specified date', function () { - const messages = [ - { created_at: '2021-01-01T00:01:00' }, - { created_at: '2021-01-01T00:02:00' }, - { created_at: '2021-01-01T00:03:00' }, - ].map(generateMsg); + it('message.truncate clears messagePaginator unread snapshot', function () { + const cachedMessage = generateMsg({ + date: '2020-01-01T00:00:00.000Z', + id: 'truncate-cached-message-id', + }); + channel.messagePaginator.setItems({ + valueOrFactory: [cachedMessage], + isFirstPage: true, + isLastPage: true, + }); + channel.messagePaginator.setUnreadSnapshot({ + firstUnreadMessageId: 'm-1', + lastReadAt: new Date('2021-01-01T00:00:00.000Z'), + lastReadMessageId: 'm-0', + unreadCount: 7, + }); - channel.state.addMessagesSorted(messages); - expect(channel.state.messages.length).to.be.equal(3); + channel._handleChannelEvent({ + type: 'channel.truncated', + user: { id: 'id' }, + channel: { + truncated_at: new Date().toISOString(), + }, + }); - channel._handleChannelEvent({ - type: 'channel.truncated', - user: { id: 'id' }, - channel: { - truncated_at: messages[1].created_at, - }, + expect(channel.messagePaginator.unreadStateSnapshot.getLatestValue()).toEqual({ + firstUnreadMessageId: null, + lastReadAt: null, + lastReadMessageId: null, + unreadCount: 0, + }); + // Partial truncate (truncated_at in the past) prunes the older-than-cutoff message; the + // emptied active window resolves to an empty item list. + expect(channel.messagePaginator.items ?? []).toEqual([]); + expect(channel.messagePaginator.getItem(cachedMessage.id)).toBeUndefined(); }); - expect(channel.state.messages.length).to.be.equal(2); - }); + it('message.truncate removes messages up to specified date', function () { + const messages = [ + { created_at: '2021-01-01T00:01:00' }, + { created_at: '2021-01-01T00:02:00' }, + { created_at: '2021-01-01T00:03:00' }, + ].map(generateMsg); - it('message.truncate removes pinned messages up to specified date', function () { - const messages = [ - { - created_at: '2021-01-01T00:01:00', - pinned: true, - pinned_at: new Date('2021-01-01T00:01:01.010Z'), - }, - { created_at: '2021-01-01T00:02:00' }, - { - created_at: '2021-01-01T00:03:00', - pinned: true, - pinned_at: new Date('2021-01-01T00:02:02.011Z'), - }, - ].map(generateMsg); + seedLatestWindow(channel, messages); + expect(channel.messagePaginator.headItems.length).to.be.equal(3); - channel.state.addMessagesSorted(messages); - channel.state.addPinnedMessages(messages.filter((m) => m.pinned)); - expect(channel.state.messages.length).to.be.equal(3); - expect(channel.state.pinnedMessages.length).to.be.equal(2); + channel._handleChannelEvent({ + type: 'channel.truncated', + user: { id: 'id' }, + channel: { + truncated_at: messages[1].created_at, + }, + }); - channel._handleChannelEvent({ - type: 'channel.truncated', - user: { id: 'id' }, - channel: { - truncated_at: messages[1].created_at, - }, + expect(channel.messagePaginator.headItems.length).to.be.equal(2); }); - expect(channel.state.messages.length).to.be.equal(2); - expect(channel.state.pinnedMessages.length).to.be.equal(1); - }); + it('prunes pinned messages older than the cutoff on a partial channel.truncated', () => { + seedPinned([ + makePinned('old', '2020-01-01T00:00:00.000Z'), + makePinned('new', '2020-03-01T00:00:00.000Z'), + ]); + expect(pinnedIds()).to.eql(['old', 'new']); - it('message.delete removes quoted messages references', function () { - const originalMessage = generateMsg({ silent: true }); - channel._handleChannelEvent({ - type: 'message.new', - user: { id: 'id' }, - message: originalMessage, - }); + channel._handleChannelEvent({ + type: 'channel.truncated', + channel: { truncated_at: '2020-02-01T00:00:00.000Z' }, + }); - const quotingMessage = generateMsg({ - silent: true, - quoted_message: originalMessage, - quoted_message_id: originalMessage.id, + expect(pinnedIds()).to.eql(['new']); }); - channel._handleChannelEvent({ - type: 'message.new', - user: { id: 'id2' }, - message: quotingMessage, - }); + it('clears pinned messages on a full channel.truncated', () => { + seedPinned([makePinned('p', '2020-01-01T00:00:00.000Z')]); - channel._handleChannelEvent({ - type: 'message.deleted', - user: { id: 'id' }, - message: { ...originalMessage, deleted_at: new Date().toISOString() }, - }); + channel._handleChannelEvent({ type: 'channel.truncated', channel: {} }); - expect( - channel.state.messages.find((msg) => msg.id === quotingMessage.id).quoted_message - .deleted_at, - ).to.be.ok; + expect(pinnedIds()).to.eql([]); + }); }); - it('message.deleted hard delete removes message from messagePaginator', function () { - const message = generateMsg({ id: 'hard-delete-message-id', silent: true }); - channel.messagePaginator.ingestItem(message); - expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); + describe('message.deleted', () => { + it('message.delete removes quoted messages references', function () { + const originalMessage = generateMsg({ silent: true }); + channel._handleChannelEvent({ + type: 'message.new', + user: { id: 'id' }, + message: originalMessage, + }); - channel._handleChannelEvent({ - type: 'message.deleted', - user: { id: 'id' }, - hard_delete: true, - message, - }); + const quotingMessage = generateMsg({ + silent: true, + quoted_message: originalMessage, + quoted_message_id: originalMessage.id, + }); - expect( - channel.messagePaginator.items?.find((m) => m.id === message.id), - ).toBeUndefined(); - }); + channel._handleChannelEvent({ + type: 'message.new', + user: { id: 'id2' }, + message: quotingMessage, + }); - it('message.deleted soft delete updates message in messagePaginator', function () { - const message = generateMsg({ id: 'soft-delete-message-id', text: 'before delete' }); - channel.messagePaginator.ingestItem(message); + channel._handleChannelEvent({ + type: 'message.deleted', + user: { id: 'id' }, + message: { ...originalMessage, deleted_at: new Date().toISOString() }, + }); - const deletedAt = new Date().toISOString(); - channel._handleChannelEvent({ - type: 'message.deleted', - user: { id: 'id' }, - message: { ...message, deleted_at: deletedAt }, + expect( + channel.messagePaginator.getItem(quotingMessage.id).quoted_message.deleted_at, + ).to.be.ok; }); - const itemFromPaginator = channel.messagePaginator.getItem(message.id); - expect(itemFromPaginator?.deleted_at?.toISOString()).to.equal(deletedAt); - }); + it('message.deleted hard delete removes message from messagePaginator', function () { + const message = generateMsg({ id: 'hard-delete-message-id', silent: true }); + channel.messagePaginator.ingestItem(message); + expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); - it('message.deleted (soft) ignores thread replies in messagePaginator', function () { - const parentMessage = generateMsg({ id: 'thread-parent-id-on-delete' }); - const threadReply = generateMsg({ - id: 'thread-reply-id-on-delete', - parent_id: parentMessage.id, - }); + channel._handleChannelEvent({ + type: 'message.deleted', + user: { id: 'id' }, + hard_delete: true, + message, + }); - channel.messagePaginator.ingestItem(parentMessage); - channel._handleChannelEvent({ - type: 'message.deleted', - user: { id: 'id' }, - message: { ...threadReply, deleted_at: new Date().toISOString() }, + expect( + channel.messagePaginator.items?.find((m) => m.id === message.id), + ).toBeUndefined(); }); - // A pure thread reply must never leak a "deleted" placeholder into the channel list. - expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; - }); + it('message.deleted soft delete updates message in messagePaginator', function () { + const message = generateMsg({ + id: 'soft-delete-message-id', + text: 'before delete', + }); + channel.messagePaginator.ingestItem(message); - it('message.deleted (hard) ignores thread replies in messagePaginator', function () { - const parentMessage = generateMsg({ id: 'thread-parent-id-on-hard-delete' }); - const threadReply = generateMsg({ - id: 'thread-reply-id-on-hard-delete', - parent_id: parentMessage.id, - }); + const deletedAt = new Date().toISOString(); + channel._handleChannelEvent({ + type: 'message.deleted', + user: { id: 'id' }, + message: { ...message, deleted_at: deletedAt }, + }); - channel.messagePaginator.ingestItem(parentMessage); - channel._handleChannelEvent({ - type: 'message.deleted', - user: { id: 'id' }, - hard_delete: true, - message: threadReply, + const itemFromPaginator = channel.messagePaginator.getItem(message.id); + expect(itemFromPaginator?.deleted_at?.toISOString()).to.equal(deletedAt); }); - expect(channel.messagePaginator.getItem(parentMessage.id)?.id).to.equal( - parentMessage.id, - ); - expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; - }); + it('message.deleted (soft) ignores thread replies in messagePaginator', function () { + const parentMessage = generateMsg({ id: 'thread-parent-id-on-delete' }); + const threadReply = generateMsg({ + id: 'thread-reply-id-on-delete', + parent_id: parentMessage.id, + }); - it('message.deleted syncs quoted_message references in messagePaginator', function () { - const quotedMessage = generateMsg({ - id: 'quoted-message-id-on-delete', - text: 'before delete', - }); - const quoteCarrier = generateMsg({ - id: 'quote-carrier-id-on-delete', - quoted_message_id: quotedMessage.id, - quoted_message: quotedMessage, - }); + channel.messagePaginator.ingestItem(parentMessage); + channel._handleChannelEvent({ + type: 'message.deleted', + user: { id: 'id' }, + message: { ...threadReply, deleted_at: new Date().toISOString() }, + }); - channel.messagePaginator.setItems({ - valueOrFactory: [quotedMessage, quoteCarrier], - isFirstPage: true, - isLastPage: true, + // A pure thread reply must never leak a "deleted" placeholder into the channel list. + expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; }); - channel._handleChannelEvent({ - type: 'message.deleted', - user: { id: 'id' }, - message: { - ...quotedMessage, - type: 'deleted', - text: 'after delete', - deleted_at: new Date().toISOString(), - }, + it('message.deleted (hard) ignores thread replies in messagePaginator', function () { + const parentMessage = generateMsg({ id: 'thread-parent-id-on-hard-delete' }); + const threadReply = generateMsg({ + id: 'thread-reply-id-on-hard-delete', + parent_id: parentMessage.id, + }); + + channel.messagePaginator.ingestItem(parentMessage); + channel._handleChannelEvent({ + type: 'message.deleted', + user: { id: 'id' }, + hard_delete: true, + message: threadReply, + }); + + expect(channel.messagePaginator.getItem(parentMessage.id)?.id).to.equal( + parentMessage.id, + ); + expect(channel.messagePaginator.getItem(threadReply.id)).to.be.undefined; }); - expect( - channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.type, - ).to.equal('deleted'); - }); + it('message.deleted syncs quoted_message references in messagePaginator', function () { + const quotedMessage = generateMsg({ + id: 'quoted-message-id-on-delete', + text: 'before delete', + }); + const quoteCarrier = generateMsg({ + id: 'quote-carrier-id-on-delete', + quoted_message_id: quotedMessage.id, + quoted_message: quotedMessage, + }); - it('reaction.new ingests message into messagePaginator for non-thread messages', function () { - const message = generateMsg({ id: 'reaction-channel-message-id' }); + channel.messagePaginator.setItems({ + valueOrFactory: [quotedMessage, quoteCarrier], + isFirstPage: true, + isLastPage: true, + }); - channel._handleChannelEvent({ - type: 'reaction.new', - message, - reaction: { - type: 'love', - user_id: 'user-1', - message_id: message.id, - created_at: new Date().toISOString(), - }, + channel._handleChannelEvent({ + type: 'message.deleted', + user: { id: 'id' }, + message: { + ...quotedMessage, + type: 'deleted', + text: 'after delete', + deleted_at: new Date().toISOString(), + }, + }); + + expect( + channel.messagePaginator.getItem(quoteCarrier.id)?.quoted_message?.type, + ).to.equal('deleted'); }); - expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); - }); + it('removes a pinned message on hard delete', () => { + const msg = makePinned('p', '2020-01-01T00:00:00.000Z'); + seedPinned([msg]); - it('reaction.new ignores thread replies in messagePaginator', function () { - const message = generateMsg({ - id: 'reaction-thread-message-id', - parent_id: 'thread-parent-id', - }); + channel._handleChannelEvent({ + type: 'message.deleted', + message: msg, + hard_delete: true, + }); - channel._handleChannelEvent({ - type: 'reaction.new', - message, - reaction: { - type: 'love', - user_id: 'user-1', - message_id: message.id, - created_at: new Date().toISOString(), - }, + expect(pinnedIds()).to.not.include('p'); }); - - expect(channel.messagePaginator.getItem(message.id)).to.be.undefined; }); - ['reaction.deleted', 'reaction.updated'].forEach((eventType) => { - it(`${eventType} ingests message into messagePaginator for non-thread messages`, function () { - const message = generateMsg({ id: `${eventType}-channel-message-id` }); + describe('reaction.new', () => { + it('reaction.new ingests message into messagePaginator for non-thread messages', function () { + const message = generateMsg({ id: 'reaction-channel-message-id' }); channel._handleChannelEvent({ - type: eventType, + type: 'reaction.new', message, reaction: { type: 'love', @@ -1017,14 +1204,14 @@ describe('Channel _handleChannelEvent', function () { expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); }); - it(`${eventType} ignores thread replies in messagePaginator`, function () { + it('reaction.new ignores thread replies in messagePaginator', function () { const message = generateMsg({ - id: `${eventType}-thread-message-id`, + id: 'reaction-thread-message-id', parent_id: 'thread-parent-id', }); channel._handleChannelEvent({ - type: eventType, + type: 'reaction.new', message, reaction: { type: 'love', @@ -1036,186 +1223,72 @@ describe('Channel _handleChannelEvent', function () { expect(channel.messagePaginator.getItem(message.id)).to.be.undefined; }); - }); - - describe('user.messages.deleted', () => { - const bannedUser = { id: 'banned-user' }; - const otherUser = { id: 'other-user' }; - const messageSet1 = [ - { - attachments: [ - { - type: 'image', - title: 'YouTube', - title_link: 'https://www.youtube.com/', - text: 'Enjoy the videos and music you love, upload original content, and share it all with friends, family, and the world on YouTube.', - image_url: 'https://www.youtube.com/img/desktop/yt_1200.png', - thumb_url: 'https://www.youtube.com/img/desktop/yt_1200.png', - og_scrape_url: 'https://www.youtube.com/', - }, - ], - created_at: '2021-01-01T00:01:00', - pinned: true, - pinned_at: '2022-01-01T00:01:00', - user: bannedUser, - }, - { - created_at: '2021-01-01T00:02:00', - pinned: true, - pinned_at: '2022-01-01T00:02:00', - user: otherUser, - }, - { created_at: '2021-01-01T00:03:00', user: bannedUser }, - ].map(generateMsg); - - const quoted_message = messageSet1[0]; - const messageSet2 = [ - { - created_at: '2020-01-01T00:01:00', - pinned: true, - pinned_at: '2022-01-01T00:03:00', - user: bannedUser, - }, - { - created_at: '2020-01-01T00:02:00', - quoted_message, - quoted_message_id: quoted_message.id, - user: otherUser, - }, - { created_at: '2020-01-01T00:03:00', user: bannedUser }, - { created_at: '2020-01-01T00:04:00', user: otherUser }, - ].map(generateMsg); - - const parent_id = messageSet2[0].id; - const thread1 = [ - { created_at: '2020-01-01T00:01:30', parent_id, user: bannedUser, type: 'reply' }, - { created_at: '2020-01-01T00:02:35', parent_id, user: otherUser, type: 'reply' }, - { created_at: '2020-01-01T00:03:45', parent_id, user: bannedUser, type: 'reply' }, - { created_at: '2020-01-01T00:04:00', parent_id, user: otherUser, type: 'reply' }, - ]; - - const pinnedMessages = [messageSet1[0], messageSet1[1], messageSet2[0]]; - - const setupChannel = (channel) => { - channel.state.addMessagesSorted(messageSet1); - channel.state.addMessagesSorted(messageSet2, false, false, true, 'new'); - - // pinned messages - channel.state.addPinnedMessages(pinnedMessages); - // thread replies - channel.state.addMessagesSorted(thread1); - }; - - it('removes the messages on hard delete', () => { - setupChannel(channel); - expect(channel.state.messageSets).toHaveLength(2); - expect(channel.state.messageSets[0].messages).toHaveLength(messageSet1.length); - expect(channel.state.messageSets[1].messages).toHaveLength(messageSet2.length); - expect(channel.state.pinnedMessages).toHaveLength(pinnedMessages.length); - expect(channel.state.threads[parent_id]).toHaveLength(thread1.length); + it('reflects a reaction on a pinned message', () => { + const msg = makePinned('p', '2020-01-01T00:00:00.000Z', { own_reactions: [] }); + seedPinned([msg]); - const event = { - type: 'user.messages.deleted', - cid: channel.cid, - channel_type: channel.type, - channel_id: channel.id, - user: bannedUser, - hard_delete: true, - created_at: '2025-02-01T14:01:30.000Z', - }; - channel._handleChannelEvent(event); - expect(channel.state.messageSets[0].messages).toHaveLength(3); - - const check = (message) => { - const deletedMessage = { - attachments: [], - cid: message.cid, - created_at: message.created_at, - deleted_at: new Date(event.created_at), - id: message.id, - latest_reactions: [], - mentioned_users: [], - own_reactions: [], - parent_id: message.parent_id, - reply_count: message.reply_count, - status: message.status, - thread_participants: message.thread_participants, - type: 'deleted', - updated_at: message.updated_at, - user: message.user, - }; - if (message.user.id === bannedUser.id) { - expect(message).toStrictEqual(deletedMessage); - } else if (message.quoted_message) { - expect(message).toStrictEqual({ - ...message, - quoted_message: { - ...deletedMessage, - id: message.quoted_message.id, - user: message.quoted_message.user, - created_at: message.quoted_message.created_at, - updated_at: message.quoted_message.updated_at, - }, - }); - } else { - expect(message).toEqual(message); - } - }; + channel._handleChannelEvent({ + type: 'reaction.new', + message: { ...msg, own_reactions: [] }, + reaction: { + type: 'like', + user_id: user.id, + message_id: 'p', + created_at: new Date().toISOString(), + }, + }); - channel.state.messageSets[0].messages.forEach(check); - channel.state.messageSets[1].messages.forEach(check); - channel.state.pinnedMessages.forEach(check); - Object.values(channel.state.threads).forEach((replies) => replies.forEach(check)); + const item = channel.pinnedMessagesPaginator.getItem('p'); + expect(item?.own_reactions?.some((r) => r.type === 'like')).to.be.true; }); - it('removes the messages on soft delete', () => { - setupChannel(channel); - expect(channel.state.messageSets).toHaveLength(2); - expect(channel.state.messageSets[0].messages).toHaveLength(messageSet1.length); - expect(channel.state.messageSets[1].messages).toHaveLength(messageSet2.length); - expect(channel.state.pinnedMessages).toHaveLength(pinnedMessages.length); - expect(channel.state.threads[parent_id]).toHaveLength(thread1.length); + }); - const event = { - type: 'user.messages.deleted', - cid: channel.cid, - channel_type: channel.type, - channel_id: channel.id, - user: bannedUser, - soft_delete: true, - created_at: '2025-02-01T14:01:30.000Z', - }; - channel._handleChannelEvent(event); - expect(channel.state.messageSets[0].messages).toHaveLength(3); - - const check = (message) => { - if (message.user.id === bannedUser.id) { - expect(message).toStrictEqual({ - ...message, - attachments: [], - deleted_at: new Date(event.created_at), - type: 'deleted', - }); - } else if (message.quoted_message) { - expect(message).toStrictEqual({ - ...message, - quoted_message: { - ...message.quoted_message, - attachments: [], - deleted_at: new Date(event.created_at), - type: 'deleted', - }, - }); - } else { - expect(message).toEqual(message); - } - }; + describe('reaction.deleted', () => { + // The parametrized cases also cover reaction.updated. + ['reaction.deleted', 'reaction.updated'].forEach((eventType) => { + it(`${eventType} ingests message into messagePaginator for non-thread messages`, function () { + const message = generateMsg({ id: `${eventType}-channel-message-id` }); + + channel._handleChannelEvent({ + type: eventType, + message, + reaction: { + type: 'love', + user_id: 'user-1', + message_id: message.id, + created_at: new Date().toISOString(), + }, + }); - channel.state.messageSets[0].messages.forEach(check); - channel.state.messageSets[1].messages.forEach(check); - channel.state.pinnedMessages.forEach(check); - Object.values(channel.state.threads).forEach((replies) => replies.forEach(check)); + expect(channel.messagePaginator.getItem(message.id)?.id).to.equal(message.id); + }); + + it(`${eventType} ignores thread replies in messagePaginator`, function () { + const message = generateMsg({ + id: `${eventType}-thread-message-id`, + parent_id: 'thread-parent-id', + }); + + channel._handleChannelEvent({ + type: eventType, + message, + reaction: { + type: 'love', + user_id: 'user-1', + message_id: message.id, + created_at: new Date().toISOString(), + }, + }); + + expect(channel.messagePaginator.getItem(message.id)).to.be.undefined; + }); }); + }); + + describe('user.messages.deleted', () => { + const bannedUser = { id: 'banned-user' }; + const otherUser = { id: 'other-user' }; it('updates messagePaginator items on soft delete', () => { const deletedAt = new Date('2025-02-01T14:01:30.000Z'); @@ -1289,12 +1362,42 @@ describe('Channel _handleChannelEvent', function () { quoteCarrierFromPaginator?.quoted_message?.deleted_at?.toISOString(), ).to.equal(deletedAt.toISOString()); }); + + // Pinned-message deletion for a banned user (moved from the pinnedMessagesPaginator suite). + it("marks a banned user's pinned messages deleted on user.messages.deleted (soft)", () => { + seedPinned([makePinned('p', '2020-01-01T00:00:00.000Z', { user: bannedUser })]); + + channel._handleChannelEvent({ + type: 'user.messages.deleted', + user: bannedUser, + soft_delete: true, + created_at: '2025-01-01T00:00:00.000Z', + }); + + expect(channel.pinnedMessagesPaginator.getItem('p')?.type).to.equal('deleted'); + }); + + it("removes a banned user's pinned messages on user.messages.deleted (hard)", () => { + seedPinned([ + makePinned('p', '2020-01-01T00:00:00.000Z', { user: bannedUser }), + makePinned('other', '2020-01-02T00:00:00.000Z', { user: otherUser }), + ]); + + channel._handleChannelEvent({ + type: 'user.messages.deleted', + user: bannedUser, + hard_delete: true, + created_at: '2025-01-01T00:00:00.000Z', + }); + + expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.eql(['other']); + }); }); // Regression coverage for GetStream/stream-chat-js#1736 at the per-channel event entry point - // (channel.ts → _handleChannelEvent → state.deleteUserMessages). Mirrors the global-event - // regression suite in client.test.js but exercises the channel-scoped user.messages.deleted - // event (one carrying a cid). + // (channel.ts → _handleChannelEvent → messagePaginator.applyMessageDeletionForUser). Mirrors + // the global-event regression suite in client.test.js but exercises the channel-scoped + // user.messages.deleted event (one carrying a cid). describe('user.messages.deleted — quoted_message regression (#1736)', () => { const bannedUser = { id: 'banned-user' }; @@ -1309,7 +1412,11 @@ describe('Channel _handleChannelEvent', function () { quoted_message: m1, quoted_message_id: m1.id, }); - channel.state.addMessagesSorted([m1, m2]); + channel.messagePaginator.setItems({ + valueOrFactory: [m1, m2], + isFirstPage: true, + isLastPage: true, + }); const event = { type: 'user.messages.deleted', @@ -1323,11 +1430,12 @@ describe('Channel _handleChannelEvent', function () { expect(() => channel._handleChannelEvent(event)).not.to.throw(); - const messages = channel.state.messageSets[0].messages; - expect(messages.find((m) => m.id === m1.id).type).to.equal('deleted'); - const quoter = messages.find((m) => m.id === m2.id); - expect(quoter.type).to.equal('deleted'); - expect(quoter.quoted_message).to.equal(undefined); + // Both messages belong to the banned user, so a hard delete drops both from the + // active window. The point of the regression is that the self-quote (m2 → m1) does + // not throw while doing so. + const items = channel.messagePaginator.items ?? []; + expect(items.find((m) => m.id === m1.id)).to.equal(undefined); + expect(items.find((m) => m.id === m2.id)).to.equal(undefined); }); }); @@ -1655,6 +1763,7 @@ describe('Channel _handleChannelEvent', function () { type: 'message.new', user: otherUser, message: generateMsg({ + cid: channel.cid, id: messageDeliveredEvent.last_delivered_message_id, date: messageDeliveredEvent.last_delivered_at, }), @@ -1676,6 +1785,7 @@ describe('Channel _handleChannelEvent', function () { }); channel.state.read[user.id] = initialReadState; const newerMessage = generateMsg({ + cid: channel.cid, id: 'some-other-id', date: new Date(3000).toISOString(), }); @@ -1710,6 +1820,7 @@ describe('Channel _handleChannelEvent', function () { type: 'message.new', user: otherUser, message: generateMsg({ + cid: channel.cid, id: messageDeliveredEvent.last_delivered_message_id, date: messageDeliveredEvent.last_delivered_at, }), @@ -1726,410 +1837,270 @@ describe('Channel _handleChannelEvent', function () { client.messageDeliveryReporter.deliveryReportCandidates.get(channel.cid), ).toBe(messageDeliveredEvent.last_delivered_message_id); }); - }); - - it('should include unread_messages for message events from another user', () => { - channel.state.read['id'] = { - unread_messages: 2, - }; - const message = generateMsg(); - - const events = [ - 'message.read', - 'message.deleted', - 'message.new', - 'message.updated', - 'member.added', - 'member.updated', - 'member.removed', - ]; - - for (const event of events) { - channel.state.read['id'].unread_messages = 2; - channel._handleChannelEvent({ - type: event, - user: { id: 'id' }, - message, - }); - expect(channel.state.read['id'].unread_messages, `${event} should not be undefined`) - .not.to.be.undefined; - } + it('does not override the delivery information in the read status', () => {}); }); - it('should include unread_messages for message events from the current user', () => { - channel.state.read[client.user.id] = { - unread_messages: 2, - }; - - const message = generateMsg({ user: { id: client.userID } }); - - const events = [ - 'message.read', - 'message.deleted', - 'message.new', - 'message.updated', - 'member.added', - 'member.updated', - 'member.removed', - ]; - - for (const event of events) { - channel.state.read['id'] = { - unread_messages: 2, + describe('channel.visible', () => { + it('should mark channel visible on channel.visible event', () => { + const channelVisibleEvent = { + channel: { + blocked: false, + }, + type: 'channel.visible', + cid: 'messaging:id', + channel_id: 'id', + channel_type: 'messaging', + user: { + id: 'admin', + role: 'admin', + created_at: '2022-03-08T09:46:56.840739Z', + updated_at: '2022-03-15T08:30:09.796926Z', + last_active: '2023-05-24T09:20:31.041292724Z', + banned: false, + online: true, + }, + created_at: '2023-05-24T09:20:43.986615426Z', }; + channel.data.hidden = true; + channel.data.blocked = true; - channel._handleChannelEvent({ - type: event, - user: { id: client.user.id }, - message, - }); - expect( - channel.state.read[client.user.id].unread_messages, - `${event} should not be undefined`, - ).not.to.be.undefined; - } - }); - - it('should extend "message.updated" and "message.deleted" event payloads with "own_reactions"', () => { - const own_reactions = [ - { - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - type: 'wow', - }, - ]; - const testCases = [ - [generateMsg({ own_reactions })], // channel message - [generateMsg({ id: '0' }), generateMsg({ parent_id: '0', own_reactions })], // thread message - ]; - - testCases.forEach((messages) => { - channel.state.addMessagesSorted(messages); - const message = messages[messages.length - 1]; - - const eventTypes = ['message.updated', 'message.deleted']; - - eventTypes.forEach((eventType) => { - let receivedEvent; - channel.on(eventType, (e) => (receivedEvent = e)); - - const event = { - type: eventType, - // own_reactions is always [] in WS events - message: { ...message, own_reactions: [] }, - }; - channel._handleChannelEvent(event); - channel._callChannelListeners(event); - - expect( - channel.state.findMessage(message.id, message.parent_id).own_reactions.length, - ).to.equal(own_reactions.length); - expect(receivedEvent.message.own_reactions.length).to.equal(own_reactions.length); - }); - }); - }); - - it('should update quoted_message references on "message.updated" and "message.deleted" event', () => { - const originalText = 'XX'; - const updatedText = 'YY'; - const parent_id = '0'; - const parentMesssage = generateMsg({ - date: new Date(0).toISOString(), - id: parent_id, - }); - const quoted_message = generateMsg({ - date: new Date(2).toISOString(), - id: 'quoted-message', - text: originalText, - }); - const quotingMessage = generateMsg({ - date: new Date(3).toISOString(), - id: 'quoting-message', - quoted_message, - quoted_message_id: quoted_message.id, - }); - const updatedQuotedMessage = { ...quoted_message, text: updatedText }; - const updatedQuotedThreadReply = { ...quoted_message, parent_id, text: updatedText }; - [ - [quoted_message, quotingMessage], // channel message - [ - parentMesssage, - { ...quoted_message, parent_id }, - { ...quotingMessage, parent_id }, - ], // thread message - ].forEach((messages) => { - ['message.updated', 'message.deleted'].forEach((eventType) => { - channel.state.addMessagesSorted(messages); - const isThread = messages.length === 3; - const quotingMessage = messages[messages.length - 1]; - const event = { - type: eventType, - message: isThread ? updatedQuotedThreadReply : updatedQuotedMessage, - }; - channel._handleChannelEvent(event); - expect( - channel.state.findMessage(quotingMessage.id, quotingMessage.parent_id) - .quoted_message.text, - ).to.equal(updatedQuotedMessage.text); - channel.state.clearMessages(); - }); + channel._handleChannelEvent(channelVisibleEvent); + expect(channel.data.hidden).eq(false); + expect(channel.data.blocked).eq(false); }); - }); - it('should mark channel visible on channel.visible event', () => { - const channelVisibleEvent = { - channel: { - blocked: false, - }, - type: 'channel.visible', - cid: 'messaging:id', - channel_id: 'id', - channel_type: 'messaging', - user: { - id: 'admin', - role: 'admin', - created_at: '2022-03-08T09:46:56.840739Z', - updated_at: '2022-03-15T08:30:09.796926Z', - last_active: '2023-05-24T09:20:31.041292724Z', - banned: false, - online: true, - }, - created_at: '2023-05-24T09:20:43.986615426Z', - }; - channel.data.hidden = true; - channel.data.blocked = true; + it('should treat blocked separately from hidden on channel.visible event', () => { + const channelVisibleEvent = { + channel: { + blocked: true, + }, + type: 'channel.visible', + cid: 'messaging:id', + channel_id: 'id', + channel_type: 'messaging', + user: { + id: 'admin', + role: 'admin', + created_at: '2022-03-08T09:46:56.840739Z', + updated_at: '2022-03-15T08:30:09.796926Z', + last_active: '2023-05-24T09:20:31.041292724Z', + banned: false, + online: true, + }, + created_at: '2023-05-24T09:20:43.986615426Z', + }; + channel.data.hidden = true; + channel.data.blocked = true; - channel._handleChannelEvent(channelVisibleEvent); - expect(channel.data.hidden).eq(false); - expect(channel.data.blocked).eq(false); + channel._handleChannelEvent(channelVisibleEvent); + expect(channel.data.hidden).eq(false); + expect(channel.data.blocked).eq(true); + }); }); - it('should treat blocked separately from hidden on channel.visible event', () => { - const channelVisibleEvent = { - channel: { - blocked: true, - }, - type: 'channel.visible', - cid: 'messaging:id', - channel_id: 'id', - channel_type: 'messaging', - user: { - id: 'admin', - role: 'admin', - created_at: '2022-03-08T09:46:56.840739Z', - updated_at: '2022-03-15T08:30:09.796926Z', - last_active: '2023-05-24T09:20:31.041292724Z', - banned: false, - online: true, - }, - created_at: '2023-05-24T09:20:43.986615426Z', - }; - channel.data.hidden = true; - channel.data.blocked = true; + describe('channel.hidden', () => { + it('should mark channel hidden on channel.hidden event', () => { + const channelVisibleEvent = { + channel: { + blocked: true, + }, + type: 'channel.hidden', + }; + channel.data.hidden = false; + channel.data.blocked = false; - channel._handleChannelEvent(channelVisibleEvent); - expect(channel.data.hidden).eq(false); - expect(channel.data.blocked).eq(true); - }); + channel._handleChannelEvent(channelVisibleEvent); + expect(channel.data.hidden).eq(true); + expect(channel.data.blocked).eq(true); + }); - it('should mark channel hidden on channel.hidden event', () => { - const channelVisibleEvent = { - channel: { - blocked: true, - }, - type: 'channel.hidden', - }; - channel.data.hidden = false; - channel.data.blocked = false; + it('should treat blocked separately from hidden on channel.hidden event', () => { + const channelVisibleEvent = { + channel: { + blocked: false, + }, + type: 'channel.hidden', + }; + channel.data.hidden = false; + channel.data.blocked = false; - channel._handleChannelEvent(channelVisibleEvent); - expect(channel.data.hidden).eq(true); - expect(channel.data.blocked).eq(true); + channel._handleChannelEvent(channelVisibleEvent); + expect(channel.data.hidden).eq(true); + expect(channel.data.blocked).eq(false); + }); }); - it('should treat blocked separately from hidden on channel.hidden event', () => { - const channelVisibleEvent = { - channel: { - blocked: false, - }, - type: 'channel.hidden', - }; - channel.data.hidden = false; - channel.data.blocked = false; - - channel._handleChannelEvent(channelVisibleEvent); - expect(channel.data.hidden).eq(true); - expect(channel.data.blocked).eq(false); - }); + describe('channel.updated', () => { + it('should update the frozen flag and reload channel state when frozen changes', () => { + const event = { + channel: { frozen: true }, + type: 'channel.updated', + }; + channel.data.frozen = false; + const channelQuerySpy = vi.spyOn(channel, 'query'); - it('should update the frozen flag and reload channel state when frozen changes', () => { - const event = { - channel: { frozen: true }, - type: 'channel.updated', - }; - channel.data.frozen = false; - const channelQuerySpy = vi.spyOn(channel, 'query'); + channel._handleChannelEvent(event); + expect(channel.data.frozen).eq(true); + expect(channelQuerySpy).toHaveBeenCalledTimes(1); - channel._handleChannelEvent(event); - expect(channel.data.frozen).eq(true); - expect(channelQuerySpy).toHaveBeenCalledTimes(1); + channel._handleChannelEvent(event); + expect(channelQuerySpy).toHaveBeenCalledTimes(1); - channel._handleChannelEvent(event); - expect(channelQuerySpy).toHaveBeenCalledTimes(1); + // Make sure that we don't wipe out any data + }); - // Make sure that we don't wipe out any data - }); + it('channel.updated updates member_count from the event channel data', () => { + channel.data = { member_count: 5 }; - it('channel.updated updates member_count from the event channel data', () => { - channel.data = { member_count: 5 }; + channel._handleChannelEvent({ + type: 'channel.updated', + channel: { member_count: 10 }, + }); - channel._handleChannelEvent({ - type: 'channel.updated', - channel: { member_count: 10 }, + expect(channel.data.member_count).to.equal(10); }); - expect(channel.data.member_count).to.equal(10); - }); + it('preserves member_count on channel.updated when event payload omits member_count', () => { + channel.data.member_count = 3; + channel.data.frozen = false; + channel._handleChannelEvent({ + channel: { frozen: false }, + type: 'channel.updated', + }); - it('preserves member_count on channel.updated when event payload omits member_count', () => { - channel.data.member_count = 3; - channel.data.frozen = false; - channel._handleChannelEvent({ - channel: { frozen: false }, - type: 'channel.updated', + expect(channel.data.member_count).to.equal(3); + expect(channel.state.member_count).to.equal(3); }); - expect(channel.data.member_count).to.equal(3); - expect(channel.state.member_count).to.equal(3); - }); - - it(`should make sure that state reload doesn't wipe out existing data`, async () => { - const mock = sinon.mock(client); - mock.expects('post').returns(Promise.resolve(mockChannelQueryResponse)); + it(`should make sure that state reload doesn't wipe out existing data`, async () => { + const mock = sinon.mock(client); + mock.expects('post').returns(Promise.resolve(mockChannelQueryResponse)); - channel.state.members = { - user: { id: 'user' }, - }; - channel.state.watchers = { - user: { id: 'user' }, - }; - channel.state.read = { - user: { id: 'user' }, - }; - channel.state.addMessageSorted(generateMsg()); - channel.state.addPinnedMessages([generateMsg()]); - channel.state.watcher_count = 5; + channel.state.members = { + user: { id: 'user' }, + }; + channel.state.watchers = { + user: { id: 'user' }, + }; + channel.state.read = { + user: { id: 'user' }, + }; + seedLatestWindow(channel, [generateMsg()]); + channel.state.watcher_count = 5; - await channel.query(); + await channel.query(); - expect(Object.keys(channel.state.members).length).to.be.eq(1); - expect(Object.keys(channel.state.watchers).length).to.be.eq(1); - expect(Object.keys(channel.state.read).length).to.be.eq(1); - expect(channel.state.messages.length).to.be.eq(1); - expect(channel.state.pinnedMessages.length).to.be.eq(1); - expect(channel.state.watcher_count).to.be.eq(5); - }); + expect(Object.keys(channel.state.members).length).to.be.eq(1); + expect(Object.keys(channel.state.watchers).length).to.be.eq(1); + expect(Object.keys(channel.state.read).length).to.be.eq(1); + expect(channel.messagePaginator.headItems.length).to.be.eq(1); + expect(channel.state.watcher_count).to.be.eq(5); + }); - it('should dispatch "capabilities.changed" event', async () => { - const mock = sinon.mock(client); - const response = mockChannelQueryResponse; - channel.data.own_capabilities = response.channel.own_capabilities.slice(0, 1); - mock.expects('post').returns(Promise.resolve(response)); - const spy = sinon.spy(); - channel.on('capabilities.changed', spy); + // capabilities.changed is emitted from the channel.updated / query path. + it('should dispatch "capabilities.changed" event', async () => { + const mock = sinon.mock(client); + const response = mockChannelQueryResponse; + channel.data.own_capabilities = response.channel.own_capabilities.slice(0, 1); + mock.expects('post').returns(Promise.resolve(response)); + const spy = sinon.spy(); + channel.on('capabilities.changed', spy); - await channel.query(); + await channel.query(); - expect(spy.calledOnce).to.be.true; + expect(spy.calledOnce).to.be.true; - const arg = spy.firstCall.args[0]; - // We don't care about received_at in the assertion - delete arg.received_at; - sinon.assert.match(arg, { - type: 'capabilities.changed', - cid: channel.cid, - own_capabilities: response.channel.own_capabilities, - }); + const arg = spy.firstCall.args[0]; + // We don't care about received_at in the assertion + delete arg.received_at; + sinon.assert.match(arg, { + type: 'capabilities.changed', + cid: channel.cid, + own_capabilities: response.channel.own_capabilities, + }); - channel.data.own_capabilities = response.channel.own_capabilities; - mock.expects('post').returns(Promise.resolve(response)); - spy.resetHistory(); + channel.data.own_capabilities = response.channel.own_capabilities; + mock.expects('post').returns(Promise.resolve(response)); + spy.resetHistory(); - await channel.query(); + await channel.query(); - expect(spy.notCalled).to.be.true; + expect(spy.notCalled).to.be.true; + }); }); - it('should update channel member ban state on user.banned and user.unbanned events', () => { - const user = { id: 'user_id' }; - const shadowBanEvent = { - type: 'user.banned', - shadow: true, - user, - }; - const shadowUnbanEvent = { - type: 'user.unbanned', - shadow: true, - user, - }; - const banEvent = { - type: 'user.banned', - user, - }; - const unbanEvent = { - type: 'user.unbanned', - user, - }; + describe('user.banned / user.unbanned', () => { + it('should update channel member ban state on user.banned and user.unbanned events', () => { + const user = { id: 'user_id' }; + const shadowBanEvent = { + type: 'user.banned', + shadow: true, + user, + }; + const shadowUnbanEvent = { + type: 'user.unbanned', + shadow: true, + user, + }; + const banEvent = { + type: 'user.banned', + user, + }; + const unbanEvent = { + type: 'user.unbanned', + user, + }; - [ [ - shadowBanEvent, - banEvent, - { shadow_banned: true, banned: false }, - { shadow_banned: false, banned: true }, - ], - [ - shadowBanEvent, - shadowUnbanEvent, - { shadow_banned: true, banned: false }, - { shadow_banned: false, banned: false }, - ], - [ - shadowBanEvent, - unbanEvent, - { shadow_banned: true, banned: false }, - { shadow_banned: false, banned: false }, - ], - [ - banEvent, - shadowBanEvent, - { shadow_banned: false, banned: true }, - { shadow_banned: true, banned: false }, - ], - [ - banEvent, - shadowUnbanEvent, - { shadow_banned: false, banned: true }, - { shadow_banned: false, banned: false }, - ], - [ - banEvent, - unbanEvent, - { shadow_banned: false, banned: true }, - { shadow_banned: false, banned: false }, - ], - ].forEach(([firstEvent, secondEvent, expectAfterFirst, expectAfterSecond]) => { - channel._handleChannelEvent(firstEvent); - expect(channel.state.members[user.id].banned).eq(expectAfterFirst.banned); - expect(channel.state.members[user.id].shadow_banned).eq( - expectAfterFirst.shadow_banned, - ); - channel._handleChannelEvent(secondEvent); - expect(channel.state.members[user.id].banned).eq(expectAfterSecond.banned); - expect(channel.state.members[user.id].shadow_banned).eq( - expectAfterSecond.shadow_banned, - ); + [ + shadowBanEvent, + banEvent, + { shadow_banned: true, banned: false }, + { shadow_banned: false, banned: true }, + ], + [ + shadowBanEvent, + shadowUnbanEvent, + { shadow_banned: true, banned: false }, + { shadow_banned: false, banned: false }, + ], + [ + shadowBanEvent, + unbanEvent, + { shadow_banned: true, banned: false }, + { shadow_banned: false, banned: false }, + ], + [ + banEvent, + shadowBanEvent, + { shadow_banned: false, banned: true }, + { shadow_banned: true, banned: false }, + ], + [ + banEvent, + shadowUnbanEvent, + { shadow_banned: false, banned: true }, + { shadow_banned: false, banned: false }, + ], + [ + banEvent, + unbanEvent, + { shadow_banned: false, banned: true }, + { shadow_banned: false, banned: false }, + ], + ].forEach(([firstEvent, secondEvent, expectAfterFirst, expectAfterSecond]) => { + channel._handleChannelEvent(firstEvent); + expect(channel.state.members[user.id].banned).eq(expectAfterFirst.banned); + expect(channel.state.members[user.id].shadow_banned).eq( + expectAfterFirst.shadow_banned, + ); + channel._handleChannelEvent(secondEvent); + expect(channel.state.members[user.id].banned).eq(expectAfterSecond.banned); + expect(channel.state.members[user.id].shadow_banned).eq( + expectAfterSecond.shadow_banned, + ); + }); }); }); }); @@ -2628,13 +2599,13 @@ describe('Channel lastMessage', async () => { it('should return last message - messages are in order', () => { channel.state = new ChannelState(channel); const latestMessageDate = '2018-01-01T00:13:24'; - channel.state.addMessagesSorted([ + seedLatestWindow(channel, [ generateMsg({ date: '2018-01-01T00:00:00' }), generateMsg({ date: '2018-01-01T00:02:00' }), generateMsg({ date: latestMessageDate }), ]); - expect(channel.lastMessage().created_at.getTime()).to.be.equal( + expect(channel.messagePaginator.headmostItem.created_at.getTime()).to.be.equal( new Date(latestMessageDate).getTime(), ); }); @@ -2642,13 +2613,13 @@ describe('Channel lastMessage', async () => { it('should return last message - messages are out of order', () => { channel.state = new ChannelState(channel); const latestMessageDate = '2018-01-01T00:13:24'; - channel.state.addMessagesSorted([ + seedLatestWindow(channel, [ generateMsg({ date: latestMessageDate }), generateMsg({ date: '2018-01-01T00:02:00' }), generateMsg({ date: '2018-01-01T00:00:00' }), ]); - expect(channel.lastMessage().created_at.getTime()).to.be.equal( + expect(channel.messagePaginator.headmostItem.created_at.getTime()).to.be.equal( new Date(latestMessageDate).getTime(), ); }); @@ -2665,10 +2636,14 @@ describe('Channel lastMessage', async () => { generateMsg({ date: '2017-11-21T00:05:33' }), generateMsg({ date: '2017-11-21T00:05:35' }), ]; - channel.state.addMessagesSorted(latestMessages); - channel.state.addMessagesSorted(otherMessages, 'new'); + // latest (head) window + a separate, older window + seedLatestWindow(channel, latestMessages); + channel.messagePaginator.ingestPage({ + page: otherMessages.map((m) => formatMessage(m)), + setActive: false, + }); - expect(channel.lastMessage().created_at.getTime()).to.be.equal( + expect(channel.messagePaginator.headmostItem.created_at.getTime()).to.be.equal( new Date(latestMessageDate).getTime(), ); }); @@ -2679,25 +2654,79 @@ describe('Channel lastMessage', async () => { config: { skip_last_msg_update_for_system_msgs: true }, }); channel.state = new ChannelState(channel); - const latestMessageDate = '2018-01-01T00:13:24'; const latestMessages = [ - generateMsg({ date: latestMessageDate, type: 'system' }), + generateMsg({ date: '2018-01-01T00:13:24', type: 'system' }), generateMsg({ date: '2018-01-01T00:02:00' }), generateMsg({ date: '2018-01-01T00:00:00' }), ]; - const otherMessages = [ - generateMsg({ date: '2017-11-21T00:05:33' }), - generateMsg({ date: '2017-11-21T00:05:35' }), - ]; - channel.state.addMessagesSorted(latestMessages); - channel.state.addMessagesSorted(otherMessages, 'new'); + // ingestion advances the tracked latest, skipping the newest (system) message per config. + seedLatestWindow(channel, latestMessages); - expect(channel.state.last_message_at.getTime()).toBe( + expect(channel.messagePaginator.lastMessageAt.getTime()).toBe( new Date(latestMessages[1].created_at).getTime(), ); }); }); +describe('Channel last_message_at', () => { + let channel; + let client; + beforeEach(async () => { + client = await getClientWithUser(); + channel = client.channel('messaging', uuidv4()); + client._addChannelConfig({ cid: channel.cid, config: {} }); + channel.state = new ChannelState(channel); + }); + + const track = (msg) => channel.messagePaginator.trackLastMessage(formatMessage(msg)); + + it('advances monotonically as messages are tracked', () => { + expect(channel.messagePaginator.lastMessageAt).to.be.null; + track(generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' })); + expect(channel.messagePaginator.lastMessageAt.getTime()).to.be.equal( + new Date('2020-01-01T00:00:00.000Z').getTime(), + ); + track(generateMsg({ id: '1', date: '2019-01-01T00:00:00.000Z' })); + expect(channel.messagePaginator.lastMessageAt.getTime()).to.be.equal( + new Date('2020-01-01T00:00:00.000Z').getTime(), + ); + + track(generateMsg({ id: '2', date: '2020-01-01T00:00:00.001Z' })); + expect(channel.messagePaginator.lastMessageAt.getTime()).to.be.equal( + new Date('2020-01-01T00:00:00.001Z').getTime(), + ); + }); + + it('is not advanced by a thread-only reply', () => { + track( + generateMsg({ id: 'reply', date: '2020-01-01T00:00:00.000Z', parent_id: 'parent' }), + ); + + expect(channel.messagePaginator.lastMessageAt).to.be.null; + }); + + it('is null when nothing has been tracked or seeded', () => { + expect(channel.messagePaginator.lastMessageAt).to.be.null; + }); + + it('is seeded from the server-provided last_message_at', () => { + // A channel surfaced by the channel-list query: lastMessageAt is seeded from the server + // aggregate so it sorts correctly even before its message paginator loads a page. + channel.messagePaginator.seedLastMessageAt('2023-05-03T11:12:53.993Z'); + expect(channel.messagePaginator.lastMessageAt.getTime()).to.be.equal( + new Date('2023-05-03T11:12:53.993Z').getTime(), + ); + }); + + it('advances past the seeded value when a newer message is tracked (monotonic max)', () => { + channel.messagePaginator.seedLastMessageAt('2020-01-01T00:00:00.000Z'); + track(generateMsg({ id: '0', date: '2021-06-01T00:00:00.000Z' })); + expect(channel.messagePaginator.lastMessageAt.getTime()).to.be.equal( + new Date('2021-06-01T00:00:00.000Z').getTime(), + ); + }); +}); + describe('Channel _initializeState', () => { it('should not keep members that have unwatched since last watch', async () => { const client = await getClientWithUser(); @@ -2783,7 +2812,8 @@ describe('Channel.query', async () => { ...mockChannelQueryResponse, messages: Array.from( { length: DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE }, - generateMsg, + (_, i) => + generateMsg({ created_at: new Date(1700000000000 + i * 1000).toISOString() }), ), }; const mock = sinon.mock(client); @@ -2793,7 +2823,7 @@ describe('Channel.query', async () => { mock.restore(); }); - it('should update pagination for queried message set to prevent more pagination', async () => { + it('seeds the message paginator with the full latest page on query', async () => { const client = await getClientWithUser(); const channel = client.channel('messaging', uuidv4()); const mockedChannelQueryResponse = { @@ -2805,16 +2835,16 @@ describe('Channel.query', async () => { }; const mock = sinon.mock(client); mock.expects('post').returns(Promise.resolve(mockedChannelQueryResponse)); - await channel.query(); - expect(channel.state.messageSets.length).to.be.equal(1); - expect(channel.state.messageSets[0].pagination).to.eql({ - hasNext: false, - hasPrev: true, - }); + await channel.query({}, 'latest'); + // A latest-page query seeds the message paginator with the returned page. + expect(channel.messagePaginator.items).to.have.length( + DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE, + ); + expect(channel.messagePaginator.headmostItem).to.not.equal(undefined); mock.restore(); }); - it('should not update pagination for queried message set', async () => { + it('seeds the message paginator with a partial latest page on query', async () => { const client = await getClientWithUser(); const channel = client.channel('messaging', uuidv4()); const mockedChannelQueryResponse = { @@ -2826,12 +2856,10 @@ describe('Channel.query', async () => { }; const mock = sinon.mock(client); mock.expects('post').returns(Promise.resolve(mockedChannelQueryResponse)); - await channel.query(); - expect(channel.state.messageSets.length).to.be.equal(1); - expect(channel.state.messageSets[0].pagination).to.eql({ - hasNext: false, - hasPrev: false, - }); + await channel.query({}, 'latest'); + expect(channel.messagePaginator.items).to.have.length( + DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE - 1, + ); mock.restore(); }); @@ -3013,8 +3041,9 @@ describe('delete reaction flow', () => { // trick the channel into being initialized channel.initialized = true; - // Add a fake message to state for reaction deletion optimistic update in the db - channel.state.messages.push({ id: messageId }); + // Add a fake message to the paginator for reaction-deletion optimistic update in the db + // (channel.deleteReaction now resolves the message via messagePaginator.getItem). + channel.messagePaginator.ingestItem({ id: messageId }); loggerSpy = vi.spyOn(client, 'logger').mockImplementation(vi.fn()); queueTaskSpy = vi.spyOn(client.offlineDb, 'queueTask').mockResolvedValue({}); diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index 91761f8f17..80f15d9ad1 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -1,1437 +1,14 @@ import { generateChannel } from './test-utils/generateChannel'; -import { generateMsg } from './test-utils/generateMessage'; -import { generateUser } from './test-utils/generateUser'; import { getClientWithUser } from './test-utils/getClient'; import { getOrCreateChannelApi } from './test-utils/getOrCreateChannelApi'; import { ChannelState, StreamChat, Channel } from '../../src'; -import { DEFAULT_MESSAGE_SET_PAGINATION } from '../../src/constants'; import { generateUUIDv4 as uuidv4 } from '../../src/utils'; import { vi, describe, beforeEach, afterEach, it, expect } from 'vitest'; -import { MockOfflineDB } from './offline-support/MockOfflineDB'; const toISOString = (timestampMs) => new Date(timestampMs).toISOString(); -describe('ChannelState addMessagesSorted', function () { - let state; - let client; - - beforeEach(async () => { - client = new StreamChat(); - const offlineDb = new MockOfflineDB({ client }); - - client.setOfflineDBApi(offlineDb); - await client.offlineDb.init(client.userID); - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - state = new ChannelState(channel); - }); - - it('empty state add single messages', async function () { - expect(state.messages).to.have.length(0); - state.addMessagesSorted([generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' })]); - expect(state.messages).to.have.length(1); - state.addMessagesSorted([generateMsg({ id: '1', date: '2020-01-01T00:00:01.000Z' })]); - - expect(state.messages).to.have.length(2); - expect(state.messages[0].id).to.be.equal('0'); - expect(state.messages[1].id).to.be.equal('1'); - }); - - it('should not add messages from shadow banned users', () => { - state.addMessagesSorted([generateMsg({ shadowed: true })]); - - expect(state.messages).to.be.empty; - }); - - it('updates an existing message with shadowed: true when applying a message update', () => { - state.addMessagesSorted([generateMsg({ id: 'shadow-update-msg' })]); - - expect(state.messages).to.have.length(1); - expect(state.messages[0].shadowed).not.to.be.ok; - - state.addMessageSorted({ ...state.messages[0], shadowed: true }, false, false); - - expect(state.messages).to.have.length(1); - expect(state.messages[0].id).to.be.equal('shadow-update-msg'); - expect(state.messages[0].shadowed).to.be.equal(true); - }); - - it('empty state add multiple messages', async function () { - state.addMessagesSorted([ - generateMsg({ id: '1', date: '2020-01-01T00:00:00.001Z' }), - generateMsg({ id: '2', date: '2020-01-01T00:00:00.002Z' }), - generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' }), - ]); - - expect(state.messages).to.have.length(3); - expect(state.messages[0].id).to.be.equal('0'); - expect(state.messages[1].id).to.be.equal('1'); - expect(state.messages[2].id).to.be.equal('2'); - }); - - it('update a message in place 1', async function () { - state.addMessagesSorted([generateMsg({ id: '0' })]); - state.addMessagesSorted([{ ...state.messages[0], text: 'update' }]); - - expect(state.messages).to.have.length(1); - expect(state.messages[0].text).to.be.equal('update'); - }); - - it('update a message in place 2', async function () { - state.addMessagesSorted([ - generateMsg({ id: '1', date: '2020-01-01T00:00:00.001Z' }), - generateMsg({ id: '2', date: '2020-01-01T00:00:00.002Z' }), - generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' }), - ]); - - state.addMessagesSorted([{ ...state.messages[1], text: 'update' }]); - - expect(state.messages).to.have.length(3); - expect(state.messages[1].text).to.be.equal('update'); - expect(state.messages[0].id).to.be.equal('0'); - expect(state.messages[1].id).to.be.equal('1'); - expect(state.messages[2].id).to.be.equal('2'); - }); - - it('update a message in place 3', async function () { - state.addMessagesSorted([ - generateMsg({ id: '1', date: '2020-01-01T00:00:00.001Z' }), - generateMsg({ id: '2', date: '2020-01-01T00:00:00.002Z' }), - generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' }), - generateMsg({ id: '3', date: '2020-01-01T00:00:00.003Z' }), - ]); - - state.addMessagesSorted([{ ...state.messages[0], text: 'update 0' }]); - expect(state.messages).to.have.length(4); - expect(state.messages[0].text).to.be.equal('update 0'); - - state.addMessagesSorted([{ ...state.messages[2], text: 'update 2' }]); - expect(state.messages).to.have.length(4); - expect(state.messages[2].text).to.be.equal('update 2'); - - state.addMessagesSorted([{ ...state.messages[3], text: 'update 3' }]); - expect(state.messages).to.have.length(4); - expect(state.messages[3].text).to.be.equal('update 3'); - }); - - it('add a message with same created_at', async function () { - for (let i = 0; i < 10; i++) { - state.addMessagesSorted([ - generateMsg({ id: `${i}`, date: `2020-01-01T00:00:00.00${i}Z` }), - ]); - } - - for (let i = 10; i < state.messages.length - 1; i++) { - for (let j = i + 1; i < state.messages.length - 1; j++) - expect(state.messages[i].created_at.getTime()).to.be.lessThan( - state.messages[j].created_at.getTime(), - ); - } - - expect(state.messages).to.have.length(10); - state.addMessagesSorted([ - generateMsg({ id: 'id', date: `2020-01-01T00:00:00.007Z` }), - ]); - expect(state.messages).to.have.length(11); - expect(state.messages[7].id).to.be.equal('7'); - expect(state.messages[8].id).to.be.equal('id'); - }); - - it('add lots of messages in order', async function () { - for (let i = 100; i < 300; i++) { - state.addMessagesSorted([ - generateMsg({ id: `${i}`, date: `2020-01-01T00:00:00.${i}Z` }), - ]); - } - - expect(state.messages).to.have.length(200); - for (let i = 100; i < state.messages.length - 1; i++) { - for (let j = i + 1; j < state.messages.length - 1; j++) - expect(state.messages[i].created_at.getTime()).to.be.lessThan( - state.messages[j].created_at.getTime(), - ); - } - }); - - it('add lots of messages out of order', async function () { - const messages = []; - for (let i = 100; i < 300; i++) { - messages.push(generateMsg({ id: `${i}`, date: `2020-01-01T00:00:00.${i}Z` })); - } - // shuffle - for (let i = messages.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [messages[i], messages[j]] = [messages[j], messages[i]]; - } - - state.addMessagesSorted(messages); - - expect(state.messages).to.have.length(200); - for (let i = 0; i < 200; i++) { - expect(state.messages[i].id).to.be.equal(`${i + 100}`); - } - }); - - it('should avoid duplicates if message.created_at changes', async function () { - state.addMessagesSorted([generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' })]); - expect(state.messages).to.have.length(1); - - state.addMessageSorted( - { - ...state.messages[0], - created_at: '2020-01-01T00:00:00.044Z', - text: 'update 0', - }, - true, - ); - expect(state.messages).to.have.length(1); - expect(state.messages[0].text).to.be.equal('update 0'); - expect(state.messages[0].created_at.getTime()).to.be.equal( - new Date('2020-01-01T00:00:00.044Z').getTime(), - ); - }); - - it('should respect order and avoid duplicates if message.created_at changes', async function () { - state.addMessagesSorted([ - generateMsg({ id: '1', date: '2020-01-01T00:00:00.001Z' }), - generateMsg({ id: '2', date: '2020-01-01T00:00:00.002Z' }), - generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' }), - generateMsg({ id: '3', date: '2020-01-01T00:00:00.003Z' }), - ]); - expect(state.messages).to.have.length(4); - - state.addMessagesSorted( - [ - { - ...state.messages[3], - created_at: '2020-01-01T00:00:00.033Z', - text: 'update 3', - }, - ], - true, - ); - expect(state.messages).to.have.length(4); - expect(state.messages[3].text).to.be.equal('update 3'); - - state.addMessageSorted( - { - ...state.messages[0], - created_at: '2020-01-01T00:00:00.044Z', - text: 'update 0', - }, - true, - ); - expect(state.messages).to.have.length(4); - expect(state.messages[3].text).to.be.equal('update 0'); - expect(state.messages[0].id).to.be.equal('1'); - expect(state.messages[1].id).to.be.equal('2'); - expect(state.messages[2].id).to.be.equal('3'); - expect(state.messages[3].id).to.be.equal('0'); - }); - - it('should add messages to new message set', () => { - state.addMessagesSorted([ - generateMsg({ id: '12', date: toISOString(100) }), - generateMsg({ id: '13', date: toISOString(200) }), - generateMsg({ id: '14', date: toISOString(300) }), - ]); - state.addMessagesSorted( - [ - generateMsg({ id: '0', date: toISOString(1000) }), - generateMsg({ id: '1', date: toISOString(1100) }), - ], - false, - false, - true, - 'new', - ); - - expect(state.messages.length).to.be.equal(3); - expect(state.messages[0].id).to.be.equal('12'); - expect(state.messages[1].id).to.be.equal('13'); - expect(state.messages[2].id).to.be.equal('14'); - // set with ids 0,1 is added at the beginning as the newest set is inserted earlier - expect(state.messageSets[0].messages.map((m) => m.id)).toStrictEqual(['0', '1']); - expect(state.messageSets[1].messages.map((m) => m.id)).toStrictEqual([ - '12', - '13', - '14', - ]); - }); - - it('should add messages to current message set', () => { - state.addMessagesSorted( - [generateMsg({ id: '12' }), generateMsg({ id: '13' }), generateMsg({ id: '14' })], - false, - false, - true, - 'current', - ); - - expect(state.messages.length).to.be.equal(3); - expect(state.messages[0].id).to.be.equal('12'); - expect(state.messages[1].id).to.be.equal('13'); - expect(state.messages[2].id).to.be.equal('14'); - }); - - it('should add messages to latest message set', () => { - state.addMessagesSorted( - [generateMsg({ id: '12' }), generateMsg({ id: '13' }), generateMsg({ id: '14' })], - false, - false, - true, - 'latest', - ); - - expect(state.messages.length).to.be.equal(3); - expect(state.messages[0].id).to.be.equal('12'); - expect(state.messages[1].id).to.be.equal('13'); - expect(state.messages[2].id).to.be.equal('14'); - expect(state.latestMessages.length).to.be.equal(3); - expect(state.latestMessages[0].id).to.be.equal('12'); - expect(state.latestMessages[1].id).to.be.equal('13'); - expect(state.latestMessages[2].id).to.be.equal('14'); - }); - - it('should remove blocked messages from the latest messages from the offline database', () => { - state.addMessagesSorted( - [ - generateMsg({ - id: '12', - date: toISOString(1200), - type: 'error', - moderation_details: { action: 'MESSAGE_RESPONSE_ACTION_REMOVE' }, - }), - generateMsg({ - id: '13', - date: toISOString(1300), - type: 'error', - moderation: { action: 'remove' }, - }), - generateMsg({ id: '14', date: toISOString(1400) }), - ], - false, - false, - true, - 'latest', - ); - expect(state.latestMessages.length).to.be.equal(3); - state.filterErrorMessages(); - expect(state.latestMessages.length).to.be.equal(1); - expect(client.offlineDb.hardDeleteMessage).toHaveBeenCalledTimes(2); - expect(client.offlineDb.hardDeleteMessage).toHaveBeenCalledWith({ id: '12' }); - expect(client.offlineDb.hardDeleteMessage).toHaveBeenCalledWith({ id: '13' }); - }); - - it('adds message page sorted', () => { - // load first page - state.addMessagesSorted( - [ - generateMsg({ id: '12', date: toISOString(1200) }), - generateMsg({ id: '13', date: toISOString(1300) }), - generateMsg({ id: '14', date: toISOString(1400) }), - ], - false, - false, - true, - 'latest', - ); - - // jump to a start - state.addMessagesSorted( - [ - generateMsg({ id: '1', date: toISOString(100) }), - generateMsg({ id: '2', date: toISOString(200) }), - ], - false, - false, - true, - 'new', - ); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - // jump to a end - - state.addMessagesSorted( - [generateMsg({ id: '10', date: toISOString(1000) })], - false, - false, - true, - 'new', - ); - - state.addMessagesSorted( - [ - generateMsg({ id: '8', date: toISOString(800) }), - generateMsg({ id: '9', date: toISOString(900) }), - ], - false, - false, - true, - 'new', - ); - - state.addMessagesSorted( - [ - generateMsg({ id: '4', date: toISOString(400) }), - generateMsg({ id: '5', date: toISOString(500) }), - generateMsg({ id: '6', date: toISOString(600) }), - ], - false, - false, - true, - 'new', - ); - - state.addMessagesSorted( - [generateMsg({ id: '1500', date: toISOString(1500) })], - false, - false, - true, - 'new', - ); - - const toTimestamp = (msg) => new Date(msg.created_at).getTime(); - expect(state.messageSets.length).to.eql(6); - expect(state.messageSets[0].messages.map(toTimestamp)).toStrictEqual([1500]); - expect(state.messageSets[1].messages.map(toTimestamp)).toStrictEqual([ - 1200, 1300, 1400, - ]); - expect(state.messageSets[2].messages.map(toTimestamp)).toStrictEqual([1000]); - expect(state.messageSets[3].messages.map(toTimestamp)).toStrictEqual([800, 900]); - expect(state.messageSets[4].messages.map(toTimestamp)).toStrictEqual([400, 500, 600]); - expect(state.messageSets[5].messages.map(toTimestamp)).toStrictEqual([100, 200]); - }); - - it('inputs messages pertaining to different sets into corresponding message set and breaks the state', () => { - // load first page - state.addMessagesSorted( - [ - generateMsg({ id: '12', date: toISOString(1200) }), - generateMsg({ id: '14', date: toISOString(1400) }), - ], - false, - false, - true, - 'latest', - ); - - state.addMessagesSorted( - [ - generateMsg({ id: '6', date: toISOString(600) }), - generateMsg({ id: '8', date: toISOString(800) }), - ], - false, - false, - true, - 'new', - ); - - state.addMessagesSorted( - [ - generateMsg({ id: '1', date: toISOString(100) }), - generateMsg({ id: '3', date: toISOString(300) }), - ], - false, - false, - true, - 'new', - ); - - state.addMessagesSorted( - [ - generateMsg({ id: '7', date: 700 }), - generateMsg({ id: '2', date: 200 }), - generateMsg({ id: '13', date: toISOString(1300) }), - ], - false, - false, - true, - 'new', - ); - - const toTimestamp = (msg) => new Date(msg.created_at).getTime(); - expect(state.messageSets.length).to.eql(4); - expect(state.messageSets[0].messages.map(toTimestamp)).toStrictEqual([1200, 1400]); - expect(state.messageSets[1].messages.map(toTimestamp)).toStrictEqual([ - 200, 700, 1300, - ]); - expect(state.messageSets[2].messages.map(toTimestamp)).toStrictEqual([600, 800]); - expect(state.messageSets[3].messages.map(toTimestamp)).toStrictEqual([100, 300]); - }); - - it(`should add messages to latest message set when it's not currently active`, () => { - state.addMessagesSorted( - [ - generateMsg({ id: '12', date: toISOString(1200) }), - generateMsg({ id: '13', date: toISOString(1300) }), - generateMsg({ id: '14', date: toISOString(1400) }), - ], - false, - false, - true, - 'latest', - ); - state.addMessagesSorted( - [ - generateMsg({ id: '1', date: toISOString(100) }), - generateMsg({ id: '2', date: toISOString(200) }), - ], - false, - false, - true, - 'new', - ); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - state.addMessagesSorted( - [generateMsg({ id: '15', date: toISOString(1500) })], - false, - false, - true, - 'latest', - ); - - expect(state.latestMessages.map((m) => m.id)).toStrictEqual(['12', '13', '14', '15']); - }); - - it('adjusts the latest set flag according to actual message creation date', () => { - state.addMessagesSorted( - [ - generateMsg({ id: '1', date: toISOString(100) }), - generateMsg({ id: '2', date: toISOString(200) }), - ], - false, - false, - true, - 'latest', - ); - expect(state.latestMessages.map((m) => m.id)).toStrictEqual(['1', '2']); - - state.addMessagesSorted( - [ - generateMsg({ id: '12', date: toISOString(1200) }), - generateMsg({ id: '13', date: toISOString(1300) }), - generateMsg({ id: '14', date: toISOString(1400) }), - ], - false, - false, - true, - 'new', - ); - expect(state.latestMessages.map((m) => m.id)).toStrictEqual(['12', '13', '14']); - expect(state.messageSets.filter((s) => s.isLatest).length).toBe(1); - }); - - it("the messageSetToAddToIfDoesNotExist: 'latest' should be ignored if the messages do not belong to the latest set based on their creation timestamp", () => { - state.addMessagesSorted( - [ - generateMsg({ id: '12', date: toISOString(1200) }), - generateMsg({ id: '13', date: toISOString(1300) }), - generateMsg({ id: '14', date: toISOString(1400) }), - ], - false, - false, - true, - 'latest', - ); - state.addMessagesSorted( - [ - generateMsg({ id: '1', date: toISOString(100) }), - generateMsg({ id: '2', date: toISOString(200) }), - ], - false, - false, - true, - 'new', - ); - expect(state.messageSets[0].isCurrent).toBeTruthy(); - expect(state.messageSets[1].isCurrent).toBeFalsy(); - - state.addMessagesSorted( - [generateMsg({ id: '15', date: toISOString(150) })], - false, - false, - true, - 'latest', - ); - - expect(state.messageSets[0].messages.map((m) => m.id)).toStrictEqual([ - '12', - '13', - '14', - ]); - expect(state.latestMessages.map((m) => m.id)).toStrictEqual(['12', '13', '14']); - expect(state.messageSets[1].messages.map((m) => m.id)).toStrictEqual([ - '1', - '15', - '2', - ]); - }); - - it(`shouldn't create new message set for thread replies`, () => { - state.addMessagesSorted( - [ - generateMsg({ parent_id: '12' }), - generateMsg({ parent_id: '12' }), - generateMsg({ parent_id: '12' }), - ], - false, - false, - true, - 'new', - ); - - expect(state.messageSets.length).to.be.equal(1); - }); - - it(`should update message in non-active message set`, () => { - state.addMessagesSorted([ - generateMsg({ id: '12' }), - generateMsg({ id: '13' }), - generateMsg({ id: '14' }), - ]); - state.addMessagesSorted( - [generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' })], - false, - false, - true, - 'new', - ); - state.addMessagesSorted( - [ - generateMsg({ - id: '0', - date: '2020-01-01T00:00:00.000Z', - text: 'Updated text', - }), - ], - false, - false, - false, - ); - - expect(state.messages.length).to.be.equal(3); - expect(state.messageSets[1].messages.length).to.be.equal(1); - expect(state.messageSets[1].messages[0].text).to.be.equal('Updated text'); - }); - - it(`should update message in active message set`, () => { - state.addMessagesSorted([ - generateMsg({ id: '12', date: '2020-01-01T00:00:00.000Z' }), - generateMsg({ id: '13', date: '2020-01-01T00:00:10.000Z' }), - generateMsg({ id: '14', date: '2020-01-01T00:00:11.000Z' }), - ]); - state.addMessagesSorted( - [ - generateMsg({ - id: '13', - date: '2020-01-01T00:00:10.000Z', - text: 'Updated text', - }), - ], - false, - false, - false, - ); - - expect(state.messages.length).to.be.equal(3); - expect(state.messages[1].text).to.be.equal('Updated text'); - expect(state.messageSets.length).to.be.equal(1); - }); - - it(`should update message in latest message set`, () => { - state.addMessagesSorted( - [ - generateMsg({ id: '12', date: '2020-01-01T00:00:00.000Z' }), - generateMsg({ id: '13', date: '2020-01-01T00:00:10.000Z' }), - generateMsg({ id: '14', date: '2020-01-01T00:00:11.000Z' }), - ], - false, - false, - true, - 'latest', - ); - state.addMessagesSorted( - [ - generateMsg({ - id: '13', - date: '2020-01-01T00:00:10.000Z', - text: 'Updated text', - }), - ], - false, - false, - false, - ); - - expect(state.latestMessages.length).to.be.equal(3); - expect(state.latestMessages[1].text).to.be.equal('Updated text'); - }); - - it(`should do nothing if message is not available locally`, () => { - state.addMessagesSorted([ - generateMsg({ id: '12', date: toISOString(1200) }), - generateMsg({ id: '13', date: toISOString(1300) }), - generateMsg({ id: '14', date: toISOString(1400) }), - ]); - state.addMessagesSorted( - [generateMsg({ id: '5', date: toISOString(500) })], - false, - false, - true, - 'new', - ); - state.addMessagesSorted( - [ - generateMsg({ id: '1', date: toISOString(100) }), - generateMsg({ id: '2', date: toISOString(200) }), - ], - false, - false, - true, - 'new', - ); - state.addMessagesSorted( - [generateMsg({ id: '8', date: toISOString(800) })], - false, - false, - false, - ); - - expect(state.latestMessages.length).to.be.equal(3); - expect(state.messages.length).to.be.equal(3); - expect(state.messageSets[1].messages.length).to.be.equal(1); - expect(state.messageSets[2].messages.length).to.be.equal(2); - }); - - it('updates last_message_at correctly', async function () { - expect(state.last_message_at).to.be.null; - state.addMessagesSorted([generateMsg({ id: '0', date: '2020-01-01T00:00:00.000Z' })]); - expect(state.last_message_at.getTime()).to.be.equal( - new Date('2020-01-01T00:00:00.000Z').getTime(), - ); - state.addMessagesSorted([generateMsg({ id: '1', date: '2019-01-01T00:00:00.000Z' })]); - expect(state.last_message_at.getTime()).to.be.equal( - new Date('2020-01-01T00:00:00.000Z').getTime(), - ); - - state.addMessagesSorted([generateMsg({ id: '2', date: '2020-01-01T00:00:00.001Z' })]); - expect(state.last_message_at.getTime()).to.be.equal( - new Date('2020-01-01T00:00:00.001Z').getTime(), - ); - }); - - it('sets pinnedMessages correctly', async function () { - const msgs = [ - generateMsg({ id: '1', date: '2020-01-01T00:00:00.001Z' }), - generateMsg({ id: '2', date: '2020-01-01T00:00:00.002Z' }), - generateMsg({ id: '3', date: '2020-01-01T00:00:00.003Z' }), - ]; - msgs[0].pinned = true; - msgs[0].pinned_at = new Date('2020-01-01T00:00:00.010Z'); - msgs[1].pinned = true; - msgs[1].pinned_at = new Date('2020-01-01T00:00:00.012Z'); - msgs[2].pinned = true; - msgs[2].pinned_at = new Date('2020-01-01T00:00:00.011Z'); - state.addPinnedMessages(msgs); - expect(state.pinnedMessages.length).to.be.equal(3); - expect(state.pinnedMessages[0].id).to.be.equal('1'); - expect(state.pinnedMessages[1].id).to.be.equal('3'); - expect(state.pinnedMessages[2].id).to.be.equal('2'); - }); - - it('should add message preview', async function () { - // these message previews are used UI SDKs - const messagePreview = generateMsg({ - id: '1', - date: new Date('2020-01-01T00:00:00.001Z'), - }); - state.addMessageSorted(messagePreview); - - expect(state.messages[0].id).to.be.equal('1'); - }); - - it('should add thread reply preview', async function () { - // these message previews are used by UI SDKs - const parentMessage = generateMsg({ - id: 'parent_id', - date: '2020-01-01T00:00:00.001Z', - }); - const threadReplyPreview = generateMsg({ - id: '2', - date: new Date('2020-01-01T00:00:00.001Z'), - parent_id: 'parent_id', - }); - state.addMessageSorted(parentMessage); - state.addMessageSorted(threadReplyPreview); - const thread = state.threads[parentMessage.id]; - - expect(thread.length).to.be.equal(1); - expect(thread[0].id).to.be.equal(threadReplyPreview.id); - }); - - describe('merges overlapping message sets', () => { - it('when new messages overlap with latest messages', () => { - const overlap = [ - generateMsg({ id: '11', date: toISOString(1100) }), - generateMsg({ id: '12', date: toISOString(1200) }), - generateMsg({ id: '13', date: toISOString(1300) }), - ]; - const messages = [ - ...overlap, - generateMsg({ id: '14', date: toISOString(1400) }), - generateMsg({ id: '15', date: toISOString(1500) }), - ]; - state.addMessagesSorted(messages); - const newMessages = [ - generateMsg({ id: '10', date: toISOString(1000) }), - ...overlap, - ]; - state.addMessagesSorted(newMessages, false, true, true, 'new'); - - expect(state.messages.length).to.be.equal(6); - expect(state.messages[0].id).to.be.equal('10'); - expect(state.messages[1].id).to.be.equal('11'); - expect(state.messages[2].id).to.be.equal('12'); - expect(state.messages[3].id).to.be.equal('13'); - expect(state.messages[4].id).to.be.equal('14'); - expect(state.messages[5].id).to.be.equal('15'); - expect(state.messageSets.length).to.be.equal(1); - expect(state.messages).to.be.equal(state.latestMessages); - }); - - it('when new messages overlap with current messages, but not with latest messages', () => { - const overlap = [generateMsg({ id: '11', date: '2020-01-01T00:00:10.001Z' })]; - const latestMessages = [ - generateMsg({ id: '20', date: '2020-01-01T00:10:10.001Z' }), - ]; - state.addMessagesSorted(latestMessages); - const currentMessages = [ - generateMsg({ id: '10', date: '2020-01-01T00:00:03.001Z' }), - ...overlap, - ]; - state.addMessagesSorted(currentMessages, false, true, true, 'new'); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - const newMessages = [ - ...overlap, - generateMsg({ id: '12', date: '2020-01-01T00:00:11.001Z' }), - ]; - state.addMessagesSorted(newMessages, false, true, true, 'new'); - - expect(state.latestMessages.length).to.be.equal(1); - expect(state.latestMessages[0].id).to.be.equal('20'); - expect(state.messages.length).to.be.equal(3); - expect(state.messages[0].id).to.be.equal('10'); - expect(state.messages[1].id).to.be.equal('11'); - expect(state.messages[2].id).to.be.equal('12'); - expect(state.messageSets.length).to.be.equal(2); - }); - - it('when new messages overlap with messages, but not current or latest messages', () => { - const overlap = [generateMsg({ id: '11', date: toISOString(1100) })]; - const latestMessages = [generateMsg({ id: '20', date: toISOString(2000) })]; - state.addMessagesSorted(latestMessages); - const currentMessages = [generateMsg({ id: '8', date: toISOString(800) })]; - state.addMessagesSorted(currentMessages, false, true, true, 'new'); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - const otherMessages = [ - generateMsg({ id: '10', date: toISOString(1000) }), - ...overlap, - ]; - state.addMessagesSorted(otherMessages, false, true, true, 'new'); - const newMessages = [ - ...overlap, - generateMsg({ id: '12', date: toISOString(1200) }), - ]; - state.addMessagesSorted(newMessages, false, true, true, 'new'); - - expect(state.messageSets.length).to.be.equal(3); - expect(state.latestMessages.map(({ id }) => id)).toStrictEqual(['20']); - expect(state.messages.map(({ id }) => id)).toStrictEqual(['8']); - expect(state.messageSets.map((s) => s.messages.map(({ id }) => id))).toStrictEqual([ - ['20'], - ['10', '11', '12'], - ['8'], - ]); - }); - - it('when current messages overlap with latest', () => { - const overlap = [generateMsg({ id: '11', date: '2020-01-01T00:00:10.001Z' })]; - const latestMessages = [ - ...overlap, - generateMsg({ id: '12', date: '2020-01-01T00:01:10.001Z' }), - ]; - state.addMessagesSorted(latestMessages); - const currentMessages = [ - generateMsg({ id: '8', date: '2020-01-01T00:00:03.001Z' }), - ]; - state.addMessagesSorted(currentMessages, false, true, true, 'new'); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - const newMessages = [ - generateMsg({ id: '9', date: '2020-01-01T00:00:04.001Z' }), - generateMsg({ id: '10', date: '2020-01-01T00:00:07.001Z' }), - ...overlap, - ]; - state.addMessagesSorted(newMessages, false, true, true, 'current'); - - expect(state.messages.length).to.be.equal(5); - expect(state.messages[0].id).to.be.equal('8'); - expect(state.messages[1].id).to.be.equal('9'); - expect(state.messages[2].id).to.be.equal('10'); - expect(state.messages[3].id).to.be.equal('11'); - expect(state.messages[4].id).to.be.equal('12'); - expect(state.latestMessages).to.be.equal(state.messages); - }); - - it('when new messages overlap with multiple message sets', () => { - const overlap1 = [generateMsg({ id: '11', date: '2020-01-01T00:00:10.001Z' })]; - const overlap2 = [generateMsg({ id: '13', date: '2020-01-01T00:01:10.001Z' })]; - const latestMessages = [ - ...overlap2, - generateMsg({ id: '14', date: '2020-01-01T00:01:15.001Z' }), - ]; - state.addMessagesSorted(latestMessages); - const currentMessages = [ - generateMsg({ id: '10', date: '2020-01-01T00:00:03.001Z' }), - ...overlap1, - ]; - state.addMessagesSorted(currentMessages, false, true, true, 'new'); - state.messageSets[0].isCurrent = false; - state.messageSets[0].pagination = { hasPrev: true, hasNext: false }; - state.messageSets[1].isCurrent = true; - state.messageSets[1].pagination = { hasPrev: false, hasNext: true }; - const newMessages = [ - ...overlap1, - generateMsg({ id: '12', date: '2020-01-01T00:00:14.001Z' }), - ...overlap2, - ]; - state.addMessagesSorted(newMessages, false, true, true, 'new'); - - expect(state.messages.length).to.be.equal(5); - expect(state.messages[0].id).to.be.equal('10'); - expect(state.messages[1].id).to.be.equal('11'); - expect(state.messages[2].id).to.be.equal('12'); - expect(state.messages[3].id).to.be.equal('13'); - expect(state.messages[4].id).to.be.equal('14'); - expect(state.messages).to.be.equal(state.latestMessages); - expect(state.messageSets.length).to.be.equal(1); - expect(state.messageSets[0].pagination).to.be.eql({ - hasPrev: false, - hasNext: false, - }); - }); - }); -}); - -describe('ChannelState message pruning', () => { - let channelState; - let initialMessages = []; - - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'userId'; - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - channelState = new ChannelState(channel); - initialMessages = Array.from({ length: 10 }, () => - generateMsg({ date: toISOString(100) }), - ); - channelState.addMessagesSorted(initialMessages); - }); - - it('should prune messages from the end when we are in the latest set', () => { - expect(channelState.messageSets.length).to.be.equal(1); - expect(channelState.messageSets[0].isLatest).to.be.equal(true); - expect(channelState.messageSets[0].isCurrent).to.be.equal(true); - expect(channelState.messages.length).to.be.equal(10); - expect(channelState.messagePagination.hasPrev).to.be.equal(false); - - const previousHasNext = channelState.messagePagination.hasNext; - - channelState.pruneOldest(5); - - expect(channelState.messageSets.length).to.be.equal(1); - expect(channelState.messages.length).to.be.equal(5); - expect(channelState.messagePagination.hasPrev).to.be.equal(true); - expect(channelState.messagePagination.hasNext).to.be.equal(previousHasNext); - }); - - it('should do nothing if the current message set is not also the latest', () => { - expect(channelState.messageSets.length).to.be.equal(1); - - channelState.messageSets[0].isLatest = false; - - expect(channelState.messages.length).to.be.equal(10); - expect(channelState.messagePagination.hasPrev).to.be.equal(false); - - channelState.pruneOldest(5); - - expect(channelState.messages.length).to.be.equal(10); - expect(channelState.messagePagination.hasPrev).to.be.equal(false); - }); - - it('should prune the correct messageSet', () => { - channelState.addMessagesSorted( - Array.from({ length: 10 }, () => generateMsg({ date: toISOString(50) })), - false, - true, - true, - 'new', - ); - - expect(channelState.messageSets.length).to.be.equal(2); - - channelState.pruneOldest(5); - - const currentMessageSet = channelState.messageSets.find((ms) => ms.isCurrent); - const otherMessageSet = channelState.messageSets.find((ms) => !ms.isCurrent); - - expect(currentMessageSet.messages.length).to.be.equal(5); - expect(currentMessageSet.pagination.hasPrev).to.be.equal(true); - expect(channelState.messages).to.be.equal(currentMessageSet.messages); - - expect(otherMessageSet.messages.length).to.be.equal(10); - expect(otherMessageSet.pagination.hasPrev).to.be.equal(false); - }); - - it('should correctly apply pruning', () => { - channelState.pruneOldest(5); - - expect(channelState.messages.length).to.be.equal(5); - for (const message of initialMessages.slice(-5)) { - expect(channelState.messages.some((m) => m.id === message.id)).to.be.equal(true); - } - - for (const message of initialMessages.slice(0, 5)) { - expect(channelState.messages.some((m) => m.id === message.id)).to.be.equal(false); - } - }); -}); - -describe('ChannelState reactions', () => { - const message = generateMsg(); - let state; - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'observer'; - state = new ChannelState(new Channel(client, 'live', 'stream', {})); - state.addMessageSorted(message); - }); - it('Add one reaction', () => { - const reaction = { - user_id: 'observer', - type: 'like', - score: 1, - }; - const msg = { ...message }; - msg.latest_reactions.push(reaction); - const newMessage = state.addReaction(reaction, msg); - expect(newMessage.own_reactions.length).to.be.eq(1); - // validate the message got updated in channel state - expect(state.messages[0].latest_reactions.length).to.be.eq(1); - }); - it('Add same reaction twice', () => { - let newMessage = state.addReaction( - { - user_id: 'observer', - type: 'like', - score: 1, - }, - message, - ); - newMessage = state.addReaction( - { - user_id: 'observer', - type: 'like', - score: 1, - }, - newMessage, - ); - expect(newMessage.own_reactions.length).to.be.eq(1); - }); - it('Add two reactions', () => { - let newMessage = state.addReaction( - { - user_id: 'observer', - type: 'like', - score: 1, - }, - message, - ); - newMessage = state.addReaction( - { - user_id: 'user2', - type: 'like', - score: 4, - }, - newMessage, - ); - expect(newMessage.own_reactions.length).to.be.eq(1); - expect(newMessage.own_reactions[0].user_id).to.be.eq('observer'); - }); - - describe('_addReactionToState', () => { - let addOwnReactionToMessageSpy; - let reaction; - let userID; - let baseMessage; - - beforeEach(() => { - userID = state._channel.getClient().userID; - baseMessage = { - id: 'msg-1', - own_reactions: [], - latest_reactions: [], - reaction_groups: {}, - }; - - reaction = { - message_id: baseMessage.id, - type: 'like', - user_id: userID, - score: 2, - created_at: new Date(), - }; - - addOwnReactionToMessageSpy = vi.spyOn(state, '_addOwnReactionToMessage'); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - it('should create a new reaction group if none exist', () => { - const messageFromState = { ...baseMessage, reaction_groups: undefined }; - const result = state._addReactionToState(messageFromState, reaction); - - expect(result.reaction_groups).to.deep.equal({ - like: { - count: 1, - sum_scores: 2, - first_reaction_at: reaction.created_at, - last_reaction_at: reaction.created_at, - }, - }); - }); - - it('should update existing reaction group', () => { - const existing = { - count: 1, - sum_scores: 1, - first_reaction_at: new Date(Date.now() - 5000), - last_reaction_at: new Date(Date.now() - 5000), - }; - const messageFromState = { - ...baseMessage, - reaction_groups: { like: { ...existing } }, - }; - - const result = state._addReactionToState(messageFromState, reaction); - - expect(result.reaction_groups.like.count).to.equal(2); - expect(result.reaction_groups.like.sum_scores).to.equal(3); - expect(result.reaction_groups.like.last_reaction_at).to.equal(reaction.created_at); - }); - - it('should remove previous own reactions from reaction_groups if enforce_unique is true', () => { - const oldReactions = [ - { - type: 'clap', - user_id: userID, - score: 1, - }, - { - type: 'wow', - user_id: userID, - score: 2, - }, - ]; - - const messageFromState = { - ...baseMessage, - own_reactions: oldReactions, - reaction_groups: { - clap: { - count: 1, - sum_scores: 1, - }, - wow: { - count: 1, - sum_scores: 2, - }, - }, - }; - - const result = state._addReactionToState(messageFromState, reaction, true); - - expect(result.reaction_groups.clap).to.be.undefined; - expect(result.reaction_groups.wow).to.be.undefined; - expect(result.reaction_groups.like.count).to.equal(1); - }); - - it('should preserve other users’ reactions when enforce_unique is true', () => { - const newOwnReaction = { - ...reaction, - type: 'wow', - }; - const messageFromState = { - ...baseMessage, - own_reactions: [ - { type: 'like', user_id: userID, score: 1 }, - { type: 'clap', user_id: userID, score: 1 }, - ], - latest_reactions: [ - { type: 'like', user_id: userID, score: 1 }, - { type: 'clap', user_id: userID, score: 1 }, - { type: 'clap', user_id: 'other-user', score: 1 }, - ], - reaction_groups: { - like: { count: 1, sum_scores: 1 }, - clap: { count: 2, sum_scores: 2 }, - }, - }; - - const result = state._addReactionToState(messageFromState, newOwnReaction, true); - - Object.keys(result.reaction_groups).forEach((key) => { - delete result.reaction_groups[key].first_reaction_at; - delete result.reaction_groups[key].last_reaction_at; - }); - - expect(result.reaction_groups).to.deep.equal({ - clap: { - count: 1, - sum_scores: 1, - }, - wow: { - count: 1, - sum_scores: 2, - }, - }); - expect(result.latest_reactions).to.deep.equal([ - { type: 'clap', user_id: 'other-user', score: 1 }, - newOwnReaction, - ]); - expect(result.own_reactions).to.deep.equal([newOwnReaction]); - }); - - it('should correctly update own_reactions with the new reaction', () => { - const oldOwnReactions = [{ type: 'clap', user_id: userID, score: 1 }]; - const messageFromState = { - ...baseMessage, - own_reactions: oldOwnReactions, - reaction_groups: { - clap: { count: 1, sum_scores: 1 }, - }, - }; - const result1 = state._addReactionToState(messageFromState, reaction); - - expect(addOwnReactionToMessageSpy).toHaveBeenCalledTimes(1); - expect(result1.own_reactions).to.deep.equal([...oldOwnReactions, reaction]); - - vi.clearAllMocks(); - - const newerReaction = { ...reaction, type: 'wow' }; - const result2 = state._addReactionToState(result1, newerReaction, true); - - expect(addOwnReactionToMessageSpy).toHaveBeenCalledTimes(1); - expect(result2.own_reactions).to.deep.equal([newerReaction]); - }); - - it('should overwrite own reaction in latest_reactions if enforce_unique is true', () => { - const oldReaction = { - type: 'clap', - user_id: userID, - }; - - const messageFromState = { - ...baseMessage, - latest_reactions: [oldReaction], - }; - - const result = state._addReactionToState(messageFromState, reaction, true); - - expect(result.latest_reactions).to.deep.equal([reaction]); - }); - - it('should append to latest_reactions if enforce_unique is false', () => { - const messageFromState = { - ...baseMessage, - latest_reactions: [], - }; - - const result = state._addReactionToState(messageFromState, reaction, false); - - expect(result.latest_reactions.length).to.equal(1); - expect(result.latest_reactions[0]).to.deep.equal(reaction); - }); - - it('should handle empty own_reactions and latest_reactions gracefully', () => { - const messageFromState = { - ...baseMessage, - own_reactions: undefined, - latest_reactions: undefined, - }; - - const result = state._addReactionToState(messageFromState, reaction, true); - - expect(result.own_reactions).to.deep.equal([reaction]); - expect(result.latest_reactions).to.deep.equal([reaction]); - }); - }); - - describe('_removeReactionFromState', () => { - let reaction; - let userID; - let baseMessage; - - beforeEach(() => { - userID = state._channel.getClient().userID; - - baseMessage = { - id: 'messageFromState-1', - own_reactions: [ - { type: 'like', user_id: userID, score: 2 }, - { type: 'clap', user_id: userID, score: 1 }, - ], - latest_reactions: [ - { type: 'like', user_id: userID, score: 2 }, - { type: 'clap', user_id: userID, score: 1 }, - { type: 'wow', user_id: 'other-user', score: 1 }, - ], - reaction_groups: { - like: { - count: 1, - sum_scores: 2, - }, - clap: { - count: 1, - sum_scores: 1, - }, - wow: { - count: 1, - sum_scores: 1, - }, - }, - }; - - reaction = { - type: 'like', - user_id: userID, - score: 2, - }; - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - it('should remove the reaction from own_reactions', () => { - const result = state._removeReactionFromState({ ...baseMessage }, reaction); - expect(result.own_reactions.some((r) => r.type === 'like')).to.be.false; - }); - - it('should decrement the count and sum_scores in the reaction group', () => { - const result = state._removeReactionFromState({ ...baseMessage }, reaction); - expect(result.reaction_groups.like).to.be.undefined; - }); - - it('should remove the reaction from latest_reactions for the same user', () => { - const result = state._removeReactionFromState({ ...baseMessage }, reaction); - expect( - result.latest_reactions.some((r) => r.type === 'like' && r.user_id === userID), - ).to.be.false; - }); - - it('should preserve other users’ reactions in latest_reactions', () => { - const reactionToRemove = { - type: 'wow', - user_id: userID, - }; - const result = state._removeReactionFromState({ ...baseMessage }, reactionToRemove); - expect( - result.latest_reactions.some( - (r) => r.user_id === 'other-user' && r.type === 'wow', - ), - ).to.be.true; - }); - - it('should handle when reaction_groups count becomes 0 by deleting the group', () => { - const reactionToRemove = { - type: 'clap', - user_id: userID, - score: 1, - }; - const result = state._removeReactionFromState({ ...baseMessage }, reactionToRemove); - expect(result.reaction_groups.clap).to.be.undefined; - }); - - it('should handle when own_reactions is undefined', () => { - const messageFromState = { - ...baseMessage, - own_reactions: undefined, - }; - const result = state._removeReactionFromState(messageFromState, reaction); - expect(result.own_reactions).to.be.undefined; - }); - - it('should handle when latest_reactions is undefined', () => { - const messageFromState = { - ...baseMessage, - latest_reactions: undefined, - }; - const result = state._removeReactionFromState(messageFromState, reaction); - expect(result.latest_reactions).to.be.undefined; - }); - - it('should not crash if reaction group does not exist', () => { - const messageFromState = { - ...baseMessage, - reaction_groups: { - wow: { - count: 1, - sum_scores: 1, - }, - }, - }; - const result = state._removeReactionFromState(messageFromState, reaction); - expect(result.reaction_groups.wow).to.exist; - }); - }); -}); - -describe('ChannelState isUpToDate', () => { - it('isUpToDate flag should be set to false, when watcher is disconnected', async () => { - const chatClient = await getClientWithUser(); - const channelId = uuidv4(); - const mockedChannelResponse = generateChannel({ - channel: { - id: channelId, - }, - }); - - // to mock the channel.watch call - chatClient.post = () => getOrCreateChannelApi(mockedChannelResponse).response.data; - const channel = chatClient.channel('messaging', channelId); - - await channel.watch(); - // This is a responsibility of application layer to set the flag, depending - // on what state is queried - most recent or some older. - channel.state.setIsUpToDate(true); - - expect(channel.state.isUpToDate).to.be.eq(true); - - await channel._disconnect(); - expect(channel.state.isUpToDate).to.be.eq(false); - }); -}); - describe('ChannelState clean', () => { let client; let channel; @@ -1469,350 +46,6 @@ describe('ChannelState clean', () => { }); }); -describe('deleteUserMessages', () => { - let state; - - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'userId'; - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - state = new ChannelState(channel); - }); - - it('should remove content of messages from given user, when hardDelete is true', () => { - const user1 = generateUser(); - const user2 = generateUser(); - - const m1u1 = generateMsg({ user: user1 }); - const m2u1 = generateMsg({ user: user1 }); - const m1u2 = generateMsg({ user: user2 }); - const m2u2 = generateMsg({ user: user2 }); - - state.addMessagesSorted([m1u1, m2u1, m1u2, m2u2]); - - expect(state.messages).to.have.length(4); - - state.deleteUserMessages(user1, true); - - expect(state.messages).to.have.length(4); - - expect(state.messages[0].type).to.be.equal('deleted'); - expect(state.messages[0].text).to.be.equal(undefined); - expect(state.messages[0].html).to.be.equal(undefined); - - expect(state.messages[1].type).to.be.equal('deleted'); - expect(state.messages[1].text).to.be.equal(undefined); - expect(state.messages[1].html).to.be.equal(undefined); - - expect(state.messages[2].type).to.be.equal('regular'); - expect(state.messages[2].text).to.be.equal(m1u2.text); - expect(state.messages[2].html).to.be.equal(m1u2.html); - - expect(state.messages[3].type).to.be.equal('regular'); - expect(state.messages[3].text).to.be.equal(m2u2.text); - expect(state.messages[3].html).to.be.equal(m2u2.html); - }); - it('should mark messages from given user as deleted, when hardDelete is false', () => { - const user1 = generateUser(); - const user2 = generateUser(); - - const m1u1 = generateMsg({ user: user1 }); - const m2u1 = generateMsg({ user: user1 }); - const m1u2 = generateMsg({ user: user2 }); - const m2u2 = generateMsg({ user: user2 }); - - state.addMessagesSorted([m1u1, m2u1, m1u2, m2u2]); - expect(state.messages).to.have.length(4); - - state.deleteUserMessages(user1); - - expect(state.messages).to.have.length(4); - - expect(state.messages[0].type).to.be.equal('deleted'); - expect(state.messages[0].text).to.be.equal(m1u1.text); - expect(state.messages[0].html).to.be.equal(m1u1.html); - - expect(state.messages[1].type).to.be.equal('deleted'); - expect(state.messages[1].text).to.be.equal(m2u1.text); - expect(state.messages[1].html).to.be.equal(m2u1.html); - - expect(state.messages[2].type).to.be.equal('regular'); - expect(state.messages[2].text).to.be.equal(m1u2.text); - expect(state.messages[2].html).to.be.equal(m1u2.html); - - expect(state.messages[3].type).to.be.equal('regular'); - expect(state.messages[3].text).to.be.equal(m2u2.text); - expect(state.messages[3].html).to.be.equal(m2u2.html); - }); -}); - -// Regression tests for GetStream/stream-chat-js#1736: -// deleteUserMessages crashed with "Cannot read property 'cid' of undefined" when -// hard-deleting a message that quotes another message from the same user. The first -// branch replaced messages[i] with the stripped hard-delete placeholder (no -// quoted_message field); the second branch then read messages[i].quoted_message as -// undefined and passed it into toDeletedMessage, which dereferences message.cid. -describe('deleteUserMessages — quoted_message regression (#1736)', () => { - let state; - - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'userId'; - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - state = new ChannelState(channel); - }); - - it('does not throw when hard-deleting a message that quotes another message from the same user', () => { - const user1 = generateUser(); - const m1 = generateMsg({ user: user1 }); - const m2 = generateMsg({ - user: user1, - quoted_message: m1, - quoted_message_id: m1.id, - }); - - state.addMessagesSorted([m1, m2]); - - expect(() => state.deleteUserMessages(user1, true)).not.to.throw(); - - expect(state.messages).to.have.length(2); - expect(state.messages[0].type).to.be.equal('deleted'); - expect(state.messages[1].type).to.be.equal('deleted'); - // Hard-deleted parent retains the stripped shape (no quoted_message field). - expect(state.messages[1].text).to.be.equal(undefined); - expect(state.messages[1].quoted_message).to.be.equal(undefined); - }); - - it('does not throw when hard-deleting a thread reply that quotes another same-user reply', () => { - const user1 = generateUser(); - const parent = generateMsg({ user: user1, id: 'parent-id' }); - const reply1 = generateMsg({ - user: user1, - parent_id: parent.id, - date: '2020-01-01T00:00:01.000Z', - }); - const reply2 = generateMsg({ - user: user1, - parent_id: parent.id, - date: '2020-01-01T00:00:02.000Z', - quoted_message: reply1, - quoted_message_id: reply1.id, - }); - - state.addMessagesSorted([parent, reply1, reply2]); - expect(state.threads[parent.id]).to.have.length(2); - - expect(() => state.deleteUserMessages(user1, true)).not.to.throw(); - - const thread = state.threads[parent.id]; - expect(thread).to.have.length(2); - expect(thread[0].type).to.be.equal('deleted'); - expect(thread[1].type).to.be.equal('deleted'); - expect(thread[1].quoted_message).to.be.equal(undefined); - }); - - it('does not throw when hard-deleting a pinned message that quotes another same-user pinned message', () => { - const user1 = generateUser(); - const m1 = generateMsg({ - user: user1, - pinned: true, - pinned_at: new Date('2022-01-01T00:00:00.001Z'), - }); - const m2 = generateMsg({ - user: user1, - pinned: true, - pinned_at: new Date('2022-01-01T00:00:00.002Z'), - quoted_message: m1, - quoted_message_id: m1.id, - }); - - state.addMessagesSorted([m1, m2]); - state.addPinnedMessages([m1, m2]); - expect(state.pinnedMessages).to.have.length(2); - - expect(() => state.deleteUserMessages(user1, true)).not.to.throw(); - - expect(state.pinnedMessages).to.have.length(2); - state.pinnedMessages.forEach((message) => { - expect(message.type).to.be.equal('deleted'); - }); - const pinnedQuoter = state.pinnedMessages.find((m) => m.id === m2.id); - expect(pinnedQuoter.quoted_message).to.be.equal(undefined); - }); - - it('soft-deletes a message that quotes another same-user message and marks the quoted_message as deleted', () => { - const user1 = generateUser(); - const m1 = generateMsg({ user: user1 }); - const m2 = generateMsg({ - user: user1, - quoted_message: m1, - quoted_message_id: m1.id, - }); - - state.addMessagesSorted([m1, m2]); - - expect(() => state.deleteUserMessages(user1, false)).not.to.throw(); - - expect(state.messages[0].type).to.be.equal('deleted'); - expect(state.messages[1].type).to.be.equal('deleted'); - // Soft-delete preserves message content via the spread path. - expect(state.messages[1].text).to.be.equal(m2.text); - // quoted_message reference is replaced with a deleted placeholder. - expect(state.messages[1].quoted_message).to.not.be.equal(undefined); - expect(state.messages[1].quoted_message.id).to.be.equal(m1.id); - expect(state.messages[1].quoted_message.type).to.be.equal('deleted'); - }); - - it('continues processing later messages after encountering a self-quote on hard-delete', () => { - const user1 = generateUser(); - const user2 = generateUser(); - const mA = generateMsg({ user: user2, date: '2020-01-01T00:00:01.000Z' }); - const m1 = generateMsg({ user: user1, date: '2020-01-01T00:00:02.000Z' }); - const m2 = generateMsg({ - user: user1, - date: '2020-01-01T00:00:03.000Z', - quoted_message: m1, - quoted_message_id: m1.id, - }); - const mB = generateMsg({ user: user1, date: '2020-01-01T00:00:04.000Z' }); - const mC = generateMsg({ user: user2, date: '2020-01-01T00:00:05.000Z' }); - - state.addMessagesSorted([mA, m1, m2, mB, mC]); - - expect(() => state.deleteUserMessages(user1, true)).not.to.throw(); - - const byId = (id) => state.messages.find((m) => m.id === id); - expect(byId(mA.id).type).to.be.equal('regular'); - expect(byId(m1.id).type).to.be.equal('deleted'); - expect(byId(m2.id).type).to.be.equal('deleted'); - // mB sits after the self-quote pair — previously the throw aborted the loop here. - expect(byId(mB.id).type).to.be.equal('deleted'); - expect(byId(mC.id).type).to.be.equal('regular'); - }); -}); - -describe('updateUserMessages', () => { - let state; - - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'userId'; - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - state = new ChannelState(channel); - }); - - it('should update user property of messages from given user', () => { - let user1 = generateUser(); - const user2 = generateUser(); - - const m1u1 = generateMsg({ user: user1 }); - const m2u1 = generateMsg({ user: user1 }); - const m1u2 = generateMsg({ user: user2 }); - const m2u2 = generateMsg({ user: user2 }); - - state.addMessagesSorted([m1u1, m2u1, m1u2, m2u2]); - - expect(state.messages).to.have.length(4); - - const user1NewName = uuidv4(); - user1 = { - ...user1, - name: user1NewName, - }; - - state.updateUserMessages(user1, true); - - expect(state.messages).to.have.length(4); - - expect(state.messages[0].user.name).to.be.equal(user1NewName); - expect(state.messages[1].user.name).to.be.equal(user1NewName); - - expect(state.messages[2].user.name).to.be.equal(user2.name); - expect(state.messages[3].user.name).to.be.equal(user2.name); - }); -}); - -describe('latestMessages', () => { - let state; - - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'userId'; - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - state = new ChannelState(channel); - }); - - it('should return latest messages - if they are the current message set', () => { - const messages = [ - generateMsg({ id: '1' }), - generateMsg({ id: '2' }), - generateMsg({ id: '3' }), - ]; - state.addMessagesSorted(messages); - - expect(state.latestMessages.length).to.be.equal(messages.length); - expect(state.latestMessages[0].id).to.be.equal(messages[0].id); - expect(state.latestMessages[1].id).to.be.equal(messages[1].id); - expect(state.latestMessages[2].id).to.be.equal(messages[2].id); - }); - - it('should return latest messages - if they are not the current message set', () => { - const latestMessages = [ - generateMsg({ id: '2', date: toISOString(200) }), - generateMsg({ id: '3', date: toISOString(300) }), - generateMsg({ id: '4', date: toISOString(400) }), - ]; - state.addMessagesSorted(latestMessages); - const newMessages = [generateMsg({ id: '1', date: toISOString(100) })]; - state.addMessagesSorted(newMessages, false, true, true, 'new'); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - - expect(state.latestMessages.length).to.be.equal(latestMessages.length); - expect(state.latestMessages[0].id).to.be.equal(latestMessages[0].id); - expect(state.latestMessages[1].id).to.be.equal(latestMessages[1].id); - expect(state.latestMessages[2].id).to.be.equal(latestMessages[2].id); - }); - - it('should return latest messages - if they are not the current message set and new messages received', () => { - const latestMessages = [ - generateMsg({ id: '2', date: toISOString(200) }), - generateMsg({ id: '3', date: toISOString(300) }), - generateMsg({ id: '4', date: toISOString(400) }), - ]; - state.addMessagesSorted(latestMessages); - const newMessages = [generateMsg({ id: '1', date: toISOString(100) })]; - state.addMessagesSorted(newMessages, false, true, true, 'new'); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - const latestMessage = generateMsg({ id: '5', date: toISOString(500) }); - state.addMessagesSorted([latestMessage], false, true, true, 'latest'); - - expect(state.latestMessages.length).to.be.equal(latestMessages.length + 1); - expect(state.latestMessages[0].id).to.be.equal(latestMessages[0].id); - expect(state.latestMessages[1].id).to.be.equal(latestMessages[1].id); - expect(state.latestMessages[2].id).to.be.equal(latestMessages[2].id); - expect(state.latestMessages[3].id).to.be.equal(latestMessage.id); - }); -}); - -describe('messagePagination', () => { - it('is initiated with defaults', () => { - const state = new ChannelState(); - expect(state.messageSets[0].pagination).to.eql(DEFAULT_MESSAGE_SET_PAGINATION); - }); - it('is retrieved as default if not set', () => { - const state = new ChannelState(); - state.messageSets[0].pagination = undefined; - expect(state.messageSets[0].pagination).to.be.undefined; - expect(state.messagePagination).to.eql(DEFAULT_MESSAGE_SET_PAGINATION); - }); -}); - describe('ChannelState members store', () => { it('initializes members store with an empty members map', () => { const state = new ChannelState(); @@ -2132,305 +365,3 @@ describe('ChannelState own capabilities store', () => { }); }); }); - -describe('loadMessageIntoState', () => { - let state; - - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'userId'; - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - state = new ChannelState(channel); - }); - - it('should do nothing if message is available locally in the current set', async () => { - state.addMessagesSorted([generateMsg({ id: '8' })], false, true, true, 'latest'); - state.addMessagesSorted([generateMsg({ id: '5' })], false, true, true, 'new'); - await state.loadMessageIntoState('8'); - - expect(state.messageSets[0].isCurrent).to.be.equal(true); - }); - - it('should switch message sets if message is available locally, but in a different set', async () => { - state.addMessagesSorted( - [generateMsg({ id: '8', date: toISOString(800) })], - false, - true, - true, - 'latest', - ); - state.addMessagesSorted( - [generateMsg({ id: '5', date: toISOString(500) })], - false, - true, - true, - 'new', - ); - await state.loadMessageIntoState('5'); - - expect(state.messageSets[0].isCurrent).to.be.equal(false); - expect(state.messageSets[1].isCurrent).to.be.equal(true); - }); - - it('should switch to latest message set', async () => { - state.addMessagesSorted( - [generateMsg({ id: '8', date: toISOString(800) })], - false, - true, - true, - 'latest', - ); - state.addMessagesSorted( - [generateMsg({ id: '5', date: toISOString(500) })], - false, - true, - true, - 'new', - ); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - await state.loadMessageIntoState('latest'); - - expect(state.messageSets[0].isCurrent).to.be.equal(true); - }); - - it('should load message from backend and switch to the new message set', async () => { - state.addMessagesSorted([ - generateMsg({ id: '5', date: toISOString(500) }), - generateMsg({ id: '6', date: toISOString(600) }), - ]); - const newMessages = [generateMsg({ id: '8', date: toISOString(800) })]; - state._channel.query = () => { - state.addMessagesSorted(newMessages, false, true, true, 'new'); - }; - await state.loadMessageIntoState('8'); - - expect(state.messages.length).to.be.equal(1); - expect(state.messages[0].id).to.be.equal('8'); - }); - - describe('if message is a thread reply', () => { - it('should do nothing if parent message and reply are available locally in the current set', async () => { - const parentMessage = generateMsg({ id: '5', date: toISOString(500) }); - const reply = generateMsg({ id: '8', date: toISOString(800), parent_id: '5' }); - state.addMessagesSorted([parentMessage]); - state.addMessagesSorted([reply]); - - await state.loadMessageIntoState('8', '5'); - - expect(state.messages[0].id).to.be.equal(parentMessage.id); - expect(state.threads[parentMessage.id][0].id).to.be.equal(reply.id); - }); - - it('should change message set if parent message and reply are available locally', async () => { - const parentMessage = generateMsg({ id: '5', date: toISOString(500) }); - const reply = generateMsg({ id: '8', date: toISOString(800), parent_id: '5' }); - state.addMessagesSorted([parentMessage]); - state.addMessagesSorted([reply]); - const otherMessages = [generateMsg(), generateMsg()]; - state.addMessagesSorted(otherMessages, false, true, true, 'new'); - state.messageSets[0].isCurrent = false; - state.messageSets[1].isCurrent = true; - - await state.loadMessageIntoState('8', '5'); - - expect(state.messages[0].id).to.be.equal(parentMessage.id); - expect(state.threads[parentMessage.id][0].id).to.be.equal(reply.id); - }); - - it(`should load replies if parent message is available locally, but reply isn't`, async () => { - const parentMessage = generateMsg({ id: '5' }); - const reply = generateMsg({ id: '8', parent_id: '5' }); - state._channel.getReplies = () => - state.addMessagesSorted([reply], false, false, true, 'current'); - state.addMessagesSorted([parentMessage]); - - await state.loadMessageIntoState('8', '5'); - - expect(state.messages[0].id).to.be.equal(parentMessage.id); - expect(state.threads[parentMessage.id][0].id).to.be.equal(reply.id); - }); - - it('should load parent message and reply from backend, and switch to new message set', async () => { - const parentMessage = generateMsg({ id: '5', date: toISOString(500) }); - const reply = generateMsg({ id: '8', date: toISOString(800), parent_id: '5' }); - state._channel.getReplies = () => - state.addMessagesSorted([reply], false, false, true, 'current'); - state._channel.query = () => - state.addMessagesSorted([parentMessage], false, true, true, 'new'); - - await state.loadMessageIntoState('8', '5'); - - expect(state.messages[0].id).to.be.equal(parentMessage.id); - expect(state.threads[parentMessage.id][0].id).to.be.equal(reply.id); - }); - }); -}); - -describe('findMessage', () => { - let state; - - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'userId'; - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - state = new ChannelState(channel); - }); - - it('message is in current message set', async () => { - const messageId = '8'; - state.addMessagesSorted( - [generateMsg({ id: messageId })], - false, - true, - true, - 'latest', - ); - state.addMessagesSorted([generateMsg({ id: '5' })], false, true, true, 'new'); - - expect(state.findMessage(messageId).id).to.eql(messageId); - }); - - it('message is in a different set', async () => { - const messageId = '5'; - state.addMessagesSorted([generateMsg({ id: '8' })], false, true, true, 'latest'); - state.addMessagesSorted([generateMsg({ id: messageId })], false, true, true, 'new'); - await state.loadMessageIntoState('5'); - - expect(state.findMessage(messageId).id).to.eql(messageId); - }); - - it('message not found', async () => { - state.addMessagesSorted([generateMsg({ id: '5' }), generateMsg({ id: '6' })]); - - expect(state.findMessage('12')).to.eql(undefined); - }); - - describe('if message is a thread reply', () => { - it('message found', async () => { - const messageId = '8'; - const parentMessageId = '5'; - const parentMessage = generateMsg({ id: parentMessageId }); - const reply = generateMsg({ id: messageId, parent_id: parentMessageId }); - state.addMessagesSorted([parentMessage]); - state.addMessagesSorted([reply]); - - expect(state.findMessage(messageId, parentMessageId).id).to.eql(messageId); - }); - - it('message not found', async () => { - const messageId = '8'; - const parentMessageId = '5'; - const parentMessage = generateMsg({ id: parentMessageId }); - const reply = generateMsg({ id: messageId, parent_id: parentMessageId }); - state.addMessagesSorted([parentMessage]); - state.addMessagesSorted([reply]); - - expect(state.findMessage(messageId, `not${parentMessageId}`)).to.eql(undefined); - }); - }); -}); - -describe('find message by timestamp', () => { - let state; - - beforeEach(() => { - const client = new StreamChat(); - client.userID = 'userId'; - const channel = new Channel(client, 'type', 'id', {}); - client._addChannelConfig({ cid: channel.cid, config: {} }); - state = new ChannelState(channel); - }); - - it('finds the message with matching timestamp', () => { - const expectedFoundMsg = generateMsg({ - id: '2', - created_at: toISOString(200), - }); - state.addMessagesSorted([ - generateMsg({ id: '12', created_at: toISOString(1200) }), - generateMsg({ id: '13', created_at: toISOString(1300) }), - generateMsg({ id: '14', created_at: toISOString(1400) }), - ]); - state.addMessagesSorted( - [ - generateMsg({ id: '1', created_at: toISOString(100) }), - expectedFoundMsg, - generateMsg({ id: '3', created_at: toISOString(300) }), - generateMsg({ id: '4', created_at: toISOString(400) }), - ], - false, - false, - true, - 'new', - ); - state.addMessagesSorted( - [ - generateMsg({ id: '6', created_at: toISOString(600) }), - generateMsg({ id: '7', created_at: toISOString(700) }), - ], - false, - false, - true, - 'new', - ); - - const foundMessage = state.findMessageByTimestamp( - new Date(expectedFoundMsg.created_at).getTime(), - ); - expect(foundMessage.id).toBe(expectedFoundMsg.id); - }); - - it('finds the first message if multiple messages with the same timestamp', () => { - const expectedFoundMessage = generateMsg({ - id: '2', - created_at: toISOString(200), - }); - const msgWithSameTimestamp = { ...expectedFoundMessage, id: '3' }; - state.addMessagesSorted([ - generateMsg({ id: '12', created_at: toISOString(1200) }), - generateMsg({ id: '13', created_at: toISOString(1300) }), - generateMsg({ id: '14', created_at: toISOString(1400) }), - ]); - state.addMessagesSorted( - [ - generateMsg({ id: '1', created_at: toISOString(100) }), - expectedFoundMessage, - msgWithSameTimestamp, - generateMsg({ id: '3.5', created_at: toISOString(300) }), - generateMsg({ id: '4', created_at: toISOString(400) }), - ], - false, - false, - true, - 'new', - ); - state.addMessagesSorted( - [ - generateMsg({ id: '6', created_at: toISOString(600) }), - generateMsg({ id: '7', created_at: toISOString(700) }), - ], - false, - false, - true, - 'new', - ); - - const foundMessage = state.findMessageByTimestamp( - new Date(msgWithSameTimestamp.created_at).getTime(), - ); - expect(foundMessage.id).toBe(expectedFoundMessage.id); - }); - - it('returns null if the message is not found', () => { - state.addMessagesSorted([ - generateMsg({ id: '12', created_at: toISOString(1200) }), - generateMsg({ id: '13', created_at: toISOString(1300) }), - generateMsg({ id: '14', created_at: toISOString(1400) }), - ]); - const foundMessage = state.findMessageByTimestamp(200); - expect(foundMessage).toBeNull(); - }); -}); diff --git a/test/unit/client.test.js b/test/unit/client.test.js index ed3307f30c..6f0b8713c3 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -1087,7 +1087,7 @@ describe('StreamChat.queryChannels', async () => { postStub.restore(); }); - it('should not update pagination for queried message set', async () => { + it('seeds each queried channel paginator with its full message page', async () => { const client = await getClientWithUser(); const mockedChannelsQueryResponse = Array.from({ length: 10 }, () => ({ ...mockChannelQueryResponse, @@ -1097,19 +1097,20 @@ describe('StreamChat.queryChannels', async () => { ), })); const mock = sinon.mock(client); - mock.expects('post').returns(Promise.resolve(mockedChannelsQueryResponse)); + mock + .expects('post') + .returns(Promise.resolve({ channels: mockedChannelsQueryResponse })); await client.queryChannels(); + expect(Object.keys(client.activeChannels).length).to.be.greaterThan(0); Object.values(client.activeChannels).forEach((channel) => { - expect(channel.state.messageSets.length).to.be.equal(1); - expect(channel.state.messageSets[0].pagination).to.eql({ - hasNext: true, - hasPrev: true, - }); + expect(channel.messagePaginator.items).to.have.length( + DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE, + ); }); mock.restore(); }); - it('should update pagination for queried message set to prevent more pagination', async () => { + it('seeds each queried channel paginator with its partial message page', async () => { const client = await getClientWithUser(); const mockedChannelQueryResponse = Array.from({ length: 10 }, () => ({ ...mockChannelQueryResponse, @@ -1119,14 +1120,15 @@ describe('StreamChat.queryChannels', async () => { ), })); const mock = sinon.mock(client); - mock.expects('post').returns(Promise.resolve(mockedChannelQueryResponse)); + mock + .expects('post') + .returns(Promise.resolve({ channels: mockedChannelQueryResponse })); await client.queryChannels(); + expect(Object.keys(client.activeChannels).length).to.be.greaterThan(0); Object.values(client.activeChannels).forEach((channel) => { - expect(channel.state.messageSets.length).to.be.equal(1); - expect(channel.state.messageSets[0].pagination).to.eql({ - hasNext: true, - hasPrev: false, - }); + expect(channel.messagePaginator.items).to.have.length( + DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE - 1, + ); }); mock.restore(); }); @@ -1531,226 +1533,155 @@ describe('message deletion', () => { }); }); -describe('user.messages.deleted', () => { +// Regression coverage for GetStream/stream-chat-js#1736. +// Hard-deleting a user whose cached messages include a self-quote (a message that +// quotes another message from the same user) used to throw inside the message-deletion +// path, aborting the entire dispatchEvent chain — downstream listeners and offline-DB +// writes silently dropped. These tests exercise both event entry points that funnel into +// _deleteUserMessageReference. +describe('user.updated propagates to message + pinned paginators', () => { let client; beforeEach(async () => { client = await getClientWithUser(); }); - const bannedUser = { id: 'banned-user' }; - const otherUser = { id: 'other-user' }; - const messageSet1 = [ - { - attachments: [ - { - type: 'image', - title: 'YouTube', - title_link: 'https://www.youtube.com/', - text: 'Enjoy the videos and music you love, upload original content, and share it all with friends, family, and the world on YouTube.', - image_url: 'https://www.youtube.com/img/desktop/yt_1200.png', - thumb_url: 'https://www.youtube.com/img/desktop/yt_1200.png', - og_scrape_url: 'https://www.youtube.com/', - }, - ], - created_at: '2021-01-01T00:01:00', - pinned: true, - pinned_at: '2022-01-01T00:01:00', - user: bannedUser, - }, - { - created_at: '2021-01-01T00:02:00', - pinned: true, - pinned_at: '2022-01-01T00:02:00', - user: otherUser, - }, - { created_at: '2021-01-01T00:03:00', user: bannedUser }, - ].map(generateMsg); - - const quoted_message = messageSet1[0]; - const messageSet2 = [ - { - created_at: '2020-01-01T00:01:00', + it('reflects the updated user on both messagePaginator and pinnedMessagesPaginator', () => { + const author = { id: 'author', name: 'Old Name' }; + const channel = client.channel('messaging', 'user-updated-1'); + const message = generateMsg({ id: 'm1', user: author }); + const pinned = generateMsg({ + id: 'p1', + cid: channel.cid, + user: author, pinned: true, - pinned_at: '2022-01-01T00:03:00', - user: bannedUser, - }, - { - created_at: '2020-01-01T00:02:00', - quoted_message, - quoted_message_id: quoted_message.id, - user: otherUser, - }, - { created_at: '2020-01-01T00:03:00', user: bannedUser }, - { created_at: '2020-01-01T00:04:00', user: otherUser }, - ].map(generateMsg); - - const parent_id = messageSet2[0].id; - const thread1 = [ - { created_at: '2020-01-01T00:01:30', parent_id, user: bannedUser, type: 'reply' }, - { created_at: '2020-01-01T00:02:35', parent_id, user: otherUser, type: 'reply' }, - { created_at: '2020-01-01T00:03:45', parent_id, user: bannedUser, type: 'reply' }, - { created_at: '2020-01-01T00:04:00', parent_id, user: otherUser, type: 'reply' }, - ]; + pinned_at: '2020-01-01T00:00:00.000Z', + }); - const pinnedMessages = [messageSet1[0], messageSet1[1], messageSet2[0]]; + channel.messagePaginator.setItems({ + valueOrFactory: [message], + isFirstPage: true, + isLastPage: true, + }); + channel.pinnedMessagesPaginator.ingestPage({ + page: [utils.formatMessage(pinned)], + isHead: true, + isTail: true, + setActive: true, + }); - const setupChannel = (type, id) => { - const channel = client.channel(type, id); - channel.state.addMessagesSorted(messageSet1); - channel.state.addMessagesSorted(messageSet2, false, false, true, 'new'); + client._handleClientEvent({ + type: 'user.updated', + user: { ...author, name: 'New Name' }, + }); - // pinned messages - channel.state.addPinnedMessages(pinnedMessages); + expect(channel.messagePaginator.getItem('m1')?.user?.name).toBe('New Name'); + expect(channel.pinnedMessagesPaginator.getItem('p1')?.user?.name).toBe('New Name'); + }); +}); - // thread replies - channel.state.addMessagesSorted(thread1); +describe('user.messages.deleted (client-level, cross-channel)', () => { + let client; + const bannedUser = { id: 'banned-user' }; + const otherUser = { id: 'other-user' }; - expect(channel.state.messageSets).toHaveLength(2); - expect(channel.state.messageSets[0].messages).toHaveLength(messageSet1.length); - expect(channel.state.messageSets[1].messages).toHaveLength(messageSet2.length); - expect(channel.state.pinnedMessages).toHaveLength(pinnedMessages.length); - expect(channel.state.threads[parent_id]).toHaveLength(thread1.length); + beforeEach(async () => { + client = await getClientWithUser(); + }); + // Seeds a channel (registered as active by `client.channel`) with one main + one pinned message + // from the banned user, plus a pinned message from another user. The client-level loop scans all + // active channels, so no explicit user->channel reference registration is needed. + const setupChannel = (id) => { + const channel = client.channel('messaging', id); + const main = generateMsg({ id: `${id}-m`, cid: channel.cid, user: bannedUser }); + const pinned = generateMsg({ + id: `${id}-p`, + cid: channel.cid, + user: bannedUser, + pinned: true, + pinned_at: '2020-01-01T00:00:00.000Z', + }); + const otherPinned = generateMsg({ + id: `${id}-op`, + cid: channel.cid, + user: otherUser, + pinned: true, + pinned_at: '2020-01-02T00:00:00.000Z', + }); + channel.messagePaginator.setItems({ + valueOrFactory: [main], + isFirstPage: true, + isLastPage: true, + }); + channel.pinnedMessagesPaginator.ingestPage({ + page: [pinned, otherPinned].map((m) => utils.formatMessage(m)), + isHead: true, + isTail: true, + setActive: true, + }); return channel; }; - it('ignores channel specific event', () => { - const channels = [setupChannel('type', 'id1'), setupChannel('type', 'id2')]; - const event = { + it('ignores a channel-scoped (cid-carrying) event — the channel owns it', () => { + const channel = setupChannel('c1'); + + client._handleClientEvent({ type: 'user.messages.deleted', - cid: channels[0].cid, - channel_type: channels[0].type, - channel_id: channels[0].id, + cid: channel.cid, user: bannedUser, hard_delete: true, - created_at: '2025-02-01T14:01:30.000Z', - }; - client._handleClientEvent(event); - - channels.forEach((channel) => { - expect(channel.state.messageSets[0].messages).toHaveLength(messageSet1.length); - expect(channel.state.messageSets[1].messages).toHaveLength(messageSet2.length); - - const check = (message) => { - expect(message).toEqual(message); - }; - - channel.state.messageSets[0].messages.forEach(check); - channel.state.messageSets[1].messages.forEach(check); - channel.state.pinnedMessages.forEach(check); - Object.values(channel.state.threads).forEach((replies) => replies.forEach(check)); + created_at: '2025-01-01T00:00:00.000Z', }); + + // cid present → the client-level cross-channel loop must be a no-op (no double-delete). + expect(channel.messagePaginator.items?.map((m) => m.id)).to.include('c1-m'); + expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.include('c1-p'); }); - it('removes the messages on hard delete', () => { - const channels = [setupChannel('type', 'id1'), setupChannel('type', 'id2')]; + it("soft-deletes the user's main and pinned messages across channels", () => { + const channels = [setupChannel('c1'), setupChannel('c2')]; - const event = { + client._handleClientEvent({ type: 'user.messages.deleted', user: bannedUser, - hard_delete: true, - created_at: '2025-02-01T14:01:30.000Z', - }; - client._handleClientEvent(event); - channels.forEach((channel) => { - expect(channel.state.messageSets[0].messages).toHaveLength(messageSet1.length); - expect(channel.state.messageSets[1].messages).toHaveLength(messageSet2.length); - - const check = (message) => { - const deletedMessage = { - attachments: [], - cid: message.cid, - created_at: message.created_at, - deleted_at: new Date(event.created_at), - id: message.id, - latest_reactions: [], - mentioned_users: [], - own_reactions: [], - parent_id: message.parent_id, - reply_count: message.reply_count, - status: message.status, - thread_participants: message.thread_participants, - type: 'deleted', - updated_at: message.updated_at, - user: message.user, - }; - if (message.user.id === bannedUser.id) { - expect(message).toStrictEqual(deletedMessage); - } else if (message.quoted_message) { - expect(message).toStrictEqual({ - ...message, - quoted_message: { - ...deletedMessage, - id: message.quoted_message.id, - user: message.quoted_message.user, - created_at: message.quoted_message.created_at, - updated_at: message.quoted_message.updated_at, - }, - }); - } else { - expect(message).toEqual(message); - } - }; + soft_delete: true, + created_at: '2025-01-01T00:00:00.000Z', + }); - channel.state.messageSets[0].messages.forEach(check); - channel.state.messageSets[1].messages.forEach(check); - channel.state.pinnedMessages.forEach(check); - Object.values(channel.state.threads).forEach((replies) => replies.forEach(check)); + channels.forEach((channel) => { + const id = channel.id; + expect(channel.messagePaginator.getItem(`${id}-m`)?.type).to.equal('deleted'); + expect(channel.pinnedMessagesPaginator.getItem(`${id}-p`)?.type).to.equal( + 'deleted', + ); + // the other user's pinned message is untouched + expect(channel.pinnedMessagesPaginator.getItem(`${id}-op`)?.type).to.not.equal( + 'deleted', + ); }); }); - it('removes the messages on soft delete', () => { - const channels = [setupChannel('type', 'id1'), setupChannel('type', 'id2')]; + it("hard-deletes the user's main and pinned messages across channels", () => { + const channels = [setupChannel('c1'), setupChannel('c2')]; - const event = { + client._handleClientEvent({ type: 'user.messages.deleted', user: bannedUser, - soft_delete: true, - created_at: '2025-02-01T14:01:30.000Z', - }; - client._handleClientEvent(event); - channels.forEach((channel) => { - expect(channel.state.messageSets[0].messages).toHaveLength(messageSet1.length); - expect(channel.state.messageSets[1].messages).toHaveLength(messageSet2.length); - - const check = (message) => { - if (message.user.id === bannedUser.id) { - expect(message).toStrictEqual({ - ...message, - attachments: [], - deleted_at: new Date(event.created_at), - type: 'deleted', - }); - } else if (message.quoted_message) { - expect(message).toStrictEqual({ - ...message, - quoted_message: { - ...message.quoted_message, - attachments: [], - deleted_at: new Date(event.created_at), - type: 'deleted', - }, - }); - } else { - expect(message).toEqual(message); - } - }; + hard_delete: true, + created_at: '2025-01-01T00:00:00.000Z', + }); - channel.state.messageSets[0].messages.forEach(check); - channel.state.messageSets[1].messages.forEach(check); - channel.state.pinnedMessages.forEach(check); - Object.values(channel.state.threads).forEach((replies) => replies.forEach(check)); + channels.forEach((channel) => { + const id = channel.id; + expect(channel.messagePaginator.items?.map((m) => m.id)).to.not.include(`${id}-m`); + expect(channel.pinnedMessagesPaginator.items?.map((m) => m.id)).to.eql([ + `${id}-op`, + ]); }); }); }); -// Regression coverage for GetStream/stream-chat-js#1736. -// Hard-deleting a user whose cached messages include a self-quote (a message that -// quotes another message from the same user) used to throw inside -// _deleteUserMessages, aborting the entire dispatchEvent chain — downstream -// listeners and offline-DB writes silently dropped. These tests exercise both -// event entry points that funnel into _deleteUserMessageReference. describe('user.messages.deleted — quoted_message regression (#1736)', () => { let client; const bannedUser = { id: 'banned-user' }; @@ -1771,7 +1702,14 @@ describe('user.messages.deleted — quoted_message regression (#1736)', () => { quoted_message_id: m1.id, }); const channel = client.channel(type, id); - channel.state.addMessagesSorted([m1, m2]); + // `client.channel` registers the channel as active; the client-level deletion loop scans all + // active channels, and setItems puts the messages in the paginator (the message list source of + // truth) so the deletion has something to act on. + channel.messagePaginator.setItems({ + valueOrFactory: [m1, m2], + isFirstPage: true, + isLastPage: true, + }); return { channel, m1, m2 }; }; @@ -1787,13 +1725,11 @@ describe('user.messages.deleted — quoted_message regression (#1736)', () => { expect(() => client._handleClientEvent(event)).not.toThrow(); - const messages = channel.state.messageSets[0].messages; - expect(messages).toHaveLength(2); - expect(messages.find((m) => m.id === m1.id).type).toBe('deleted'); - const quoter = messages.find((m) => m.id === m2.id); - expect(quoter.type).toBe('deleted'); - // Hard-delete strips the parent — no quoted_message field remains on it. - expect(quoter.quoted_message).toBeUndefined(); + // Both messages belong to the banned user, so a hard delete drops both from the + // active window; the point is that the self-quote (m2 -> m1) does not throw. + const items = channel.messagePaginator.items ?? []; + expect(items.find((m) => m.id === m1.id)).toBeUndefined(); + expect(items.find((m) => m.id === m2.id)).toBeUndefined(); }); it('still fires downstream client listeners after the self-quote encounter on hard-delete', () => { @@ -1825,9 +1761,9 @@ describe('user.messages.deleted — quoted_message regression (#1736)', () => { expect(() => client._handleClientEvent(event)).not.toThrow(); - const messages = channel.state.messageSets[0].messages; - expect(messages.find((m) => m.id === m1.id).type).toBe('deleted'); - expect(messages.find((m) => m.id === m2.id).type).toBe('deleted'); + const items = channel.messagePaginator.items ?? []; + expect(items.find((m) => m.id === m1.id)).toBeUndefined(); + expect(items.find((m) => m.id === m2.id)).toBeUndefined(); }); }); diff --git a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts index 83f501b782..3228388aa9 100644 --- a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts +++ b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts @@ -23,6 +23,20 @@ const otherUser = { const mkMsg = (id: string, at: string | number | Date) => ({ id, created_at: new Date(at) }) as any; +// The delivery reporter now derives the latest message from `channel.messagePaginator.headItems`, +// so tests seed the paginator's latest (head) window instead of assigning `channel.state.latestMessages`. +const setLatest = (channel: Channel, msgs: ReturnType[]) => { + channel.messagePaginator.clearStateAndCache(); + if (msgs.length) { + channel.messagePaginator.ingestPage({ + page: msgs, + isHead: true, + isTail: true, + setActive: true, + }); + } +}; + describe('MessageDeliveryReporter', () => { let client: StreamChat; let channel: Channel; @@ -57,7 +71,7 @@ describe('MessageDeliveryReporter', () => { .mockResolvedValue({ ok: true } as any); // last_read < last message - channel.state.latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z')]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; client.syncDeliveredCandidates([channel]); @@ -86,7 +100,7 @@ describe('MessageDeliveryReporter', () => { const channels = Array.from({ length: 110 }, (_, i) => { const channel = client.channel(channelType, i.toString()); channel.initialized = true; - channel.state.latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z')]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; return channel; }); @@ -130,7 +144,7 @@ describe('MessageDeliveryReporter', () => { .spyOn(client, 'markChannelsDelivered') .mockResolvedValue({ ok: true } as any); - channel.state.latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z')]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; client.syncDeliveredCandidates([channel]); @@ -151,7 +165,7 @@ describe('MessageDeliveryReporter', () => { .spyOn(client, 'markChannelsDelivered') .mockResolvedValue({ ok: true } as any); - channel.state.latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z')]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; client.syncDeliveredCandidates([channel]); @@ -165,7 +179,7 @@ describe('MessageDeliveryReporter', () => { .spyOn(client, 'markChannelsDelivered') .mockResolvedValue({ ok: true } as any); - (channel.state as any).latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z')]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z'), last_delivered_at: new Date('2025-01-01T11:00:00Z'), @@ -182,7 +196,7 @@ describe('MessageDeliveryReporter', () => { .spyOn(client, 'markChannelsDelivered') .mockResolvedValue({} as any); - channel.state.latestMessages = [mkMsg('m1', 1000)]; + setLatest(channel, [mkMsg('m1', 1000)]); (channel.state as any).read['me'] = { last_read: new Date(0) }; client.syncDeliveredCandidates([channel]); @@ -201,12 +215,15 @@ describe('MessageDeliveryReporter', () => { .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; - channel.state.latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z')]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); client.syncDeliveredCandidates([channel]); // newer message arrives before throttle fires - channel.state.latestMessages.push(mkMsg('m2', '2025-01-01T10:05:00Z')); + setLatest(channel, [ + mkMsg('m1', '2025-01-01T10:00:00Z'), + mkMsg('m2', '2025-01-01T10:05:00Z'), + ]); client.syncDeliveredCandidates([channel]); vi.advanceTimersByTime(1000); @@ -234,7 +251,7 @@ describe('MessageDeliveryReporter', () => { const ch1 = client.channel('messaging', 'ch1'); ch1.initialized = true; (ch1.state as any).read['me'] = { last_read: new Date(0) }; - (ch1.state as any).latestMessages = [mkMsg('m1', 1000)]; + setLatest(ch1, [mkMsg('m1', 1000)]); const ch2 = client.channel('messaging', 'ch2'); ch2.initialized = true; @@ -269,7 +286,7 @@ describe('MessageDeliveryReporter', () => { // While request is in-flight, a new candidate (different channel) arrives. (ch2.state as any).read['me'] = { last_read: new Date(0) }; - (ch2.state as any).latestMessages = [mkMsg('n1', 2000)]; + setLatest(ch2, [mkMsg('n1', 2000)]); client.syncDeliveredCandidates([ch2]); // Trying to announce during in-flight should be a no-op for sending @@ -314,7 +331,7 @@ describe('MessageDeliveryReporter', () => { vi.spyOn(channel, 'markAsReadRequest').mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; - channel.state.latestMessages = [mkMsg('m1', 1000)]; + setLatest(channel, [mkMsg('m1', 1000)]); client.syncDeliveredCandidates([channel]); @@ -329,7 +346,7 @@ describe('MessageDeliveryReporter', () => { const channels = Array.from({ length: count }, (_, i) => { const channel = client.channel(channelType, (i + startId).toString()); channel.initialized = true; - channel.state.latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z')]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; return channel; }); @@ -513,7 +530,7 @@ describe('MessageDeliveryReporter', () => { vi.spyOn(channel, 'markAsReadRequest').mockRejectedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; - channel.state.latestMessages = [mkMsg('m1', 1000)]; + setLatest(channel, [mkMsg('m1', 1000)]); client.syncDeliveredCandidates([channel]); @@ -538,14 +555,15 @@ describe('MessageDeliveryReporter', () => { .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; - channel.state.latestMessages = []; + setLatest(channel, []); // simulate incoming message.new event const ev: Event = { type: 'message.new', created_at: new Date('2025-01-01T10:00:00Z').toISOString(), user: otherUser, - message: mkMsg('m1', '2025-01-01T10:00:00Z') as any, + // cid must match the paginator filter so message.new ingests into an interval + message: { ...mkMsg('m1', '2025-01-01T10:00:00Z'), cid: channel.cid } as any, }; channel._handleChannelEvent(ev); @@ -569,7 +587,7 @@ describe('MessageDeliveryReporter', () => { .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; - channel.state.latestMessages = []; + setLatest(channel, []); // simulate incoming message.new event const ev: Event = { @@ -592,7 +610,7 @@ describe('MessageDeliveryReporter', () => { .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; - channel.state.latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z') as any]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); client.syncDeliveredCandidates([channel]); @@ -617,7 +635,7 @@ describe('MessageDeliveryReporter', () => { .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; - channel.state.latestMessages = [mkMsg('m1', '2025-01-01T10:00:00Z') as any]; + setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); client.syncDeliveredCandidates([channel]); diff --git a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts index a946bdc276..95a66cfad4 100644 --- a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts +++ b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts @@ -40,9 +40,13 @@ const createChannelMock = ({ return { channel: { state: { - findMessageByTimestamp, readStore, }, + // The default receipts locator now resolves timestamps via the message paginator; this mock + // fn (still named findMessageByTimestamp in tests) backs messagePaginator.findItemByTimestamp. + messagePaginator: { + findItemByTimestamp: findMessageByTimestamp, + }, } as unknown as Channel, readStore, }; diff --git a/test/unit/offline-support/offline_support_api.test.ts b/test/unit/offline-support/offline_support_api.test.ts index 6ecbba8968..af118c3a9d 100644 --- a/test/unit/offline-support/offline_support_api.test.ts +++ b/test/unit/offline-support/offline_support_api.test.ts @@ -2103,7 +2103,7 @@ describe('OfflineSupportApi', () => { _deleteReaction: vi.fn(), _createDraft: vi.fn(), _deleteDraft: vi.fn(), - state: { addMessageSorted: vi.fn() }, + messagePaginator: { trackLastMessage: vi.fn() }, } as unknown as Channel; _updateMessageSpy = vi @@ -2169,7 +2169,7 @@ describe('OfflineSupportApi', () => { expect(mockChannel._deleteDraft).toHaveBeenCalledWith(...task.payload); }); - it('should call _sendMessage and addMessageSorted if isPendingTask is true', async () => { + it('should call _sendMessage and track the latest message if isPendingTask is true', async () => { const task = generatePendingTask('send-message') as PendingTask; const messageResponse = { message: { id: 'msg1', text: 'hello' } }; @@ -2180,9 +2180,8 @@ describe('OfflineSupportApi', () => { await offlineDb['executeTask']({ task }, true); expect(mockChannel._sendMessage).toHaveBeenCalledWith(...task.payload); - expect(mockChannel.state.addMessageSorted).toHaveBeenCalledWith( - messageResponse.message, - true, + expect(mockChannel.messagePaginator.trackLastMessage).toHaveBeenCalledWith( + expect.objectContaining({ id: 'msg1' }), ); }); diff --git a/test/unit/pagination/paginators/BasePaginator.test.ts b/test/unit/pagination/paginators/BasePaginator.test.ts index 1089e38587..22817156cd 100644 --- a/test/unit/pagination/paginators/BasePaginator.test.ts +++ b/test/unit/pagination/paginators/BasePaginator.test.ts @@ -305,13 +305,15 @@ describe('BasePaginator', () => { await sleep(0); expect(paginator.isLoading).toBe(true); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + // Offset pagination establishes its window from the start offset (0 here) as soon as the + // first-page load begins, so the head is known to be loaded before the query resolves. + expect(paginator.hasMoreHead).toBe(false); paginator.queryResolve({ items: [{ id: 'id1' }] }); await nextPromise; expect(paginator.isLoading).toBe(false); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(1); @@ -327,7 +329,7 @@ describe('BasePaginator', () => { paginator.queryResolve({ items: [{ id: 'id2' }] }); await nextPromise; expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(2); @@ -336,7 +338,7 @@ describe('BasePaginator', () => { paginator.queryResolve({ items: [] }); await nextPromise; expect(paginator.hasMoreTail).toBe(false); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(2); @@ -346,6 +348,61 @@ describe('BasePaginator', () => { expect(paginator.mockClientQuery).toHaveBeenCalledTimes(3); }); + it('keeps hasMoreHead unchanged on a keepPreviousItems first-page refresh (offset)', async () => { + // Regression: hasMoreHead is derived from the start offset only when the first page resets + // the window (isFirstPage && !keepPreviousItems). A keepPreviousItems refresh is isFirstPage + // but does NOT reset the offset, so it must not re-derive hasMoreHead from the grown offset. + const paginator = new Paginator({ pageSize: 1 }); + + // First page from offset 0 -> head is loaded. + let nextPromise = paginator.toTail(); + await sleep(0); + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await nextPromise; + expect(paginator.hasMoreHead).toBe(false); + expect(paginator.offset).toBe(1); + + // Grow the tail so the offset is well past 0. + nextPromise = paginator.toTail(); + paginator.queryResolve({ items: [{ id: 'id2' }] }); + await nextPromise; + expect(paginator.offset).toBe(2); + expect(paginator.hasMoreHead).toBe(false); + + // A non-destructive first-page refresh (isFirstPage via reset, keepPreviousItems) must NOT + // flip hasMoreHead to true off the grown offset (2 > 0) — the window still starts at 0. + const refreshPromise = paginator.executeQuery({ + keepPreviousItems: true, + reset: 'yes', + }); + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await refreshPromise; + expect(paginator.hasMoreHead).toBe(false); + }); + + it('anchors hasMoreHead from a new start offset when the window is re-established via reset (offset)', async () => { + // To start a window mid-list, set the start offset and reset. isFirstPage is true, + // getStateBeforeFirstQuery runs, and hasMoreHead is anchored from the (new) start offset. + const paginator = new Paginator({ pageSize: 10 }); + + // First window at the head (offset 0) -> head loaded. + let queryPromise = paginator.toTail(); + await sleep(0); + paginator.queryResolve({ items: [{ id: 'id1' }] }); + await queryPromise; + expect(paginator.hasMoreHead).toBe(false); + + // Move the start offset and re-establish the window from it (reload -> reset: 'yes'). + paginator.initialOffset = 30; + queryPromise = paginator.reload(); + await sleep(0); + paginator.queryResolve({ items: [{ id: 'id2' }] }); + await queryPromise; + + // Anchored correctly: a window starting at offset 30 reports items before it. + expect(paginator.hasMoreHead).toBe(true); + }); + it('paginates to next pages debounced (cursor)', async () => { vi.useFakeTimers(); const paginator = new Paginator({ @@ -400,7 +457,8 @@ describe('BasePaginator', () => { await toNextTick(); expect(paginator.isLoading).toBe(true); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + // Head is known to be loaded once the offset-0 first-page load begins (see non-debounced case). + expect(paginator.hasMoreHead).toBe(false); paginator.queryResolve({ items: [{ id: 'id1' }], @@ -409,7 +467,7 @@ describe('BasePaginator', () => { await toNextTick(); expect(paginator.isLoading).toBe(false); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(1); @@ -627,7 +685,7 @@ describe('BasePaginator', () => { expect(paginator.mockClientQuery).toHaveBeenCalledTimes(1); }); - it('resets the state if the query shape changed', async () => { + it('discards accumulated pages when the query shape is reset (sort/filter change)', async () => { const paginator = new Paginator({ pageSize: 1 }); let nextPromise = paginator.toTail(); await sleep(0); @@ -635,20 +693,26 @@ describe('BasePaginator', () => { await nextPromise; expect(paginator.isLoading).toBe(false); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(1); + // A genuine query-shape change (new filter/sort) is applied by a subclass setter, which calls + // resetState() to discard the now-invalid interval cache before re-querying. Without that, the + // freshly loaded page would merge into the stale intervals from the previous shape. paginator.getNextQueryShape.mockReturnValueOnce({ filters: { id: 'test' }, sort: { id: -1 }, }); + paginator.resetState(); + expect(paginator.items).toBeUndefined(); + expect(paginator.offset).toBe(0); + nextPromise = paginator.toTail(); await sleep(0); expect(paginator.isLoading).toBe(true); expect(paginator.items).toBeUndefined(); - expect(paginator.offset).toBe(0); paginator.queryResolve({ items: [{ id: 'id2' }] }); await nextPromise; expect(paginator.isLoading).toBe(false); @@ -664,7 +728,7 @@ describe('BasePaginator', () => { await nextPromise; expect(paginator.isLoading).toBe(false); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(1); @@ -689,7 +753,7 @@ describe('BasePaginator', () => { await nextPromise; expect(paginator.isLoading).toBe(false); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(1); @@ -1846,21 +1910,6 @@ describe('BasePaginator', () => { ); }); - it('does not ingest if itemIndex is not available', () => { - paginator = new Paginator(); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ - sort: { age: -1 }, - }); - expect(paginator.items).toBeUndefined(); - paginator.ingestPage({ page: [a] }); - expect(paginator.items).toBeUndefined(); - // @ts-expect-error accessing protected property _itemIntervals - expect(Array.from(paginator._itemIntervals.values())).toStrictEqual([]); - }); - it('does not ingest if page has no items', () => { paginator.ingestPage({ page: [] }); expect(paginator.items).toBeUndefined(); @@ -1896,204 +1945,6 @@ describe('BasePaginator', () => { }); }); - describe('ingestItem to state only', () => { - it.each([ - ['on lockItemOrder: false', false], - ['on lockItemOrder: true', true], - ])( - 'item exists but does not match the filter anymore removes the item %s', - (_, lockItemOrder) => { - const paginator = new Paginator({ lockItemOrder }); - - paginator.state.partialNext({ - items: [item3, item2, item1], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - teams: { $eq: ['abc', 'efg'] }, // required membership in these two teams - }); - - const adjustedItem = { - ...item1, - teams: ['efg'], // removed from the team abc - }; - - expect(paginator.ingestItem(adjustedItem)).toBeTruthy(); // item removed - expect(paginator.items).toStrictEqual([item3, item2]); - }, - ); - - it.each([ - [' adjusts the order on lockItemOrder: false', false], - [' does not adjust the order on lockItemOrder: true', true], - ])('exists and matches the filter updates the item and %s', (_, lockItemOrder) => { - const paginator = new Paginator({ lockItemOrder }); - paginator.state.partialNext({ - items: [item1, item2, item3], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - age: { $gt: 100 }, - }); - - const adjustedItem1 = { - ...item1, - age: 103, - }; - - expect(paginator.ingestItem(adjustedItem1)).toBeTruthy(); // item updated - - if (lockItemOrder) { - expect(paginator.items).toStrictEqual([adjustedItem1, item2, item3]); - } else { - expect(paginator.items).toStrictEqual([item2, item3, adjustedItem1]); - } - }); - - it.each([ - ['on lockItemOrder: false', false], - ['on lockItemOrder: true', true], - ])( - 'does not exist and does not match the filter results in no action %s', - (_, lockItemOrder) => { - const paginator = new Paginator({ lockItemOrder }); - paginator.state.partialNext({ - items: [item1], // age: 100 - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - age: { $gt: 100 }, - }); - - const adjustedItem = { - ...item1, - id: 'id2', - name: 'test2', - }; - - expect(paginator.ingestItem(adjustedItem)).toBeFalsy(); // no action - expect(paginator.items).toStrictEqual([item1]); - }, - ); - - it.each([ - ['on lockItemOrder: false', false], - ['on lockItemOrder: true', true], - ])( - 'does not exist and matches the filter inserts according to default sort order (append) %s', - (_, lockItemOrder) => { - const paginator = new Paginator({ lockItemOrder }); - paginator.state.partialNext({ - items: [item3, item1], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - teams: { $contains: 'abc' }, - }); - - expect(paginator.ingestItem(item2)).toBeTruthy(); - expect(paginator.items).toStrictEqual([item3, item1, item2]); - }, - ); - - it.each([ - ['on lockItemOrder: false', false], - ['on lockItemOrder: true', true], - ])( - 'does not exist and matches the filter inserts according to sort order %s', - (_, lockItemOrder) => { - const paginator = new Paginator({ lockItemOrder }); - paginator.state.partialNext({ - items: [item3, item1], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - teams: { $contains: 'abc' }, - }); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ sort: { age: -1 } }); - - expect(paginator.ingestItem(item2)).toBeTruthy(); - expect(paginator.items).toStrictEqual([item3, item2, item1]); - }, - ); - - it('reflects the boost priority on lockItemOrder: false for newly ingested items', () => { - const paginator = new Paginator(); - paginator.state.partialNext({ - items: [item3, item1], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - teams: { $contains: 'abc' }, - }); - - paginator.boost(item2.id); - expect(paginator.ingestItem(item2)).toBeTruthy(); - expect(paginator.items).toStrictEqual([item2, item3, item1]); - }); - - it('reflects the boost priority on lockItemOrder: false for existing items recently boosted', () => { - const paginator = new Paginator(); - paginator.state.partialNext({ - items: [item1, item2, item3], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - age: { $gt: 100 }, - }); - - const adjustedItem2 = { - ...item2, - age: 103, - }; - paginator.boost(item2.id); - expect(paginator.ingestItem(adjustedItem2)).toBeTruthy(); // item updated - expect(paginator.items).toStrictEqual([adjustedItem2, item1, item3]); - }); - - it('does not reflect the boost priority on lockItemOrder: true', () => { - const paginator = new Paginator({ lockItemOrder: true }); - paginator.state.partialNext({ - items: [item1, item2, item3], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - age: { $gt: 100 }, - }); - - paginator.boost(item2.id); - expect(paginator.ingestItem(item2)).toBeTruthy(); // item updated - expect(paginator.items).toStrictEqual([item1, item2, item3]); - }); - - it('reflects the boost priority on lockItemOrder: true when ingesting a new item', () => { - const paginator = new Paginator({ lockItemOrder: true }); - paginator.state.partialNext({ - items: [item3, item1], - }); - - // @ts-expect-error accessing protected property - paginator.buildFilters = () => ({ - teams: { $contains: 'abc' }, - }); - - paginator.boost(item2.id); - expect(paginator.ingestItem(item2)).toBeTruthy(); - expect(paginator.items).toStrictEqual([item2, item3, item1]); - }); - }); - describe('ingestItem with itemIndex', () => { beforeEach(() => { itemIndex.clear(); @@ -3041,16 +2892,6 @@ describe('BasePaginator', () => { expect(paginator.items!.map((i) => i.id)).toEqual(['id3', 'id1']); }); - it('falls back to linear scan by id when no itemIndex is provided', () => { - const paginator = new Paginator(); // no itemIndex - paginator.state.partialNext({ items: [item3, item2, item1] }); - - const res = paginator.removeItem({ id: item2.id }); - - expect(res).toEqual({ state: { currentIndex: 1, insertionIndex: -1 } }); - expect(paginator.items!.map((i) => i.id)).toEqual(['id3', 'id1']); - }); - it('removeItem is a no-op when itemIndex exists but does not have the interval for the given id', () => { const paginator = new Paginator({ itemIndex }); paginator.state.partialNext({ items: [item1] }); @@ -3116,6 +2957,39 @@ describe('BasePaginator', () => { }); }); + describe('headItems (newest loaded window)', () => { + it('is empty before the first load and mirrors items in flat mode', () => { + const paginator = new Paginator(); + expect(paginator.headItems).toEqual([]); + + const loaded = [{ id: 'a' }]; + paginator.setItems({ valueOrFactory: loaded }); + + expect(paginator.headItems).toStrictEqual(loaded); + }); + + it('materializes the head window in interval-storage mode', () => { + const paginator = new Paginator({ itemIndex }); + paginator.sortComparator = makeComparator< + TestItem, + Partial> + >({ sort: { age: -1 } }); + + paginator.ingestPage({ + page: [item2, item1], + isHead: true, + isTail: true, + setActive: true, + }); + + expect(paginator.headItems.length).toBeGreaterThan(0); + expect(paginator.headItems.map((item) => item.id)).toEqual( + paginator.items?.map((item) => item.id), + ); + itemIndex.clear(); + }); + }); + describe('setItems', () => { it('overrides all the items in the state with provided value', () => { const paginator = new Paginator(); @@ -3270,36 +3144,6 @@ describe('BasePaginator', () => { ]); }); - it('does not reflect on isFirstPage and isLastPage when item interval storage is disabled', () => { - const paginator = new Paginator(); - paginator.sortComparator = makeComparator< - TestItem, - Partial> - >({ sort: { age: -1 } }); - - const page = [item2, item1]; - - paginator.setItems({ - valueOrFactory: page, - isFirstPage: true, - isLastPage: true, - }); - - // @ts-expect-error accessing protected property - expect(paginator._itemIntervals.size).toBe(0); - expect(paginator.items).toStrictEqual([item2, item1]); - - paginator.setItems({ - valueOrFactory: [item3], - isFirstPage: false, - isLastPage: false, - }); - - // @ts-expect-error accessing protected property - expect(paginator._itemIntervals.size).toBe(0); - expect(paginator.items).toStrictEqual([item3]); - }); - it('with itemIndex creates an anchored interval and sets it active', () => { const paginator = new Paginator({ itemIndex }); paginator.sortComparator = makeComparator< @@ -3347,13 +3191,15 @@ describe('BasePaginator', () => { await sleep(0); expect(paginator.isLoading).toBe(true); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + // reload() restarts offset pagination from the beginning (offset 0), so the head is loaded + // as soon as the reload query begins — before it resolves. + expect(paginator.hasMoreHead).toBe(false); paginator.queryResolve({ items: [{ id: 'id1' }] }); await reloadPromise; expect(paginator.isLoading).toBe(false); expect(paginator.hasMoreTail).toBe(false); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(1); @@ -3369,13 +3215,14 @@ describe('BasePaginator', () => { await sleep(0); expect(paginator.isLoading).toBe(true); expect(paginator.hasMoreTail).toBe(true); - expect(paginator.hasMoreHead).toBe(true); + // Offset-0 reload again: head loaded from the start of the reload query. + expect(paginator.hasMoreHead).toBe(false); paginator.queryResolve({ items: [{ id: 'id2' }], tailward: 'next2' }); await reloadPromise; expect(paginator.isLoading).toBe(false); expect(paginator.hasMoreTail).toBe(false); - expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreHead).toBe(false); expect(paginator.items).toEqual([{ id: 'id2' }]); expect(paginator.cursor).toBeUndefined(); expect(paginator.offset).toBe(1); @@ -3747,16 +3594,24 @@ describe('BasePaginator', () => { TestItem, Partial> >({ - sort: { age: 1 }, // ascending age (so normally a < b < c by age) + sort: { age: 1 }, // ascending age + }); + + // Load the non-boosted items as a single anchored (active) interval. + paginator.setItems({ + valueOrFactory: [a, b], + isFirstPage: true, + isLastPage: true, }); - paginator.state.partialNext({ items: [a, b] }); - // Boost "c" before ingest → it should be placed ahead of non-boosted even though age is highest + // Boost "c" before ingest → it floats ahead of the non-boosted items regardless of where + // the fallback age sort would otherwise place it. paginator.boost('c', { ttlMs: 60000, seq: 1 }); expect(paginator.ingestItem(c)).toBeTruthy(); - // c should be first due to boost, then a, then b (fallback sort would place c last otherwise) - expect(paginator.items!.map((i) => i.id)).toEqual(['c', 'a', 'b']); + const ids = paginator.items!.map((i) => i.id); + expect(ids[0]).toBe('c'); + expect([...ids].sort()).toEqual(['a', 'b', 'c']); vi.useRealTimers(); }); diff --git a/test/unit/pagination/paginators/ChannelPaginator.test.ts b/test/unit/pagination/paginators/ChannelPaginator.test.ts index 9776d4bfd8..df81b79c14 100644 --- a/test/unit/pagination/paginators/ChannelPaginator.test.ts +++ b/test/unit/pagination/paginators/ChannelPaginator.test.ts @@ -7,15 +7,29 @@ import { ChannelSort, DEFAULT_PAGINATION_OPTIONS, type FilterBuilderGenerators, + formatMessage, PaginatorCursor, type StreamChat, } from '../../../../src'; 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'; const user = { id: 'custom-id' }; +// `channel.state.last_message_at` is derived (read-only) from the message paginator's tracked latest +// message. To stage a specific value for sort tests, seed the paginator: clear first so any value +// (including an earlier one) applies, since tracking is monotonic. +const setLastMessageAt = (channel: Channel, date: Date | null) => { + channel.messagePaginator.clearStateAndCache(); + if (date) { + channel.messagePaginator.trackLastMessage( + formatMessage(generateMsg({ date: date.toISOString() })), + ); + } +}; + describe('ChannelPaginator', () => { let client: StreamChat; let channel1: Channel; @@ -25,11 +39,11 @@ describe('ChannelPaginator', () => { client = getClientWithUser(user); channel1 = new Channel(client, 'type', 'id1', {}); - channel1.state.last_message_at = new Date('1972-01-01T08:39:35.235Z'); + setLastMessageAt(channel1, new Date('1972-01-01T08:39:35.235Z')); channel1.data!.updated_at = '1972-01-01T08:39:35.235Z'; channel2 = new Channel(client, 'type', 'id1', {}); - channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); + setLastMessageAt(channel2, new Date('1971-01-01T08:39:35.235Z')); channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; }); @@ -39,7 +53,7 @@ describe('ChannelPaginator', () => { expect(paginator.pageSize).toBe(DEFAULT_PAGINATION_OPTIONS.pageSize); expect(paginator.state.getLatestValue()).toEqual({ hasMoreTail: true, - hasMoreHead: true, + hasMoreHead: true, // initial state (pre-query); becomes false after the first offset-0 query isLoading: false, items: undefined, lastQueryError: undefined, @@ -49,10 +63,10 @@ describe('ChannelPaginator', () => { expect(paginator.id.startsWith('channel-paginator')).toBeTruthy(); expect(paginator.sortComparator).toBeDefined(); - channel1.state.last_message_at = new Date('1970-01-01T08:39:35.235Z'); + setLastMessageAt(channel1, new Date('1970-01-01T08:39:35.235Z')); channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; - channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); + setLastMessageAt(channel2, new Date('1971-01-01T08:39:35.235Z')); channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; expect(paginator.sortComparator(channel1, channel2)).toBe(1); // channel2 comes before channel1 @@ -152,10 +166,10 @@ describe('ChannelPaginator', () => { const paginator = new ChannelPaginator({ client }); expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); - channel1.state.last_message_at = new Date('1970-01-01T08:39:35.235Z'); + setLastMessageAt(channel1, new Date('1970-01-01T08:39:35.235Z')); channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; - channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); + setLastMessageAt(channel2, new Date('1971-01-01T08:39:35.235Z')); channel2.data!.updated_at = '1971-01-01T08:39:35.235Z'; expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); @@ -201,16 +215,16 @@ describe('ChannelPaginator', () => { const paginator = new ChannelPaginator({ client, sort: { last_updated: 1 } }); // compares channel1.state.last_message_at with channel2.data!.updated_at - channel1.state.last_message_at = new Date('1975-01-01T08:39:35.235Z'); + setLastMessageAt(channel1, new Date('1975-01-01T08:39:35.235Z')); channel1.data!.updated_at = '1970-01-01T08:39:35.235Z'; - channel2.state.last_message_at = new Date('1971-01-01T08:39:35.235Z'); + setLastMessageAt(channel2, new Date('1971-01-01T08:39:35.235Z')); channel2.data!.updated_at = '1973-01-01T08:39:35.235Z'; expect(paginator.sortComparator(channel1, channel2)).toBe(changeOrder); // compares channel2.state.last_message_at with channel1.data!.updated_at - channel1.state.last_message_at = new Date('1975-01-01T08:39:35.235Z'); + setLastMessageAt(channel1, new Date('1975-01-01T08:39:35.235Z')); channel1.data!.updated_at = '1976-01-01T08:39:35.235Z'; - channel2.state.last_message_at = new Date('1978-01-01T08:39:35.235Z'); + setLastMessageAt(channel2, new Date('1978-01-01T08:39:35.235Z')); channel2.data!.updated_at = '1973-01-01T08:39:35.235Z'; expect(paginator.sortComparator(channel1, channel2)).toBe(keepOrder); }); @@ -365,17 +379,17 @@ describe('ChannelPaginator', () => { filters: { last_updated: new Date(1000).toISOString() }, }); channel1.data = { updated_at: undefined }; - channel1.state.last_message_at = new Date(1000); + setLastMessageAt(channel1, new Date(1000)); expect(paginator.matchesFilter(channel1)).toBeTruthy(); channel1.data = { updated_at: new Date(1000).toISOString() }; - channel1.state.last_message_at = null; + setLastMessageAt(channel1, null); expect(paginator.matchesFilter(channel1)).toBeTruthy(); channel1.data = { updated_at: undefined }; - channel1.state.last_message_at = null; + setLastMessageAt(channel1, null); expect(paginator.matchesFilter(channel1)).toBeFalsy(); }); @@ -429,18 +443,18 @@ describe('ChannelPaginator', () => { channel1.data = { updated_at: undefined }; scenarios.forEach(({ val, expected }) => { - channel1.state.last_message_at = new Date(val); + setLastMessageAt(channel1, new Date(val)); expect(paginator.matchesFilter(channel1)).toBe(expected); }); - channel1.state.last_message_at = null; + setLastMessageAt(channel1, null); scenarios.forEach(({ val, expected }) => { channel1.data = { updated_at: new Date(val).toISOString() }; expect(paginator.matchesFilter(channel1)).toBe(expected); }); channel1.data = { updated_at: undefined }; - channel1.state.last_message_at = null; + setLastMessageAt(channel1, null); expect(paginator.matchesFilter(channel1)).toBe(false); }); }); @@ -588,51 +602,51 @@ describe('ChannelPaginator', () => { }); describe('setters', () => { - const stateAfterQuery = { - items: [channel1, channel2], - hasMoreTail: false, - hasMoreHead: false, - offset: 10, - isLoading: false, - lastQueryError: undefined, - cursor: undefined, + // Seed via the real ingestion path (distinct cids — interval storage dedupes by cid) and capture + // the resulting state. These setters must not re-emit / reset it, so the state reference should + // be identical afterwards. + const seed = (paginator: ChannelPaginator) => { + const a = new Channel(client, 'type', 'setter-a', {}); + const b = new Channel(client, 'type', 'setter-b', {}); + paginator.setItems({ + valueOrFactory: [a, b], + isFirstPage: true, + isLastPage: true, + }); + return paginator.state.getLatestValue(); }; it('filters reset does not reset the paginator state', () => { const paginator = new ChannelPaginator({ client }); - paginator.state.partialNext(stateAfterQuery); - expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + const before = seed(paginator); paginator.staticFilters = {}; - expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + expect(paginator.state.getLatestValue()).toBe(before); expect(paginator.staticFilters).toStrictEqual({}); }); it('sort reset does not reset the paginator state updates the comparator', () => { const paginator = new ChannelPaginator({ client }); - paginator.state.partialNext(stateAfterQuery); - expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + const before = seed(paginator); const originalComparator = paginator.sortComparator; paginator.sort = {}; - expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + expect(paginator.state.getLatestValue()).toBe(before); expect(paginator.sort).toStrictEqual({}); expect(paginator.sortComparator).not.toEqual(originalComparator); }); it('options reset does not reset the paginator state', () => { const paginator = new ChannelPaginator({ client }); - paginator.state.partialNext(stateAfterQuery); - expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + const before = seed(paginator); paginator.options = {}; - expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + expect(paginator.state.getLatestValue()).toBe(before); expect(paginator.options).toStrictEqual({}); }); it('channelStateOptions reset does not reset the paginator state', () => { const paginator = new ChannelPaginator({ client }); - paginator.state.partialNext(stateAfterQuery); - expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + const before = seed(paginator); paginator.channelStateOptions = {}; - expect(paginator.state.getLatestValue()).toStrictEqual(stateAfterQuery); + expect(paginator.state.getLatestValue()).toBe(before); expect(paginator.channelStateOptions).toStrictEqual({}); }); }); @@ -711,4 +725,101 @@ describe('ChannelPaginator', () => { ); }); }); + + describe('interval storage', () => { + it('is index-addressable by cid, populates headItems, and dedupes across pages', async () => { + const a = new Channel(client, 'type', 'iv-a', {}); + const b = new Channel(client, 'type', 'iv-b', {}); + let page: Channel[] = [a, b]; + const paginator = new ChannelPaginator({ + client, + paginatorOptions: { + doRequest: () => Promise.resolve({ items: page }), + pageSize: 2, + }, + }); + + await paginator.executeQuery({}); + + // resolvable by cid + mirrored into the head window (interval storage) + expect(paginator.getItem('type:iv-a')).toBe(a); + expect(paginator.getItem('type:iv-b')).toBe(b); + expect(paginator.headItems.map((c) => c.cid).sort()).toEqual([ + 'type:iv-a', + 'type:iv-b', + ]); + + // next offset page returns an already-loaded channel — dedup keeps a single entry + page = [a]; + await paginator.toTail(); + const cids = (paginator.items ?? []).map((c) => c.cid); + expect(cids.filter((cid) => cid === 'type:iv-a')).toHaveLength(1); + }); + + it('keeps the head (newest) at index 0 and the tail (oldest) at the end', async () => { + // Contrary to the message list, the channel list is head-first: the newest (head) item sits at + // the top (index 0) and the oldest (tail) at the bottom. + const newest = new Channel(client, 'type', 'newest', {}); + const middle = new Channel(client, 'type', 'middle', {}); + const oldest = new Channel(client, 'type', 'oldest', {}); + setLastMessageAt(newest, new Date('2020-03-01T00:00:00.000Z')); + setLastMessageAt(middle, new Date('2020-02-01T00:00:00.000Z')); + setLastMessageAt(oldest, new Date('2020-01-01T00:00:00.000Z')); + + const paginator = new ChannelPaginator({ + client, + paginatorOptions: { + // server returns them out of order; interval storage sorts by the default (desc) comparator + doRequest: () => Promise.resolve({ items: [middle, oldest, newest] }), + pageSize: 10, + }, + }); + + await paginator.executeQuery({}); + + expect(paginator.items?.map((c) => c.cid)).toEqual([ + 'type:newest', + 'type:middle', + 'type:oldest', + ]); + // head edge = index 0 = newest; head window starts with it too + expect(paginator.headmostItem?.cid).toBe('type:newest'); + expect(paginator.headItems[0]?.cid).toBe('type:newest'); + }); + + it('promotes a non-headmost channel to the top on re-ingest without dropping it', async () => { + // Reproduces the reorder-on-new-message bug: a channel below the head gets a newer + // last_message_at and is re-ingested (as the orchestrator does on message.new). It must move to + // the top and stay visible — not escape into the logical-head interval and disappear. + const a = new Channel(client, 'type', 'a', {}); + const b = new Channel(client, 'type', 'b', {}); + const c = new Channel(client, 'type', 'c', {}); + setLastMessageAt(a, new Date('2020-03-01T00:00:00.000Z')); + setLastMessageAt(b, new Date('2020-02-01T00:00:00.000Z')); + setLastMessageAt(c, new Date('2020-01-01T00:00:00.000Z')); // oldest / non-headmost + + const paginator = new ChannelPaginator({ + client, + paginatorOptions: { + doRequest: () => Promise.resolve({ items: [a, b, c] }), + pageSize: 10, + }, + }); + await paginator.executeQuery({}); + expect(paginator.items?.map((ch) => ch.cid)).toEqual([ + 'type:a', + 'type:b', + 'type:c', + ]); + + // c receives a new message → newest; re-ingest to reposition (mirrors updateLists) + setLastMessageAt(c, new Date('2020-04-01T00:00:00.000Z')); + paginator.ingestItem(c); + + const cids = paginator.items?.map((ch) => ch.cid); + expect(cids).toContain('type:c'); // not dropped + expect(cids?.[0]).toBe('type:c'); // moved to the head (top) + expect(cids).toHaveLength(3); // no duplicates, nothing lost + }); + }); }); diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 73b32e8e8f..0dd4d24dee 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -50,6 +50,10 @@ describe('MessagePaginator', () => { lastQueryError: undefined, offset: 0, }); + expect(paginator.aggregateState.getLatestValue()).toEqual({ + lastMessage: null, + seededLastMessageAt: null, + }); // @ts-expect-error accessing protected property expect(paginator._filterFieldToDataResolvers).toHaveLength(1); @@ -1071,6 +1075,137 @@ describe('MessagePaginator', () => { }); }); + describe('reflectUserUpdate()', () => { + it('patches the user on cached messages authored by the user and re-emits the active window', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + const byA1 = createMessage({ + id: 'a1', + user: { id: 'A' }, + created_at: '2021-01-01T00:00:00.000Z', + }); + const byB = createMessage({ + id: 'b1', + user: { id: 'B' }, + created_at: '2021-01-02T00:00:00.000Z', + }); + const byA2 = createMessage({ + id: 'a2', + user: { id: 'A' }, + created_at: '2021-01-03T00:00:00.000Z', + }); + + paginator.setItems({ + valueOrFactory: [byA1, byB, byA2], + isFirstPage: true, + isLastPage: true, + }); + + paginator.reflectUserUpdate({ id: 'A', name: 'Renamed A' }); + + expect(paginator.getItem('a1')?.user?.name).toBe('Renamed A'); + expect(paginator.getItem('a2')?.user?.name).toBe('Renamed A'); + expect(paginator.getItem('b1')?.user?.name).not.toBe('Renamed A'); + // the active window is re-emitted with the updated user object + expect(paginator.items?.find((m) => m.id === 'a1')?.user?.name).toBe('Renamed A'); + }); + }); + + describe('reflectReaction()', () => { + const currentUserId = 'me'; + const reaction = (type: string, userId: string) => ({ + created_at: '2021-01-01T00:00:00.000Z', + message_id: 'r1', + type, + user_id: userId, + }); + + beforeEach(() => { + (channel as unknown as { getClient: () => unknown }).getClient = () => ({ + userID: currentUserId, + }); + }); + + const seed = ( + paginator: MessagePaginator, + ownReactions: ReturnType[], + ) => { + paginator.setItems({ + valueOrFactory: [ + createMessage({ + created_at: '2021-01-01T00:00:00.000Z', + id: 'r1', + latest_reactions: ownReactions, + own_reactions: ownReactions, + }), + ], + isFirstPage: true, + isLastPage: true, + }); + }; + + it("preserves the current user's own_reactions when another user reacts", () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + seed(paginator, [reaction('love', currentUserId)]); + + paginator.reflectReaction({ + message: createMessage({ + id: 'r1', + // server event omits our own_reactions and carries the merged groups + own_reactions: [], + reaction_groups: { + like: { count: 1, sum_scores: 1 } as never, + love: { count: 1, sum_scores: 1 } as never, + }, + }), + reaction: reaction('like', 'other'), + }); + + const updated = paginator.getItem('r1'); + expect(updated?.own_reactions?.map((r) => r.type)).toEqual(['love']); + // the event's server-computed reaction_groups are applied as-is + expect(updated?.reaction_groups?.like).toBeDefined(); + }); + + it("adds the current user's reaction to own_reactions", () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + seed(paginator, []); + + paginator.reflectReaction({ + message: createMessage({ id: 'r1' }), + reaction: reaction('love', currentUserId), + }); + + expect(paginator.getItem('r1')?.own_reactions?.map((r) => r.type)).toEqual([ + 'love', + ]); + }); + + it('removes the reaction from own_reactions on reaction.deleted', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + seed(paginator, [reaction('love', currentUserId)]); + + paginator.reflectReaction({ + message: createMessage({ id: 'r1', own_reactions: [] }), + reaction: reaction('love', currentUserId), + removed: true, + }); + + expect(paginator.getItem('r1')?.own_reactions ?? []).toEqual([]); + }); + + it('does not add another user reaction to own_reactions', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + seed(paginator, []); + + paginator.reflectReaction({ + message: createMessage({ id: 'r1' }), + reaction: reaction('love', 'other'), + }); + + expect(paginator.getItem('r1')?.own_reactions ?? []).toEqual([]); + }); + }); + describe.todo('postQueryReconcile and deriveCursor for', () => {}); describe('linear pagination', () => { describe('updates the hasMoreTail flag only if the first message on page is the first message in interval', () => { @@ -1236,6 +1371,307 @@ describe('MessagePaginator', () => { }); }); + describe('seedFirstPageSync()', () => { + const msg = (id: string, day: string) => + createMessage({ + cid: 'channel-id', + id, + created_at: `2020-01-${day}T00:00:00.000Z`, + }); + + it('seeds a latest page as the head window (nothing newer to load)', () => { + // First-page reconcile reads the client for the unread snapshot; no user => snapshot skipped. + (channel as unknown as { getClient: () => unknown }).getClient = () => ({ + user: undefined, + }); + const paginator = new MessagePaginator({ channel, itemIndex }); + // Fewer messages than the requested page size => dataset edges reached both ways. + paginator.seedFirstPageSync([msg('m8', '08'), msg('m9', '09')], 100); + + expect(paginator.headmostItem?.id).toBe('m9'); + expect(paginator.hasMoreHead).toBe(false); + expect(paginator.hasMoreTail).toBe(false); + }); + + it('seeds an around/jump open as a middle window, not the head', () => { + (channel as unknown as { getClient: () => unknown }).getClient = () => ({ + user: undefined, + }); + const paginator = new MessagePaginator({ channel, itemIndex }); + // A full page centered on m6: messages exist on both sides beyond this window, so the + // paginator must NOT flag it as the latest (head) page — regression for a channel opened + // via `messages: { id_around }` rather than the latest page. + paginator.seedFirstPageSync( + [ + msg('m4', '04'), + msg('m5', '05'), + msg('m6', '06'), + msg('m7', '07'), + msg('m8', '08'), + ], + 5, + { id_around: 'm6' }, + ); + + expect(paginator.hasMoreHead).toBe(true); + expect(paginator.hasMoreTail).toBe(true); + }); + }); + + describe('latest window, truncation & live message routing', () => { + const msg = (id: string, day: string) => + createMessage({ + cid: 'channel-id', + id, + created_at: `2020-01-${day}T00:00:00.000Z`, + }); + + describe('headItems / headmostItem', () => { + it('reflect the active head window', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m8', '08'), msg('m9', '09')], + isHead: true, + isTail: true, + setActive: true, + }); + + expect(paginator.items?.map((m) => m.id)).toEqual(['m8', 'm9']); + expect(paginator.headItems.map((m) => m.id)).toEqual(['m8', 'm9']); + expect(paginator.headmostItem?.id).toBe('m9'); + }); + + it('reflect the head window even while an older window is active (after a jump)', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m8', '08'), msg('m9', '09')], + isHead: true, + setActive: false, + }); + // an older, disjoint window is now the active one + paginator.ingestPage({ + page: [msg('m4', '04'), msg('m5', '05')], + setActive: true, + }); + + expect(paginator.items?.map((m) => m.id)).toEqual(['m4', 'm5']); // active window + expect(paginator.headItems.map((m) => m.id)).toEqual(['m8', 'm9']); // newest window + expect(paginator.headmostItem?.id).toBe('m9'); + }); + + it('return the newest loaded window even when it is not flagged isHead (query/hydration seed)', () => { + // The query/hydration seed does not reliably mark a latest page as isHead, so headItems + // uses the head-most *loaded* window rather than requiring the flag. + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m4', '04'), msg('m5', '05')], + setActive: true, + }); + + expect(paginator.headItems.map((m) => m.id)).toEqual(['m4', 'm5']); + expect(paginator.headmostItem?.id).toBe('m5'); + }); + + it('are empty when nothing is loaded', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + expect(paginator.headItems).toEqual([]); + expect(paginator.headmostItem).toBeUndefined(); + }); + }); + + describe('truncate()', () => { + it('drops messages strictly older than truncated_at and keeps the rest', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m1', '01'), msg('m5', '05'), msg('m9', '09')], + isHead: true, + isTail: true, + setActive: true, + }); + + paginator.truncate({ truncatedAt: new Date('2020-01-05T00:00:00.000Z') }); + + // m1 dropped; m5 kept (equal, not strictly older); m9 kept + expect(paginator.items?.map((m) => m.id)).toEqual(['m5', 'm9']); + expect(paginator.getItem('m1')).toBeUndefined(); + expect(paginator.getItem('m5')).toBeTruthy(); + }); + + it('marks the interval that spanned the cutoff as the new tail', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + // isHead but NOT isTail → hasMoreTail starts true + paginator.ingestPage({ + page: [msg('m1', '01'), msg('m5', '05'), msg('m9', '09')], + isHead: true, + setActive: true, + }); + expect(paginator.hasMoreTail).toBe(true); + + paginator.truncate({ truncatedAt: new Date('2020-01-05T00:00:00.000Z') }); + + expect(paginator.items?.map((m) => m.id)).toEqual(['m5', 'm9']); + // it lost its oldest member → nothing older remains → it is now the tail + expect(paginator.hasMoreTail).toBe(false); + }); + + it('leaves an interval that did not span the cutoff untouched (keeps hasMoreTail)', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + // active newer window, not a tail (older messages may still be unloaded) + paginator.ingestPage({ + page: [msg('m8', '08'), msg('m9', '09')], + isHead: true, + setActive: true, + }); + // a separate, older, disjoint window + paginator.ingestPage({ page: [msg('m2', '02'), msg('m3', '03')] }); + + paginator.truncate({ truncatedAt: new Date('2020-01-05T00:00:00.000Z') }); + + // the older window was entirely older than the cutoff → dropped + expect(paginator.getItem('m2')).toBeUndefined(); + expect(paginator.getItem('m3')).toBeUndefined(); + // the active (newer) window did not span the cutoff → unchanged, still expects older pages + expect(paginator.items?.map((m) => m.id)).toEqual(['m8', 'm9']); + expect(paginator.hasMoreTail).toBe(true); + }); + + it('re-emits the active window only once (batched)', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m1', '01'), msg('m2', '02'), msg('m3', '03'), msg('m9', '09')], + isHead: true, + isTail: true, + setActive: true, + }); + + const partialNextSpy = vi.spyOn(paginator.state, 'partialNext'); + paginator.truncate({ truncatedAt: new Date('2020-01-05T00:00:00.000Z') }); + + // three messages removed, but a single state emission + expect(paginator.items?.map((m) => m.id)).toEqual(['m9']); + expect(partialNextSpy).toHaveBeenCalledTimes(1); + }); + + it('activates the surviving window instead of blanking when the active window is truncated away', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + // a surviving newer window + paginator.ingestPage({ + page: [msg('m8', '08'), msg('m9', '09')], + isHead: true, + setActive: false, + }); + // the active window is an older, disjoint one + paginator.ingestPage({ + page: [msg('m1', '01'), msg('m2', '02')], + setActive: true, + }); + expect(paginator.items?.map((m) => m.id)).toEqual(['m1', 'm2']); + + paginator.truncate({ truncatedAt: new Date('2020-01-05T00:00:00.000Z') }); + + // active window removed entirely, but we show the surviving window — NOT an empty list + expect(paginator.getItem('m1')).toBeUndefined(); + expect(paginator.items?.map((m) => m.id)).toEqual(['m8', 'm9']); + }); + + it('falls back to the nearest (tail-most) surviving window when several survive', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m8', '08'), msg('m9', '09')], + isHead: true, + setActive: false, + }); + paginator.ingestPage({ + page: [msg('m5', '05'), msg('m6', '06')], + setActive: false, + }); + // active is the oldest window + paginator.ingestPage({ + page: [msg('m1', '01'), msg('m2', '02')], + setActive: true, + }); + + paginator.truncate({ truncatedAt: new Date('2020-01-04T00:00:00.000Z') }); + + // active [m1,m2] removed; nearest survivor to where it was = the oldest survivor [m5,m6] + expect(paginator.items?.map((m) => m.id)).toEqual(['m5', 'm6']); + }); + + it('splits at the correct point with duplicate timestamps at the boundary', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [ + msg('m1', '01'), + msg('m3a', '03'), + msg('m3b', '03'), + msg('m5', '05'), + msg('m7', '07'), + ], + isHead: true, + isTail: true, + setActive: true, + }); + + // cutoff 04: everything strictly older (m1, both m3*) dropped; m5, m7 kept + paginator.truncate({ truncatedAt: new Date('2020-01-04T00:00:00.000Z') }); + + expect(paginator.items?.map((m) => m.id)).toEqual(['m5', 'm7']); + expect(paginator.getItem('m3b')).toBeUndefined(); + }); + + it('is a no-op for an invalid cutoff date', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m1', '01')], + isHead: true, + isTail: true, + setActive: true, + }); + const partialNextSpy = vi.spyOn(paginator.state, 'partialNext'); + + paginator.truncate({ truncatedAt: new Date('not-a-date') }); + + expect(paginator.items?.map((m) => m.id)).toEqual(['m1']); + expect(partialNextSpy).not.toHaveBeenCalled(); + }); + }); + + describe('message.new routing (replaces the isUpToDate flag)', () => { + it('appends a newer message when the head window is active', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m1', '01'), msg('m2', '02')], + isHead: true, + isTail: true, + setActive: true, + }); + + paginator.ingestItem(msg('m3', '03')); + + expect(paginator.items?.map((m) => m.id)).toEqual(['m1', 'm2', 'm3']); + expect(paginator.headmostItem?.id).toBe('m3'); + }); + + it('does not inject a newer message into an older active window (viewer scrolled away)', () => { + const paginator = new MessagePaginator({ channel, itemIndex }); + paginator.ingestPage({ + page: [msg('m8', '08'), msg('m9', '09')], + isHead: true, + setActive: false, + }); + paginator.ingestPage({ + page: [msg('m4', '04'), msg('m5', '05')], + setActive: true, + }); + + paginator.ingestItem(msg('m10', '10')); + + // the viewed (older) window is unchanged — the new message is not pushed onto it + expect(paginator.items?.map((m) => m.id)).toEqual(['m4', 'm5']); + }); + }); + }); + describe('mergeNewestPage()', () => { const m = (id: string, day: string, overrides: Partial = {}) => createMessage({ @@ -1473,7 +1909,371 @@ describe('MessagePaginator', () => { }); }); - it('cannot be customized', () => { - const paginator = new MessagePaginator({ channel, itemIndex }); + describe('trackLastMessage() / lastMessageAt', () => { + let skipSystemMessages: boolean; + let trackingChannel: Channel; + + const buildPaginator = (parentMessageId?: string) => { + trackingChannel = { + cid: 'channel-id', + getConfig: () => ({ skip_last_msg_update_for_system_msgs: skipSystemMessages }), + getReplies: vi.fn(), + query: vi.fn(), + } as unknown as Channel; + return new MessagePaginator({ + channel: trackingChannel, + parentMessageId, + itemIndex: new ItemIndex({ getId: (message) => message.id }), + }); + }; + + const at = (iso: string) => new Date(iso).getTime(); + + beforeEach(() => { + skipSystemMessages = false; + }); + + it('is null until a message is tracked', () => { + const paginator = buildPaginator(); + expect(paginator.aggregateState.getLatestValue()).toEqual({ + lastMessage: null, + seededLastMessageAt: null, + }); + expect(paginator.lastMessageAt).toBeNull(); + expect(paginator.lastMessage).toBeNull(); + }); + + it('advances lastMessageAt and lastMessage without ingesting a window', () => { + const paginator = buildPaginator(); + + paginator.trackLastMessage( + createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), + ); + + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); + // The display message is tracked on aggregateState (reactive off-window), not the visible list. + expect(paginator.lastMessage?.id).toBe('a'); + expect(paginator.items).toBeUndefined(); + }); + + it('seed advances only the timestamp, leaving lastMessage null', () => { + const paginator = buildPaginator(); + paginator.seedLastMessageAt('2023-05-03T11:12:53.993Z'); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2023-05-03T11:12:53.993Z')); + // The server seed has a timestamp but not the message itself. + expect(paginator.lastMessage).toBeNull(); + }); + + it('lastMessageAt is the max of the loaded message and the seed; a seed never blocks the display message', () => { + const paginator = buildPaginator(); + // Server says the newest message is far in the future (not yet loaded). + paginator.seedLastMessageAt('2030-01-01T00:00:00.000Z'); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2030-01-01T00:00:00.000Z')); + expect(paginator.lastMessage).toBeNull(); + + // A real (older-than-seed) message must still become the display message — the guard is against + // the display message's own timestamp, not the seed-inflated lastMessageAt. + paginator.trackLastMessage( + createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), + ); + expect(paginator.lastMessage?.id).toBe('a'); + // Sort key stays the max (the seed), so it can never drift below the display message. + expect(paginator.lastMessageAt?.getTime()).toBe(at('2030-01-01T00:00:00.000Z')); + + // Once a message newer than the seed arrives, lastMessageAt follows it. + paginator.trackLastMessage( + createMessage({ id: 'b', created_at: '2031-01-01T00:00:00.000Z' }), + ); + expect(paginator.lastMessage?.id).toBe('b'); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2031-01-01T00:00:00.000Z')); + }); + + it('does not emit on the pagination state (writes the separate aggregateState store)', () => { + const paginator = buildPaginator(); + let stateEmissions = 0; + const unsubscribe = paginator.state.subscribe(() => { + stateEmissions += 1; + }); + stateEmissions = 0; // ignore the synchronous initial subscribe call + + paginator.trackLastMessage( + createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), + ); + unsubscribe(); + + expect(stateEmissions).toBe(0); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); + }); + + it('advances monotonically by created_at', () => { + const paginator = buildPaginator(); + + paginator.trackLastMessage( + createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), + ); + paginator.trackLastMessage( + createMessage({ id: 'b', created_at: '2019-01-01T00:00:00.000Z' }), + ); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); + + paginator.trackLastMessage( + createMessage({ id: 'c', created_at: '2021-01-01T00:00:00.000Z' }), + ); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2021-01-01T00:00:00.000Z')); + }); + + it('never advances for a shadowed message', () => { + const paginator = buildPaginator(); + + paginator.trackLastMessage( + createMessage({ + id: 'a', + created_at: '2020-01-01T00:00:00.000Z', + shadowed: true, + }), + ); + + expect(paginator.lastMessageAt).toBeNull(); + }); + + it('never advances for a thread-only reply, but does for a reply shown in the channel', () => { + const paginator = buildPaginator(); + + paginator.trackLastMessage( + createMessage({ + id: 'reply', + parent_id: 'parent', + created_at: '2020-01-01T00:00:00.000Z', + }), + ); + expect(paginator.lastMessageAt).toBeNull(); + + paginator.trackLastMessage( + createMessage({ + id: 'reply-shown', + parent_id: 'parent', + show_in_channel: true, + created_at: '2021-01-01T00:00:00.000Z', + }), + ); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2021-01-01T00:00:00.000Z')); + }); + + it('skips system messages only when skip_last_msg_update_for_system_msgs is set', () => { + skipSystemMessages = true; + const skipping = buildPaginator(); + const systemMessage = createMessage({ + id: 'sys', + type: 'system', + created_at: '2020-01-01T00:00:00.000Z', + }); + skipping.trackLastMessage(systemMessage); + expect(skipping.lastMessageAt).toBeNull(); + + skipSystemMessages = false; + const tracking = buildPaginator(); + tracking.trackLastMessage(systemMessage); + expect(tracking.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); + }); + + it('auto-tracks on ingestion for the main channel list too', () => { + const paginator = buildPaginator(); + paginator.ingestItem( + createMessage({ + id: 'a', + cid: 'channel-id', + created_at: '2020-01-01T00:00:00.000Z', + }), + ); + // The main list no longer relies on an explicit channel-level call: ingestion advances the + // lastMessageAt aggregate directly. + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); + }); + + it('seeds lastMessageAt from the server value (monotonic)', () => { + const paginator = buildPaginator(); + + paginator.seedLastMessageAt('2020-06-01T00:00:00.000Z'); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-06-01T00:00:00.000Z')); + + // an older server value does not move it back + paginator.seedLastMessageAt('2020-01-01T00:00:00.000Z'); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-06-01T00:00:00.000Z')); + + // a newer ingested message advances past the seed + paginator.ingestItem( + createMessage({ + id: 'a', + cid: 'channel-id', + created_at: '2021-01-01T00:00:00.000Z', + }), + ); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2021-01-01T00:00:00.000Z')); + }); + + describe('reply list (parentMessageId) auto-tracks on ingestion', () => { + const reply = (id: string, createdAt: string): LocalMessage => + createMessage({ + id, + cid: 'channel-id', + parent_id: 'parent', + created_at: createdAt, + }); + + it('advances to the newest reply on ingestItem, regardless of ingestion order', () => { + const paginator = buildPaginator('parent'); + + paginator.ingestItem(reply('r2', '2020-01-01T00:00:02.000Z')); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:02.000Z')); + + // an older reply arriving later must not move the value back + paginator.ingestItem(reply('r1', '2020-01-01T00:00:01.000Z')); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:02.000Z')); + + paginator.ingestItem(reply('r3', '2020-01-01T00:00:03.000Z')); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:03.000Z')); + }); + + it('advances to the newest reply when a page is seeded via setItems', () => { + const paginator = buildPaginator('parent'); + + paginator.setItems({ + valueOrFactory: [ + reply('r1', '2020-01-01T00:00:01.000Z'), + reply('r3', '2020-01-01T00:00:03.000Z'), + reply('r2', '2020-01-01T00:00:02.000Z'), + ], + isFirstPage: true, + }); + + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:03.000Z')); + }); + }); + + it('resets lastMessageAt on clearStateAndCache()', () => { + const paginator = buildPaginator(); + paginator.trackLastMessage( + createMessage({ id: 'a', created_at: '2020-01-01T00:00:00.000Z' }), + ); + expect(paginator.lastMessageAt?.getTime()).toBe(at('2020-01-01T00:00:00.000Z')); + + paginator.clearStateAndCache(); + + expect(paginator.lastMessageAt).toBeNull(); + }); + + it('refreshes lastMessage in place (and emits) when the current latest is edited', () => { + const paginator = buildPaginator(); + paginator.ingestItem( + createMessage({ + id: 'a', + cid: 'channel-id', + created_at: '2020-01-01T00:00:00.000Z', + text: 'hello', + }), + ); + expect(paginator.lastMessage?.text).toBe('hello'); + + let emissions = 0; + const unsubscribe = paginator.aggregateState.subscribe(() => { + emissions += 1; + }); + emissions = 0; + + // edit: same id + same created_at (monotonic guard would otherwise reject it) + paginator.ingestItem( + createMessage({ + id: 'a', + cid: 'channel-id', + created_at: '2020-01-01T00:00:00.000Z', + text: 'edited', + }), + ); + unsubscribe(); + + expect(paginator.lastMessage?.text).toBe('edited'); + expect(emissions).toBe(1); + }); + + it('reflects a soft-delete of the current latest', () => { + const paginator = buildPaginator(); + paginator.ingestItem( + createMessage({ + id: 'a', + cid: 'channel-id', + created_at: '2020-01-01T00:00:00.000Z', + }), + ); + paginator.ingestItem( + createMessage({ + id: 'a', + cid: 'channel-id', + created_at: '2020-01-01T00:00:00.000Z', + type: 'deleted', + deleted_at: '2020-01-02T00:00:00.000Z', + }), + ); + expect(paginator.lastMessage?.type).toBe('deleted'); + }); + + it('recomputes lastMessage to the next newest when the current latest is hard-removed', () => { + const paginator = buildPaginator(); + paginator.ingestItem( + createMessage({ + id: 'a', + cid: 'channel-id', + created_at: '2020-01-01T00:00:00.000Z', + }), + ); + paginator.ingestItem( + createMessage({ + id: 'b', + cid: 'channel-id', + created_at: '2020-01-02T00:00:00.000Z', + }), + ); + expect(paginator.lastMessage?.id).toBe('b'); + + paginator.removeItem({ id: 'b' }); + expect(paginator.lastMessage?.id).toBe('a'); + + paginator.removeItem({ id: 'a' }); + expect(paginator.lastMessage).toBeNull(); + }); + + it('recompute after hard-remove skips a trailing system message (unlike the unfiltered headmostItem)', () => { + skipSystemMessages = true; + const paginator = buildPaginator(); + paginator.ingestItem( + createMessage({ + id: 'm0', + cid: 'channel-id', + created_at: '2020-01-01T00:00:01.000Z', + }), + ); + paginator.ingestItem( + createMessage({ + id: 'm1', + cid: 'channel-id', + created_at: '2020-01-01T00:00:02.000Z', + }), + ); + // A system message is the newest LOADED item, but the config keeps it from becoming the latest. + paginator.ingestItem( + createMessage({ + id: 'sys', + cid: 'channel-id', + type: 'system', + created_at: '2020-01-01T00:00:03.000Z', + }), + ); + expect(paginator.lastMessage?.id).toBe('m1'); + expect(paginator.headmostItem?.id).toBe('sys'); // headmostItem is unfiltered + + // Hard-removing the tracked latest must recompute to the previous NON-system message (m0), + // not to `headmostItem` (which is the system message). + paginator.removeItem({ id: 'm1' }); + expect(paginator.lastMessage?.id).toBe('m0'); + }); }); }); diff --git a/test/unit/pagination/paginators/MessageReplyPaginator.test.ts b/test/unit/pagination/paginators/MessageReplyPaginator.test.ts deleted file mode 100644 index 43f73495cc..0000000000 --- a/test/unit/pagination/paginators/MessageReplyPaginator.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { MessageReplyPaginator } from '../../../../src/pagination/paginators/MessageReplyPaginator'; -import type { - LocalMessage, - MessagePaginationOptions, - MessageResponse, -} from '../../../../src/types'; - -const makeLocalMessage = (id: string, createdAtMs: number): LocalMessage => - ({ - attachments: [], - created_at: new Date(createdAtMs), - deleted_at: null, - id, - mentioned_users: [], - pinned_at: null, - reaction_groups: null, - status: 'received', - text: id, - type: 'regular', - updated_at: new Date(createdAtMs), - }) as LocalMessage; - -const makeChannel = () => - ({ - cid: 'messaging:cid', - getClient: () => ({ - notifications: { addError: vi.fn() }, - }), - // Not used when config.doRequest is provided - getReplies: vi.fn(), - }) as unknown as import('../../../../src/channel').Channel; - -describe('MessageReplyPaginator', () => { - it('jumpToMessage does not query if message already in an interval', async () => { - const channel = makeChannel(); - const paginator = new MessageReplyPaginator({ - channel, - parentMessageId: 'parent-1', - }); - - const doRequest = vi.fn(async (query) => { - const options = query.options as MessagePaginationOptions; - const ids = options.id_around ? ['m1'] : ['m1']; - return { - items: ids.map((id) => makeLocalMessage(id, 1)), - }; - }); - - paginator.config.doRequest = doRequest; - - // Seed intervals + index - await paginator.executeQuery({ - queryShape: { options: { limit: 1 }, sort: paginator.sort }, - }); - expect(doRequest).toHaveBeenCalledTimes(1); - - const executeSpy = vi.spyOn(paginator, 'executeQuery'); - const ok = await paginator.jumpToMessage('m1'); - expect(ok).toBe(true); - expect(executeSpy).not.toHaveBeenCalled(); - }); - - it('jumpToMessage queries id_around when message not present', async () => { - const channel = makeChannel(); - const paginator = new MessageReplyPaginator({ - channel, - parentMessageId: 'parent-1', - }); - - const doRequest = vi.fn(async () => { - return { - items: [makeLocalMessage('m2', 2)], - }; - }); - paginator.config.doRequest = doRequest; - - const ok = await paginator.jumpToMessage('m2', { pageSize: 10 }); - expect(ok).toBe(true); - - expect(doRequest).toHaveBeenCalledTimes(1); - expect(doRequest).toHaveBeenCalledWith({ - options: { id_around: 'm2', limit: 10 }, - sort: [{ created_at: 1 }], - }); - }); - - it('jumpToTheLatestMessage calls jumpToMessage with latest id from head interval', async () => { - const channel = makeChannel(); - const paginator = new MessageReplyPaginator({ - channel, - parentMessageId: 'parent-1', - }); - - const doRequest = vi.fn(async () => { - return { - items: [makeLocalMessage('m1', 1), makeLocalMessage('m2', 2)], - }; - }); - paginator.config.doRequest = doRequest; - - // Ensure intervals are populated - await paginator.executeQuery({ - queryShape: { options: { limit: 2 }, sort: paginator.sort }, - }); - - const jumpSpy = vi.spyOn(paginator, 'jumpToMessage'); - await paginator.jumpToTheLatestMessage(); - - // We don't hard assert the id here because interval "head" semantics are internal, - // but we ensure it uses jumpToMessage as the final step. - expect(jumpSpy).toHaveBeenCalled(); - }); -}); diff --git a/test/unit/pagination/paginators/PinnedMessagePaginator.test.ts b/test/unit/pagination/paginators/PinnedMessagePaginator.test.ts new file mode 100644 index 0000000000..a4c7fc220f --- /dev/null +++ b/test/unit/pagination/paginators/PinnedMessagePaginator.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PinnedMessagePaginator } from '../../../../src/pagination/paginators/PinnedMessagePaginator'; +import type { LocalMessage, MessageResponse } from '../../../../src/types'; + +const CID = 'messaging:cid'; + +const makePinned = ( + id: string, + pinnedAtMs: number, + overrides: Partial = {}, +): MessageResponse => + ({ + attachments: [], + cid: CID, + created_at: new Date(pinnedAtMs).toISOString(), + id, + mentioned_users: [], + pinned: true, + pinned_at: new Date(pinnedAtMs).toISOString(), + status: 'received', + text: id, + type: 'regular', + updated_at: new Date(pinnedAtMs).toISOString(), + ...overrides, + }) as MessageResponse; + +const makeChannel = (getPinnedMessages = vi.fn()) => + ({ + cid: CID, + getClient: () => ({ + notifications: { addError: vi.fn() }, + userID: 'me', + }), + getPinnedMessages, + }) as unknown as import('../../../../src/channel').Channel; + +describe('PinnedMessagePaginator', () => { + it('fetches from getPinnedMessages and orders by pinned_at ascending', async () => { + const getPinnedMessages = vi.fn().mockResolvedValue({ + messages: [makePinned('c', 3000), makePinned('a', 1000), makePinned('b', 2000)], + }); + const paginator = new PinnedMessagePaginator({ + channel: makeChannel(getPinnedMessages), + }); + + await paginator.executeQuery(); + + expect(getPinnedMessages).toHaveBeenCalledTimes(1); + expect(paginator.items?.map((m) => m.id)).toEqual(['a', 'b', 'c']); + }); + + it('excludes non-pinned and shadowed messages from the queried page', async () => { + const getPinnedMessages = vi.fn().mockResolvedValue({ + messages: [ + makePinned('p', 1000), + makePinned('u', 2000, { pinned: false, pinned_at: null }), + makePinned('s', 3000, { shadowed: true }), + ], + }); + const paginator = new PinnedMessagePaginator({ + channel: makeChannel(getPinnedMessages), + }); + + await paginator.executeQuery(); + + expect(paginator.items?.map((m) => m.id)).toEqual(['p']); + }); + + it('auto-removes a message from the active window when it is unpinned', async () => { + const getPinnedMessages = vi + .fn() + .mockResolvedValue({ messages: [makePinned('p', 1000)] }); + const paginator = new PinnedMessagePaginator({ + channel: makeChannel(getPinnedMessages), + }); + + await paginator.executeQuery(); + expect(paginator.items?.map((m) => m.id)).toEqual(['p']); + + // Same message, now unpinned → matchesFilter({ pinned: true }) fails → removed from the list. + paginator.ingestItem({ + ...makePinned('p', 1000), + created_at: new Date(1000), + pinned: false, + pinned_at: null, + } as unknown as LocalMessage); + expect(paginator.items?.map((m) => m.id)).toEqual([]); + }); + + it('does not expose the unread / live-view surface (never coupled to read state)', () => { + const paginator = new PinnedMessagePaginator({ channel: makeChannel() }); + const surface = paginator as unknown as Record; + + expect(surface.unreadStateSnapshot).toBeUndefined(); + expect(surface.liveViewState).toBeUndefined(); + expect(surface.seedUnreadSnapshot).toBeUndefined(); + expect(surface.setUnreadSnapshot).toBeUndefined(); + expect(surface.clearUnreadSnapshot).toBeUndefined(); + expect(surface.setViewingLive).toBeUndefined(); + expect(surface.isViewingLive).toBeUndefined(); + expect(surface.jumpToTheFirstUnreadMessage).toBeUndefined(); + }); + + it('retains message-interval navigation (jumpToMessage is inherited)', () => { + const paginator = new PinnedMessagePaginator({ channel: makeChannel() }); + expect(typeof paginator.jumpToMessage).toBe('function'); + expect(typeof paginator.jumpToTheLatestMessage).toBe('function'); + expect(typeof paginator.reflectReaction).toBe('function'); + }); +}); diff --git a/test/unit/pagination/paginators/ReminderPaginator.test.ts b/test/unit/pagination/paginators/ReminderPaginator.test.ts new file mode 100644 index 0000000000..8baa8e418f --- /dev/null +++ b/test/unit/pagination/paginators/ReminderPaginator.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ReminderResponse, StreamChat } from '../../../../src'; +import { ReminderPaginator } from '../../../../src/pagination/paginators/ReminderPaginator'; +import { getClientWithUser } from '../../test-utils/getClient'; + +const makeReminder = (messageId: string, createdAt: string): ReminderResponse => + ({ + channel_cid: 'messaging:x', + created_at: createdAt, + updated_at: createdAt, + user_id: 'user', + message_id: messageId, + }) as unknown as ReminderResponse; + +const response = ( + reminders: ReminderResponse[], + cursors: { next?: string; prev?: string } = {}, +) => ({ duration: '', reminders, ...cursors }); + +describe('ReminderPaginator', () => { + let client: StreamChat; + + beforeEach(() => { + client = getClientWithUser({ id: 'user' }); + }); + + it('stores results in interval storage keyed by message_id', async () => { + const paginator = new ReminderPaginator(client, { pageSize: 2 }); + vi.spyOn(client, 'queryReminders').mockResolvedValue( + response( + [ + makeReminder('m1', '2020-01-01T00:00:00.000Z'), + makeReminder('m2', '2020-01-02T00:00:00.000Z'), + ], + { next: 'next-cursor' }, + ), + ); + + await paginator.executeQuery({}); + + expect(paginator.items?.map((r) => r.message_id)).toEqual(['m1', 'm2']); + // interval storage: addressable by message_id + mirrored into the head window + expect(paginator.getItem('m1')?.message_id).toBe('m1'); + expect(paginator.headItems.map((r) => r.message_id)).toEqual(['m1', 'm2']); + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.cursor?.tailward).toBe('next-cursor'); + }); + + it('appends forward pages and dedupes by message_id', async () => { + const paginator = new ReminderPaginator(client, { pageSize: 2 }); + const spy = vi.spyOn(client, 'queryReminders'); + spy.mockResolvedValueOnce( + response( + [ + makeReminder('m1', '2020-01-01T00:00:00.000Z'), + makeReminder('m2', '2020-01-02T00:00:00.000Z'), + ], + { next: 'c1' }, + ), + ); + await paginator.executeQuery({}); + + spy.mockResolvedValueOnce( + response([ + makeReminder('m2', '2020-01-02T00:00:00.000Z'), // duplicate + makeReminder('m3', '2020-01-03T00:00:00.000Z'), + ]), + ); + await paginator.toTail(); + + expect(paginator.items?.map((r) => r.message_id)).toEqual(['m1', 'm2', 'm3']); + expect(paginator.hasMoreTail).toBe(false); + expect(paginator.cursor?.tailward).toBeNull(); + }); + + it('orders by the requested sort; changing sort resets and re-orders', async () => { + const paginator = new ReminderPaginator(client, { pageSize: 3 }); + const page = [ + makeReminder('m2', '2020-01-02T00:00:00.000Z'), + makeReminder('m1', '2020-01-01T00:00:00.000Z'), + makeReminder('m3', '2020-01-03T00:00:00.000Z'), + ]; + const spy = vi.spyOn(client, 'queryReminders').mockResolvedValue(response(page)); + + // default sort: created_at ascending + await paginator.executeQuery({}); + expect(paginator.items?.map((r) => r.message_id)).toEqual(['m1', 'm2', 'm3']); + + // changing sort resets accumulated pages and re-orders the next load + paginator.sort = { created_at: -1 }; + expect(paginator.items).toBeUndefined(); + spy.mockResolvedValue(response(page)); + await paginator.executeQuery({}); + expect(paginator.items?.map((r) => r.message_id)).toEqual(['m3', 'm2', 'm1']); + }); +}); diff --git a/test/unit/pagination/paginators/UserGroupPaginator.test.ts b/test/unit/pagination/paginators/UserGroupPaginator.test.ts new file mode 100644 index 0000000000..82d5929966 --- /dev/null +++ b/test/unit/pagination/paginators/UserGroupPaginator.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { StreamChat, UserGroupResponse } from '../../../../src'; +import { UserGroupPaginator } from '../../../../src/pagination/paginators/UserGroupPaginator'; +import { getClientWithUser } from '../../test-utils/getClient'; + +const makeGroup = (id: string, createdAt: string): UserGroupResponse => ({ + id, + name: id, + created_at: createdAt, + updated_at: createdAt, +}); + +const response = (groups: UserGroupResponse[]) => ({ duration: '', user_groups: groups }); + +describe('UserGroupPaginator', () => { + let client: StreamChat; + + beforeEach(() => { + client = getClientWithUser({ id: 'user' }); + }); + + it('stores results in interval storage (index-addressable, headItems populated)', async () => { + const paginator = new UserGroupPaginator(client, { pageSize: 2 }); + vi.spyOn(client, 'queryUserGroups').mockResolvedValue( + response([ + makeGroup('a', '2020-01-01T00:00:00.000Z'), + makeGroup('b', '2020-01-02T00:00:00.000Z'), + ]), + ); + + await paginator.executeQuery({}); + + expect(paginator.items?.map((g) => g.id)).toEqual(['a', 'b']); + // interval storage: items are now resolvable by id and mirrored into the head window + expect(paginator.getItem('a')?.id).toBe('a'); + expect(paginator.getItem('b')?.id).toBe('b'); + expect(paginator.headItems.map((g) => g.id)).toEqual(['a', 'b']); + // full page -> more forward; backward pagination is disabled for this listing + expect(paginator.hasMoreTail).toBe(true); + expect(paginator.hasMoreHead).toBe(false); + }); + + it('appends forward pages and stops at a short (final) page', async () => { + const paginator = new UserGroupPaginator(client, { pageSize: 2 }); + const spy = vi.spyOn(client, 'queryUserGroups'); + spy.mockResolvedValueOnce( + response([ + makeGroup('a', '2020-01-01T00:00:00.000Z'), + makeGroup('b', '2020-01-02T00:00:00.000Z'), + ]), + ); + await paginator.executeQuery({}); + expect(paginator.hasMoreTail).toBe(true); + + spy.mockResolvedValueOnce(response([makeGroup('c', '2020-01-03T00:00:00.000Z')])); + await paginator.toTail(); + + expect(paginator.items?.map((g) => g.id)).toEqual(['a', 'b', 'c']); + expect(paginator.hasMoreTail).toBe(false); + expect(paginator.cursor?.tailward).toBeNull(); + // the forward request carried the cursor derived from the previous last item + expect(spy).toHaveBeenLastCalledWith( + expect.objectContaining({ + id_gt: 'b', + created_at_gt: '2020-01-02T00:00:00.000Z', + }), + ); + }); + + it('dedupes by id when a group is returned again', async () => { + const paginator = new UserGroupPaginator(client, { pageSize: 2 }); + const spy = vi.spyOn(client, 'queryUserGroups'); + spy.mockResolvedValueOnce( + response([ + makeGroup('a', '2020-01-01T00:00:00.000Z'), + makeGroup('b', '2020-01-02T00:00:00.000Z'), + ]), + ); + await paginator.executeQuery({}); + + spy.mockResolvedValueOnce( + response([ + makeGroup('b', '2020-01-02T00:00:00.000Z'), // duplicate + makeGroup('c', '2020-01-03T00:00:00.000Z'), + ]), + ); + await paginator.toTail(); + + expect(paginator.items?.map((g) => g.id)).toEqual(['a', 'b', 'c']); + }); + + it('orders by created_at/id via the comparator even if the server returns out of order', async () => { + const paginator = new UserGroupPaginator(client, { pageSize: 3 }); + vi.spyOn(client, 'queryUserGroups').mockResolvedValue( + response([ + makeGroup('b', '2020-01-02T00:00:00.000Z'), + makeGroup('a', '2020-01-01T00:00:00.000Z'), + makeGroup('c', '2020-01-03T00:00:00.000Z'), + ]), + ); + + await paginator.executeQuery({}); + + expect(paginator.items?.map((g) => g.id)).toEqual(['a', 'b', 'c']); + }); + + it('does not paginate backward (headward is exhausted)', async () => { + const paginator = new UserGroupPaginator(client, { pageSize: 2 }); + const spy = vi + .spyOn(client, 'queryUserGroups') + .mockResolvedValue(response([makeGroup('a', '2020-01-01T00:00:00.000Z')])); + await paginator.executeQuery({}); + spy.mockClear(); + + await paginator.toHead(); + + expect(spy).not.toHaveBeenCalled(); + expect(paginator.hasMoreHead).toBe(false); + }); +}); diff --git a/test/unit/pagination/sortCompiler.test.ts b/test/unit/pagination/sortCompiler.test.ts index ccc03a21bd..34c56191a3 100644 --- a/test/unit/pagination/sortCompiler.test.ts +++ b/test/unit/pagination/sortCompiler.test.ts @@ -140,7 +140,7 @@ describe('makeComparator', () => { expect(orderByComparator(items, cmp)).toEqual(['1', '2', '3', '4']); }); - it('fallback ordering: null/undefined come last (ascending) and first (descending)', () => { + it('fallback ordering: null/undefined come last regardless of direction', () => { const items: Item[] = [ { cid: 'a', v: 10 }, { cid: 'b', v: undefined }, @@ -148,11 +148,35 @@ describe('makeComparator', () => { { cid: 'd', v: 5 }, ]; + // null/undefined always sort to the tail; the direction only orders the real values. const asc = toComparator({ v: 1 }); - expect(orderByComparator(items, asc)).toEqual(['d', 'a', 'b', 'c']); // null/undefined last + expect(orderByComparator(items, asc)).toEqual(['d', 'a', 'b', 'c']); const desc = toComparator({ v: -1 }); - expect(orderByComparator(items, desc)).toEqual(['b', 'c', 'a', 'd']); // null/undefined first + expect(orderByComparator(items, desc)).toEqual(['a', 'd', 'b', 'c']); + }); + + it('keeps null date values at the tail for a descending sort (last_message_at regression)', () => { + // Reproduces the channel-list bug: channels with no last_message_at must sort to the BOTTOM of a + // `{ last_message_at: -1 }` list, not float to the top. Before the fix, the "null last" result + // was negated by the descending direction flip and value-less channels were prepended at the head. + type Chan = { cid: string; last_message_at: string | null }; + const chans: Chan[] = [ + { cid: 'old', last_message_at: '2022-01-01T00:00:00.000Z' }, + { cid: 'none1', last_message_at: null }, + { cid: 'new', last_message_at: '2026-07-21T00:00:00.000Z' }, + { cid: 'none2', last_message_at: null }, + ]; + const desc = makeComparator>({ + sort: { last_message_at: -1 }, + resolvePathValue: defaultResolvePathValue, + }); + expect([...chans].sort(desc).map((c) => c.cid)).toEqual([ + 'new', + 'old', + 'none1', + 'none2', + ]); }); it('applies custom tiebreaker when provided', () => { diff --git a/test/unit/pagination/utility.search.test.ts b/test/unit/pagination/utility.search.test.ts new file mode 100644 index 0000000000..c2d53b649e --- /dev/null +++ b/test/unit/pagination/utility.search.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { lowerBound } from '../../../src/pagination/utility.search'; + +describe('lowerBound', () => { + // predicate = "value at index is >= threshold" over a sorted array + const firstAtLeast = (sorted: number[], threshold: number) => + lowerBound(sorted.length, (index) => sorted[index] >= threshold); + + it('returns 0 when the whole range satisfies the predicate', () => { + expect(firstAtLeast([5, 6, 7], 5)).toBe(0); + }); + + it('returns length when no index satisfies the predicate', () => { + expect(firstAtLeast([1, 2, 3], 10)).toBe(3); + }); + + it('returns 0 for an empty range', () => { + expect(lowerBound(0, () => true)).toBe(0); + }); + + it('finds the boundary in the middle', () => { + expect(firstAtLeast([1, 3, 5, 7, 9], 5)).toBe(2); + expect(firstAtLeast([1, 3, 5, 7, 9], 6)).toBe(3); + }); + + it('returns the first satisfying index across a plateau of equal values', () => { + expect(firstAtLeast([1, 5, 5, 5, 9], 5)).toBe(1); + }); +}); diff --git a/test/unit/poll_manager.test.ts b/test/unit/poll_manager.test.ts index 07b09a9855..422f32d269 100644 --- a/test/unit/poll_manager.test.ts +++ b/test/unit/poll_manager.test.ts @@ -280,7 +280,6 @@ describe('PollManager', () => { const channel = client.channel('messaging', mockChannelQueryResponse.channel.id); const { messages: prevMessages, pollMessages: prevPollMessages } = generateRandomMessagesWithPolls(5, `_prev`); - channel.state.addMessagesSorted(prevMessages); const { messages, pollMessages } = generateRandomMessagesWithPolls(5, ``); const mockedChannelQueryResponse = { ...mockChannelQueryResponse, diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index c9e805de00..bda52ca123 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -168,6 +168,19 @@ describe('Threads 2.0', () => { expect(thread.messagePaginator.isInitialized).to.be.false; }); + it('seeds the reply paginator lastMessageAt from the thread last_message_at', () => { + const thread = createTestThread({ + latest_replies: [], + reply_count: 0, + last_message_at: '2030-01-01T00:00:00.000Z', + }); + // The server floor seeds the sort key even with no replies loaded to display. + expect(thread.messagePaginator.lastMessageAt?.getTime()).to.equal( + new Date('2030-01-01T00:00:00.000Z').getTime(), + ); + expect(thread.messagePaginator.lastMessage).to.be.null; + }); + it('initializes properly without threadData', () => { const thread = createMinimalThread(); const state = thread.state.getLatestValue(); @@ -1310,14 +1323,9 @@ describe('Threads 2.0', () => { }); describe('Events: message.updated, reaction.new, reaction.deleted', () => { - ( - [ - 'message.updated', - 'reaction.new', - 'reaction.deleted', - 'reaction.updated', - ] as const - ).forEach((eventType) => { + // Reaction events are routed through messagePaginator.reflectReaction (see the "ingests" + // tests below); only message-update events go through updateParentMessageOrReplyLocally. + (['message.updated', 'message.undeleted'] as const).forEach((eventType) => { it(`updates reply or parent message on "${eventType}"`, () => { const thread = createTestThread(); const updateParentMessageOrReplyLocallySpy = sinon.spy( @@ -1337,6 +1345,81 @@ describe('Threads 2.0', () => { }); }); + it("preserves the current user's own_reactions on a cross-user reaction to a reply", () => { + const thread = createTestThread(); + thread.registerSubscriptions(); + const messageId = uuidv4(); + // Seed a reply that already carries the current user's own reaction. + thread.messagePaginator.ingestItem( + formatMessage( + generateMsg({ + id: messageId, + parent_id: thread.id, + own_reactions: [ + { type: 'love', user_id: TEST_USER_ID, message_id: messageId }, + ], + }) as MessageResponse, + ), + ); + + // A different user reacts; the WS event message carries own_reactions: []. + client.dispatchEvent({ + type: 'reaction.new', + message: generateMsg({ + id: messageId, + parent_id: thread.id, + own_reactions: [], + }) as MessageResponse, + reaction: { + type: 'like', + user_id: 'other-user', + message_id: messageId, + created_at: new Date().toISOString(), + }, + }); + + const own = thread.messagePaginator.getItem(messageId)?.own_reactions ?? []; + expect(own.some((r) => r.type === 'love' && r.user_id === TEST_USER_ID)).to.be + .true; + // The other user's reaction is not added to the current user's own_reactions. + expect(own.some((r) => r.user_id === 'other-user')).to.be.false; + + thread.unregisterSubscriptions(); + }); + + (['user.messages.deleted', 'user.deleted'] as const).forEach((eventType) => { + it(`soft-deletes a banned user's replies in the thread paginator on "${eventType}"`, () => { + const thread = createTestThread(); + thread.registerSubscriptions(); + const bannedUserId = 'banned-user'; + const replyId = uuidv4(); + thread.messagePaginator.ingestPage({ + page: [ + formatMessage( + generateMsg({ + id: replyId, + parent_id: thread.id, + user: { id: bannedUserId }, + }) as MessageResponse, + ), + ], + isHead: true, + isTail: true, + setActive: true, + }); + + client.dispatchEvent({ + type: eventType, + user: { id: bannedUserId, deleted_at: new Date().toISOString() }, + created_at: new Date().toISOString(), + }); + + expect(thread.messagePaginator.getItem(replyId)?.type).to.equal('deleted'); + + thread.unregisterSubscriptions(); + }); + }); + it('ingests "reaction.new" message into thread messagePaginator when parent_id matches thread.id', () => { const thread = createTestThread(); thread.registerSubscriptions(); diff --git a/test/unit/utils.test.js b/test/unit/utils.test.js index b6faa42045..52fb78056c 100644 --- a/test/unit/utils.test.js +++ b/test/unit/utils.test.js @@ -1,8 +1,6 @@ import { axiosParamsSerializer, - binarySearchByDateEqualOrNearestGreater, formatMessage, - messageSetPagination, normalizeQuerySort, } from '../../src/utils'; import sinon from 'sinon'; @@ -144,3082 +142,3 @@ describe('reaction groups fallback', () => { }); }); }); - -describe('messageSetPagination', () => { - const consoleErrorSpy = () => { - const _consoleError = console.error; - console.error = () => null; - return () => { - console.error = _consoleError; - }; - }; - const messages = [ - { created_at: '2024-08-05T08:55:00.199808Z', id: '0' }, - { created_at: '2024-08-05T08:55:01.199808Z', id: '1' }, - { created_at: '2024-08-05T08:55:02.199808Z', id: '2' }, - { created_at: '2024-08-05T08:55:03.199808Z', id: '3' }, - { created_at: '2024-08-05T08:55:04.199808Z', id: '4' }, - { created_at: '2024-08-05T08:55:05.199808Z', id: '5' }, - { created_at: '2024-08-05T08:55:06.199808Z', id: '6' }, - { created_at: '2024-08-05T08:55:07.199808Z', id: '7' }, - { created_at: '2024-08-05T08:55:08.199808Z', id: '8' }, - ]; - const shadowOlder = { - created_at: '2024-08-05T08:54:59.199808Z', - id: 'shadow-older', - }; - const shadowNewer = { - created_at: '2024-08-05T08:55:09.199808Z', - id: 'shadow-newer', - }; - - describe('linear', () => { - describe('returned page size size is 0', () => { - ['created_at_after_or_equal', 'created_at_after', 'id_gt', 'id_gte'].forEach( - (option) => { - it(`requested page size === returned page size === parent set size pagination with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages: [], pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size === parent set size > returned page size with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: 1, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages: messages.slice(0, 1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`returned page size === parent set size pagination < requested page size with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: 1, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages: [], pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`returned page size < parent set size < requested page size pagination with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: 1, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - }, - ); - - [ - 'created_at_before_or_equal', - 'created_at_before', - 'id_lt', - 'id_lte', - undefined, - 'unrecognized', - ].forEach((option) => { - it(`requested page size === returned page size === parent set size pagination with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages: [], pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size === parent set size > returned page size with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: 1, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages: messages.slice(0, 1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`returned page size === parent set size pagination < requested page size with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: 1, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages: [], pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size === returned page size < parent set size pagination with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`returned page size < parent set size < requested page size pagination with option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: 1, - returnedPage: [], - filteredReturnedPage: [], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - }); - }); - - ['created_at_after_or_equal', 'created_at_after', 'id_gt', 'id_gte'].forEach( - (option) => { - it(`requested page size === returned page size === parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: true }); - }); - - it(`returned page size === parent set size pagination < requested page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - - it(`returned page size === parent set size pagination > requested page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: true }); - }); - - describe('first (oldest) page message matches the first parent set message', () => { - it(`requested page size === returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(0, -1), - filteredReturnedPage: messages.slice(0, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === parent set size > returned page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(0, -1), - filteredReturnedPage: messages.slice(0, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size < returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(0, -1), - filteredReturnedPage: messages.slice(0, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size < returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(0, -2), - filteredReturnedPage: messages.slice(0, -2), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size < parent set size < requested page size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(0, -2), - filteredReturnedPage: messages.slice(0, -2), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === returned page size > parent set size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(0, -1), - filteredReturnedPage: messages.slice(0, -1), - parentSet: { messages: messages.slice(0, -2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('last page message matches the last parent set message', () => { - it(`requested page size === returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: true }); - }); - it(`requested page size === parent set size > returned page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size < returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: true }); - }); - it(`returned page size < parent set size < requested page size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(2), - filteredReturnedPage: messages.slice(2), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(-1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages: messages.slice(2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('first page message & last page message do not match the first and last parent set messages', () => { - it(`requested page size === returned page size === parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === parent set size > returned page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size === parent set size pagination < requested page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length + 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - - it(`returned page size === parent set size pagination > requested page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - - it(`requested page size === returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size < returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size < parent set size < requested page size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length + 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === returned page size > parent set size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 3, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - }, - ); - - [ - 'created_at_before_or_equal', - 'created_at_before', - 'id_lt', - 'id_lte', - undefined, - 'unrecognized', - ].forEach((option) => { - it(`requested page size === returned page size === parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - - it(`returned page size === parent set size pagination < requested page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - - it(`returned page size === parent set size pagination > requested page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - - describe('first (oldest) page message matches the first parent set message', () => { - it(`requested page size === returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(0, -1), - filteredReturnedPage: messages.slice(0, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - it(`requested page size < returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(0, -1), - filteredReturnedPage: messages.slice(0, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - it(`requested page size === parent set size > returned page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(0, -1), - filteredReturnedPage: messages.slice(0, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size < returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(0, -2), - filteredReturnedPage: messages.slice(0, -2), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - it(`returned page size < parent set size < requested page size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(0, -2), - filteredReturnedPage: messages.slice(0, -2), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size === returned page size > parent set size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(0, -1), - filteredReturnedPage: messages.slice(0, -1), - parentSet: { messages: messages.slice(0, -2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('last page message matches the last parent set message', () => { - it(`requested page size === returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === parent set size > returned page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size < returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size < parent set size < requested page size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(2), - filteredReturnedPage: messages.slice(2), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === returned page size > parent set size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(-1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages: messages.slice(2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('first page message & last page message do not match the first and last parent set messages', () => { - it(`requested page size === returned page size === parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === parent set size > returned page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size === parent set size pagination < requested page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length + 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - - it(`returned page size === parent set size pagination > requested page size option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - - it(`requested page size === returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size < returned page size < parent set size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size < parent set size < requested page size pagination option ${option}`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length + 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === returned page size > parent set size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1), - filteredReturnedPage: messages.slice(1), - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination option ${option}`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: option && { [option]: 'X' }, - requestedPageSize: messages.length - 3, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - }); - - describe('with filtered first or last returned messages', () => { - it('sets hasPrev when oldest returned message is filtered but raw page is full (id_lt)', () => { - const filtered = messages.slice(0, 8); - expect( - messageSetPagination({ - messagePaginationOptions: { id_lt: 'X' }, - requestedPageSize: 9, - returnedPage: [shadowOlder, ...filtered], - filteredReturnedPage: filtered, - parentSet: { messages: filtered, pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - - it('sets hasPrev false when oldest returned message is filtered and raw page is not full (id_lt)', () => { - const filtered = messages.slice(0, 4); - expect( - messageSetPagination({ - messagePaginationOptions: { id_lt: 'X' }, - requestedPageSize: 9, - returnedPage: [shadowOlder, ...filtered], - filteredReturnedPage: filtered, - parentSet: { messages: filtered, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - - it('sets hasNext when newest returned message is filtered but raw page is full (id_gt)', () => { - const filtered = messages.slice(1, 9); - expect( - messageSetPagination({ - messagePaginationOptions: { id_gt: 'X' }, - requestedPageSize: 9, - returnedPage: [...filtered, shadowNewer], - filteredReturnedPage: filtered, - parentSet: { messages: filtered, pagination: {} }, - }), - ).to.eql({ hasNext: true }); - }); - - it('sets hasNext false when newest returned message is filtered and raw page is not full (id_gt)', () => { - const filtered = messages.slice(4, 9); - expect( - messageSetPagination({ - messagePaginationOptions: { id_gt: 'X' }, - requestedPageSize: 9, - returnedPage: [...filtered, shadowNewer], - filteredReturnedPage: filtered, - parentSet: { messages: filtered, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - }); - }); - - describe('jumping to a message', () => { - const oddSizeReturnPage = messages; - const evenSizeReturnPage = messages.slice(0, -1); - const createdAtISOString = (index, msgs) => - new Date(new Date(msgs[index].created_at).getTime() - 500).toISOString(); - - [ - { - description: 'odd return page size', - messages: oddSizeReturnPage, - messagePaginationOptions: { - firstHalf: { created_at_around: createdAtISOString(2, oddSizeReturnPage) }, - mid: { created_at_around: createdAtISOString(4, oddSizeReturnPage) }, - secondHalf: { created_at_around: createdAtISOString(6, oddSizeReturnPage) }, - }, - option: 'created_at_around', - }, - { - description: 'even return page size', - messages: evenSizeReturnPage, - messagePaginationOptions: { - firstHalf: { created_at_around: createdAtISOString(2, evenSizeReturnPage) }, - mid: { created_at_around: createdAtISOString(4, evenSizeReturnPage) }, - secondHalf: { created_at_around: createdAtISOString(5, evenSizeReturnPage) }, - }, - option: 'created_at_around', - }, - { - description: 'odd return page size', - messages: oddSizeReturnPage, - messagePaginationOptions: { - firstHalf: { id_around: oddSizeReturnPage[2].id }, - mid: { id_around: oddSizeReturnPage[4].id }, - secondHalf: { id_around: oddSizeReturnPage[6].id }, - }, - option: 'id_around', - }, - { - description: 'even return page size', - messages: evenSizeReturnPage, - messagePaginationOptions: { - firstHalf: { id_around: evenSizeReturnPage[2].id }, - mid: { id_around: evenSizeReturnPage[4].id }, - secondHalf: { id_around: evenSizeReturnPage[5].id }, - }, - option: 'id_around', - }, - ].forEach(({ description, messagePaginationOptions, messages, option }) => { - describe(description, () => { - describe(`with ${option}`, () => { - describe('the target msg is in the first page half', () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: true }); - }); - - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: true }); - }); - - describe('first (oldest) page message matches the first parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(0, -2), - filteredReturnedPage: messages.slice(0, -2), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(0, -2), - filteredReturnedPage: messages.slice(0, -2), - parentSet: { messages: messages.slice(0, -3), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('last page message matches the last parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(2), - filteredReturnedPage: messages.slice(2), - parentSet: { messages: messages.slice(3), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('first page message & last page message do not match the first and last parent set messages', () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: [ - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - ], - filteredReturnedPage: [ - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - ], - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 3, - returnedPage: [ - messages[0], - ...messages.slice(2, -2), - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[0], - ...messages.slice(2, -2), - messages.slice(-2, -1)[0], - ], - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length + 1, - returnedPage: [ - messages[1], - ...messages.slice(2, -2), - messages.slice(-1)[0], - ], - filteredReturnedPage: [ - messages[1], - ...messages.slice(2, -2), - messages.slice(-1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length, - returnedPage: [ - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - ], - filteredReturnedPage: [ - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - ], - parentSet: { messages: messages.slice(1, -2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.firstHalf, - requestedPageSize: messages.length - 2, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - }); - - describe('the target msg is in the middle of the page', () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true, hasNext: true }); - }); - - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true, hasNext: true }); - }); - - describe('first (oldest) page message matches the first parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1, -2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('last page message matches the last parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasNext: true }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasNext: true }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(2, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('first page message & last page message do not match the first and last parent set messages', () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length + 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length + 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.mid, - requestedPageSize: messages.length - 3, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - }); - - describe('the target msg is in the second page half', () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true, hasNext: false }); - }); - - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true, hasNext: false }); - }); - - describe('first (oldest) page message matches the first parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: true }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1, -2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('last page message matches the last parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(2, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('first page message & last page message do not match the first and last parent set messages', () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length + 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length + 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions.secondHalf, - requestedPageSize: messages.length - 3, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - }); - }); - - describe('with created_at_around', () => { - describe('the target msg created_at < the earliest parent set message creation date', () => { - const created_at_around = '2000-08-05T08:55:00.199808Z'; - - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - - describe('first (oldest) page message matches the first parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1, -2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('last page message matches the last parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(2, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('first page message & last page message do not match the first and last parent set messages', () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length + 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length + 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 3, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - }); - - describe('the target msg created_at > the latest parent set message creation date', () => { - const created_at_around = '3000-08-05T08:55:00.199808Z'; - - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - - describe('first (oldest) page message matches the first parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(1, -2), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('last page message matches the last parent set message', () => { - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(0, -1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages: messages.slice(2, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - - describe('first page message & last page message do not match the first and last parent set messages', () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length + 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination > requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 1, - returnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - filteredReturnedPage: [ - messages[1], - messages[0], - ...messages.slice(2, -2), - messages.slice(-1)[0], - messages.slice(-2, -1)[0], - ], - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 2, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`requested page size < returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 3, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasNext: false }); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length + 1, - returnedPage: messages.slice(1, -1), - filteredReturnedPage: messages.slice(1, -1), - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size > parent set size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`parent set size < returned page size < requested page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length + 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - it(`requested page size < parent set size < returned page size pagination`, () => { - const restore = consoleErrorSpy(); - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around }, - requestedPageSize: messages.length - 3, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: messages.slice(1, -1), pagination: {} }, - }), - ).to.eql({}); - restore(); - }); - }); - }); - }); - }); - }); - - [ - { - description: '0 return page size', - messages: [], - messagePaginationOptions: { - created_at_around: createdAtISOString(2, oddSizeReturnPage), - }, - option: 'created_at_around', - }, - { - description: '0 return page size', - messages: [], - messagePaginationOptions: { id_around: oddSizeReturnPage[4].id }, - option: 'id_around', - }, - ].forEach(({ description, messagePaginationOptions, messages, option }) => { - describe(description, () => { - describe(`with ${option}`, () => { - it(`requested page size === returned page size === parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions: messagePaginationOptions, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({}); - }); - it(`requested page size === parent set size > returned page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions, - requestedPageSize: 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: evenSizeReturnPage.slice(0, 1), pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`returned page size === parent set size pagination < requested page size`, () => { - expect( - messageSetPagination({ - messagePaginationOptions, - requestedPageSize: 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - it(`requested page size === returned page size < parent set size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions, - requestedPageSize: messages.length, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: evenSizeReturnPage, pagination: {} }, - }), - ).to.eql({}); - }); - it(`returned page size < parent set size < requested page size pagination`, () => { - expect( - messageSetPagination({ - messagePaginationOptions, - requestedPageSize: 1, - returnedPage: messages, - filteredReturnedPage: messages, - parentSet: { messages: evenSizeReturnPage, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: false }); - }); - }); - }); - }); - - describe('with filtered first or last returned messages', () => { - const createdAtAroundMidMessage4 = new Date( - new Date(messages[4].created_at).getTime() - 500, - ).toISOString(); - - it('id_around: sets hasPrev and hasNext when oldest returned row is filtered out', () => { - expect( - messageSetPagination({ - messagePaginationOptions: { id_around: messages[4].id }, - requestedPageSize: messages.length, - returnedPage: [shadowOlder, ...messages], - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true, hasNext: true }); - }); - - it('id_around: sets hasPrev false when newest returned row is filtered out (target in first half)', () => { - expect( - messageSetPagination({ - messagePaginationOptions: { id_around: messages[2].id }, - requestedPageSize: messages.length, - returnedPage: [...messages, shadowNewer], - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: true }); - }); - - it('created_at_around: sets hasPrev and hasNext when oldest returned row is filtered out', () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around: createdAtAroundMidMessage4 }, - requestedPageSize: messages.length, - returnedPage: [shadowOlder, ...messages], - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: true, hasNext: true }); - }); - - it('created_at_around: sets hasPrev false and hasNext true when newest returned row is filtered out', () => { - expect( - messageSetPagination({ - messagePaginationOptions: { created_at_around: createdAtAroundMidMessage4 }, - requestedPageSize: messages.length, - returnedPage: [...messages, shadowNewer], - filteredReturnedPage: messages, - parentSet: { messages, pagination: {} }, - }), - ).to.eql({ hasPrev: false, hasNext: true }); - }); - }); - }); -}); - -describe('binarySearchByDateEqualOrNearestGreater', () => { - const messages = [ - { created_at: '2024-08-05T08:55:00.199808Z', id: '0' }, - { created_at: '2024-08-05T08:55:01.199808Z', id: '1' }, - { created_at: '2024-08-05T08:55:02.199808Z', id: '2' }, - { created_at: '2024-08-05T08:55:03.199808Z', id: '3' }, - { created_at: '2024-08-05T08:55:04.199808Z', id: '4' }, - { created_at: '2024-08-05T08:55:05.199808Z', id: '5' }, - { created_at: '2024-08-05T08:55:06.199808Z', id: '6' }, - { created_at: '2024-08-05T08:55:07.199808Z', id: '7' }, - { created_at: '2024-08-05T08:55:08.199808Z', id: '8' }, - ]; - it('finds the nearest newer item', () => { - expect( - binarySearchByDateEqualOrNearestGreater( - messages, - new Date('2024-08-05T08:55:02.299808Z'), - ), - ).to.eql(3); - }); - it('finds the nearest matching item', () => { - expect( - binarySearchByDateEqualOrNearestGreater( - messages, - new Date('2024-08-05T08:55:07.199808Z'), - ), - ).to.eql(7); - }); -}); diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index 2c45746f6e..a5f3b854d0 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -1,16 +1,13 @@ import sinon from 'sinon'; import { describe, beforeEach, afterEach, it, expect, vi } from 'vitest'; -import { generateMsg } from './test-utils/generateMessage'; import { generateChannel } from './test-utils/generateChannel'; import { generateMember } from './test-utils/generateMember'; import { generateUser } from './test-utils/generateUser'; import { getClientWithUser } from './test-utils/getClient'; -import { generateUUIDv4 as uuidv4 } from '../../src/utils'; import { getAndWatchChannel, - addToMessageList, findIndexInSortedArray, channelHasReadEvents, channelTracksReadLocally, @@ -31,147 +28,9 @@ import { sleep, } from '../../src/utils'; -import type { - ChannelFilters, - ChannelSortBase, - FormatMessageResponse, - MessageResponse, -} from '../../src'; +import type { ChannelFilters, ChannelSortBase, MessageResponse } from '../../src'; import { StreamChat, Channel } from '../../src'; -describe('addToMessageList', () => { - const timestamp = new Date('2024-09-18T15:30:00.000Z').getTime(); - // messages with each created_at 10 seconds apart - let messagesBefore: FormatMessageResponse[]; - - const getNewFormattedMessage = ({ - timeOffset, - id = uuidv4(), - }: { - timeOffset: number; - id?: string; - }) => - formatMessage( - generateMsg({ - id, - created_at: new Date(timestamp + timeOffset), - }) as MessageResponse, - ); - - beforeEach(() => { - messagesBefore = Array.from({ length: 5 }, (_, index) => - formatMessage( - generateMsg({ - created_at: new Date(timestamp + index * 10 * 1000), - }) as MessageResponse, - ), - ); - }); - - it('new message is inserted at the correct index', () => { - const newMessage = getNewFormattedMessage({ timeOffset: 25 * 1000 }); - - const messagesAfter = addToMessageList(messagesBefore, newMessage); - - expect(messagesAfter).to.not.equal(messagesBefore); - expect(messagesAfter).to.have.length(6); - expect(messagesAfter).to.contain(newMessage); - expect(messagesAfter[3]).to.equal(newMessage); - }); - - it('replaces the message which created_at changed to a server response created_at', () => { - const newMessage = getNewFormattedMessage({ - timeOffset: 33 * 1000, - id: messagesBefore[2].id, - }); - - expect(newMessage.id).to.equal(messagesBefore[2].id); - - const messagesAfter = addToMessageList(messagesBefore, newMessage, true); - - expect(messagesAfter).to.not.equal(messagesBefore); - expect(messagesAfter).to.have.length(5); - expect(messagesAfter).to.contain(newMessage); - expect(messagesAfter[3]).to.equal(newMessage); - }); - - it('adds a new message to an empty message list', () => { - const newMessage = getNewFormattedMessage({ timeOffset: 0 }); - - const emptyMessagesBefore = []; - - const messagesAfter = addToMessageList(emptyMessagesBefore, newMessage); - - expect(messagesAfter).to.have.length(1); - expect(messagesAfter).to.contain(newMessage); - }); - - it("doesn't add a new message to an empty message list if timestampChanged & addIfDoesNotExist are false", () => { - const newMessage = getNewFormattedMessage({ timeOffset: 0 }); - - const emptyMessagesBefore = []; - - const messagesAfter = addToMessageList( - emptyMessagesBefore, - newMessage, - false, - 'created_at', - false, - ); - - expect(messagesAfter).to.have.length(0); - }); - - it("adds message to the end of the list if it's the newest one", () => { - const newMessage = getNewFormattedMessage({ timeOffset: 50 * 1000 }); - - const messagesAfter = addToMessageList(messagesBefore, newMessage); - - expect(messagesAfter).to.have.length(6); - expect(messagesAfter).to.contain(newMessage); - expect(messagesAfter.at(-1)).to.equal(newMessage); - }); - - it("doesn't add a newest message to a message list if timestampChanged & addIfDoesNotExist are false", () => { - const newMessage = getNewFormattedMessage({ timeOffset: 50 * 1000 }); - - const messagesAfter = addToMessageList( - messagesBefore, - newMessage, - false, - 'created_at', - false, - ); - - expect(messagesAfter).to.have.length(5); - // FIXME: it'd be nice if the function returned old - // unchanged array in case of no modification such as this one - expect(messagesAfter).to.deep.equal(messagesBefore); - }); - - it("updates an existing message that wasn't filtered due to changed timestamp (timestampChanged)", () => { - const newMessage = getNewFormattedMessage({ - timeOffset: 30 * 1000, - id: messagesBefore[4].id, - }); - - expect(messagesBefore[4].id).to.equal(newMessage.id); - expect(messagesBefore[4].text).to.not.equal(newMessage.text); - expect(messagesBefore[4]).to.not.equal(newMessage); - - const messagesAfter = addToMessageList( - messagesBefore, - newMessage, - false, - 'created_at', - false, - ); - - expect(messagesAfter).to.have.length(5); - expect(messagesAfter[4]).to.equal(newMessage); - }); -}); - describe('findIndexInSortedArray', () => { it('finds index in the middle of haystack (asc)', () => { const needle = 5; From 896f4997cf52dc965b2368d12a31d86427049a54 Mon Sep 17 00:00:00 2001 From: MartinCupela <32706194+MartinCupela@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:06:17 +0200 Subject: [PATCH 43/48] feat(pagination): add intervalViews store (#1807) --- src/pagination/paginators/BasePaginator.ts | 176 +++++++++++++++++- src/pagination/paginators/MessagePaginator.ts | 3 +- .../paginators/BasePaginator.test.ts | 165 ++++++++++++++++ 3 files changed, 333 insertions(+), 11 deletions(-) diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 05e1d7e331..1ed3c51669 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -249,6 +249,27 @@ export type PaginatorState = { offset?: number; }; +/** + * Reactive projections of specific (fixed-identity) intervals, published independently of the + * paginated `state` (which tracks only the *active* interval). See {@link BasePaginator.intervalViews}. + * A field is rewritten only when its own interval changes, so a `useStateStore`/`subscribeWithSelector` + * consumer selecting one field only wakes when *that* interval changes. (The head-most window that can + * be either a logical or an anchored interval is a derived *role*, not a fixed interval, so it is not + * published here — read it one-shot via {@link BasePaginator.headItems} / {@link BasePaginator.headmostItem}.) + */ +export type PaginatorIntervalViews = { + /** Live logical-head interval — out-of-order items above the loaded window. */ + logicalHead: T[]; + /** Live logical-tail interval — out-of-order items below the loaded window. */ + logicalTail: T[]; + /** + * Anchored head interval — the loaded page bounded at the dataset head (`isHead`), i.e. the newest + * loaded page; empty when the head is not loaded. Its content updates when that page ingests/removes + * an item, and its identity updates when a page's `isHead` flag flips during query reconciliation. + */ + anchoredHead: T[]; +}; + // todo: think whether plugins are necessary. Maybe we could just document how to add export type PaginatorItemsChangeProcessor = (params: { @@ -361,6 +382,15 @@ export const DEFAULT_PAGINATION_OPTIONS: BasePaginatorConfig = { export abstract class BasePaginator { state: StateStore>; + /** + * Reactive projections of specific intervals — see {@link PaginatorIntervalViews}. Unlike `state` + * (which only re-emits when the *active* interval is impacted), a field here is rewritten whenever + * its own interval changes, regardless of which interval is active — so consumers can reactively + * render off-window "sideloaded" content (`logicalHead`/`logicalTail`) and the newest loaded page + * (`anchoredHead`). Kept separate from `state` so the paginated-list contract stays focused on the + * active window + pagination status. + */ + intervalViews: StateStore>; config: BasePaginatorConfig; /** @@ -444,6 +474,11 @@ export abstract class BasePaginator { cursor: initialCursor, offset: initialOffset ?? 0, }); + this.intervalViews = new StateStore>({ + logicalHead: [], + logicalTail: [], + anchoredHead: [], + }); this.setDebounceOptions({ debounceMs }); this.sortComparator = noOrderChange; this._filterFieldToDataResolvers = []; @@ -606,6 +641,120 @@ export abstract class BasePaginator { return itv && isLiveTailInterval(itv) ? itv : undefined; } + /** + * The current contents of the live logical-head interval (items ingested out of pagination order). + * Reads the same value published to {@link BasePaginator.intervalViews}.`logicalHead`. + */ + get logicalHeadItems(): T[] { + return this.intervalViews.getLatestValue().logicalHead; + } + + /** + * The current contents of the live logical-tail interval (out-of-order items below the loaded + * window). Reads the same value published to {@link BasePaginator.intervalViews}.`logicalTail`. + */ + get logicalTailItems(): T[] { + return this.intervalViews.getLatestValue().logicalTail; + } + + /** + * The current contents of the anchored head interval (the loaded page bounded at the dataset head, + * `isHead`). Reads the same value published to {@link BasePaginator.intervalViews}.`anchoredHead`. + */ + get anchoredHeadItems(): T[] { + return this.intervalViews.getLatestValue().anchoredHead; + } + + /** + * Commit an interval into storage. Single choke point for adding/updating an interval, so it also + * republishes the matching {@link intervalViews} field when the committed interval is a tracked one + * (logical head / logical tail / anchored head). Use this instead of writing `_itemIntervals` + * directly — bulk re-sorting (which does not change any interval's membership) goes through + * {@link setIntervals}. + */ + protected commitInterval(interval: AnyInterval) { + this._itemIntervals.set(interval.id, interval); + this.publishIntervalViewFor(interval); + } + + /** Drop an interval from storage, republishing the matching {@link intervalViews} field if tracked. */ + protected dropInterval(id: string) { + const removed = this._itemIntervals.get(id); + this._itemIntervals.delete(id); + if (removed) this.publishIntervalViewFor(removed, { removed: true }); + } + + /** + * Republish the {@link intervalViews} field backed by the given interval — called from + * {@link commitInterval} / {@link dropInterval} (i.e. when that interval ingests or removes an item). + * A write to an untracked interval touches nothing here. (The anchored head is also published + * directly via {@link publishAsAnchoredHead} from the reconciliation points that flip `isHead` — + * see {@link postQueryReconcile}.) + */ + private publishIntervalViewFor(interval: AnyInterval, { removed = false } = {}) { + if (interval.id === LOGICAL_HEAD_INTERVAL_ID) { + this.intervalViews.partialNext({ + logicalHead: this.intervalItemsOrEmpty(this.liveHeadLogical), + }); + } else if (interval.id === LOGICAL_TAIL_INTERVAL_ID) { + this.intervalViews.partialNext({ + logicalTail: this.intervalItemsOrEmpty(this.liveTailLogical), + }); + } else if ((interval as Interval).isHead) { + // On removal the head page is gone (no other interval is `isHead`) → clear; otherwise the + // committed page IS the head. + this.publishAsAnchoredHead(removed ? undefined : interval); + } + } + + /** + * Publish `interval` as the anchored head — the loaded page bounded at the dataset head (`isHead`), + * or `undefined` to clear it (the head page was removed or a page stopped being the head). Callers + * pass the interval they already have, so this does not re-scan storage for the head. Its content + * changes via ingest/remove (routed through {@link commitInterval}/{@link dropInterval}) and its + * identity changes when a page's `isHead` flag flips during query reconciliation — both call here. + */ + protected publishAsAnchoredHead(interval: AnyInterval | undefined) { + this.intervalViews.partialNext({ anchoredHead: this.intervalItemsOrEmpty(interval) }); + } + + /** + * Keep `anchoredHead` in sync after a page's `isHead` flag was (re)computed during query + * reconciliation, given its value `wasHead` beforehand. Acts only on an actual transition: + * - became the head page → publish it as the anchored head; + * - stopped being the head page → clear the anchored head; + * - unchanged → nothing (a content change, if any, was already published when the interval was + * committed — see {@link commitInterval}). + */ + protected syncAnchoredHeadAfterHeadFlip(interval: Interval, wasHead: boolean) { + if (interval.isHead === wasHead) return; + this.publishAsAnchoredHead(interval.isHead ? interval : undefined); + } + + private intervalItemsOrEmpty(interval: AnyInterval | undefined): T[] { + return interval ? this.intervalToItems(interval) : []; + } + + /** + * Empty every {@link intervalViews} field. Used by reset paths that clear intervals in bulk (via + * {@link setIntervals}), which bypasses the per-interval {@link commitInterval}/{@link dropInterval} + * publishing. No-ops when the views are already empty so a reset does not emit needlessly. + */ + protected clearIntervalViews() { + const { logicalHead, logicalTail, anchoredHead } = + this.intervalViews.getLatestValue(); + // Clear whenever any view holds items; skip only when all are already empty, so a reset on an + // empty paginator does not emit a redundant empty→empty change (new `[]` refs would wake selectors). + const alreadyEmpty = + logicalHead.length === 0 && logicalTail.length === 0 && anchoredHead.length === 0; + if (alreadyEmpty) return; + this.intervalViews.partialNext({ + logicalHead: [], + logicalTail: [], + anchoredHead: [], + }); + } + // --------------------------------------------------------------------------- // Abstracts // --------------------------------------------------------------------------- @@ -1473,7 +1622,7 @@ export abstract class BasePaginator { resultingInterval = merged; for (const itv of toMerge) { if (merged.id === itv.id) continue; - this._itemIntervals.delete(itv.id); + this.dropInterval(itv.id); } } @@ -1489,9 +1638,9 @@ export abstract class BasePaginator { isHead: false, isTail: false, }; - this._itemIntervals.set(convertedInterval.id, convertedInterval); + this.commitInterval(convertedInterval); } else { - this._itemIntervals.set(LOGICAL_HEAD_INTERVAL_ID, logicalHead); + this.commitInterval(logicalHead); } } @@ -1506,13 +1655,13 @@ export abstract class BasePaginator { isHead: false, isTail: false, }; - this._itemIntervals.set(convertedInterval.id, convertedInterval); + this.commitInterval(convertedInterval); } else { - this._itemIntervals.set(LOGICAL_TAIL_INTERVAL_ID, logicalTail); + this.commitInterval(logicalTail); } } - this._itemIntervals.set(resultingInterval.id, resultingInterval); + this.commitInterval(resultingInterval); // keep the intervals sorted this.setIntervals(this.sortIntervals(this.itemIntervals)); @@ -1652,7 +1801,7 @@ export abstract class BasePaginator { } const addedNewInterval = !this._itemIntervals.has(targetInterval.id); - this._itemIntervals.set(targetInterval.id, targetInterval); + this.commitInterval(targetInterval); if (addedNewInterval) { this.setIntervals(this.sortIntervals(this.itemIntervals)); @@ -1714,14 +1863,14 @@ export abstract class BasePaginator { const { interval } = updatedInterval; if (interval.itemIds.length === 0) { // Drop empty interval - this._itemIntervals.delete(interval.id); + this.dropInterval(interval.id); // If it was active -> clear active if (this.isActiveInterval(interval)) { this.setActiveInterval(undefined); } } else { - this._itemIntervals.set(updatedInterval.interval.id, updatedInterval.interval); + this.commitInterval(updatedInterval.interval); } result.interval = updatedInterval; } @@ -1973,6 +2122,7 @@ export abstract class BasePaginator { this.setIntervals([]); this.setActiveInterval(undefined); this._itemIndex.clear(); + this.clearIntervalViews(); } let items: T[] | undefined = undefined; if (!this.isInitialized) { @@ -1998,7 +2148,6 @@ export abstract class BasePaginator { reset, retryCount, }); - return this.postQueryReconcile({ direction, isFirstPage, @@ -2136,10 +2285,13 @@ export abstract class BasePaginator { ? stateUpdate.hasMoreTail : current.hasMoreTail; + const wasHead = interval.isHead; interval.hasMoreHead = resolvedHasMoreHead; interval.hasMoreTail = resolvedHasMoreTail; interval.isHead = resolvedHasMoreHead === false; interval.isTail = resolvedHasMoreTail === false; + // `isHead` is decided here (not at ingest); reflect any head-status flip in `anchoredHead`. + this.syncAnchoredHeadAfterHeadFlip(interval, wasHead); } else if (!items.length && direction) { // An empty directional response means the dataset edge was reached in `direction`, but // `ingestPage` returns no interval for an empty page so the block above never runs. Flag the @@ -2151,8 +2303,11 @@ export abstract class BasePaginator { : undefined; if (activeInterval && !isLogicalInterval(activeInterval)) { if (direction === 'headward') { + const wasHead = activeInterval.isHead; activeInterval.isHead = true; activeInterval.hasMoreHead = false; + // The active page just reached the dataset head; reflect the flip in `anchoredHead`. + this.syncAnchoredHeadAfterHeadFlip(activeInterval, wasHead); } else if (direction === 'tailward') { activeInterval.isTail = true; activeInterval.hasMoreTail = false; @@ -2182,6 +2337,7 @@ export abstract class BasePaginator { this.state.next(this.initialState); this.setIntervals([]); this.setActiveInterval(undefined); + this.clearIntervalViews(); } toTail = (params: Omit, 'direction' | 'queryShape'> = {}) => diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 76e470f58f..d86bb05174 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -118,7 +118,8 @@ export class MessagePaginator extends MessageIntervalPaginator { * Auxiliary (non-pagination) state — see {@link MessagePaginatorAggregateState}. A store separate * from `state` so `lastMessageAt` can be advanced from inside a `state.next` updater * (`ingestPage`) without being clobbered, and so consumers subscribe to a quiet signal that only - * emits when the aggregate actually changes (not on every scroll/pagination emission). + * emits when the aggregate actually changes (not on every scroll/pagination emission). This is + * distinct from the base's `intervalViews` interval-projection store. */ readonly aggregateState: StateStore; diff --git a/test/unit/pagination/paginators/BasePaginator.test.ts b/test/unit/pagination/paginators/BasePaginator.test.ts index 22817156cd..c3d1ed189f 100644 --- a/test/unit/pagination/paginators/BasePaginator.test.ts +++ b/test/unit/pagination/paginators/BasePaginator.test.ts @@ -2832,6 +2832,171 @@ describe('BasePaginator', () => { }); }); + describe('intervalViews (per-interval reactivity)', () => { + const makeItem = (id: string, age: number): TestItem => ({ id, age, name: id }); + const descByAge = () => + makeComparator>>({ + sort: { age: -1 }, // newest (highest age) is the head + }); + const withItemIndex = () => + new Paginator({ itemIndex: new ItemIndex({ getId: ({ id }) => id }) }); + + // Subscribe to a single `intervalViews` field via a scoped selector (as `useStateStore` does), + // ignoring the initial synchronous emit so `fires` counts only post-subscribe changes. + const trackKey = ( + paginator: Paginator, + key: 'logicalHead' | 'logicalTail' | 'anchoredHead', + ) => { + const tracker = { fires: 0, last: [] as string[] }; + const unsub = paginator.intervalViews.subscribeWithSelector( + (s) => ({ items: s[key] }), + ({ items }) => { + tracker.fires += 1; + tracker.last = items.map((i) => i.id); + }, + ); + tracker.fires = 0; + return { tracker, unsub }; + }; + + it('all views start empty', () => { + const paginator = withItemIndex(); + expect(paginator.intervalViews.getLatestValue()).toEqual({ + logicalHead: [], + logicalTail: [], + anchoredHead: [], + }); + expect(paginator.logicalHeadItems).toEqual([]); + expect(paginator.logicalTailItems).toEqual([]); + expect(paginator.anchoredHeadItems).toEqual([]); + expect(paginator.headItems).toEqual([]); + }); + + it('an out-of-order head ingest publishes logicalHead only, leaving the active interval, logicalTail, and anchoredHead untouched', () => { + const paginator = withItemIndex(); + paginator.sortComparator = descByAge(); + + // A bounded (non-head) anchored interval, loaded and active. No isHead page → anchoredHead empty. + paginator.ingestPage({ + page: [makeItem('m1', 50), makeItem('m2', 40)], + isHead: false, + isTail: false, + setActive: true, + }); + expect(paginator.items?.map((i) => i.id)).toEqual(['m1', 'm2']); + expect(paginator.logicalHeadItems).toEqual([]); + expect(paginator.anchoredHeadItems).toEqual([]); + + const activeItemsBefore = paginator.items; + const logicalHead = trackKey(paginator, 'logicalHead'); + const logicalTail = trackKey(paginator, 'logicalTail'); + const anchoredHead = trackKey(paginator, 'anchoredHead'); + + // Ingest an item newer (more headward) than the bounded interval → logical head, which is NOT + // the active interval. + paginator.ingestItem(makeItem('x', 100)); + + // Active interval untouched (state.items reference unchanged)... + expect(paginator.items).toBe(activeItemsBefore); + // ...only logicalHead's own ingest published it... + expect(paginator.logicalHeadItems.map((i) => i.id)).toEqual(['x']); + expect(logicalHead.tracker).toEqual({ fires: 1, last: ['x'] }); + // ...selectors on the untouched logicalTail / anchoredHead never fired... + expect(logicalTail.tracker.fires).toBe(0); + expect(anchoredHead.tracker.fires).toBe(0); + // ...and the computed head-most getter still reflects the flip (non-reactive read). + expect(paginator.headItems.map((i) => i.id)).toEqual(['x']); + + logicalHead.unsub(); + logicalTail.unsub(); + anchoredHead.unsub(); + }); + + it('publishes anchoredHead when the isHead page loads and when it ingests, without waking logical selectors', () => { + const paginator = withItemIndex(); + paginator.sortComparator = descByAge(); + + // An isHead page (the newest loaded page) populates anchoredHead on load. + paginator.ingestPage({ + page: [makeItem('m1', 50), makeItem('m2', 40)], + isHead: true, + isTail: false, + setActive: true, + }); + expect(paginator.anchoredHeadItems.map((i) => i.id)).toEqual(['m1', 'm2']); + + const anchoredHead = trackKey(paginator, 'anchoredHead'); + const logicalHead = trackKey(paginator, 'logicalHead'); + const logicalTail = trackKey(paginator, 'logicalTail'); + + // A newer message merges into the isHead page → anchoredHead grows. + paginator.ingestItem(makeItem('m0', 60)); + + expect(paginator.anchoredHeadItems.map((i) => i.id)).toEqual(['m0', 'm1', 'm2']); + expect(anchoredHead.tracker).toEqual({ fires: 1, last: ['m0', 'm1', 'm2'] }); + // No logical interval was touched. + expect(logicalHead.tracker.fires).toBe(0); + expect(logicalTail.tracker.fires).toBe(0); + + anchoredHead.unsub(); + logicalHead.unsub(); + logicalTail.unsub(); + }); + + it('an out-of-order tail ingest publishes logicalTail without waking logicalHead / anchoredHead selectors', () => { + const paginator = withItemIndex(); + paginator.sortComparator = descByAge(); + + // A bounded interval (tail open) loaded and active. + paginator.ingestPage({ + page: [makeItem('m1', 50), makeItem('m2', 40)], + isHead: false, + isTail: false, + setActive: true, + }); + expect(paginator.logicalTailItems).toEqual([]); + + const tail = trackKey(paginator, 'logicalTail'); + const head = trackKey(paginator, 'logicalHead'); + const anchoredHead = trackKey(paginator, 'anchoredHead'); + + // Ingest an item older (more tailward) than the loaded window → logical tail. + paginator.ingestItem(makeItem('z', 10)); + + expect(paginator.logicalTailItems.map((i) => i.id)).toEqual(['z']); + expect(tail.tracker).toEqual({ fires: 1, last: ['z'] }); + // Neither the logical head nor the anchored head was touched. + expect(head.tracker.fires).toBe(0); + expect(anchoredHead.tracker.fires).toBe(0); + expect(paginator.logicalHeadItems).toEqual([]); + + tail.unsub(); + head.unsub(); + anchoredHead.unsub(); + }); + + it('clears all views on reset', () => { + const paginator = withItemIndex(); + paginator.sortComparator = descByAge(); + paginator.ingestPage({ + page: [makeItem('m1', 50)], + isHead: true, + isTail: false, + setActive: true, + }); + paginator.ingestItem(makeItem('z', 10)); // logical tail + expect(paginator.anchoredHeadItems.map((i) => i.id)).toEqual(['m1']); + expect(paginator.logicalTailItems.map((i) => i.id)).toEqual(['z']); + + paginator.resetState(); + expect(paginator.intervalViews.getLatestValue()).toEqual({ + logicalHead: [], + logicalTail: [], + anchoredHead: [], + }); + }); + }); + describe('removeItem', () => { it('removes existing item', () => { const paginator = new Paginator(); From 1acbefb29d4cc1be9e866442c547cc97964cf683 Mon Sep 17 00:00:00 2001 From: MartinCupela <32706194+MartinCupela@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:30:02 +0200 Subject: [PATCH 44/48] refactor: merge OpenAPI changes (#1809) --- .github/workflows/lint.yml | 4 + .github/workflows/pr-check.yml | 4 + .github/workflows/scheduled_test.yml | 3 + .github/workflows/size.yml | 4 + .github/workflows/type.yml | 4 + .github/workflows/unit.yml | 4 + CHANGELOG.md | 12 + CONTRIBUTING.md | 4 + JSDOC.md | 66 + eslint.config.mjs | 39 +- package.json | 8 +- scripts/generate-client.sh | 11 + scripts/generate-filter-types.mts | 120 + src/ChannelPaginatorsOrchestrator.ts | 15 +- src/CooldownTimer.ts | 2 +- src/EventHandlerPipeline.ts | 48 +- src/LiveLocationManager.ts | 23 +- src/api-client.ts | 275 + src/campaign.ts | 78 +- src/channel.ts | 1772 +-- src/channel_batch_updater.ts | 212 +- src/channel_manager.ts | 171 +- src/channel_state.ts | 7 +- src/client.ts | 4692 +----- src/client_state.ts | 8 +- src/connection.ts | 239 +- src/connection_fallback.ts | 56 +- src/constants.ts | 2 +- src/custom_types.ts | 5 +- src/errors.ts | 4 +- src/events.ts | 75 - src/gen-imports.ts | 3 + src/gen/chat/ChannelApi.ts | 275 + src/gen/chat/ChatApi.ts | 2554 ++++ src/gen/model-decoders/decoders.ts | 2680 ++++ .../model-decoders/event-decoder-mapping.ts | 198 + src/gen/models/index.ts | 12673 ++++++++++++++++ src/gen/moderation/ModerationApi.ts | 607 + src/index.ts | 5 +- src/insights.ts | 11 +- src/logger.ts | 28 + src/messageComposer/LocationComposer.ts | 14 +- src/messageComposer/attachmentIdentity.ts | 25 +- src/messageComposer/attachmentManager.ts | 38 +- src/messageComposer/configuration/types.ts | 22 +- src/messageComposer/linkPreviewsManager.ts | 19 +- src/messageComposer/messageComposer.ts | 89 +- .../middleware/messageComposer/attachments.ts | 3 +- .../middleware/messageComposer/cleanData.ts | 2 +- .../messageComposer/compositionValidation.ts | 4 +- .../messageComposer/messageComposerState.ts | 4 +- .../messageComposer/sharedLocation.ts | 7 +- .../messageComposer/textComposer.ts | 4 +- .../middleware/messageComposer/types.ts | 7 +- .../messageComposer/userDataInjection.ts | 14 +- .../middleware/pollComposer/state.ts | 2 - .../middleware/pollComposer/types.ts | 11 +- .../TextComposerMiddlewareExecutor.ts | 11 +- .../middleware/textComposer/commandEffects.ts | 8 +- .../middleware/textComposer/commandUtils.ts | 9 +- .../middleware/textComposer/commands.ts | 6 +- .../middleware/textComposer/mentionUtils.ts | 2 +- .../middleware/textComposer/mentions.ts | 46 +- .../middleware/textComposer/types.ts | 8 +- src/messageComposer/pollComposer.ts | 14 +- src/messageComposer/textComposer.ts | 10 +- src/messageComposer/types.ts | 34 +- .../MessageDeliveryReporter.ts | 112 +- src/messageDelivery/MessageReceiptsTracker.ts | 71 +- .../MessageOperationStatePolicy.ts | 18 +- src/messageOperations/MessageOperations.ts | 8 +- src/messageOperations/types.ts | 8 +- src/moderation.ts | 469 +- src/notifications/types.ts | 15 +- src/offline-support/offline_support_api.ts | 327 +- src/offline-support/offline_sync_manager.ts | 24 +- src/offline-support/types.ts | 49 +- src/offline-support/util.ts | 9 +- src/pagination/filterCompiler.ts | 8 +- src/pagination/paginators/BasePaginator.ts | 55 +- src/pagination/paginators/ChannelPaginator.ts | 27 +- .../paginators/MessageIntervalPaginator.ts | 32 +- src/pagination/paginators/MessagePaginator.ts | 2 +- .../paginators/PinnedMessagePaginator.ts | 2 +- .../paginators/ReminderPaginator.ts | 20 +- .../paginators/UserGroupPaginator.ts | 16 +- src/pagination/sortCompiler.ts | 14 +- src/pagination/utility.normalization.ts | 2 - src/pagination/utility.queryChannel.ts | 29 +- src/permissions.ts | 15 +- src/poll.ts | 211 +- src/poll_manager.ts | 22 +- src/reminders/Reminder.ts | 7 +- src/reminders/ReminderManager.ts | 36 +- src/search/BaseSearchSource.ts | 3 - src/search/ChannelMemberSearchSource.ts | 8 +- src/search/ChannelSearchSource.ts | 13 +- src/search/MessageSearchSource.ts | 59 +- src/search/UserSearchSource.ts | 20 +- src/search/types.ts | 7 +- src/segment.ts | 96 +- src/signing.ts | 174 +- src/store.ts | 3 +- src/thread.ts | 143 +- src/thread_manager.ts | 65 +- src/token_manager.ts | 31 +- src/types.ts | 4895 +----- src/uploadManager.ts | 8 + src/utils.ts | 240 +- src/utils/FixedSizeQueueCache.ts | 18 +- src/utils/WithSubscriptions.ts | 3 +- src/utils/concurrency.ts | 8 +- src/utils/mergeWith/mergeWith.ts | 6 +- src/utils/mergeWith/mergeWithDiff.ts | 6 +- src/utils/retryable.ts | 117 + test/typescript/unit-test.ts | 12 +- .../ChannelPaginatorsOrchestrator.test.ts | 4 +- test/unit/CooldownTimer.test.ts | 40 +- test/unit/LiveLocationManager.test.ts | 103 +- .../MessageComposer/LocationComposer.test.ts | 4 +- .../attachmentIdentity.test.ts | 28 +- .../MessageComposer/attachmentManager.test.ts | 25 +- .../linkPreviewsManager.test.ts | 32 +- .../MessageComposer/messageComposer.test.ts | 96 +- .../postUpload/uploadErrorHandler.test.ts | 1 + .../blockedUploadNotification.test.ts | 1 + .../preUpload/serverUploadConfigCheck.test.ts | 1 + .../messageComposer/cleanData.test.ts | 16 +- .../messageComposer/commandInjection.test.ts | 6 +- .../compositionValidation.test.ts | 1 - .../messageComposer/linkPreviews.test.ts | 10 +- .../messageComposer/sharedLocation.test.ts | 5 +- .../middleware/pollComposer/state.test.ts | 2 +- .../textComposer/MentionsSearchSource.test.ts | 31 +- .../TextComposerMiddlewareExecutor.test.ts | 15 +- .../middleware/textComposer/command.test.ts | 3 +- .../unit/MessageComposer/pollComposer.test.ts | 12 +- .../unit/MessageComposer/textComposer.test.ts | 5 +- test/unit/channel.test.js | 518 +- test/unit/channel_manager.test.ts | 654 +- test/unit/channel_state.test.js | 2 +- test/unit/client.construction.test.ts | 409 + test/unit/client.test.js | 895 +- test/unit/connection.test.js | 2 - test/unit/connection_fallback.test.js | 27 +- test/unit/draft.test.js | 215 +- .../MessageDeliveryReporter.test.ts | 232 +- .../MessageReceiptsTracker.test.ts | 118 +- .../offline_support_api.test.ts | 110 +- .../pagination/UserGroupPaginator.test.ts | 29 +- .../paginators/ChannelPaginator.test.ts | 17 +- .../paginators/MessagePaginator.test.ts | 24 +- .../paginators/UserGroupPaginator.test.ts | 14 +- test/unit/poll.test.js | 40 +- test/unit/poll_manager.test.ts | 28 +- test/unit/predefined_filters.test.ts | 455 - test/unit/reminders/Reminder.test.ts | 6 +- test/unit/reminders/ReminderManager.test.ts | 105 +- test/unit/reminders/reminder.api.test.js | 428 - test/unit/retention_policy.test.ts | 266 - .../search/ChannelMemberSearchSource.test.ts | 67 +- test/unit/search/ChannelSearchSource.test.ts | 36 +- test/unit/search/MessageSearchSource.test.ts | 124 +- test/unit/search/SearchController.test.js | 64 +- test/unit/search/UserSearchSource.test.ts | 70 +- test/unit/team_usage_stats.test.ts | 298 - test/unit/test-utils/generateChannel.ts | 24 +- test/unit/test-utils/generateMessage.ts | 10 +- test/unit/test-utils/generateMessageDraft.ts | 2 +- test/unit/test-utils/generatePendingTask.js | 13 +- test/unit/test-utils/generateReadResponse.js | 2 +- .../unit/test-utils/generateThreadResponse.js | 6 +- test/unit/test-utils/generateUser.js | 4 +- test/unit/test-utils/getClient.js | 4 +- test/unit/threads.test.ts | 117 +- test/unit/user_groups.test.ts | 216 - test/unit/utils.test.js | 52 +- test/unit/utils.test.ts | 100 +- test/unit/webhook-compression.test.ts | 274 - tsconfig.json | 2 +- ...v10-migration-guide-client-construction.md | 149 + v9-to-v10-migration-guide-logging.md | 239 + v9-to-v10-migration-guide-methods.md | 981 ++ v9-to-v10-migration-guide-other.md | 427 + v9-to-v10-migration-guide-sort.md | 146 + v9-to-v10-migration-guide-type-renames.md | 83 + yarn.lock | 189 +- 187 files changed, 27832 insertions(+), 15859 deletions(-) create mode 100644 JSDOC.md create mode 100755 scripts/generate-client.sh create mode 100644 scripts/generate-filter-types.mts create mode 100644 src/api-client.ts delete mode 100644 src/events.ts create mode 100644 src/gen-imports.ts create mode 100644 src/gen/chat/ChannelApi.ts create mode 100644 src/gen/chat/ChatApi.ts create mode 100644 src/gen/model-decoders/decoders.ts create mode 100644 src/gen/model-decoders/event-decoder-mapping.ts create mode 100644 src/gen/models/index.ts create mode 100644 src/gen/moderation/ModerationApi.ts create mode 100644 src/logger.ts create mode 100644 src/utils/retryable.ts create mode 100644 test/unit/client.construction.test.ts delete mode 100644 test/unit/predefined_filters.test.ts delete mode 100644 test/unit/reminders/reminder.api.test.js delete mode 100644 test/unit/retention_policy.test.ts delete mode 100644 test/unit/team_usage_stats.test.ts delete mode 100644 test/unit/user_groups.test.ts delete mode 100644 test/unit/webhook-compression.test.ts create mode 100644 v9-to-v10-migration-guide-client-construction.md create mode 100644 v9-to-v10-migration-guide-logging.md create mode 100644 v9-to-v10-migration-guide-methods.md create mode 100644 v9-to-v10-migration-guide-other.md create mode 100644 v9-to-v10-migration-guide-sort.md create mode 100644 v9-to-v10-migration-guide-type-renames.md diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 70c7057ae0..de29ef053a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,6 +7,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true +permissions: + contents: read + pull-requests: read + jobs: lint: runs-on: ubuntu-latest diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 6f266459b0..7a83000827 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -4,6 +4,10 @@ on: pull_request: types: [opened, edited, synchronize, reopened] +permissions: + contents: read + pull-requests: read + jobs: pr-title: name: Validate PR Title diff --git a/.github/workflows/scheduled_test.yml b/.github/workflows/scheduled_test.yml index c7ecbd8ac2..b1263149cf 100644 --- a/.github/workflows/scheduled_test.yml +++ b/.github/workflows/scheduled_test.yml @@ -6,6 +6,9 @@ on: # Monday at 9:00 UTC - cron: '0 9 * * 1' +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml index 5d64ed4bb4..35d48583f8 100644 --- a/.github/workflows/size.yml +++ b/.github/workflows/size.yml @@ -10,6 +10,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true +permissions: + contents: read + pull-requests: read + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/type.yml b/.github/workflows/type.yml index 537ad6b6a8..403809ad9b 100644 --- a/.github/workflows/type.yml +++ b/.github/workflows/type.yml @@ -5,6 +5,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true +permissions: + contents: read + pull-requests: read + jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 2149b94dd5..9778735aed 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -5,6 +5,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref }} cancel-in-progress: true +permissions: + contents: read + pull-requests: read + jobs: test: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d916fd38a..8d911ff964 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [9.50.2](https://github.com/GetStream/stream-chat-js/compare/v9.50.1...v9.50.2) (2026-07-16) + +### Bug Fixes + +* **AttachmentManager:** add permission bypass for custom upload functions ([#1800](https://github.com/GetStream/stream-chat-js/issues/1800)) ([30e6bc4](https://github.com/GetStream/stream-chat-js/commit/30e6bc41f9d779da50078cf844883dc2713c346c)) + +## [9.50.1](https://github.com/GetStream/stream-chat-js/compare/v9.50.0...v9.50.1) (2026-07-09) + +### Bug Fixes + +* prevent reload if ThreadManager has never been activated ([#1798](https://github.com/GetStream/stream-chat-js/issues/1798)) ([affbb9c](https://github.com/GetStream/stream-chat-js/commit/affbb9cac73eea798084879523b9bad709ad4dc1)) + ## [9.50.0](https://github.com/GetStream/stream-chat-js/compare/v9.49.0...v9.50.0) (2026-07-03) ### Features diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ba31b3453e..ffe13323a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,10 @@ $ yarn run test-unit We use [ESLint](https://eslint.org/) for linting and [Prettier](https://prettier.io/) for code formatting. We enforce it during the build process. If your IDE has integration with these tools, it's recommended to set them up. +## JSDoc + +See [`JSDOC.md`](./JSDOC.md) for the canonical JSDoc format used in this repository. The format is enforced by `eslint-plugin-jsdoc`. + ## Commit message convention Since we're autogenerating our [CHANGELOG](./CHANGELOG.md), we need to follow a specific commit message convention. diff --git a/JSDOC.md b/JSDOC.md new file mode 100644 index 0000000000..28d9298c90 --- /dev/null +++ b/JSDOC.md @@ -0,0 +1,66 @@ +# JSDoc style guide + +This repository uses TSDoc-flavored JSDoc. The TypeScript signature is the source of truth for parameter and return **types** and for **optionality** — JSDoc only carries human-readable descriptions and the few semantic tags listed below. + +The format is enforced by `eslint-plugin-jsdoc`. Run `yarn lint` to validate locally. + +## Canonical block + +````ts +/** + * One-sentence summary in sentence case, ending with a period. + * + * Optional longer prose paragraph. Wrap at ~100 columns. + * + * @param name - Description in sentence case, ending with a period. + * @param options - Send options. + * @param options.skip_enrich_url - Skip URL enrichment for this message. + * @returns Description of the return value. + * @throws When the channel is frozen. + * @deprecated Use {@link newName} instead. + * @example + * ```ts + * client.connectUser({ id: 'foo' }, token); + * ``` + */ +```` + +## Rules + +- **No `{Type}` annotations on `@param` / `@returns`.** TypeScript already provides them. Enforced by `jsdoc/no-types`. +- **No bracketed-optional syntax** (`@param [name]`). TypeScript marks optionality via `?` or default values. Enforced by `jsdoc/check-param-names`. +- **Use `@returns`, not `@return`.** Enforced by `jsdoc/check-tag-names`. +- **Drop legacy tags**: `@method`, `@memberof`, `@class`, `@type` — TypeScript provides these. +- **Allowed tags**: `@param`, `@returns`, `@throws`, `@example`, `@default`, `@deprecated`, `@see`, `@internal`, `@private`, `@experimental`, `@remarks`, `@template`, and the inline `{@link}`. +- **Hyphen before description**: `@param name - description`. Enforced by `jsdoc/require-hyphen-before-param-description`. +- **Destructured object params** use dot notation: `@param options.foo - ...`. +- **`@deprecated`** must point at the replacement: `@deprecated Use {@link newName} instead.` +- **Short single-line form** `/** Foo. */` is allowed only when there are no tags and the description fits on one line. +- **Field-level JSDoc** on interfaces/types may use the single-line form (mirrors `src/gen/models/index.ts`). + +## Casing in prose + +Apply consistently in JSDoc and `//` comments. Do **not** rewrite identifiers, string literals, or `@example` code blocks. + +| Wrong | Right | +| ------------ | ------------ | +| `websocket` | `WebSocket` | +| `sdk` | `SDK` | +| `api` | `API` | +| `url` | `URL` | +| `json` | `JSON` | +| `http(s)` | `HTTP(S)` | +| `jwt` | `JWT` | +| `id` (prose) | `ID` | +| `javascript` | `JavaScript` | +| `typescript` | `TypeScript` | + +## Reusing field descriptions + +Hand-written types in `src/types.ts` and elsewhere often share field names with the OpenAPI-generated types in `src/gen/models/index.ts` (`cid`, `created_at`, `channel_id`, `team`, `duration`, etc.). When documenting such a field, reuse the wording from the generated model for consistency. + +## When in doubt + +- Cross-check that `@param` names match the actual parameter names. +- Add `@returns` iff the function returns something other than `void` / `Promise`. +- Keep existing wording verbatim except to fix grammar, typos, casing, or factual mismatches with the signature. diff --git a/eslint.config.mjs b/eslint.config.mjs index 394d62bb14..e51175f3d6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,8 @@ import js from '@eslint/js'; import globals from 'globals'; import tseslint from 'typescript-eslint'; +import unusedImports from 'eslint-plugin-unused-imports'; +import jsdoc from 'eslint-plugin-jsdoc'; import importPlugin from 'eslint-plugin-import'; @@ -18,6 +20,7 @@ export default tseslint.config( }, plugins: { import: importPlugin, + 'unused-imports': unusedImports, }, settings: { react: { @@ -71,9 +74,18 @@ export default tseslint.config( }, ], 'no-unused-vars': 'off', - '@typescript-eslint/no-unused-vars': [ + '@typescript-eslint/no-unused-vars': 'off', + 'unused-imports/no-unused-imports': 'warn', + 'unused-imports/no-unused-vars': [ 'warn', - { ignoreRestSiblings: false, caughtErrors: 'none' }, + { + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_', + ignoreRestSiblings: false, + caughtErrors: 'none', + }, ], '@typescript-eslint/no-unsafe-function-type': 'error', '@typescript-eslint/no-wrapper-object-types': 'error', @@ -83,6 +95,29 @@ export default tseslint.config( '@typescript-eslint/no-require-imports': 'off', // TODO: remove this rule once all files are .mjs (and require is not used) '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/no-empty-object-type': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, + }, + { + ignores: ['src/gen/**'], + files: ['src/**/*.{js,ts}'], + plugins: { + jsdoc, + }, + rules: { + 'jsdoc/no-types': 'error', + 'jsdoc/check-param-names': ['error', { checkDestructured: false }], + 'jsdoc/check-tag-names': [ + 'error', + { definedTags: ['internal', 'experimental', 'remarks'] }, + ], + 'jsdoc/require-param-description': 'warn', + 'jsdoc/require-returns-description': 'warn', + 'jsdoc/require-hyphen-before-param-description': ['warn', 'always'], + 'jsdoc/tag-lines': ['error', 'any', { startLines: 1 }], + 'jsdoc/no-multi-asterisks': 'error', + 'jsdoc/empty-tags': 'error', + 'jsdoc/no-bad-blocks': 'error', }, }, ); diff --git a/package.json b/package.json index cc7188f1f4..de9e1c1492 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "/src" ], "dependencies": { + "@stream-io/logger": "^2.0.0", "@types/jsonwebtoken": "^9.0.8", "@types/ws": "^8.18.1", "axios": "^1.16.1", @@ -75,6 +76,8 @@ "esbuild": "^0.28.0", "eslint": "^9.39.4", "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsdoc": "^63.0.7", + "eslint-plugin-unused-imports": "^4.4.1", "globals": "^17.6.0", "husky": "^9.1.7", "lint-staged": "^17.0.5", @@ -90,7 +93,7 @@ "start": "concurrently 'tsc --watch' './scripts/bundle.mjs --watch'", "types": "tsc --noEmit", "lint": "yarn run prettier && yarn run eslint", - "lint-fix": "yarn run prettier-fix && yarn run eslint-fix", + "lint-fix": "yarn run eslint-fix; yarn run prettier-fix", "prettier": "prettier '**/*.{json,js,mjs,ts,yml,md}' --check", "prettier-fix": "yarn run prettier --write", "eslint": "eslint --max-warnings 0", @@ -104,7 +107,8 @@ "fix-staged": "lint-staged --config .lintstagedrc.fix.json --concurrent 1", "semantic-release": "semantic-release", "postinstall": "node -e \"require('fs').existsSync('scripts/install-husky.mjs') && import('./scripts/install-husky.mjs')\"", - "prepare": "yarn run build" + "prepare": "yarn run build", + "generate-client": "./scripts/generate-client.sh" }, "engines": { "node": ">=18" diff --git a/scripts/generate-client.sh b/scripts/generate-client.sh new file mode 100755 index 0000000000..7858fbecbe --- /dev/null +++ b/scripts/generate-client.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -euo pipefail + +OUTPUT_DIR="../stream-chat-js/src/gen" +CHAT_DIR="../chat" + +rm -rf $OUTPUT_DIR + +( cd $CHAT_DIR ; make openapi ; make -C projects/chat-manager build; build/chat-manager openapi generate-client --language ts --spec releases/v2/chat-clientside-api.yaml --output $OUTPUT_DIR ) + +yarn lint-fix \ No newline at end of file diff --git a/scripts/generate-filter-types.mts b/scripts/generate-filter-types.mts new file mode 100644 index 0000000000..8fc9f4f30c --- /dev/null +++ b/scripts/generate-filter-types.mts @@ -0,0 +1,120 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { parseArgs, type ParseArgsOptionsConfig } from 'node:util'; +import { parse } from 'yaml'; + +const options = { + spec: { + type: 'string', + short: 's', + }, + out: { + type: 'string', + short: 'o', + }, +} satisfies ParseArgsOptionsConfig; + +type OpenAPISpecification = { + components: { + schemas: { + [key: string]: { + properties?: Partial< + Record< + 'filter_conditions' | string, + Partial< + Record< + 'x-stream-filter-fields' | string, + Record< + string, + { + operators: string[]; + type: string; + } + > + > + > + > + >; + }; + }; + }; +}; + +const { values } = parseArgs({ + args: process.argv, + options, + allowPositionals: true, + tokens: false, +}); + +const specPath = values.spec; +const outputPath = values.out; + +if (!specPath || !outputPath) { + console.error( + 'Usage: node generate-filter-types.mts -s -o ', + ); + process.exit(1); +} + +const spec = parse(readFileSync(specPath, 'utf8')) as OpenAPISpecification; +const schemas = spec.components?.schemas; + +if (!schemas) { + console.error('No components.schemas found in the specification'); + process.exit(1); +} + +const lines = []; + +const typeMapping = { + string: 'string', + number: 'number', + boolean: 'boolean', + date: 'Date', +}; + +const snakeToCamelCase = (snakeCaseString: string) => + snakeCaseString + .split('_') + .map((wordSegment) => wordSegment.slice(0, 1).toUpperCase() + wordSegment.slice(1)) + .join(''); + +for (const [schemaName, schema] of Object.entries(schemas)) { + if (!schema.properties) { + console.log(schemaName, 'missing'); + continue; + } + + for (const [propertyName, propertyDef] of Object.entries(schema.properties)) { + if (!propertyDef?.['x-stream-filter-fields']) continue; + + const filterFields = propertyDef['x-stream-filter-fields']; + + let typeName = `${schemaName}${snakeToCamelCase(propertyName)}`; + + const fieldEntries = Object.entries(filterFields).map( + ([fieldName, fieldDefinition]) => { + // TODO: add support for such properties later on (custom/nested filters) + if (fieldDefinition.type === 'object' || fieldName.startsWith('_')) { + return ''; + } + + const operators = fieldDefinition.operators.length + ? fieldDefinition.operators.map((operator) => `"${operator}"`).join(' | ') + : 'never'; + return ` "${fieldName}": { type: ${typeMapping[fieldDefinition.type as keyof typeof typeMapping] ?? `"${fieldDefinition.type}"`}; operators: ${operators} };`; + }, + ); + + lines.push(`export type ${typeName} = {`); + lines.push(...fieldEntries); + lines.push(`};\n`); + } +} + +if (lines.length > 0) { + writeFileSync(outputPath, '\n' + lines.join('\n') + '\n'); + console.log(`Appended ${lines.length} lines to ${outputPath}`); +} else { + console.log('No filter types found'); +} diff --git a/src/ChannelPaginatorsOrchestrator.ts b/src/ChannelPaginatorsOrchestrator.ts index ad03a2556f..d3a6ff3a80 100644 --- a/src/ChannelPaginatorsOrchestrator.ts +++ b/src/ChannelPaginatorsOrchestrator.ts @@ -1,6 +1,6 @@ import { EventHandlerPipeline } from './EventHandlerPipeline'; import { WithSubscriptions } from './utils/WithSubscriptions'; -import type { Event, EventTypes } from './types'; +import type { EventType } from './types'; import type { ChannelPaginator } from './pagination'; import type { StreamChat } from './client'; import type { Unsubscribe } from './store'; @@ -10,6 +10,7 @@ import type { FindEventHandlerParams, InsertEventHandlerPayload, LabeledEventHandler, + PipelineEvent, } from './EventHandlerPipeline'; import { getChannel } from './pagination/utility.queryChannel'; import type { Channel } from './channel'; @@ -20,7 +21,7 @@ export type ChannelPaginatorsOrchestratorEventHandlerContext = { type EventHandlerContext = ChannelPaginatorsOrchestratorEventHandlerContext; -type SupportedEventType = EventTypes | (string & {}); +type SupportedEventType = EventType | (string & {}); /** * Resolves which paginators should be the "owners" of a channel @@ -69,7 +70,7 @@ export const createPriorityOwnershipResolver = ( }; const getCachedChannelFromEvent = ( - event: Event, + event: PipelineEvent, cache: Record, ): Channel | undefined => { let channel: Channel | undefined = undefined; @@ -452,8 +453,10 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { * If paginator already exists → remove old, reinsert at new index. * If index not provided → append at the end. * If index provided → insert (or move) at that index. - * @param paginator - * @param index + * + * @param params - The insertion parameters. + * @param params.paginator - The paginator to insert or move. + * @param params.index - Target index; when omitted the paginator is appended. */ insertPaginator({ paginator, index }: { paginator: ChannelPaginator; index?: number }) { const paginators = [...this.paginators]; @@ -507,7 +510,7 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions { if (!this.hasSubscriptions) { this.addUnsubscribeFunction( // todo: maybe we should have a wrapper here to decide, whether the event is a LocalEventBus event or else supported by client - this.client.on((event: Event) => { + this.client.on((event) => { const pipe = this._pipelines.get(event.type); if (pipe) { pipe.run(event, this.ctx); diff --git a/src/CooldownTimer.ts b/src/CooldownTimer.ts index 1421461de3..07d293a54a 100644 --- a/src/CooldownTimer.ts +++ b/src/CooldownTimer.ts @@ -139,7 +139,7 @@ export class CooldownTimer extends WithSubscriptions { private getOwnUserId() { const client = this.channel.getClient(); - return client.userID ?? client.user?.id; + return client.userId ?? client.user?.id; } private findOwnLatestMessageDate({ diff --git a/src/EventHandlerPipeline.ts b/src/EventHandlerPipeline.ts index 925e7c1e64..03a5a63982 100644 --- a/src/EventHandlerPipeline.ts +++ b/src/EventHandlerPipeline.ts @@ -1,7 +1,34 @@ import { generateUUIDv4 } from './utils'; -import type { Event } from './types'; +import type { + ChannelResponse, + Event, + EventType, + MessageResponse, + ReactionResponse, + UserResponse, +} from './types'; import type { Unsubscribe } from './store'; +/** + * Flat routing view of an event, as seen by pipeline handlers. The public `Event` type is now a + * discriminated union (each WS event exposes only its own fields) and also admits bare custom-event + * name strings — neither is convenient for the generic routers here, which only read a bounded set of + * common optional fields. Dispatched event *values* are always objects, so the pipeline casts to + * this view at the boundary (see `processOne`). + */ +export type PipelineEvent = { + type: EventType | (string & {}); + channel?: ChannelResponse; + channel_id?: string; + channel_type?: string; + cid?: string; + created_at?: string | Date; + hard_delete?: boolean; + message?: MessageResponse; + reaction?: ReactionResponse; + user?: UserResponse; +}; + type MatchById = { id: string | RegExp; regexMatch?: boolean }; export type FindEventHandlerParams> = { handler?: LabeledEventHandler | EventHandlerPipelineHandler; @@ -19,7 +46,7 @@ export type InsertEventHandlerPayload> = { }; export type EventHandlerPipelineHandler> = (payload: { - event: Event; + event: PipelineEvent; ctx: CTX; }) => EventHandlerResult | void | Promise; @@ -74,10 +101,11 @@ export class EventHandlerPipeline = {}> { * (or appended if the index is greater than the pipeline size). Unsubscribe * will only remove this handler. * - * @param handler The handler function to insert. - * @param index Target index in the pipeline (clamped to valid range). - * @param replace If true, replace existing handler at index instead of inserting. - * @param revertOnUnsubscribe If true, restore the replaced handler when unsubscribing. + * @param payload - Insertion options. + * @param payload.handle - The handler function to insert. + * @param payload.index - Target index in the pipeline (clamped to valid range). + * @param payload.replace - If true, replace existing handler at index instead of inserting. + * @param payload.revertOnUnsubscribe - If true, restore the replaced handler when unsubscribing. * @returns An unsubscribe function that removes (and optionally restores) the handler. */ insert({ @@ -114,7 +142,8 @@ export class EventHandlerPipeline = {}> { * - handler function identity or * - by id that could be an exact match or * - match by regexp. - * @param params {FindEventHandlerParams} + * + * @param params - {FindEventHandlerParams} */ remove(params: FindEventHandlerParams): void { let index = this.findIndex(params); @@ -183,7 +212,10 @@ export class EventHandlerPipeline = {}> { for (let i = 0; i < snapshot.length; i++) { const handler = snapshot[i]; try { - const result = await handler.handle({ event, ctx }); + const result = await handler.handle({ + event: event as unknown as PipelineEvent, + ctx, + }); if (result?.action === 'stop') return; } catch { console.error(`[pipeline:${this.id}] handler failed`, { diff --git a/src/LiveLocationManager.ts b/src/LiveLocationManager.ts index 49df7c157d..ec56cdb5c6 100644 --- a/src/LiveLocationManager.ts +++ b/src/LiveLocationManager.ts @@ -14,10 +14,10 @@ import { WithSubscriptions } from './utils/WithSubscriptions'; import type { StreamChat } from './client'; import type { Unsubscribe } from './store'; import type { - EventTypes, + EventType, MessageResponse, SharedLiveLocationResponse, - SharedLocationResponse, + SharedLocationResponseData, } from './types'; import type { Coords } from './messageComposer'; @@ -73,7 +73,7 @@ export class LiveLocationManager extends WithSubscriptions { getDeviceId, watchLocation, }: LiveLocationManagerConstructorParameters) { - if (!client.userID) { + if (!client.userId) { throw new Error('Live-location sharing is reserved for client-side use only'); } @@ -121,10 +121,10 @@ export class LiveLocationManager extends WithSubscriptions { private async assureStateInit() { if (this.stateIsReady) return; - const { active_live_locations } = await this.client.getSharedLocations(); + const { active_live_locations } = await this.client.getUserLiveLocations(); this.state.next({ messages: new Map( - active_live_locations + (active_live_locations as SharedLiveLocationResponse[]) .filter((location) => !isExpiredLocation(location)) .map((location) => [ location.message_id, @@ -178,7 +178,7 @@ export class LiveLocationManager extends WithSubscriptions { Date.now() + UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT; withCancellation(LiveLocationManager.symbol, async () => { - const promises: Promise[] = []; + const promises: Promise[] = []; await this.assureStateInit(); const expiredLocations: string[] = []; @@ -189,8 +189,9 @@ export class LiveLocationManager extends WithSubscriptions { } if (location.latitude === latitude && location.longitude === longitude) continue; - const promise = this.client.updateLocation({ - created_by_device_id: location.created_by_device_id, + const promise = this.client.updateLiveLocation({ + // TODO: this is missing from the OAPI spec + // created_by_device_id: location.created_by_device_id, message_id: messageId, latitude, longitude, @@ -221,7 +222,7 @@ export class LiveLocationManager extends WithSubscriptions { 'live_location_sharing.started', 'message.updated', 'message.deleted', - ] as EventTypes[] + ] satisfies EventType[] ).map((eventType) => this.client.on(eventType, (event) => { if (!event.message) return; @@ -251,8 +252,8 @@ export class LiveLocationManager extends WithSubscriptions { private registerMessage(message: MessageResponse) { if ( - !this.client.userID || - message?.user?.id !== this.client.userID || + !this.client.userId || + message?.user?.id !== this.client.userId || !isValidLiveLocationMessage(message) ) return; diff --git a/src/api-client.ts b/src/api-client.ts new file mode 100644 index 0000000000..3cb41fdc9a --- /dev/null +++ b/src/api-client.ts @@ -0,0 +1,275 @@ +import type { AxiosRequestConfig, AxiosResponse, Method } from 'axios'; +import { AxiosError } from 'axios'; + +import type { + APIError, + RateLimit, + RequestMetadata, + SendFileAPIResponse, + UserResponse, +} from './types'; +import { StreamAPIError } from './types'; +import { addFileToFormData, chatCodes, randomId, retryInterval } from './utils'; +import type { StreamChat } from './client'; +import { chatLoggerSystem } from './logger'; +import { runWithRetry } from './utils/retryable'; + +const logger = chatLoggerSystem.getLogger('api-client'); + +export class ApiClient { + client!: StreamChat; + + private nextRequestAbortController: AbortController | null = null; + + constructor(client?: StreamChat) { + if (client) this.client = client; + } + + _getToken(): string | undefined { + if (this.client.getAuthType() === 'anonymous') return; + + return this.client.tokenManager.getToken(); + } + + createAbortControllerForNextRequest() { + return (this.nextRequestAbortController = new AbortController()); + } + + sendRequest( + method: Method, + url: string, + pathParams?: Record, + queryParams?: Record, + body?: unknown, + requestContentType?: string, + ): Promise<{ body: T; metadata: RequestMetadata }> { + const resolvedUrl = this.resolveUrl(url, pathParams); + + return this._doRequest(method, resolvedUrl, body, { + params: queryParams, + headers: { 'Content-Type': requestContentType }, + }); + } + + async doAxiosRequest( + type: string, + url: string, + data?: unknown, + options: AxiosRequestConfig = {}, + ): Promise { + return (await this._doRequest(type as Method, url, data, options)).body; + } + + get(url: string, params?: AxiosRequestConfig['params']) { + return this._doRequest('get', url, null, { params }).then((r) => r.body); + } + + put(url: string, data?: unknown) { + return this._doRequest('put', url, data).then((r) => r.body); + } + + post(url: string, data?: unknown) { + return this._doRequest('post', url, data).then((r) => r.body); + } + + patch(url: string, data?: unknown) { + return this._doRequest('patch', url, data).then((r) => r.body); + } + + delete(url: string, params?: AxiosRequestConfig['params']) { + return this._doRequest('delete', url, null, { params }).then((r) => r.body); + } + + sendFile( + url: string, + uri: string | NodeJS.ReadableStream | Buffer | File, + name?: string, + contentType?: string, + user?: UserResponse, + axiosRequestConfig?: AxiosRequestConfig, + ) { + const data = addFileToFormData(uri, name, contentType || 'multipart/form-data'); + if (user != null) data.append('user', JSON.stringify(user)); + + return this._doRequest('post', url, data, { + headers: data.getHeaders ? data.getHeaders() : {}, + timeout: 0, + maxContentLength: Infinity, + maxBodyLength: Infinity, + ...axiosRequestConfig, + }).then((response) => response.body); + } + + // --- private --- + + private resolveUrl(url: string, pathParams?: Record): string { + let resolved = url; + if (pathParams) { + for (const [key, value] of Object.entries(pathParams)) { + resolved = resolved.replace(`{${key}}`, encodeURIComponent(value)); + } + } + if (resolved.startsWith('/')) { + resolved = this.client.baseURL + resolved; + } + return resolved; + } + + private getNextAbortSignal(): AbortSignal | undefined { + if (!this.nextRequestAbortController) return; + + const signal = this.nextRequestAbortController.signal; + this.nextRequestAbortController = null; + return signal; + } + + populateRequestConfigWithDefaults( + additonalConfig: AxiosRequestConfig, + ): AxiosRequestConfig { + const token = this._getToken(); + const signal = this.getNextAbortSignal(); + + return { + ...additonalConfig, + headers: { + Authorization: token, + 'stream-auth-type': this.client.getAuthType(), + 'x-stream-client': this.client.getUserAgent(), + ...additonalConfig.headers, + // TODO: figure out whether this is needed, setting these at a later time (client.options.axiosRequestConfig = {...}) should probably be a setter + // that updates existing axios instance options instead + ...this.client.options.axiosRequestConfig?.headers, + 'x-client-request-id': + additonalConfig.headers?.['x-client-request-id'] || randomId(), + }, + params: { + user_id: this.client.userId, + api_key: this.client.key, + // TODO: figure out whether this is needed, setting these at a later time (client.options.axiosRequestConfig = {...}) should probably be a setter + // that updates existing axios instance options instead + ...this.client.options.axiosRequestConfig?.params, + ...additonalConfig.params, + connection_id: + additonalConfig.params?.connection_id || this.client._getConnectionID(), + }, + signal, + } satisfies AxiosRequestConfig; + } + + private extractMetadata( + response: AxiosResponse, + clientRequestId: string, + ): RequestMetadata { + const headers = response.headers || {}; + const rateLimit: RateLimit = {}; + + const limit = headers['x-ratelimit-limit'] as string | undefined; + if (limit) rateLimit.rate_limit = parseInt(limit, 10); + + const remaining = headers['x-ratelimit-remaining'] as string | undefined; + if (remaining) rateLimit.rate_limit_remaining = parseInt(remaining, 10); + + const reset = headers['x-ratelimit-reset'] as string | undefined; + if (reset) rateLimit.rate_limit_reset = new Date(reset); + + return { + response_headers: headers as Record, + rate_limit: rateLimit, + response_code: response.status, + client_request_id: clientRequestId, + }; + } + + private async _doRequest( + type: Method, + url: string, + data?: unknown | null, + additionalConfig: AxiosRequestConfig = {}, + ): Promise<{ body: T; metadata: RequestMetadata }> { + const initialRequestConfig = this.populateRequestConfigWithDefaults(additionalConfig); + const clientRequestId = initialRequestConfig.headers?.[ + 'x-client-request-id' + ] as string; + + try { + const response = await runWithRetry( + async () => { + await this.client.tokenManager.tokenReady(); + + const token = this._getToken(); + + const config: AxiosRequestConfig = { + ...initialRequestConfig, + method: type, + url, + data, + }; + + if ( + token && + config.headers?.Authorization && + token !== config.headers?.Authorization + ) { + config.headers.Authorization = token; + } + + let requestResponse: AxiosResponse; + try { + requestResponse = await this.client.axiosInstance.request(config); + } catch (error) { + if (isTokenExpiredError(error)) { + logger + .withExtraTags('_doRequest') + .debug( + `The token expired on a ${type.toUpperCase()} request. Reloading the token before retrying.`, + { url, config }, + ); + this.client.tokenManager.loadToken(); + } + + throw error; + } + + return requestResponse; + }, + { + delayBetweenRetries: (attemptNumber) => retryInterval(attemptNumber + 1), + retryAttempts: 10, + isRetryable: (error) => { + if (!(error instanceof AxiosError)) return false; + + if (error.status === 429 || isTokenExpiredError(error)) return true; + + return false; + }, + }, + )(); + + return { + body: response.data, + metadata: this.extractMetadata(response, clientRequestId), + }; + } catch (error) { + if (errorIsApiError(error)) { + throw new StreamAPIError(error.response?.data.message ?? error.message, { + code: error.response?.data.code, + status: error.status, + response: error.response, + }); + } else { + throw error; + } + } + } +} + +const errorIsApiError = (error: unknown): error is AxiosError => { + if (!(error instanceof AxiosError)) return false; + + return ( + typeof (error as AxiosError).response?.data?.code === 'number' + ); +}; + +const isTokenExpiredError = (error: unknown): boolean => + errorIsApiError(error) && error.response?.data.code === chatCodes.TOKEN_EXPIRED; diff --git a/src/campaign.ts b/src/campaign.ts index 97c5084977..2b550cc55f 100644 --- a/src/campaign.ts +++ b/src/campaign.ts @@ -1,77 +1 @@ -import type { StreamChat } from './client'; -import type { CampaignData, GetCampaignOptions } from './types'; - -export class Campaign { - id: string | null; - data?: CampaignData; - client: StreamChat; - - constructor(client: StreamChat, id: string | null, data?: CampaignData) { - this.client = client; - this.id = id; - this.data = data; - } - - async create() { - const body = { - id: this.id, - message_template: this.data?.message_template, - segment_ids: this.data?.segment_ids, - sender_id: this.data?.sender_id, - sender_mode: this.data?.sender_mode, - sender_visibility: this.data?.sender_visibility, - channel_template: this.data?.channel_template, - create_channels: this.data?.create_channels, - show_channels: this.data?.show_channels, - description: this.data?.description, - name: this.data?.name, - skip_push: this.data?.skip_push, - skip_webhook: this.data?.skip_webhook, - user_ids: this.data?.user_ids, - }; - - const result = await this.client.createCampaign(body); - - this.id = result.campaign.id; - this.data = result.campaign; - return result; - } - - verifyCampaignId() { - if (!this.id) { - throw new Error( - 'Campaign id is missing. Either create the campaign using campaign.create() or set the id during instantiation - const campaign = client.campaign(id)', - ); - } - } - - async start(options?: { scheduledFor?: string; stopAt?: string }) { - this.verifyCampaignId(); - - return await this.client.startCampaign(this.id as string, options); - } - - update(data: Partial) { - this.verifyCampaignId(); - - return this.client.updateCampaign(this.id as string, data); - } - - async delete() { - this.verifyCampaignId(); - - return await this.client.deleteCampaign(this.id as string); - } - - stop() { - this.verifyCampaignId(); - - return this.client.stopCampaign(this.id as string); - } - - get(options?: GetCampaignOptions) { - this.verifyCampaignId(); - - return this.client.getCampaign(this.id as string, options); - } -} +// Campaign functionality has been moved to the server-side SDK. diff --git a/src/channel.ts b/src/channel.ts index ac81a095da..3cd142f4ab 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -10,87 +10,77 @@ import { channelHasReadEvents, formatMessage, generateChannelTempCid, + localMessageToNewMessagePayload, logChatPromiseExecution, - normalizeQuerySort, } from './utils'; import type { StreamChat } from './client'; +import { chatLoggerSystem } from './logger'; import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from './constants'; import type { AIState, APIResponse, - AscDesc, BanUserOptions, - ChannelAPIResponse, ChannelData, - ChannelFilters, - ChannelMemberAPIResponse, + ChannelGetOrCreateRequest, ChannelMemberResponse, - ChannelPushPreference, - ChannelQueryOptions, ChannelResponse, + ChannelStateResponseFields, ChannelUpdateOptions, CreateDraftResponse, - DeleteChannelAPIResponse, DeleteMessageOptions, - DraftMessagePayload, Event, EventAPIResponse, EventHandler, - EventTypes, - GetDraftResponse, - GetMultipleMessagesAPIResponse, - GetReactionsAPIResponse, + EventPayload, + EventType, GetRepliesAPIResponse, - LiveLocationPayload, + GetRepliesRequest, LocalMessage, - MarkReadOptions, - MarkUnreadOptions, - MemberFilters, - MemberSort, - Message, - MessageFilters, - MessageOptions, + MarkReadRequest, + MarkUnreadRequest, MessagePaginationOptions, + MessageRequest, MessageResponse, MessageSetType, - MuteChannelAPIResponse, - NewMemberPayload, - PartialUpdateChannel, - PartialUpdateChannelAPIResponse, - PartialUpdateMember, - PartialUpdateMemberAPIResponse, PinnedMessagePaginationOptions, PinnedMessagesSort, - PollVoteData, - QueryChannelAPIResponse, - QueryMembersOptions, - Reaction, + QueryMembersPayload, ReactionAPIResponse, - SearchAPIResponse, - SearchMessageSortBase, - SearchOptions, + ReactionResponse, SearchPayload, - SendMessageAPIResponse, SendMessageOptions, - SendReactionOptions, - StaticLocationPayload, - TruncateChannelAPIResponse, - TruncateOptions, + SharedLocation, UnBanUserOptions, - UpdateChannelAPIResponse, - UpdateChannelOptions, - UpdateLocationPayload, + UpdateChannelPartialRequest, + UpdateLiveLocationRequest, UpdateMessageOptions, UserResponse, } from './types'; -import type { Role } from './permissions'; -import type { CustomChannelData } from './custom_types'; +import type { RoleName } from './permissions'; import { StateStore } from './store'; +import type { + ChannelMemberRequest as Gen_ChannelMemberRequest, + ChannelPushPreferencesResponse as Gen_ChannelPushPreferencesResponse, + ChannelStopWatchingRequest as Gen_ChannelStopWatchingRequest, + CreateDraftRequest as Gen_CreateDraftRequest, + HideChannelRequest as Gen_HideChannelRequest, + MuteChannelRequest as Gen_MuteChannelRequest, + SendMessageRequest as Gen_SendMessageRequest, + ShowChannelRequest as Gen_ShowChannelRequest, + UnmuteChannelRequest as Gen_UnmuteChannelRequest, + UpdateChannelRequest as Gen_UpdateChannelRequest, + WSEvent, +} from './gen/models'; +import type { ChatApi } from './gen/chat/ChatApi'; +import { ChannelApi } from './gen/chat/ChannelApi'; + +const logger = chatLoggerSystem.getLogger('channel'); +const offlineDbLogger = chatLoggerSystem.getLogger('offline-db'); // todo: move to dedicated file export type SendMessageWithStateUpdateParams = { localMessage: LocalMessage; - message?: Message; + message?: MessageRequest; options?: SendMessageOptions; /** * Per-call override for the send/retry request (advanced). @@ -139,7 +129,7 @@ export type CustomDeleteMessageRequestFn = ( export type CustomMarkReadRequestFn = (params: { channel: Channel; - options?: MarkReadOptions; + options?: MarkReadRequest; }) => Promise; export type ChannelInstanceConfig = { @@ -153,20 +143,18 @@ export type ChannelInstanceConfig = { }; /** - * Channel - The Channel class manages it's own state. + * The Channel class manages its own state. */ -export class Channel { +export class Channel extends ChannelApi { _client: StreamChat; - type: string; - id: string | undefined; - data: Partial | undefined; - _data: Partial; + data: Partial | undefined; + _data: ChannelData; cid: string; /** */ - listeners: { [key: string]: (string | EventHandler)[] }; + listeners: Map>; state: ChannelState; /** - * This boolean is a vague indication of weather the channel exists on chat backend. + * This boolean is a vague indication of whether the channel exists on chat backend. * * If the value is true, then that means the channel has been initialized by either calling * channel.create() or channel.query() or channel.watch(). @@ -176,7 +164,7 @@ export class Channel { */ initialized: boolean; /** - * Indicates weather channel has been initialized by manually populating the state with some messages, members etc. + * Indicates whether channel has been initialized by manually populating the state with some messages, members etc. * Static state indicates that channel exists on backend, but is not being watched yet. */ offlineMode: boolean; @@ -184,7 +172,7 @@ export class Channel { lastTypingEvent: Date | null; isTyping: boolean; disconnected: boolean; - push_preferences?: ChannelPushPreference; + push_preferences?: Gen_ChannelPushPreferencesResponse; public readonly configState = new StateStore({}); public readonly messageComposer: MessageComposer; public readonly messageReceiptsTracker: MessageReceiptsTracker; @@ -194,14 +182,13 @@ export class Channel { public readonly cooldownTimer: CooldownTimer; /** - * constructor - Create a channel - * - * @param {StreamChat} client the chat client - * @param {string} type the type of channel - * @param {string} [id] the id of the chat - * @param {ChannelData} data any additional custom params + * Creates a `Channel` instance bound to the given chat client. * - * @return {Channel} Returns a new uninitialized channel + * @param client - The chat client. + * @param type - The type of channel. + * @param id - The ID of the chat (optional). + * @param data - Any additional custom params. + * @returns A new uninitialized channel. */ constructor( client: StreamChat, @@ -219,15 +206,15 @@ export class Channel { throw new Error(`Invalid chat id ${id}, letters, numbers and "!-_" are allowed`); } + super(client, type, id); + this._client = client; - this.type = type; - this.id = id; // used by the frontend, gets updated: - this.data = data; + this.data = data as Partial; // this._data is used for the requests... this._data = { ...data }; this.cid = `${type}:${id}`; - this.listeners = {}; + this.listeners = new Map(); // perhaps the state variable should be private this.state = new ChannelState(this); this.initialized = false; @@ -296,15 +283,19 @@ export class Channel { }, defaults: { delete: async (id, o) => { - const result = await this.getClient().deleteMessage(id, o); + const result = await this.getClient().deleteMessage({ id, ...o }); return { message: result.message }; }, send: async (m, o) => { - const result = await this.sendMessage(m, o); + const result = await this.sendMessage({ message: m, ...o }); return { message: result.message }; }, update: async (m, o) => { - const result = await this.getClient().updateMessage(m, undefined, o); + const result = await this.getClient().updateMessage({ + id: m.id, + message: localMessageToNewMessagePayload(m), + ...o, + }); return { message: result.message }; }, }, @@ -312,9 +303,9 @@ export class Channel { } /** - * getClient - Get the chat client for this channel. If client.disconnect() was called, this function will error + * Returns the chat client for this channel. Throws if `client.disconnect()` was called. * - * @return {StreamChat} + * @returns The chat client. */ getClient(): StreamChat { if (this.disconnected === true) { @@ -324,62 +315,47 @@ export class Channel { } /** - * getConfig - Get the config for this channel id (cid) + * Returns the config for this channel ID (CID). * - * @return {Record} + * @returns The channel config. */ getConfig() { const client = this.getClient(); return client.configs[this.cid]; } + _sendMessage(request: Gen_SendMessageRequest) { + return super.sendMessage(request); + } + /** - * sendMessage - Send a message to this channel + * Sends a message to this channel. * - * @param {Message} message The Message object - * @param {boolean} [options.skip_enrich_url] Do not try to enrich the URLs within message - * @param {boolean} [options.skip_push] Skip sending push notifications - * @param {boolean} [options.is_pending_message] DEPRECATED, please use `pending` instead. - * @param {boolean} [options.pending] Make this message pending - * @param {Record} [options.pending_message_metadata] Metadata for the pending message - * @param {boolean} [options.force_moderation] Apply force moderation for server-side requests - * - * @return {Promise} The Server Response + * @param request - The send message request payload, including the message body and optional flags + * such as `skip_enrich_url`, `skip_push`, and `keep_channel_hidden`. + * @returns The server response. */ - async _sendMessage(message: Message, options?: SendMessageOptions) { - return await this.getClient().post( - this._channelURL() + '/message', - { - message, - ...options, - }, - ); - } - - async sendMessage(message: Message, options?: SendMessageOptions) { + override async sendMessage(request: Gen_SendMessageRequest) { try { const offlineDb = this.getClient().offlineDb; - if (offlineDb) { - const messageId = message.id; - if (messageId) { - return await offlineDb.queueTask({ - task: { - channelId: this.id as string, - channelType: this.type, - messageId, - payload: [message, options], - type: 'send-message', - }, - }); - } + const messageId = request.message?.id; + if (offlineDb && messageId) { + return await offlineDb.queueTask>>({ + task: { + channelId: this.id as string, + channelType: this.type, + messageId, + payload: [request], + type: 'send-message', + }, + }); } } catch (error) { - this._client.logger('error', `offlineDb:send-message`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('sendMessage', this.cid) + .error('Sending the message failed.', { error }); } - return await this._sendMessage(message, options); + return await this._sendMessage(request); } /** @@ -443,12 +419,12 @@ export class Channel { /** * Upload a file to this channel’s file endpoint (multipart). Forwards to the client’s `sendFile` implementation. * - * @param uri File source: URL string, `File`, `Buffer`, or readable stream (Node). - * @param name File name sent in the multipart body. - * @param contentType MIME type; defaults are applied when omitted. - * @param user Optional user payload appended to the form as JSON. - * @param axiosRequestConfig Optional Axios per-request config, merged after upload defaults (e.g. `onUploadProgress`, `signal` from `AbortController`). - * @return Promise resolving to `{ file: string, ... }` with the CDN URL. + * @param uri - File source: URL string, `File`, `Buffer`, or readable stream (Node). + * @param name - File name sent in the multipart body (optional). + * @param contentType - MIME type; defaults are applied when omitted (optional). + * @param user - User payload appended to the form as JSON (optional). + * @param axiosRequestConfig - Axios per-request config, merged after upload defaults, e.g. `onUploadProgress`, `signal` from `AbortController` (optional). + * @returns A promise resolving to `{ file: string, ... }` with the CDN URL. */ sendFile( uri: string | NodeJS.ReadableStream | Buffer | File, @@ -457,7 +433,7 @@ export class Channel { user?: UserResponse, axiosRequestConfig?: AxiosRequestConfig, ) { - return this.getClient().sendFile( + return this.getClient().api.sendFile( `${this._channelURL()}/file`, uri, name, @@ -468,14 +444,14 @@ export class Channel { } /** - * Upload an image to this channel’s image endpoint (multipart). Uses the same transport as `sendFile`. + * Upload an image to this channel's image endpoint (multipart). Uses the same transport as `sendFile`. * - * @param uri Image source: URL string, `File`, or readable stream (Node). For `Buffer` uploads, use `sendFile` toward the channel file endpoint instead. - * @param name File name sent in the multipart body. - * @param contentType MIME type. - * @param user Optional user payload appended to the form as JSON. - * @param axiosRequestConfig Optional Axios per-request config, merged after upload defaults (e.g. `onUploadProgress`, `signal`). - * @return Promise resolving to `{ file: string, ... }` with the CDN URL. + * @param uri - Image source: URL string, `File`, or readable stream (Node). For `Buffer` uploads, use `sendFile` toward the channel file endpoint instead. + * @param name - File name sent in the multipart body (optional). + * @param contentType - MIME type (optional). + * @param user - User payload appended to the form as JSON (optional). + * @param axiosRequestConfig - Axios per-request config, merged after upload defaults, e.g. `onUploadProgress`, `signal` (optional). + * @returns A promise resolving to `{ file: string, ... }` with the CDN URL. */ sendImage( uri: string | NodeJS.ReadableStream | File, @@ -484,7 +460,7 @@ export class Channel { user?: UserResponse, axiosRequestConfig?: AxiosRequestConfig, ) { - return this.getClient().sendFile( + return this.getClient().api.sendFile( `${this._channelURL()}/image`, uri, name, @@ -495,176 +471,80 @@ export class Channel { } deleteFile(url: string) { - return this.getClient().delete(`${this._channelURL()}/file`, { url }); + return this.deleteChannelFile({ url }); } deleteImage(url: string) { - return this.getClient().delete(`${this._channelURL()}/image`, { url }); + return this.deleteChannelImage({ url }); } /** - * sendEvent - Send an event on this channel - * - * @param {Event} event for example {type: 'message.read'} + * Sends an event on this channel. * - * @return {Promise} The Server Response + * @param event - For example `{ type: 'message.read' }`. + * @returns The server response. */ - async sendEvent(event: Event) { + override async sendEvent(request: { event: Event }) { this._checkInitialized(); - return await this.getClient().post(this._channelURL() + '/event', { - event, - }); + return await super.sendEvent(request); } /** - * search - Query messages - * - * @param {MessageFilters | string} query search query or object MongoDB style filters - * @param {{client_id?: string; connection_id?: string; query?: string; message_filter_conditions?: MessageFilters}} options Option object, {user_id: 'tommaso'} + * Queries messages. * - * @return {Promise} search messages response + * @param request - The search request payload (optional). The inner `payload` accepts + * MongoDB-style filters and additional options such as `user_id`. + * @returns The search messages response. */ - async search( - query: MessageFilters | string, - options: SearchOptions & { - client_id?: string; - connection_id?: string; - message_filter_conditions?: MessageFilters; - message_options?: MessageOptions; - query?: string; - } = {}, - ) { - if (options.offset && options.next) { - throw Error(`Cannot specify offset with next`); - } - // Return a list of channels - const payload: SearchPayload = { - filter_conditions: { cid: this.cid } as ChannelFilters, - ...options, - sort: options.sort - ? normalizeQuerySort(options.sort) - : undefined, - }; - if (typeof query === 'string') { - payload.query = query; - } else if (typeof query === 'object') { - payload.message_filter_conditions = query; - } else { - throw Error(`Invalid type ${typeof query} for query parameter`); - } - // Make sure we wait for the connect promise if there is a pending one - await this.getClient().wsPromise; - - return await this.getClient().get( - this.getClient().baseURL + '/search', - { - payload, - }, - ); + async search(request?: { payload?: SearchPayload }) { + return await this.getClient().search(request); } /** - * queryMembers - Query Members + * Queries members. * - * @param {MemberFilters} filterConditions object MongoDB style filters - * @param {MemberSort} [sort] Sort options, for instance [{created_at: -1}]. - * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{name: -1}, {created_at: 1}] - * @param {{ limit?: number; offset?: number }} [options] Option object, {limit: 10, offset:10} - * - * @return {Promise} Query Members response + * @param request - The query members request payload (optional). The inner `payload` accepts + * MongoDB-style filters, sort directions (e.g. `[{ field: 'created_at', direction: -1 }]`), + * and pagination options (`limit`, `offset`). + * @returns The query members response. */ - async queryMembers( - filterConditions: MemberFilters, - sort: MemberSort = [], - options: QueryMembersOptions = {}, - ) { - let id: string | undefined; - const type = this.type; - let members: string[] | ChannelMemberResponse[] | undefined; + async queryMembers(request?: { payload?: Partial }) { + const payload = { + type: this.type, + // TODO: these should be probably optional in the OAPI spec + // filter_conditions: ... + } as QueryMembersPayload; + if (this.id) { - id = this.id; - } else if (this.data?.members && Array.isArray(this.data.members)) { - members = this.data.members; + payload.id = this.id; + } else if (Array.isArray(this.data?.members)) { + payload.members = this.data.members.map((m) => ({ + ...m, + // TODO: this should not be needed Gen_QueryMembersResponse should not come with user_id as optinal + user_id: (m.user_id ?? m.user?.id) as string, + })); } // Return a list of members - return await this.getClient().get( - this.getClient().baseURL + '/members', - { - payload: { - type, - id, - members, - sort: normalizeQuerySort(sort), - filter_conditions: filterConditions, - ...options, - }, + return await this.getClient().queryMembers({ + payload: { + ...payload, + ...request?.payload, }, - ); - } - - /** - * updateMemberPartial - Partial update a member - * - * @param {PartialUpdateMember} updates - * @param {{ user_id?: string }} [options] Option object, {user_id: 'jane'} to optionally specify the user id - - * @return {Promise} Updated member - */ - async updateMemberPartial(updates: PartialUpdateMember, options?: { userId?: string }) { - const url = new URL(`${this._channelURL()}/member`); - - if (options?.userId) { - url.searchParams.append('user_id', options.userId); - } - - return await this.getClient().patch( - url.toString(), - updates, - ); - } - - /** - * @deprecated Use `updateMemberPartial` instead - * partialUpdateMember - Partial update a member - * - * @param {string} user_id member user id - * @param {PartialUpdateMember} updates - * - * @return {Promise} Updated member - */ - async partialUpdateMember(user_id: string, updates: PartialUpdateMember) { - if (!user_id) { - throw Error('Please specify the user id'); - } - - return await this.getClient().patch( - this._channelURL() + `/member/${encodeURIComponent(user_id)}`, - updates, - ); + }); } /** - * sendReaction - Sends a reaction to a message. If offline support is enabled, it will make sure + * Sends a reaction to a message. If offline support is enabled, it will make sure * that sending the reaction is queued up if it fails due to bad internet conditions and executed * later. * - * @param {string} messageID the message id - * @param {Reaction} reaction the reaction object for instance {type: 'love'} - * @param {{ enforce_unique?: boolean, skip_push?: boolean }} [options] Option object, {enforce_unique: true, skip_push: true} to override any existing reaction or skip sending push notifications - * - * @return {Promise} The Server Response + * @param request - The send-reaction request payload, including the target message ID, the + * reaction object (e.g. `{ type: 'love' }`), and optional flags such as `enforce_unique` and + * `skip_push`. + * @returns The server response. */ - async sendReaction( - messageID: string, - reaction: Reaction, - options?: SendReactionOptions, - ) { - if (!messageID) { - throw Error(`Message id is missing`); - } - if (!reaction || Object.keys(reaction).length === 0) { - throw Error(`Reaction object is missing`); - } + async sendReaction(request: Parameters[0]) { + const { id: messageId } = request; try { const offlineDb = this.getClient().offlineDb; @@ -673,71 +553,36 @@ export class Channel { task: { channelId: this.id as string, channelType: this.type, - messageId: messageID, - payload: [messageID, reaction, options], + messageId, + payload: [request], type: 'send-reaction', }, }); } } catch (error) { - this._client.logger('error', `offlineDb:send-reaction`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('sendReaction', this.cid) + .error('Sending the reaction failed.', { error }); } - return this._sendReaction(messageID, reaction, options); + return this._sendReaction(request); } - /** - * sendReaction - Send a reaction about a message - * - * @param {string} messageID the message id - * @param {Reaction} reaction the reaction object for instance {type: 'love'} - * @param {{ enforce_unique?: boolean, skip_push?: boolean }} [options] Option object, {enforce_unique: true, skip_push: true} to override any existing reaction or skip sending push notifications - * - * @return {Promise} The Server Response - */ - async _sendReaction( - messageID: string, - reaction: Reaction, - options?: SendReactionOptions, - ) { - if (!messageID) { - throw Error(`Message id is missing`); - } - if (!reaction || Object.keys(reaction).length === 0) { - throw Error(`Reaction object is missing`); - } - - return await this.getClient().post( - this.getClient().baseURL + `/messages/${encodeURIComponent(messageID)}/reaction`, - { - reaction, - ...options, - }, - ); + _sendReaction(request: Parameters[0]) { + return this.getClient().sendReaction(request); } - async deleteReaction(messageID: string, reactionType: string, user_id?: string) { + async deleteReaction(request: Parameters[0]) { this._checkInitialized(); - if (!reactionType || !messageID) { - throw Error( - 'Deleting a reaction requires specifying both the message and reaction type', - ); - } try { const offlineDb = this.getClient().offlineDb; if (offlineDb) { - const message = this.messagePaginator.getItem(messageID); + const message = this.messagePaginator.getItem(request.id); const reaction = { - created_at: '', - updated_at: '', - message_id: messageID, - type: reactionType, - user_id: (this.getClient().userID as string) ?? user_id, - }; + message_id: request.id, + type: request.type, + } as ReactionResponse; if (message) { await offlineDb.deleteReaction({ @@ -750,178 +595,108 @@ export class Channel { task: { channelId: this.id as string, channelType: this.type, - messageId: messageID, - payload: [messageID, reactionType], + messageId: request.id, + payload: [request], type: 'delete-reaction', }, }); } } catch (error) { - this._client.logger('error', `offlineDb:delete-reaction`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('deleteReaction', this.cid) + .error('Deleting the reaction failed.', { error }); } - return await this._deleteReaction(messageID, reactionType, user_id); + return await this._deleteReaction(request); } /** - * deleteReaction - Delete a reaction by user and type - * - * @param {string} messageID the id of the message from which te remove the reaction - * @param {string} reactionType the type of reaction that should be removed - * @param {string} [user_id] the id of the user (used only for server side request) default null + * Deletes a reaction by user and type. * - * @return {Promise} The Server Response + * @param request - The delete reaction request payload identifying the target message and reaction type. + * @returns The server response. */ - async _deleteReaction(messageID: string, reactionType: string, user_id?: string) { - this._checkInitialized(); - if (!reactionType || !messageID) { - throw Error( - 'Deleting a reaction requires specifying both the message and reaction type', - ); - } - - const url = - this.getClient().baseURL + - `/messages/${encodeURIComponent(messageID)}/reaction/${encodeURIComponent( - reactionType, - )}`; - //provided when server side request - if (user_id) { - return await this.getClient().delete(url, { user_id }); - } - - return await this.getClient().delete(url, {}); + async _deleteReaction(request: Parameters[0]) { + return await this.getClient().deleteReaction(request); } /** - * update - Edit the channel's custom properties + * Edit the channel using the inherited `update()` from `ChannelApi`. Caches the + * server-returned channel onto `this.data`. * - * @param {ChannelData} channelData The object to update the custom properties of this channel with - * @param {Message} [updateMessage] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param request - Channel update payload, e.g. `{ data: { name: 'foo' }, message }` (optional). + * @returns The server response. */ - async update( - channelData: Partial = {}, - updateMessage?: Message, - options?: ChannelUpdateOptions, - ) { - // Strip out reserved names that will result in API errors. - // TODO: this needs to be typed better - const reserved: Exclude< - keyof (ChannelResponse & ChannelData), - keyof CustomChannelData - >[] = [ - 'config', - 'cid', - 'created_by', - 'id', - 'member_count', - 'type', - 'created_at', - 'updated_at', - 'last_message_at', - 'own_capabilities', - ]; - - reserved.forEach((key) => { - delete channelData[key]; - }); - - return await this._update({ - message: updateMessage, - data: channelData, - ...options, - }); + override async update(request?: Gen_UpdateChannelRequest) { + const previousData = this.data; + const data = await super.update(request); + this.data = data.channel; + this._syncStateFromChannelData(this.data, previousData); + return data; } /** - * updatePartial - partial update channel properties - * - * @param {PartialUpdateChannel} partial update request + * Partial update of channel properties. * - * @return {Promise} + * @param update - The partial update request. + * @returns The server response. */ - async updatePartial(update: PartialUpdateChannel) { - const data = await this.getClient().patch( - this._channelURL(), - update, - ); + async updatePartial(update: UpdateChannelPartialRequest) { + const data = await this.updateChannelPartial(update); + + if (!this.getClient()._cacheEnabled) return data; + + const channel = data.channel; + const currentCapabilities = this.data?.own_capabilities ?? []; + const newCapabilities = channel?.own_capabilities; + + const capabilitiesChanged = + newCapabilities && + [...currentCapabilities].sort().join() !== [...newCapabilities].sort().join(); - const areCapabilitiesChanged = - [...(data.channel.own_capabilities || [])].sort().join() !== - [ - ...(Array.isArray(this.data?.own_capabilities) - ? (this.data?.own_capabilities as string[]) - : []), - ] - .sort() - .join(); const previousData = this.data; - this.data = data.channel; + this.data = channel; this._syncStateFromChannelData(this.data, previousData); // If the capabiltities are changed, we trigger the `capabilities.changed` event. - if (areCapabilitiesChanged) { + if (capabilitiesChanged) { this.getClient().dispatchEvent({ type: 'capabilities.changed', cid: this.cid, - own_capabilities: data.channel.own_capabilities, + own_capabilities: newCapabilities, }); } + return data; } /** - * enableSlowMode - enable slow mode + * Enables slow mode. * - * @param {number} coolDownInterval the cooldown interval in seconds - * @return {Promise} The server response + * @param coolDownInterval - The cooldown interval in seconds. + * @returns The server response. */ async enableSlowMode(coolDownInterval: number) { - const data = await this.getClient().post( - this._channelURL(), - { - cooldown: coolDownInterval, - }, - ); - const previousData = this.data; - this.data = data.channel; - this._syncStateFromChannelData(this.data, previousData); - return data; + return await this.update({ cooldown: coolDownInterval }); } /** - * disableSlowMode - disable slow mode + * Disables slow mode. * - * @return {Promise} The server response + * @returns The server response. */ async disableSlowMode() { - const data = await this.getClient().post( - this._channelURL(), - { - cooldown: 0, - }, - ); - const previousData = this.data; - this.data = data.channel; - this._syncStateFromChannelData(this.data, previousData); - return data; + return await this.update({ cooldown: 0 }); } - public async sendSharedLocation( - location: StaticLocationPayload | LiveLocationPayload, - userId?: string, - ) { + public async sendSharedLocation(location: SharedLocation & { message_id?: string }) { const result = await this.sendMessage({ - id: location.message_id, - shared_location: location, - user: userId ? { id: userId } : undefined, + message: { + id: location.message_id, + shared_location: location, + }, }); - if ((location as LiveLocationPayload).end_at) { + if (location.end_at) { this.getClient().dispatchEvent({ message: result.message, type: 'live_location_sharing.started', @@ -931,10 +706,10 @@ export class Channel { return result; } - public async stopLiveLocationSharing(payload: UpdateLocationPayload) { - const location = await this.getClient().updateLocation({ + public async stopLiveLocationSharing(payload: UpdateLiveLocationRequest) { + const location = await this.getClient().updateLiveLocation({ ...payload, - end_at: new Date().toISOString(), + end_at: new Date(), }); this.getClient().dispatchEvent({ live_location: location, @@ -943,361 +718,284 @@ export class Channel { } /** - * delete - Delete the channel. Messages are permanently removed. - * - * @param {boolean} [options.hard_delete] Defines if the channel is hard deleted or not + * Accepts an invitation to the channel. * - * @return {Promise} The server response + * @param options - The object to update the custom properties of this channel with (optional, defaults to `{}`). + * @returns The server response. */ - async delete(options: { hard_delete?: boolean } = {}) { - return await this.getClient().delete(this._channelURL(), { - ...options, - }); - } - - /** - * truncate - Removes all messages from the channel - * @param {TruncateOptions} [options] Defines truncation options - * @return {Promise} The server response - */ - async truncate(options: TruncateOptions = {}) { - return await this.getClient().post( - this._channelURL() + '/truncate', - options, - ); + async acceptInvite(options: ChannelUpdateOptions = {}) { + return await this.update({ accept_invite: true, ...options }); } /** - * acceptInvite - accept invitation to the channel - * - * @param {UpdateChannelOptions} [options] The object to update the custom properties of this channel with + * Rejects an invitation to the channel. * - * @return {Promise} The server response + * @param options - The object to update the custom properties of this channel with (optional, defaults to `{}`). + * @returns The server response. */ - async acceptInvite(options: UpdateChannelOptions = {}) { - return await this._update({ accept_invite: true, ...options }); + async rejectInvite(options: ChannelUpdateOptions = {}) { + return await this.update({ reject_invite: true, ...options }); } /** - * rejectInvite - reject invitation to the channel + * Adds members to the channel. * - * @param {UpdateChannelOptions} [options] The object to update the custom properties of this channel with - * - * @return {Promise} The server response - */ - async rejectInvite(options: UpdateChannelOptions = {}) { - return await this._update({ reject_invite: true, ...options }); - } - - /** - * addMembers - add members to the channel - * - * @param {string[] | Array} members An array of members to add to the channel - * @param {Message} [message] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param members - An array of members to add to the channel. + * @param message - Message object for channel members notification (optional). + * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @returns The server response. */ async addMembers( - members: string[] | Array, - message?: Message, + members: string[] | Array, + message?: MessageRequest, options: ChannelUpdateOptions = {}, ) { - return await this._update({ add_members: members, message, ...options }); + return await this.update({ + add_members: members.map((member) => + typeof member === 'string' ? { user_id: member } : member, + ), + message, + ...options, + }); } /** - * addFilterTags - add filter tags to the channel + * Adds filter tags to the channel. * - * @param {string[]} tags An array of tags to add to the channel - * @param {Message} [message] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param tags - An array of tags to add to the channel. + * @param message - Message object for channel members notification (optional). + * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @returns The server response. */ async addFilterTags( tags: string[], - message?: Message, + message?: MessageRequest, options: ChannelUpdateOptions = {}, ) { - return await this._update({ add_filter_tags: tags, message, ...options }); + return await this.update({ add_filter_tags: tags, message, ...options }); } /** - * removeFilterTags - remove filter tags from the channel + * Removes filter tags from the channel. * - * @param {string[]} tags An array of tags to remove from the channel - * @param {Message} [message] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param tags - An array of tags to remove from the channel. + * @param message - Message object for channel members notification (optional). + * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @returns The server response. */ async removeFilterTags( tags: string[], - message?: Message, + message?: MessageRequest, options: ChannelUpdateOptions = {}, ) { - return await this._update({ remove_filter_tags: tags, message, ...options }); + return await this.update({ remove_filter_tags: tags, message, ...options }); } /** - * addModerators - add moderators to the channel + * Adds moderators to the channel. * - * @param {string[]} members An array of member identifiers - * @param {Message} [message] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param members - An array of member identifiers. + * @param message - Message object for channel members notification (optional). + * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @returns The server response. */ async addModerators( members: string[], - message?: Message, + message?: MessageRequest, options: ChannelUpdateOptions = {}, ) { - return await this._update({ add_moderators: members, message, ...options }); + return await this.update({ add_moderators: members, message, ...options }); } /** - * assignRoles - sets member roles in a channel + * Sets member roles in a channel. * - * @param {{channel_role: Role, user_id: string}[]} roles List of role assignments - * @param {Message} [message] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param roles - List of role assignments. + * @param message - Message object for channel members notification (optional). + * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @returns The server response. */ async assignRoles( - roles: { channel_role: Role; user_id: string }[], - message?: Message, + roles: { channel_role: RoleName; user_id: string }[], + message?: MessageRequest, options: ChannelUpdateOptions = {}, ) { - return await this._update({ assign_roles: roles, message, ...options }); + return await this.update({ assign_roles: roles, message, ...options }); } /** - * inviteMembers - invite members to the channel + * Invite members to the channel. * - * @param {string[] | Array} members An array of members to invite to the channel - * @param {Message} [message] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param members - An array of members to invite to the channel. + * @param message - Message object for channel members notification (optional). + * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @returns The server response. */ async inviteMembers( - members: string[] | Required>[], - message?: Message, + members: string[] | Required>[], + message?: MessageRequest, options: ChannelUpdateOptions = {}, ) { - return await this._update({ invites: members, message, ...options }); + return await this.update({ + invites: members.map((member) => + typeof member === 'string' ? { user_id: member } : member, + ), + message, + ...options, + }); } /** - * removeMembers - remove members from channel + * Removes members from the channel. * - * @param {string[]} members An array of member identifiers - * @param {Message} [message] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param members - An array of member identifiers. + * @param message - Message object for channel members notification (optional). + * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @returns The server response. */ async removeMembers( members: string[], - message?: Message, + message?: MessageRequest, options: ChannelUpdateOptions = {}, ) { - return await this._update({ remove_members: members, message, ...options }); + return await this.update({ remove_members: members, message, ...options }); } /** - * demoteModerators - remove moderator role from channel members + * Removes the moderator role from channel members. * - * @param {string[]} members An array of member identifiers - * @param {Message} [message] Optional message object for channel members notification - * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating - * @return {Promise} The server response + * @param members - An array of member identifiers. + * @param message - Message object for channel members notification (optional). + * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`). + * @returns The server response. */ async demoteModerators( members: string[], - message?: Message, + message?: MessageRequest, options: ChannelUpdateOptions = {}, ) { - return await this._update({ demote_moderators: members, message, ...options }); - } - - /** - * _update - executes channel update request - * @param payload Object Update Channel payload - * @return {Promise} The server response - * TODO: introduce new type instead of Object in the next major update - */ - async _update(payload: object) { - const data = await this.getClient().post( - this._channelURL(), - payload, - ); - const previousData = this.data; - this.data = data.channel; - this._syncStateFromChannelData(this.data, previousData); - return data; + return await this.update({ demote_moderators: members, message, ...options }); } /** - * mute - mutes the current channel - * @param {{ user_id?: string, expiration?: string }} opts expiration in minutes or user_id - * @return {Promise} The server response + * Mutes the current channel. * - * example with expiration: - * await channel.mute({expiration: moment.duration(2, 'weeks')}); + * @example + * // with expiration + * await channel.mute({ expiration: moment.duration(2, 'weeks') }); * - * example server side: - * await channel.mute({user_id: userId}); + * @example + * // server side + * await channel.mute({ user_id: userId }); * + * @param options - Mute options (optional, defaults to `{}`). + * @param options.expiration - Expiration in minutes (optional). + * @returns The server response. */ - async mute(opts: { expiration?: number; user_id?: string } = {}) { - return await this.getClient().post( - this.getClient().baseURL + '/moderation/mute/channel', - { - channel_cid: this.cid, - ...opts, - }, - ); + async mute(options?: Gen_MuteChannelRequest) { + return await this.getClient().muteChannel({ + channel_cids: [this.cid], + ...options, + }); } /** - * unmute - mutes the current channel - * @param {{ user_id?: string}} opts user_id - * @return {Promise} The server response + * Unmutes the current channel. + * + * @example + * // server side + * await channel.unmute({ user_id: userId }); * - * example server side: - * await channel.unmute({user_id: userId}); + * @param options - Unmute options (optional, defaults to `{}`). + * @param options.user_id - User ID (optional). + * @returns The server response. */ - async unmute(opts: { user_id?: string } = {}) { - return await this.getClient().post( - this.getClient().baseURL + '/moderation/unmute/channel', - { - channel_cid: this.cid, - ...opts, - }, - ); + async unmute(options?: Gen_UnmuteChannelRequest) { + return await this.getClient().unmuteChannel({ + channel_cids: [this.cid], + ...options, + }); } /** - * archive - archives the current channel - * @param {{ user_id?: string }} opts user_id if called server side - * @return {Promise} The server response - * - * example: - * await channel.archives(); + * Archives the current channel. * - * example server side: - * await channel.archive({user_id: userId}); + * @example + * await channel.archive(); * + * @returns The server response. */ - async archive(opts: { user_id?: string } = {}) { - const cli = this.getClient(); - const uid = opts.user_id || cli.userID; - if (!uid) { - throw Error('A user_id is required for archiving a channel'); - } - const resp = await this.partialUpdateMember(uid, { set: { archived: true } }); - return resp.channel_member; + async archive() { + return await this.updateMemberPartial({ set: { archived: true } }); } /** - * unarchive - unarchives the current channel - * @param {{ user_id?: string }} opts user_id if called server side - * @return {Promise} The server response + * Unarchives the current channel. * - * example: + * @example * await channel.unarchive(); * - * example server side: - * await channel.unarchive({user_id: userId}); - * + * @returns The server response. */ - async unarchive(opts: { user_id?: string } = {}) { - const cli = this.getClient(); - const uid = opts.user_id || cli.userID; - if (!uid) { - throw Error('A user_id is required for unarchiving a channel'); - } - const resp = await this.partialUpdateMember(uid, { set: { archived: false } }); - return resp.channel_member; + async unarchive() { + return await this.updateMemberPartial({ set: { archived: false } }); } /** - * pin - pins the current channel - * @param {{ user_id?: string }} opts user_id if called server side - * @return {Promise} The server response + * Pins the current channel. * - * example: + * @example * await channel.pin(); * - * example server side: - * await channel.pin({user_id: userId}); - * + * @returns The server response. */ - async pin(opts: { user_id?: string } = {}) { - const cli = this.getClient(); - const uid = opts.user_id || cli.userID; - if (!uid) { - throw new Error('A user_id is required for pinning a channel'); - } - const resp = await this.partialUpdateMember(uid, { set: { pinned: true } }); - return resp.channel_member; + async pin() { + return await this.updateMemberPartial({ set: { pinned: true } }); } /** - * unpin - unpins the current channel - * @param {{ user_id?: string }} opts user_id if called server side - * @return {Promise} The server response + * Unpins the current channel. * - * example: + * @example * await channel.unpin(); * - * example server side: - * await channel.unpin({user_id: userId}); - * + * @returns The server response. */ - async unpin(opts: { user_id?: string } = {}) { - const cli = this.getClient(); - const uid = opts.user_id || cli.userID; - if (!uid) { - throw new Error('A user_id is required for unpinning a channel'); - } - const resp = await this.partialUpdateMember(uid, { set: { pinned: false } }); - return resp.channel_member; + async unpin() { + return await this.updateMemberPartial({ set: { pinned: false } }); } /** - * muteStatus - returns the mute status for the current channel - * @return {{ muted: boolean; createdAt: Date | null; expiresAt: Date | null }} { muted: true | false, createdAt: Date | null, expiresAt: Date | null} + * Returns the mute status for the current channel. + * + * @returns An object of the form `{ muted: true | false, createdAt: Date | null, expiresAt: Date | null }`. */ - muteStatus(): { - createdAt: Date | null; - expiresAt: Date | null; - muted: boolean; - } { + muteStatus() { this._checkInitialized(); return this.getClient()._muteStatus(this.cid); } - sendAction(messageID: string, formData: Record) { + sendAction(messageId: string, formData: Record) { this._checkInitialized(); - if (!messageID) { - throw Error(`Message id is missing`); + if (!messageId) { + throw Error(`MessageRequest id is missing`); } - return this.getClient().post( - this.getClient().baseURL + `/messages/${encodeURIComponent(messageID)}/action`, - { - message_id: messageID, - form_data: formData, - id: this.id, - type: this.type, - }, - ); + return this.getClient().runMessageAction({ + id: messageId, + form_data: formData, + }); } /** - * keystroke - First of the typing.start and typing.stop events based on the users keystrokes. - * Call this on every keystroke + * First of the `typing.start` and `typing.stop` events based on the user's keystrokes. + * Call this on every keystroke. + * * @see {@link https://getstream.io/chat/docs/typing_indicators/?language=js|Docs} - * @param {string} [parent_id] set this field to `message.id` to indicate that typing event is happening in a thread + * + * @param parentId - Set this field to `message.id` to indicate that the typing event is happening in a thread (optional). + * @param options - Optional override carrying a `user_id` (optional). */ - async keystroke(parent_id?: string, options?: { user_id: string }) { + async keystroke(parentId?: string, options?: { user_id: string }) { if (!this._isTypingIndicatorsEnabled()) { return; } @@ -1309,10 +1007,14 @@ export class Channel { if (diff === null || diff > 2000) { this.lastTypingEvent = new Date(); await this.sendEvent({ - type: 'typing.start', - parent_id, - ...(options || {}), - } as Event); + event: { + type: 'typing.start', + parent_id: parentId, + ...(options || {}), + created_at: new Date(), + custom: {}, + }, + }); } } @@ -1321,8 +1023,9 @@ export class Channel { * Typically used by the server connected to the AI service to notify clients of state changes. * * @param messageId - The ID of the message associated with the AI state. - * @param state - The new state of the AI process (e.g., thinking, generating). - * @param options - Optional parameters, such as `ai_message`, to include additional details in the event. + * @param state - The new state of the AI process, e.g. thinking, generating. + * @param options - Parameters such as `ai_message` to include additional details in the event (optional, defaults to `{}`). + * @param options.ai_message - Additional message detail to include in the event (optional). */ async updateAIState( messageId: string, @@ -1330,11 +1033,15 @@ export class Channel { options: { ai_message?: string } = {}, ) { await this.sendEvent({ - ...options, - type: 'ai_indicator.update', - message_id: messageId, - ai_state: state, - } as Event); + event: { + ...options, + type: 'ai_indicator.update', + message_id: messageId, + ai_state: state, + created_at: new Date(), + custom: {}, + }, + }); } /** @@ -1343,8 +1050,12 @@ export class Channel { */ async clearAIIndicator() { await this.sendEvent({ - type: 'ai_indicator.clear', - } as Event); + event: { + type: 'ai_indicator.clear', + created_at: new Date(), + custom: {}, + }, + }); } /** @@ -1353,26 +1064,37 @@ export class Channel { */ async stopAIResponse() { await this.sendEvent({ - type: 'ai_indicator.stop', - } as Event); + event: { + type: 'ai_indicator.stop', + created_at: new Date(), + custom: {}, + }, + }); } /** - * stopTyping - Sets last typing to null and sends the typing.stop event + * Sets last typing to null and sends the `typing.stop` event. + * * @see {@link https://getstream.io/chat/docs/typing_indicators/?language=js|Docs} - * @param {string} [parent_id] set this field to `message.id` to indicate that typing event is happening in a thread + * + * @param parentId - Set this field to `message.id` to indicate that the typing event is happening in a thread (optional). + * @param options - Optional override carrying a `user_id` (optional). */ - async stopTyping(parent_id?: string, options?: { user_id: string }) { + async stopTyping(parentId?: string, options?: { user_id: string }) { if (!this._isTypingIndicatorsEnabled()) { return; } this.lastTypingEvent = null; this.isTyping = false; await this.sendEvent({ - type: 'typing.stop', - parent_id, - ...(options || {}), - } as Event); + event: { + type: 'typing.stop', + parent_id: parentId, + ...(options || {}), + created_at: new Date(), + custom: {}, + }, + }); } _isTypingIndicatorsEnabled(): boolean { @@ -1383,53 +1105,53 @@ export class Channel { } /** - * markRead - Send the mark read event for this user, only works if the `read_events` setting is enabled. Syncs the message delivery report candidates local state. + * Run this user's mark-read reporter for this channel. Delegates to + * `MessageDeliveryReporter`, which batches the underlying `markRead` request + * with the user's read receipts state. + * + * Use the inherited `markRead()` from `ChannelApi` for a direct, unbatched call. * - * @param {MarkReadOptions} data - * @return {Promise} Description + * @param data - Mark read options (optional, defaults to `{}`). */ - async markRead(data: MarkReadOptions = {}) { + async markReadViaReporter(data: MarkReadRequest = {}) { return await this.getClient().messageDeliveryReporter.markRead(this, data); } /** - * markAsReadRequest - Send the mark read event for this user, only works if the `read_events` setting is enabled + * Override of the inherited `markRead()` from `ChannelApi` that requires the + * channel to be initialized and respects the `read_events` channel config. * - * @param {MarkReadOptions} data - * @return {Promise} Description + * @param data - Mark read options (optional, defaults to `{}`). + * @returns The server response, or `null` if the request was skipped. */ - async markAsReadRequest(data: MarkReadOptions = {}) { + override async markRead(data?: MarkReadRequest) { this._checkInitialized(); - if (!this.getConfig()?.read_events && !this.getClient()._isUsingServerAuth()) { - return null; + if (!this.getConfig()?.read_events) { + throw new Error('Read events are disabled for this application'); } - return await this.getClient().post(this._channelURL() + '/read', { - ...data, - }); + return await super.markRead(data); } /** - * markUnread - Mark the channel as unread from messageID, only works if the `read_events` setting is enabled + * Marks the channel as unread from `messageId`. Only works when the `read_events` setting is enabled. * - * @param {MarkUnreadOptions} data - * @return {APIResponse} An API response + * @param data - Mark unread options. + * @returns An API response, or `null` if the request was skipped. */ - async markUnread(data: MarkUnreadOptions) { + override async markUnread(data?: MarkUnreadRequest) { this._checkInitialized(); - if (!this.getConfig()?.read_events && !this.getClient()._isUsingServerAuth()) { - return Promise.resolve(null); + if (!this.getConfig()?.read_events) { + throw new Error('Read events are disabled for this application'); } - return await this.getClient().post(this._channelURL() + '/unread', { - ...data, - }); + return await super.markUnread(data); } /** - * markReadLocally - Resets this user's unread count locally, without any backend call. Intended for + * Resets this user's unread count locally, without any backend call. Intended for * channels that have read events disabled (e.g. livestreams) when the client is created with the * `isLocalUnreadCountEnabled` option. Dispatches a dedicated, client-only `message.read_locally` event * that runs through the same `_handleChannelEvent` read logic as a real `message.read` (minus the @@ -1437,21 +1159,21 @@ export class Channel { * is enabled, the offline DB persists the reset for read-events-disabled channels, so the local * count stays consistent across app restarts. * - * @return {Event | undefined} The dispatched `message.read_locally` event, or `undefined` if there is no connected user. + * @returns The dispatched `message.read_locally` event, or `undefined` if there is no connected user. */ markReadLocally() { const client = this.getClient(); - if (!client.userID) return; + if (!client.userId) return; - const event: Event = { + const event: EventPayload<'message.read_locally'> = { channel_id: this.id, channel_type: this.type, cid: this.cid, - created_at: new Date().toISOString(), + created_at: new Date(), last_read_message_id: this.messagePaginator.headmostItem?.id, team: this.data?.team, type: 'message.read_locally', - user: client.user, + user: client.user as UserResponse, }; client.dispatchEvent(event); @@ -1459,7 +1181,7 @@ export class Channel { } /** - * clean - Cleans the channel state and fires stop typing if needed + * Cleans the channel state and fires stop typing if needed. */ clean() { if (this.lastKeyStroke) { @@ -1474,13 +1196,12 @@ export class Channel { } /** - * watch - Loads the initial channel state and watches for changes + * Loads the initial channel state and watches for changes. * - * @param {ChannelQueryOptions} options additional options for the query endpoint - * - * @return {Promise} The server response + * @param options - Additional options for the query endpoint (optional). + * @returns The server response. */ - async watch(options?: ChannelQueryOptions) { + async watch(options?: ChannelGetOrCreateRequest) { const defaultOptions = { state: true, watch: true, @@ -1505,133 +1226,94 @@ export class Channel { // so a channel opened via watch() alone — a deep-link restore, a search result, a freshly // created DM — already has its latest page loaded here. - this._client.logger( - 'info', - `channel:watch() - started watching channel ${this.cid}`, - { - tags: ['channel'], - channel: this, - }, - ); + logger.withExtraTags('watch', this.cid).info('Started watching the channel.'); return state; } /** - * stopWatching - Stops watching the channel + * Stops watching the channel. * - * @return {Promise} The server response + * @param request - The stop-watching request payload (optional). + * @returns The server response. */ - async stopWatching() { - const response = await this.getClient().post( - this._channelURL() + '/stop-watching', - {}, - ); + override async stopWatching(request?: Gen_ChannelStopWatchingRequest) { + const response = await super.stopWatching(request); - this._client.logger( - 'info', - `channel:watch() - stopped watching channel ${this.cid}`, - { - tags: ['channel'], - channel: this, - }, - ); + logger.withExtraTags('stopWatching', this.cid).info('Stopped watching the channel.'); return response; } /** - * getReplies - List the message replies for a parent message. + * List the message replies for a parent message. * - * The recommended way of working with threads is to use the Thread class. + * The recommended way of working with threads is to use the `Thread` class. * - * @param {string} parent_id The message parent id, ie the top of the thread - * @param {MessagePaginationOptions & { user?: UserResponse; user_id?: string }} options Pagination params, ie {limit:10, id_lte: 10} - * - * @return {Promise} A response with a list of messages + * @param request - The get-replies request payload, including the parent message ID, pagination + * params, and optional sort directions for `created_at`. + * @returns A response with a list of messages. */ - async getReplies( - parent_id: string, - options: MessagePaginationOptions & { user?: UserResponse; user_id?: string }, - sort?: { created_at: AscDesc }[], - ) { - const normalizedSort = sort ? normalizeQuerySort(sort) : undefined; - const data = await this.getClient().get( - this.getClient().baseURL + `/messages/${encodeURIComponent(parent_id)}/replies`, - { - sort: normalizedSort, - ...options, - }, - ); + async getReplies(request: GetRepliesRequest) { + const data = await this.getClient().getReplies(request); // Thread reply state is owned by the Thread object (Thread.messagePaginator); the returned // replies are consumed there. The channel message list is owned by channel.messagePaginator. return data; } + // TODO: find out v2 equivalent /** - * getPinnedMessages - List list pinned messages of the channel - * - * @param {PinnedMessagePaginationOptions & { user?: UserResponse; user_id?: string }} options Pagination params, ie {limit:10, id_lte: 10} - * @param {PinnedMessagesSort} sort defines sorting direction of pinned messages + * List pinned messages of the channel. * - * @return {Promise} A response with a list of messages + * @param options - Pagination params, e.g. `{ limit: 10, id_lte: 10 }`. + * @param sort - Defines sorting direction of pinned messages (optional, defaults to `[]`). + * @returns A response with a list of messages. */ async getPinnedMessages( - options: PinnedMessagePaginationOptions & { user?: UserResponse; user_id?: string }, + options: PinnedMessagePaginationOptions, sort: PinnedMessagesSort = [], ) { - return await this.getClient().get( + return await this.getClient().api.get( this._channelURL() + '/pinned_messages', { payload: { ...options, - sort: normalizeQuerySort(sort), + sort, }, }, ); } /** - * getReactions - List the reactions, supports pagination + * List the reactions; supports pagination. * - * @param {string} message_id The message id - * @param {{ limit?: number; offset?: number }} options The pagination options - * - * @return {Promise} Server response + * @param request - The request payload, including the target message ID and + * pagination options (`limit`, `offset`). + * @returns The server response. */ - getReactions(message_id: string, options: { limit?: number; offset?: number }) { - return this.getClient().get( - this.getClient().baseURL + `/messages/${encodeURIComponent(message_id)}/reactions`, - { - ...options, - }, - ); + getReactions(request: Parameters[0]) { + return this.getClient().getReactions(request); } /** - * getMessagesById - Retrieves a list of messages by ID + * Retrieves a list of messages by ID. * - * @param {string[]} messageIds The ids of the messages to retrieve from this channel - * - * @return {Promise} Server response + * @param messageIds - The IDs of the messages to retrieve from this channel. + * @returns Server response. */ getMessagesById(messageIds: string[]) { - return this.getClient().get( - this._channelURL() + '/messages', - { - ids: messageIds.join(','), - }, - ); + return this.getManyMessages({ ids: messageIds }); } /** - * lastRead - returns the last time the user marked the channel as read if the user never marked the channel as read, this will return null - * @return {Date | null | undefined} + * Returns the last time the user marked the channel as read. If the user never marked the channel as read, this will return `null`. + * + * @returns The last-read `Date`, `null` if never read, or `undefined` if the user is unset. */ lastRead() { - const { userID } = this.getClient(); - if (userID) { - return this.state.read[userID] ? this.state.read[userID].last_read : null; + const { userId } = this.getClient(); + if (userId) { + return this.state.read[userId] ? this.state.read[userId].last_read : null; } } @@ -1639,7 +1321,7 @@ export class Channel { if (message.shadowed) return false; if (message.silent) return false; if (message.parent_id && !message.show_in_channel) return false; - if (message.user?.id === this.getClient().userID) return false; + if (message.user?.id === this.getClient().userId) return false; if (message.user?.id && this.getClient().userMuteStatus(message.user.id)) return false; @@ -1661,11 +1343,10 @@ export class Channel { } /** - * countUnread - Count of unread messages + * Count of unread messages. * - * @param {Date | null} [lastRead] lastRead the time that the user read a message, defaults to current user's read state - * - * @return {number} Unread count + * @param lastRead - The time that the user read a message (optional, defaults to the current user's read state). + * @returns Unread count. */ countUnread(lastRead?: Date | null) { if (!lastRead) return this.state.unreadCount; @@ -1681,13 +1362,13 @@ export class Channel { } /** - * countUnreadMentions - Count the number of unread messages mentioning the current user + * Count the number of unread messages mentioning the current user. * - * @return {number} Unread mentions count + * @returns Unread mentions count. */ countUnreadMentions() { const lastRead = this.lastRead(); - const userID = this.getClient().userID; + const userId = this.getClient().userId; let count = 0; const latestMessages = this.messagePaginator.headItems; @@ -1696,7 +1377,7 @@ export class Channel { if ( this._countMessageAsUnread(message) && (!lastRead || message.created_at > lastRead) && - message.mentioned_users?.some((user) => user.id === userID) + message.mentioned_users?.some((user) => user.id === userId) ) { count++; } @@ -1705,12 +1386,12 @@ export class Channel { } /** - * create - Creates a new channel - * - * @return {Promise} The Server Response + * Creates a new channel. * + * @param options - Channel query options (optional). + * @returns The server response. */ - create = async (options?: ChannelQueryOptions) => { + create = async (options?: ChannelGetOrCreateRequest) => { const defaultOptions = { ...options, watch: false, @@ -1720,54 +1401,43 @@ export class Channel { return await this.query(defaultOptions, 'latest'); }; - async _query(options: ChannelQueryOptions = {}) { + /** + * Queries the API to load messages, members, or other channel fields. + * + * @param options - The query options (optional, defaults to `{}`). + * @param messageSetToAddToIfDoesNotExist - It's possible to load disjunct sets of a channel's + * messages into state. Use `current` to load the initial channel state or to extend the + * currently displayed messages; use `latest` to load/extend the latest messages; `new` is + * used for loading a specific message and its surroundings (optional, defaults to `'current'`). + * @returns A query response. + */ + async query( + options: ChannelGetOrCreateRequest = {}, + messageSetToAddToIfDoesNotExist: MessageSetType = 'current', + ) { // Make sure we wait for the connect promise if there is a pending one await this.getClient().wsPromise; - const createdById = - options.created_by?.id ?? - options.created_by_id ?? - this._data?.created_by?.id ?? - this._data?.created_by_id; - - if (this.getClient()._isUsingServerAuth() && typeof createdById !== 'string') { - this.getClient().logger( - 'warn', - 'Either `created_by` (with `id` property) or `created_by_id` are missing from both `Channel._data` and `options` parameter', - ); - } - - let queryURL = `${this.getClient().baseURL}/channels/${encodeURIComponent( - this.type, - )}`; - if (this.id) { - queryURL += `/${encodeURIComponent(this.id)}`; - } - - return await this.getClient().post(queryURL + '/query', { + const queryPayload: ChannelGetOrCreateRequest = { data: this._data, state: true, ...options, - }); - } + }; + + const state = this.id + ? await this.getOrCreate(queryPayload) + : await this.getClient().getOrCreateDistinctChannel({ + type: this.type, + ...queryPayload, + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const channel = state.channel!; - /** - * query - Query the API, get messages, members or other channel fields - * - * @param {ChannelQueryOptions} options The query options - * @param {MessageSetType} messageSetToAddToIfDoesNotExist It's possible to load disjunct sets of a channel's messages into state, use `current` to load the initial channel state or if you want to extend the currently displayed messages, use `latest` if you want to load/extend the latest messages, `new` is used for loading a specific message and it's surroundings - * - * @return {Promise} Returns a query response - */ - async query( - options: ChannelQueryOptions = {}, - messageSetToAddToIfDoesNotExist: MessageSetType = 'current', - ) { - const state = await this._query(options); // update the channel id if it was missing if (!this.id) { - this.id = state.channel.id; - this.cid = state.channel.cid; + this.id = channel.id; + this.cid = channel.cid; // set the channel as active... const tempChannelCid = generateChannelTempCid( @@ -1789,12 +1459,12 @@ export class Channel { } } - this.getClient()._addChannelConfig(state.channel); + 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 state.channel.config?.shared_locations !== 'undefined') { + if (typeof channel.config?.shared_locations !== 'undefined') { this.messageComposer.updateConfig({ - location: { enabled: state.channel.config.shared_locations }, + location: { enabled: channel.config.shared_locations }, }); } @@ -1831,7 +1501,7 @@ export class Channel { this.messageComposer.initStateFromChannelResponse(state); const areCapabilitiesChanged = - [...(state.channel.own_capabilities || [])].sort().join() !== + [...(channel.own_capabilities || [])].sort().join() !== [ ...(this.data && Array.isArray(this.data?.own_capabilities) ? this.data.own_capabilities @@ -1840,7 +1510,7 @@ export class Channel { .sort() .join(); const previousData = this.data; - this.data = state.channel; + this.data = channel; this._syncStateFromChannelData(this.data, previousData); this.offlineMode = false; this.cooldownTimer.refresh(); @@ -1849,7 +1519,7 @@ export class Channel { this.getClient().dispatchEvent({ type: 'capabilities.changed', cid: this.cid, - own_capabilities: state.channel.own_capabilities, + own_capabilities: channel.own_capabilities ?? [], }); } @@ -1874,15 +1544,15 @@ export class Channel { } /** - * banUser - Bans a user from a channel + * Bans a user from a channel. * - * @param {string} targetUserID - * @param {BanUserOptions} options - * @returns {Promise} + * @param targetUserId - The user to ban. + * @param options - Ban options. + * @returns The server response. */ - async banUser(targetUserID: string, options: BanUserOptions) { + async banUser(targetUserId: string, options: BanUserOptions) { this._checkInitialized(); - return await this.getClient().banUser(targetUserID, { + return await this.getClient().banUser(targetUserId, { ...options, type: this.type, id: this.id, @@ -1890,45 +1560,39 @@ export class Channel { } /** - * hides the channel from queryChannels for the user until a message is added - * If clearHistory is set to true - all messages will be removed for the user + * Hides the channel from `queryChannels` for the user until a message is added. + * If `clear_history` is set to `true`, all messages will be removed for the user. * - * @param {string | null} userId - * @param {boolean} clearHistory - * @returns {Promise} + * @param request - The hide channel request payload (optional). Pass `{ clear_history: true }` + * to clear message history for the user. + * @returns The server response. */ - async hide(userId: string | null = null, clearHistory = false) { + override async hide(request?: Gen_HideChannelRequest) { this._checkInitialized(); - - return await this.getClient().post(`${this._channelURL()}/hide`, { - user_id: userId, - clear_history: clearHistory, - }); + return await super.hide(request); } /** - * removes the hidden status for a channel + * Removes the hidden status for a channel. Ensures the channel is initialized first. * - * @param {string | null} userId - * @returns {Promise} + * @param request - The show channel request payload (optional). + * @returns The server response. */ - async show(userId: string | null = null) { + override async show(request?: Gen_ShowChannelRequest) { this._checkInitialized(); - return await this.getClient().post(`${this._channelURL()}/show`, { - user_id: userId, - }); + return await super.show(request); } /** - * unbanUser - Removes the bans for a user on a channel + * Removes the bans for a user on a channel. * - * @param {string} targetUserID - * @param {UnBanUserOptions} options - * @returns {Promise} + * @param targetUserId - The user to unban. + * @param options - Unban options (optional). + * @returns The server response. */ - async unbanUser(targetUserID: string, options?: UnBanUserOptions) { + async unbanUser(targetUserId: string, options?: UnBanUserOptions) { this._checkInitialized(); - return await this.getClient().unbanUser(targetUserID, { + return await this.getClient().unbanUser(targetUserId, { ...options, type: this.type, id: this.id, @@ -1936,15 +1600,15 @@ export class Channel { } /** - * shadowBan - Shadow bans a user from a channel + * Shadow bans a user from a channel. * - * @param {string} targetUserID - * @param {BanUserOptions} options - * @returns {Promise} + * @param targetUserId - The user to shadow ban. + * @param options - Ban options. + * @returns The server response. */ - async shadowBan(targetUserID: string, options: BanUserOptions) { + async shadowBan(targetUserId: string, options: BanUserOptions) { this._checkInitialized(); - return await this.getClient().shadowBan(targetUserID, { + return await this.getClient().shadowBan(targetUserId, { ...options, type: this.type, id: this.id, @@ -1952,218 +1616,176 @@ export class Channel { } /** - * removeShadowBan - Removes the shadow ban for a user on a channel + * Removes the shadow ban for a user on a channel. * - * @param {string} targetUserID - * @returns {Promise} + * @param targetUserId - The user to remove the shadow ban for. + * @returns The server response. */ - async removeShadowBan(targetUserID: string) { + async removeShadowBan(targetUserId: string) { this._checkInitialized(); - return await this.getClient().removeShadowBan(targetUserID, { + return await this.getClient().removeShadowBan(targetUserId, { type: this.type, id: this.id, }); } /** - * Cast or cancel one or more votes on a poll - * @param pollId string The poll id - * @param votes PollVoteData[] The votes that will be casted (or canceled in case of an empty array) - * @returns {APIResponse & PollVoteResponse} The poll votes + * Casts or cancels one or more votes on a poll. + * + * @param request - The cast-poll-vote request payload, including the target message ID, poll ID, + * and the vote to cast (or an empty payload to cancel). + * @returns The poll vote response. */ - async vote(messageId: string, pollId: string, vote: PollVoteData) { - return await this.getClient().castPollVote(messageId, pollId, vote); + async vote(request: Parameters[0]) { + return await this.getClient().castPollVote(request); } - async removeVote(messageId: string, pollId: string, voteId: string) { - return await this.getClient().removePollVote(messageId, pollId, voteId); + async removeVote(request: Parameters[0]) { + return await this.getClient().deletePollVote(request); } - /** - * createDraft - Creates or updates a draft message in a channel - * - * @param {DraftMessagePayload} message The draft message to create or update - * - * @return {Promise} Response containing the created draft - */ - async _createDraft(message: DraftMessagePayload) { - return await this.getClient().post( - this._channelURL() + '/draft', - { - message, - }, - ); + async _createDraft(request: Gen_CreateDraftRequest) { + return await super.createDraft(request); } /** - * createDraft - Creates or updates a draft message in a channel. If offline support is - * enabled, it will make sure that creating the draft is queued up if it fails due to - * bad internet conditions and executed later. - * - * @param {DraftMessagePayload} message The draft message to create or update - * - * @return {Promise} Response containing the created draft + * Creates or updates a draft message in a channel. If offline support is enabled, the + * call is queued so it is replayed on reconnect. */ - async createDraft(message: DraftMessagePayload) { + override async createDraft(request: Gen_CreateDraftRequest) { try { const offlineDb = this.getClient().offlineDb; if (offlineDb) { - return await offlineDb.queueTask({ + return (await offlineDb.queueTask({ task: { channelId: this.id as string, channelType: this.type, - threadId: message.parent_id, - payload: [message], + threadId: request.message?.parent_id, + payload: [request], type: 'create-draft', }, - }); + })) as Awaited>; } } catch (error) { - this._client.logger('error', `offlineDb:create-draft`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('createDraft', this.cid) + .error('Creating the draft in the offline database failed.', { error }); } - return this._createDraft(message); + return this._createDraft(request); } - /** - * deleteDraft - Deletes a draft message from a channel or a thread. - * - * @param {Object} options - * @param {string} options.parent_id Optional parent message ID for drafts in threads - * - * @return {Promise} API response - */ - async _deleteDraft({ parent_id }: { parent_id?: string } = {}) { - return await this.getClient().delete(this._channelURL() + '/draft', { - parent_id, - }); + async _deleteDraft(request?: Parameters[0]) { + return await super.deleteDraft(request); } /** - * deleteDraft - Deletes a draft message from a channel or a thread. If offline support is - * enabled, it will make sure that deleting the draft is queued up if it fails due to - * bad internet conditions and executed later. - * - * @param {Object} options - * @param {string} options.parent_id Optional parent message ID for drafts in threads - * - * @return {Promise} API response + * Deletes a draft message from a channel or a thread. If offline support is enabled, the + * call is queued so it is replayed on reconnect. */ - async deleteDraft(options: { parent_id?: string } = {}) { - const { parent_id } = options; + override async deleteDraft(request?: Parameters[0]) { try { const offlineDb = this.getClient().offlineDb; if (offlineDb) { - return await offlineDb.queueTask({ + return (await offlineDb.queueTask({ task: { channelId: this.id as string, channelType: this.type, - threadId: parent_id, - payload: [options], + threadId: request?.parent_id, + payload: [request], type: 'delete-draft', }, - }); + })) as Awaited>; } } catch (error) { - this._client.logger('error', `offlineDb:delete-draft`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('deleteDraft', this.cid) + .error('Deleting the draft from the offline database failed.', { error }); } - return this._deleteDraft(options); + return this._deleteDraft(request); } /** - * getDraft - Retrieves a draft message from a channel + * Listens to events on this channel. * - * @param {Object} options - * @param {string} options.parent_id Optional parent message ID for drafts in threads - * - * @return {Promise} Response containing the draft - */ - async getDraft({ parent_id }: { parent_id?: string } = {}) { - return await this.getClient().get(this._channelURL() + '/draft', { - parent_id, - }); - } - - /** - * on - Listen to events on this channel. + * @example + * channel.on('message.new', (event) => { + * console.log('my new message', event, channel.state.messages); + * }); * - * channel.on('message.new', event => {console.log("my new message", event, channel.messagePaginator.state.items)}) - * or - * channel.on(event => {console.log(event.type)}) + * @example + * channel.on((event) => { + * console.log(event.type); + * }); * - * @param {EventHandler | EventTypes} callbackOrString The event type to listen for (optional) - * @param {EventHandler} [callbackOrNothing] The callback to call + * @param callbackOrString - The event type to listen for, or the callback when listening to all events. + * @param callbackOrNothing - The callback to call when an event type was provided (optional). + * @returns An object with an `unsubscribe()` method. */ - on(eventType: EventTypes, callback: EventHandler): { unsubscribe: () => void }; + on( + eventType: T, + callback: EventHandler, + ): { unsubscribe: () => void }; on(callback: EventHandler): { unsubscribe: () => void }; on( - callbackOrString: EventHandler | EventTypes, + callbackOrString: EventHandler | string, callbackOrNothing?: EventHandler, ): { unsubscribe: () => void } { - const key = callbackOrNothing ? (callbackOrString as string) : 'all'; - const callback = callbackOrNothing ? callbackOrNothing : callbackOrString; - if (!(key in this.listeners)) { - this.listeners[key] = []; - } - this._client.logger( - 'info', - `Attaching listener for ${key} event on channel ${this.cid}`, - { - tags: ['event', 'channel'], - channel: this, - }, - ); + const key = callbackOrNothing ? (callbackOrString as EventType) : 'all'; + const callback = callbackOrNothing + ? callbackOrNothing + : (callbackOrString as EventHandler); + + const set = this.listeners.get(key) ?? new Set(); - this.listeners[key].push(callback); + logger + .withExtraTags('on', this.cid) + .debug(`Attaching a listener for the "${key}" event.`); + set.add(callback); + + if (!this.listeners.has(key)) { + this.listeners.set(key, set); + } return { unsubscribe: () => { - this._client.logger( - 'info', - `Removing listener for ${key} event from channel ${this.cid}`, - { - tags: ['event', 'channel'], - channel: this, - }, - ); - - this.listeners[key] = this.listeners[key].filter((el) => el !== callback); + logger + .withExtraTags('on', this.cid) + .debug(`Removing the listener for the "${key}" event.`); + set.delete(callback); + if (!set.size) { + this.listeners.delete(key); + } }, }; } /** - * off - Remove the event handler + * Removes the event handler. * + * @param callbackOrString - The event type, or the callback when removing an all-events listener. + * @param callbackOrNothing - The callback to remove when an event type was provided (optional). */ - off(eventType: EventTypes, callback: EventHandler): void; + off(eventType: T, callback: EventHandler): void; off(callback: EventHandler): void; - off( - callbackOrString: EventHandler | EventTypes, - callbackOrNothing?: EventHandler, - ): void { - const key = callbackOrNothing ? (callbackOrString as string) : 'all'; - const callback = callbackOrNothing ? callbackOrNothing : callbackOrString; - if (!(key in this.listeners)) { - this.listeners[key] = []; - } + off(callbackOrString: EventHandler | string, callbackOrNothing?: EventHandler): void { + const key = callbackOrNothing ? (callbackOrString as EventType) : 'all'; + const callback = callbackOrNothing + ? callbackOrNothing + : (callbackOrString as EventHandler); - this._client.logger( - 'info', - `Removing listener for ${key} event from channel ${this.cid}`, - { - tags: ['event', 'channel'], - channel: this, - }, - ); - this.listeners[key] = this.listeners[key].filter((value) => value !== callback); + logger + .withExtraTags('off', this.cid) + .debug(`Removing the listener for the "${key}" event.`); + + const set = this.listeners.get(key); + + set?.delete(callback); + + if (!set?.size) { + this.listeners.delete(key); + } } private _patchReadState( @@ -2219,14 +1841,9 @@ export class Channel { _handleChannelEvent(event: Event) { // eslint-disable-next-line @typescript-eslint/no-this-alias const channel = this; - this._client.logger( - 'info', - `channel:_handleChannelEvent - Received event of type { ${event.type} } on ${this.cid}`, - { - tags: ['event', 'channel'], - channel: this, - }, - ); + logger + .withExtraTags('_handleChannelEvent', this.cid) + .debug(`Received an event of type "${event.type}".`, { event }); const channelState = channel.state; switch (event.type) { @@ -2533,7 +2150,7 @@ export class Channel { }; } - const currentUserId = this.getClient().userID; + const currentUserId = this.getClient().userId; if ( typeof currentUserId === 'string' && typeof memberCopy?.user?.id === 'string' && @@ -2658,8 +2275,9 @@ export class Channel { case 'channel.hidden': { const previousChannelData = channel.data; channel.data = { - ...channel.data, - blocked: !!event.channel?.blocked, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ...channel.data!, + blocked: event.channel?.blocked ?? false, hidden: true, }; channel._syncStateFromChannelData(channel.data, previousChannelData); @@ -2672,8 +2290,9 @@ export class Channel { case 'channel.visible': { const previousChannelData = channel.data; channel.data = { - ...channel.data, - blocked: !!event.channel?.blocked, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ...channel.data!, + blocked: event.channel?.blocked ?? false, hidden: false, }; channel._syncStateFromChannelData(channel.data, previousChannelData); @@ -2701,36 +2320,26 @@ export class Channel { default: } + const typedEvent = event as Extract; // any event can send over the online count - if (event.watcher_count !== undefined) { - channel.state.watcher_count = event.watcher_count; + if (typeof typedEvent.watcher_count !== 'undefined') { + channel.state.watcher_count = typedEvent.watcher_count; } } - _callChannelListeners = (event: Event) => { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const channel = this; - // gather and call the listeners - const listeners = []; - if (channel.listeners.all) { - listeners.push(...channel.listeners.all); - } - if (channel.listeners[event.type]) { - listeners.push(...channel.listeners[event.type]); - } + _callChannelListeners = (event: WSEvent) => { + const allSet = this.listeners.get('all'); + const targetSet = this.listeners.get(event.type); - // call the event and send it to the listeners - for (const listener of listeners) { - if (typeof listener !== 'string') { - listener(event); - } - } + [allSet, targetSet].forEach((set) => + set?.forEach((handleEvent) => handleEvent(event)), + ); }; /** - * _channelURL - Returns the channel url + * Returns the channel url. * - * @return {string} The channel url + * @returns The channel url. */ _channelURL = () => { if (!this.id) { @@ -2742,11 +2351,7 @@ export class Channel { }; _checkInitialized() { - if ( - !this.initialized && - !this.offlineMode && - !this.getClient()._isUsingServerAuth() - ) { + if (!this.initialized && !this.offlineMode) { throw Error( `Channel ${this.cid} hasn't been initialized yet. Make sure to call .watch() and wait for it to resolve`, ); @@ -2761,7 +2366,7 @@ export class Channel { this.state.syncMemberCountFromChannelData(data, fallbackData); } - _initializeState(state: ChannelAPIResponse) { + _initializeState(state: ChannelStateResponseFields) { const { state: clientState, user, userID } = this.getClient(); // add the members and users @@ -2775,7 +2380,9 @@ export class Channel { } } - this.state.membership = state.membership || {}; + if (state.membership) { + this.state.membership = state.membership; + } // Seed the message paginator's `lastMessageAt` aggregate from the server's authoritative // `last_message_at`. The first-page seed (Channel.query / client.hydrateActiveChannels) also @@ -2813,7 +2420,7 @@ export class Channel { const last_read = this.messagePaginator.lastMessageAt || new Date(); if (user) { readUpdates[user.id] = { - user, + user: user as UserResponse, last_read, unread_messages: 0, }; @@ -2860,7 +2467,9 @@ export class Channel { } } - _extendEventWithOwnReactions(event: Event) { + _extendEventWithOwnReactions( + event: EventPayload<'message.undeleted' | 'message.updated' | 'message.deleted'>, + ) { if (!event.message) { return; } @@ -2907,14 +2516,7 @@ export class Channel { } _disconnect() { - this._client.logger( - 'info', - `channel:disconnect() - Disconnecting the channel ${this.cid}`, - { - tags: ['connection', 'channel'], - channel: this, - }, - ); + logger.withExtraTags('_disconnect', this.cid).info('Disconnecting the channel.'); this.disconnected = true; this.messageReceiptsTracker.unregisterSubscriptions(); diff --git a/src/channel_batch_updater.ts b/src/channel_batch_updater.ts index 88ad0bfb1f..518b9b1f2d 100644 --- a/src/channel_batch_updater.ts +++ b/src/channel_batch_updater.ts @@ -1,211 +1 @@ -import type { StreamChat } from './client'; -import type { - APIResponse, - BatchChannelDataUpdate, - NewMemberPayload, - UpdateChannelsBatchFilters, - UpdateChannelsBatchResponse, -} from './types'; - -/** - * ChannelBatchUpdater - A class that provides convenience methods for batch channel operations - */ -export class ChannelBatchUpdater { - client: StreamChat; - - constructor(client: StreamChat) { - this.client = client; - } - - // Member operations - - /** - * addMembers - Add members to channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @param {string[] | NewMemberPayload[]} members Members to add - * @return {Promise} The server response - */ - async addMembers( - filter: UpdateChannelsBatchFilters, - members: string[] | NewMemberPayload[], - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'addMembers', - filter, - members, - }); - } - - /** - * removeMembers - Remove members from channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @param {string[]} members Member IDs to remove - * @return {Promise} The server response - */ - async removeMembers( - filter: UpdateChannelsBatchFilters, - members: string[], - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'removeMembers', - filter, - members, - }); - } - - /** - * inviteMembers - Invite members to channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @param {string[] | NewMemberPayload[]} members Members to invite - * @return {Promise} The server response - */ - async inviteMembers( - filter: UpdateChannelsBatchFilters, - members: string[] | NewMemberPayload[], - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'inviteMembers', - filter, - members, - }); - } - - /** - * addModerators - Add moderators to channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @param {string[]} members Member IDs to promote to moderator - * @return {Promise} The server response - */ - async addModerators( - filter: UpdateChannelsBatchFilters, - members: string[], - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'addModerators', - filter, - members, - }); - } - - /** - * demoteModerators - Remove moderator role from members in channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @param {string[]} members Member IDs to demote - * @return {Promise} The server response - */ - async demoteModerators( - filter: UpdateChannelsBatchFilters, - members: string[], - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'demoteModerators', - filter, - members, - }); - } - - /** - * assignRoles - Assign roles to members in channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @param {NewMemberPayload[]} members Members with role assignments - * @return {Promise} The server response - */ - async assignRoles( - filter: UpdateChannelsBatchFilters, - members: NewMemberPayload[], - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'assignRoles', - filter, - members, - }); - } - - // Visibility operations - - /** - * hide - Hide channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @return {Promise} The server response - */ - async hide( - filter: UpdateChannelsBatchFilters, - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'hide', - filter, - }); - } - - /** - * show - Show channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @return {Promise} The server response - */ - async show( - filter: UpdateChannelsBatchFilters, - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'show', - filter, - }); - } - - /** - * archive - Archive channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @return {Promise} The server response - */ - async archive( - filter: UpdateChannelsBatchFilters, - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'archive', - filter, - }); - } - - /** - * unarchive - Unarchive channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @return {Promise} The server response - */ - async unarchive( - filter: UpdateChannelsBatchFilters, - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'unarchive', - filter, - }); - } - - // Data operations - - /** - * updateData - Update data on channels matching the filter - * - * @param {UpdateChannelsBatchFilters} filter Filter to select channels - * @param {BatchChannelDataUpdate} data Data to update - * @return {Promise} The server response - */ - async updateData( - filter: UpdateChannelsBatchFilters, - data: BatchChannelDataUpdate, - ): Promise { - return await this.client.updateChannelsBatch({ - operation: 'updateData', - filter, - data, - }); - } -} +// ChannelBatchUpdater functionality has been moved to the server-side SDK. diff --git a/src/channel_manager.ts b/src/channel_manager.ts index 871599a209..b6f059a68a 100644 --- a/src/channel_manager.ts +++ b/src/channel_manager.ts @@ -1,12 +1,14 @@ import type { QueryChannelsResponseWithChannels, StreamChat } from './client'; import type { ChannelFilters, - ChannelOptions, ChannelSort, ChannelStateOptions, Event, - QueryChannelsAPIResponse, + EventPayload, + QueryChannelsRequest, + QueryChannelsResponse, } from './types'; +import { chatLoggerSystem } from './logger'; import type { ValueOrPatch } from './store'; import { isPatch, StateStore } from './store'; import type { Channel } from './channel'; @@ -29,15 +31,15 @@ import { } from './constants'; import { WithSubscriptions } from './utils/WithSubscriptions'; +const logger = chatLoggerSystem.getLogger('channel-manager'); + export type ChannelManagerPagination = { - filters: ChannelFilters; hasNext: boolean; isLoading: boolean; isLoadingNext: boolean; - options: ChannelOptions; - responseFilters?: ChannelFilters; - responseSort?: ChannelSort; - sort: ChannelSort; + options?: QueryChannelsRequest; + responseFilters?: QueryChannelsRequest['filter_conditions']; + responseSort?: QueryChannelsRequest['sort']; }; export type ChannelManagerState = { @@ -56,9 +58,7 @@ export type ChannelManagerState = { export type ChannelSetterParameterType = ValueOrPatch; export type ChannelSetterType = (arg: ChannelSetterParameterType) => void; -export type GenericEventHandlerType = ( - ...args: T -) => void | (() => void) | ((...args: T) => Promise) | Promise; +export type GenericEventHandlerType = (...args: T) => any; export type EventHandlerType = GenericEventHandlerType<[Event]>; export type EventHandlerOverrideType = GenericEventHandlerType< [ChannelSetterType, Event] @@ -92,10 +92,9 @@ export type ChannelManagerEventHandlerOverrides = Partial< Record >; -export type ExecuteChannelsQueryPayload = Pick< - ChannelManagerPagination, - 'filters' | 'sort' | 'options' -> & { stateOptions: ChannelStateOptions }; +export type ExecuteChannelsQueryPayload = Pick & { + stateOptions: ChannelStateOptions; +}; export const channelManagerEventToHandlerMapping: { [key in ChannelManagerEventTypes]: ChannelManagerEventHandlerNames; @@ -139,9 +138,7 @@ export type ChannelManagerOptions = { export type QueryChannelsRequestOutput = Channel[] | QueryChannelsResponseWithChannels; export type QueryChannelsRequestType = ( - filters: ChannelFilters, - sort?: ChannelSort, - options?: ChannelOptions, + options?: QueryChannelsRequest, stateOptions?: ChannelStateOptions, ) => Promise; @@ -160,18 +157,11 @@ export const DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS = { offset: 0, }; -const mapPredefinedFilterSortToChannelSort = ( - sort: NonNullable['sort'], -): ChannelSort => - (sort ?? []).map(({ direction = 1, field }) => ({ - [field]: direction, - })) as ChannelSort; - const getResponsePaginationParams = ({ queryChannelsResponse, sort, }: { - queryChannelsResponse?: Pick; + queryChannelsResponse?: Pick; sort: ChannelSort; }): Pick => { const predefinedFilter = queryChannelsResponse?.predefined_filter; @@ -182,18 +172,13 @@ const getResponsePaginationParams = ({ return { responseFilters: predefinedFilter.filter as ChannelFilters, - responseSort: - predefinedFilter.sort !== undefined - ? mapPredefinedFilterSortToChannelSort(predefinedFilter.sort) - : sort, + responseSort: predefinedFilter.sort ?? sort, }; }; -const getResponseFiltersAndSort = ( - pagination: ChannelManagerPagination, -): Pick => ({ - filters: pagination.responseFilters ?? pagination.filters, - sort: pagination.responseSort ?? pagination.sort, +const getResponseFiltersAndSort = (pagination: ChannelManagerPagination) => ({ + filters: pagination.responseFilters ?? pagination.options?.filter_conditions, + sort: pagination.responseSort ?? pagination.options?.sort, }); const omitResponsePaginationParams = (pagination: ChannelManagerPagination) => { @@ -245,8 +230,6 @@ export class ChannelManager extends WithSubscriptions { isLoading: false, isLoadingNext: false, hasNext: false, - filters: {}, - sort: {}, options: DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS, }, initialized: false, @@ -255,7 +238,8 @@ export class ChannelManager extends WithSubscriptions { this.setEventHandlerOverrides(eventHandlerOverrides); this.setOptions(options); this.queryChannelsRequest = - queryChannelsOverride ?? ((...params) => this.client.queryChannels(...params)); + queryChannelsOverride ?? + ((...params) => this.client.queryChannelsAndHydrate(...params)); this.eventHandlers = new Map( Object.entries({ channelDeletedHandler: this.channelDeletedHandler, @@ -287,15 +271,13 @@ export class ChannelManager extends WithSubscriptions { }); const { channels, - pagination: { filters, options, sort }, + pagination: { options }, } = this.state.getLatestValue(); this.client.offlineDb?.executeQuerySafely( (db) => db.upsertCidsForQuery({ cids: channels.map((channel) => channel.cid), - filters, options, - sort, }), { method: 'upsertCidsForQuery' }, ); @@ -329,18 +311,16 @@ export class ChannelManager extends WithSubscriptions { payload: ExecuteChannelsQueryPayload, retryCount = 0, ): Promise => { - const { filters, sort, options, stateOptions } = payload; + const { options, stateOptions } = payload; const { offset, limit } = { ...DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS, ...options, }; try { - const queryChannelsResponse = await this.queryChannelsRequest( - filters, - sort, - options, - { ...stateOptions, withResponse: true }, - ); + const queryChannelsResponse = await this.queryChannelsRequest(options, { + ...stateOptions, + withResponse: true, + }); const channels = isQueryChannelsResponseWithChannels(queryChannelsResponse) ? queryChannelsResponse.channels : queryChannelsResponse; @@ -351,7 +331,7 @@ export class ChannelManager extends WithSubscriptions { queryChannelsResponse: isQueryChannelsResponseWithChannels(queryChannelsResponse) ? queryChannelsResponse : undefined, - sort, + sort: options?.sort ?? [], }); const paginationWithoutResponseParams = omitResponsePaginationParams(pagination); @@ -376,18 +356,22 @@ export class ChannelManager extends WithSubscriptions { (db) => db.upsertCidsForQuery({ cids: channels.map((channel) => channel.cid), - filters: pagination.filters, + filters: pagination.options?.filter_conditions, options, - sort: pagination.sort, + sort: pagination.options?.sort, }), { method: 'upsertCidsForQuery' }, ); - } catch (err) { + } catch (error) { if (retryCount >= DEFAULT_QUERY_CHANNELS_RETRY_COUNT) { - console.warn(err); + 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: ${err}`, + `Maximum number of retries reached in queryChannels. Last error message is: ${error}`, ); const state = this.state.getLatestValue(); @@ -413,13 +397,11 @@ export class ChannelManager extends WithSubscriptions { }; public queryChannels = async ( - filters: ChannelFilters, - sort: ChannelSort = [], - options: ChannelOptions = {}, + request?: QueryChannelsRequest, stateOptions: ChannelStateOptions = {}, ) => { const { - pagination: { isLoading, filters: filtersFromState }, + pagination: { isLoading, options: optionsFromState }, initialized, } = this.state.getLatestValue(); @@ -428,12 +410,18 @@ export class ChannelManager extends WithSubscriptions { !this.options.abortInFlightQuery && // TODO: Figure a proper way to either deeply compare these or // create hashes from each. - JSON.stringify(filtersFromState) === JSON.stringify(filters) + JSON.stringify(optionsFromState?.filter_conditions) === + JSON.stringify(request?.filter_conditions) ) { return; } - const executeChannelsQueryPayload = { filters, sort, options, stateOptions }; + const executeChannelsQueryPayload = { + filters: request?.filter_conditions, + sort: request?.sort, + options: request, + stateOptions, + }; try { this.stateOptions = stateOptions; @@ -443,9 +431,7 @@ export class ChannelManager extends WithSubscriptions { ...omitResponsePaginationParams(currentState.pagination), isLoading: true, isLoadingNext: false, - filters, - sort, - options, + options: request, }, error: undefined, })); @@ -454,9 +440,7 @@ export class ChannelManager extends WithSubscriptions { if (!initialized) { const channelsFromDB = await this.client.offlineDb.getChannelsForQuery({ userId: this.client.user.id, - filters, - options, - sort, + options: request, }); if (channelsFromDB) { @@ -481,7 +465,7 @@ export class ChannelManager extends WithSubscriptions { } await this.executeChannelsQuery(executeChannelsQueryPayload); } catch (error) { - this.client.logger('error', (error as Error).message); + logger.withExtraTags('queryChannels').error('Failed to query channels.', { error }); this.state.next((currentState) => ({ ...currentState, pagination: { ...currentState.pagination, isLoading: false }, @@ -492,7 +476,7 @@ export class ChannelManager extends WithSubscriptions { public loadNext = async () => { const { pagination, initialized } = this.state.getLatestValue(); - const { filters, sort, options, isLoadingNext, hasNext } = pagination; + const { options, isLoadingNext, hasNext } = pagination; if (!initialized || isLoadingNext || !hasNext) { return; @@ -507,8 +491,6 @@ export class ChannelManager extends WithSubscriptions { pagination: { ...pagination, isLoading: false, isLoadingNext: true }, }); const queryChannelsResponse = await this.queryChannelsRequest( - filters, - sort, options, this.stateOptions, ); @@ -530,7 +512,9 @@ export class ChannelManager extends WithSubscriptions { }, }); } catch (error) { - this.client.logger('error', (error as Error).message); + logger + .withExtraTags('loadNext') + .error('Failed to load the next page of channels.', { error }); this.state.next((currentState) => ({ ...currentState, pagination: { @@ -543,7 +527,8 @@ export class ChannelManager extends WithSubscriptions { } }; - private notificationAddedToChannelHandler = async (event: Event) => { + private notificationAddedToChannelHandler = async (event_: Event) => { + const event = event_ as EventPayload<'notification.added_to_channel'>; const { id, type, members } = event?.channel ?? {}; if ( @@ -573,7 +558,7 @@ export class ChannelManager extends WithSubscriptions { return; } - const { sort } = getResponseFiltersAndSort(pagination); + const { sort = [] } = getResponseFiltersAndSort(pagination); this.setChannels( promoteChannel({ @@ -584,7 +569,11 @@ export class ChannelManager extends WithSubscriptions { ); }; - private channelDeletedHandler = (event: Event) => { + 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; @@ -605,12 +594,14 @@ export class ChannelManager extends WithSubscriptions { private channelHiddenHandler = this.channelDeletedHandler; - private newMessageHandler = (event: Event) => { + 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 { filters, sort = [] } = getResponseFiltersAndSort(pagination); const channelType = event.channel_type; const channelId = event.channel_id; @@ -631,9 +622,9 @@ export class ChannelManager extends WithSubscriptions { if ( // filter is defined, target channel is archived and filter option is set to false - (considerArchivedChannels && isTargetChannelArchived && !filters.archived) || + (considerArchivedChannels && isTargetChannelArchived && !filters?.archived) || // filter is defined, target channel isn't archived and filter option is set to true - (considerArchivedChannels && !isTargetChannelArchived && filters.archived) || + (considerArchivedChannels && !isTargetChannelArchived && filters?.archived) || // sort option is defined, target channel is pinned (considerPinnedChannels && isTargetChannelPinned) || // list order is locked @@ -655,7 +646,9 @@ export class ChannelManager extends WithSubscriptions { ); }; - private notificationNewMessageHandler = async (event: Event) => { + private notificationNewMessageHandler = async (event_: Event) => { + const event = event_ as EventPayload<'notification.message_new'>; + const { id, type } = event?.channel ?? {}; if (!id || !type) { @@ -669,15 +662,15 @@ export class ChannelManager extends WithSubscriptions { }); const { channels, pagination } = this.state.getLatestValue(); - const { filters, sort } = getResponseFiltersAndSort(pagination); + const { filters, sort = [] } = getResponseFiltersAndSort(pagination); const considerArchivedChannels = shouldConsiderArchivedChannels(filters); const isTargetChannelArchived = isChannelArchived(channel); if ( !channels || - (considerArchivedChannels && isTargetChannelArchived && !filters.archived) || - (considerArchivedChannels && !isTargetChannelArchived && filters.archived) || + (considerArchivedChannels && isTargetChannelArchived && !filters?.archived) || + (considerArchivedChannels && !isTargetChannelArchived && filters?.archived) || !this.options.allowNotLoadedChannelPromotionForEvent?.['notification.message_new'] ) { return; @@ -692,7 +685,8 @@ export class ChannelManager extends WithSubscriptions { ); }; - private channelVisibleHandler = async (event: Event) => { + 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) { @@ -706,15 +700,15 @@ export class ChannelManager extends WithSubscriptions { }); const { channels, pagination } = this.state.getLatestValue(); - const { filters, sort } = getResponseFiltersAndSort(pagination); + const { filters, sort = [] } = getResponseFiltersAndSort(pagination); const considerArchivedChannels = shouldConsiderArchivedChannels(filters); const isTargetChannelArchived = isChannelArchived(channel); if ( !channels || - (considerArchivedChannels && isTargetChannelArchived && !filters.archived) || - (considerArchivedChannels && !isTargetChannelArchived && filters.archived) || + (considerArchivedChannels && isTargetChannelArchived && !filters?.archived) || + (considerArchivedChannels && !isTargetChannelArchived && filters?.archived) || !this.options.allowNotLoadedChannelPromotionForEvent?.['channel.visible'] ) { return; @@ -731,12 +725,13 @@ export class ChannelManager extends WithSubscriptions { private notificationRemovedFromChannelHandler = this.channelDeletedHandler; - private memberUpdatedHandler = (event: Event) => { + private memberUpdatedHandler = (event_: Event) => { + const event = event_ as EventPayload<'member.updated'>; const { pagination, channels } = this.state.getLatestValue(); - const { filters, sort } = getResponseFiltersAndSort(pagination); + const { filters, sort = [] } = getResponseFiltersAndSort(pagination); if ( !event.member?.user || - event.member.user.id !== this.client.userID || + event.member.user.id !== this.client.userId || !event.channel_type || !event.channel_id ) { diff --git a/src/channel_state.ts b/src/channel_state.ts index e61a4bff5f..3cf6fa9897 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -4,7 +4,6 @@ import type { Event, LocalMessage, MessageResponse, - MessageResponseBase, PendingMessageResponse, UserResponse, } from './types'; @@ -85,7 +84,7 @@ export class ChannelState { this.syncMemberCountFromChannelData(channel?.data); this.syncOwnCapabilitiesFromChannelData(channel?.data); this.pending_messages = []; - this.membership = {}; + this.membership = {} as ChannelMemberResponse; this.unreadCount = 0; } @@ -237,9 +236,9 @@ export class ChannelState { * Takes the message object, parses the dates, sets `__html` * and sets the status to `received` if missing; returns a new message object. * - * @param {MessageResponse} message `MessageResponse` object + * @param message - `MessageResponse` object */ - formatMessage = (message: MessageResponse | MessageResponseBase | LocalMessage) => + formatMessage = (message: MessageResponse | MessageResponse | LocalMessage) => formatMessage(message); /** diff --git a/src/client.ts b/src/client.ts index 456bf5edaa..82683c92cf 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,7 +1,7 @@ /* eslint no-unused-vars: "off" */ /* global process */ -import type { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; +import type { AxiosInstance, AxiosRequestConfig } from 'axios'; import axios from 'axios'; import https from 'https'; @@ -9,273 +9,63 @@ import { Channel } from './channel'; import { ClientState } from './client_state'; import { StableWSConnection } from './connection'; import { UploadManager } from './uploadManager'; -import { - DevToken, - InvalidWebhookError, - JWTUserToken, - parseSns as parseSnsHelper, - parseSqs as parseSqsHelper, - verifyAndParseWebhook as verifyAndParseWebhookHelper, - verifySignature, -} from './signing'; -import { TokenManager } from './token_manager'; +import { TokenManager, type TokenManagerMinimalUser } from './token_manager'; import { WSConnectionFallback } from './connection_fallback'; -import { Campaign } from './campaign'; -import { ChannelBatchUpdater } from './channel_batch_updater'; -import { Segment } from './segment'; -import { isErrorResponse, isWSFailure } from './errors'; +import { isWSFailure } from './errors'; +import { ApiClient } from './api-client'; import { - addFileToFormData, axiosParamsSerializer, - chatCodes, formatMessage, generateChannelTempCid, - isFunction, + getEnv, isOnline, isOwnUserBaseProperty, - normalizeQuerySort, randomId, - retryInterval, - sleep, - toUpdatedMessagePayload, } from './utils'; import type { - ActiveLiveLocationsAPIResponse, - AddUserGroupMembersOptions, - AddUserGroupMembersResponse, - APIErrorResponse, APIResponse, AppIdentifier, - AppSettings, - AppSettingsAPIResponse, - BannedUsersFilters, - BannedUsersPaginationOptions, - BannedUsersResponse, - BannedUsersSort, BanUserOptions, BaseDeviceFields, - BlockList, - BlockListResponse, - BlockUserAPIResponse, - CampaignData, - CampaignFilters, - CampaignQueryOptions, - CampaignResponse, - CampaignSort, - CastVoteAPIResponse, - ChannelAPIResponse, ChannelData, - ChannelFilters, ChannelMute, ChannelOptions, ChannelResponse, - ChannelSort, ChannelStateOptions, - CheckPushResponse, - CheckSNSResponse, - CheckSQSResponse, + ChannelStateResponseFields, Configs, ConnectAPIResponse, - CreateChannelOptions, - CreateChannelResponse, - CreateCommandOptions, - CreateCommandResponse, - CreateImportOptions, - CreateImportResponse, - CreateImportURLResponse, - CreatePollAPIResponse, - CreatePollData, - CreatePollOptionAPIResponse, - CreatePredefinedFilterOptions, - CreateReminderOptions, - CreateRoleAPIResponse, - CreateUserGroupOptions, - CreateUserGroupResponse, - CustomPermissionOptions, - DeactivateUsersOptions, - DeleteChannelsResponse, - DeleteCommandResponse, - DeleteMessageOptions, - DeleteRetentionPolicyResponse, - DeleteUserGroupOptions, - DeleteUserOptions, - Device, DeviceIdentifier, - DraftFilters, - DraftSort, - EndpointName, Event, - EventAPIResponse, EventHandler, - ExportChannelOptions, - ExportChannelRequest, - ExportChannelResponse, - ExportChannelStatusResponse, - ExportUsersRequest, - ExportUsersResponse, + EventType, FlagMessageResponse, - FlagReportsFilters, - FlagReportsPaginationOptions, - FlagReportsResponse, - FlagsFilters, - FlagsPaginationOptions, - FlagsResponse, FlagUserResponse, - FutureChannelBansResponse, - GetBlockedUsersAPIResponse, - GetCampaignOptions, - GetChannelTypeResponse, - GetCommandResponse, - GetHookEventsResponse, - GetImportResponse, - GetMessageAPIResponse, - GetMessageOptions, - GetPollAPIResponse, - GetPollOptionAPIResponse, - GetRateLimitsResponse, - GetRetentionPolicyResponse, - GetRetentionPolicyRunsOptions, - GetRetentionPolicyRunsResponse, - GetThreadAPIResponse, GetThreadOptions, - GetUnreadCountAPIResponse, - GetUnreadCountBatchAPIResponse, - GetUserGroupOptions, - GetUserGroupResponse, - ListChannelResponse, - ListCommandsResponse, - ListImportsPaginationOptions, - ListImportsResponse, - ListPredefinedFiltersOptions, - ListPredefinedFiltersResponse, - ListRolesAPIResponse, LocalMessage, - Logger, - MarkChannelsReadOptions, - MarkDeliveredOptions, - MessageFilters, - MessageFlagsFilters, - MessageFlagsPaginationOptions, - MessageFlagsResponse, - MessageResponse, - Mute, MuteUserOptions, MuteUserResponse, - NewMemberPayload, - OGAttachment, OwnUserResponse, - Pager, - PartialMessageUpdate, - PartialPollUpdate, + PartializeAllBut, PartialThreadUpdate, - PartialUserUpdate, - PermissionAPIResponse, - PermissionsAPIResponse, - PollAnswersAPIResponse, - PollData, - PollOptionData, - PollSort, - PollVote, - PollVoteData, - PollVotesAPIResponse, - PredefinedFilterResponse, - Product, - PushPreference, - PushProvider, - PushProviderConfig, - PushProviderID, - PushProviderListResponse, - PushProviderUpsertResponse, - QueryChannelsAPIResponse, - QueryDraftsResponse, - QueryFutureChannelBansOptions, - QueryMessageHistoryFilters, - QueryMessageHistoryOptions, - QueryMessageHistoryResponse, - QueryMessageHistorySort, - QueryPollsFilters, - QueryPollsOptions, - QueryPollsResponse, - QueryReactionsAPIResponse, - QueryReactionsOptions, - QueryRemindersOptions, - QueryRemindersResponse, - QuerySegmentsOptions, - QuerySegmentTargetsFilter, - QueryTeamUsageStatsOptions, - QueryTeamUsageStatsResponse, - QueryThreadsAPIResponse, - QueryThreadsOptions, - QueryUserGroupsOptions, - QueryUserGroupsResponse, - QueryVotesFilters, - QueryVotesOptions, - ReactionFilters, + QueryBannedUsersPayload, + QueryChannelsRequest, + QueryChannelsResponse, + QueryReactionsRequestWithId, + QueryThreadsRequest, ReactionResponse, - ReactionSort, - ReactivateUserOptions, - ReactivateUsersOptions, - ReminderAPIResponse, - RemoveUserGroupMembersOptions, - RemoveUserGroupMembersResponse, - ReviewFlagReportOptions, - ReviewFlagReportResponse, SdkIdentifier, - SearchAPIResponse, - SearchMessageSortBase, - SearchOptions, SearchPayload, - SearchRolesAPIResponse, - SearchRolesOptions, - SearchUserGroupsOptions, - SearchUserGroupsResponse, - SegmentData, - SegmentResponse, - SegmentTargetsResponse, - SegmentType, - SendFileAPIResponse, - SetRetentionPolicyResponse, - SharedLocationResponse, - SortParam, StreamChatOptions, - SyncOptions, - SyncResponse, - TaskResponse, - TaskStatus, - TestPushDataInput, - TestSNSDataInput, - TestSQSDataInput, TokenOrProvider, - TranslateResponse, UnBanUserOptions, - UpdateChannelsBatchOptions, - UpdateChannelsBatchResponse, - UpdateChannelTypeRequest, - UpdateChannelTypeResponse, - UpdateCommandOptions, - UpdateCommandResponse, - UpdateLocationPayload, - UpdateMessageAPIResponse, - UpdateMessageOptions, - UpdatePollAPIResponse, - UpdatePollOptionAPIResponse, - UpdatePredefinedFilterOptions, - UpdateReminderOptions, - UpdateSegmentData, - UpdateUserGroupOptions, - UpdateUserGroupResponse, - UpdateUsersAPIResponse, - UpsertPushPreferencesResponse, - UserCustomEvent, - UserFilters, - UserOptions, + UpdateUserPartialRequest, + UserMuteResponse, UserResponse, - UserSort, - VoteSort, } from './types'; -import { ErrorFromResponse } from './types'; import { InsightMetrics, postInsights } from './insights'; +import { chatLoggerSystem } from './logger'; import { Thread } from './thread'; import { Moderation } from './moderation'; import { ThreadManager } from './thread_manager'; @@ -300,13 +90,24 @@ import type { } from './configuration'; import { InstanceConfigurationService } from './configuration/InstanceConfigurationService'; import { StateStore } from './store'; - -function isString(x: unknown): x is string { - return typeof x === 'string' || x instanceof String; +import type { + GetApplicationResponse as Gen_GetApplicationResponse, + MarkDeliveredRequest as Gen_MarkDeliveredRequest, + QueryUsersPayload as Gen_QueryUsersPayload, + WSEvent, +} from './gen/models'; +import { ChatApi } from './gen-imports'; +import type { StreamResponse } from './types'; + +function isString(value: unknown): value is string { + return typeof value === 'string' || value instanceof String; } +const logger = chatLoggerSystem.getLogger('client'); +const offlineDbLogger = chatLoggerSystem.getLogger('offline-db'); + export type QueryChannelsResponseWithChannels = Omit< - QueryChannelsAPIResponse, + QueryChannelsResponse, 'channels' > & { channels: Channel[]; @@ -318,15 +119,20 @@ export type ChannelConfigsState = { configs: Configs; }; -export class StreamChat { +export type ClientUser = PartializeAllBut & { anon?: boolean }; + +export class StreamChat extends ChatApi { private static _instance?: unknown | StreamChat; // type is undefined|StreamChat, unknown is due to TS limitations with statics messageDeliveryReporter: MessageDeliveryReporter; /** * @internal */ uploadManager: UploadManager; - _user?: OwnUserResponse | UserResponse; - appSettingsPromise?: Promise; + /** + * @private + */ + _user?: ClientUser; + appSettingsPromise?: Promise>; activeChannels: { [key: string]: Channel; }; @@ -335,16 +141,14 @@ export class StreamChat { offlineDb?: AbstractOfflineDB; notifications: NotificationManager; reminders: ReminderManager; - anonymous: boolean; persistUserOnConnectionFailure?: boolean; axiosInstance: AxiosInstance; baseURL?: string; browser: boolean; cleaningIntervalRef?: NodeJS.Timeout; - clientID?: string; + clientId?: string; key: string; - listeners: Record void>>; - logger: Logger; + listeners: Map>; /** * When network is recovered, we re-query the active channels on client. But in single query, you can recover * only 30 channels. So its not guaranteed that all the channels in activeChannels object have updated state. @@ -362,23 +166,44 @@ export class StreamChat { preventThreadCleanup = false; moderation: Moderation; mutedChannels: ChannelMute[]; - readonly mutedUsersStore: StateStore<{ mutedUsers: Mute[] }>; + readonly mutedUsersStore: StateStore<{ mutedUsers: UserMuteResponse[] }>; readonly configsStore: StateStore; blockedUsers: StateStore; node: boolean; options: StreamChatOptions; - secret?: string; setUserPromise: ConnectAPIResponse | null; state: ClientState; tokenManager: TokenManager; - user?: OwnUserResponse | UserResponse; + user?: ClientUser; userAgent?: string; - userID?: string; wsBaseURL?: string; wsConnection: StableWSConnection | null; wsFallback?: WSConnectionFallback; wsPromise: ConnectAPIResponse | null; - consecutiveFailures: number; + get anonymous(): boolean { + return this.user?.anon ?? false; + } + get userId() { + return this.user?.id; + } + /** + * @deprecated Use `userId` instead. + */ + get userID() { + return this.user?.id; + } + /** + * @deprecated Use `clientId` instead. + */ + get clientID() { + return this.clientId; + } + set clientID(id: string | undefined) { + this.clientId = id; + } + get api() { + return this.apiClient; + } insightMetrics: InsightMetrics; defaultWSTimeoutWithFallback: number; defaultWSTimeout: number; @@ -391,38 +216,39 @@ export class StreamChat { instanceConfigurationService = new InstanceConfigurationService(); /** - * Initialize a client + * Initializes a client. + * + * **Only use constructor for advanced usages. It is strongly advised to use `StreamChat.getInstance()` instead of `new StreamChat()` to reduce integration issues due to multiple WebSocket connections.** * - * **Only use constructor for advanced usages. It is strongly advised to use `StreamChat.getInstance()` instead of `new StreamChat()` to reduce integration issues due to multiple WebSocket connections** - * @param {string} key - the api key - * @param {string} [secret] - the api secret - * @param {StreamChatOptions} [options] - additional options, here you can pass custom options to axios instance - * @param {boolean} [options.browser] - enforce the client to be in browser mode - * @param {boolean} [options.warmUp] - default to false, if true, client will open a connection as soon as possible to speed up following requests - * @param {Logger} [options.Logger] - custom logger - * @param {number} [options.timeout] - default to 3000 - * @param {httpsAgent} [options.httpsAgent] - custom httpsAgent, in node it's default to https.agent() * @example initialize the client in user mode * new StreamChat('api_key') * @example initialize the client in user mode with options - * new StreamChat('api_key', { warmUp:true, timeout:5000 }) + * new StreamChat('api_key', { warmUp: true, timeout: 5000 }) * @example secret is optional and only used in server side mode - * new StreamChat('api_key', "secret", { httpsAgent: customAgent }) - */ - constructor(key: string, options?: StreamChatOptions); - constructor(key: string, secret?: string, options?: StreamChatOptions); - constructor( - key: string, - secretOrOptions?: StreamChatOptions | string, - options?: StreamChatOptions, - ) { + * new StreamChat('api_key', 'secret', { httpsAgent: customAgent }) + * + * @param key - The API key. + * @param options - Additional options; here you can pass custom options to the axios instance (optional). + * @param options.browser - Enforce the client to be in browser mode (optional). + * @param options.warmUp - If `true`, the client will open a connection as soon as possible to speed up following requests (optional, defaults to `false`). + * @param options.logLevel - Minimum log level for the default sink (optional, defaults to `'info'`). + * @param options.logOptions - Per-scope sink/level overrides for `chatLoggerSystem` (optional). + * @param options.timeout - Request timeout (optional, defaults to `3000`). + * @param options.httpsAgent - Custom `httpsAgent` (optional, in Node defaults to `https.agent()`). + */ + constructor(key: string, options: StreamChatOptions = {}) { + // generated client requires ApiClient right away + super(new ApiClient()); + // but ApiClient relies on properties defined here so we set it after (can't pass `this` in super call) + this.apiClient.client = this; + // set the key this.key = key; - this.listeners = {}; + this.listeners = new Map(); this.state = new ClientState({ client: this }); // a list of channels to hide ws events from this.mutedChannels = []; - this.mutedUsersStore = new StateStore<{ mutedUsers: Mute[] }>({ + this.mutedUsersStore = new StateStore<{ mutedUsers: UserMuteResponse[] }>({ mutedUsers: [], }); this.configsStore = new StateStore<{ configs: Configs }>({ @@ -435,60 +261,37 @@ export class StreamChat { this.notifications = options?.notifications ?? new NotificationManager(); this.uploadManager = new UploadManager(this); - // set the secret - if (secretOrOptions && isString(secretOrOptions)) { - this.secret = secretOrOptions; - } - - // set the options... and figure out defaults... - const inputOptions = options - ? options - : secretOrOptions && !isString(secretOrOptions) - ? secretOrOptions - : {}; - - this.browser = - typeof inputOptions.browser !== 'undefined' - ? inputOptions.browser - : typeof window !== 'undefined'; + this.browser = options.browser ?? typeof window !== 'undefined'; this.node = !this.browser; this.options = { - timeout: 3000, - withCredentials: false, // making sure cookies are not sent warmUp: false, recoverStateOnReconnect: true, disableCache: false, isLocalUnreadCountEnabled: false, wsUrlParams: new URLSearchParams({}), - ...inputOptions, + ...options, }; - if (this.node && !this.options.httpsAgent) { - this.options.httpsAgent = new https.Agent({ - keepAlive: true, - keepAliveMsecs: 3000, - }); - } - - this.axiosInstance = axios.create(this.options); + this.axiosInstance = axios.create({ + timeout: 3000, + withCredentials: false, + httpsAgent: this.node + ? new https.Agent({ keepAlive: true, keepAliveMsecs: 3000 }) + : undefined, + ...this.options.axiosRequestConfig, + paramsSerializer: axiosParamsSerializer, + }); this.setBaseURL(this.options.baseURL || 'https://chat.stream-io-api.com'); - if ( - typeof process !== 'undefined' && - 'env' in process && - process.env.STREAM_LOCAL_TEST_RUN - ) { + const streamLocalTestRun = getEnv('STREAM_LOCAL_TEST_RUN'); + const streamLocalTestHost = getEnv('STREAM_LOCAL_TEST_HOST'); + if (streamLocalTestRun) { this.setBaseURL('http://localhost:3030'); } - - if ( - typeof process !== 'undefined' && - 'env' in process && - process.env.STREAM_LOCAL_TEST_HOST - ) { - this.setBaseURL('http://' + process.env.STREAM_LOCAL_TEST_HOST); + if (streamLocalTestHost) { + this.setBaseURL(`http://${streamLocalTestHost}`); } // WS connection is initialized when setUser is called @@ -500,69 +303,16 @@ export class StreamChat { // mapping between channel groups and configs this.configs = {}; - this.anonymous = false; this.persistUserOnConnectionFailure = this.options?.persistUserOnConnectionFailure; // If its a server-side client, then lets initialize the tokenManager, since token will be // generated from secret. - this.tokenManager = new TokenManager(this.secret); - this.consecutiveFailures = 0; + this.tokenManager = new TokenManager(); this.insightMetrics = new InsightMetrics(); this.defaultWSTimeoutWithFallback = 6 * 1000; this.defaultWSTimeout = 15 * 1000; - this.axiosInstance.defaults.paramsSerializer = axiosParamsSerializer; - - /** - * logger function should accept 3 parameters: - * @param logLevel string - * @param message string - * @param extraData object - * - * e.g., - * const client = new StreamChat('api_key', {}, { - * logger = (logLevel, message, extraData) => { - * console.log(message); - * } - * }) - * - * extraData contains tags array attached to log message. Tags can have one/many of following values: - * 1. api - * 2. api_request - * 3. api_response - * 4. client - * 5. channel - * 6. connection - * 7. event - * - * It may also contains some extra data, some examples have been mentioned below: - * 1. { - * tags: ['api', 'api_request', 'client'], - * url: string, - * payload: object, - * config: object - * } - * 2. { - * tags: ['api', 'api_response', 'client'], - * url: string, - * response: object - * } - * 3. { - * tags: ['api', 'api_response', 'client'], - * url: string, - * error: object - * } - * 4. { - * tags: ['event', 'client'], - * event: object - * } - * 5. { - * tags: ['channel'], - * channel: object - * } - */ - this.logger = isFunction(inputOptions.logger) ? inputOptions.logger : () => null; this.recoverStateOnReconnect = this.options.recoverStateOnReconnect; this.threads = new ThreadManager({ client: this }); this.polls = new PollManager({ client: this }); @@ -575,7 +325,7 @@ export class StreamChat { return this.mutedUsersStore.getLatestValue().mutedUsers; } - set mutedUsers(mutedUsers: Mute[]) { + set mutedUsers(mutedUsers: UserMuteResponse[]) { this.mutedUsersStore.next({ mutedUsers }); } @@ -588,44 +338,32 @@ export class StreamChat { } /** - * Get a client instance + * Returns a client instance. * - * This function always returns the same Client instance to avoid issues raised by multiple Client and WS connections + * This function always returns the same client instance to avoid issues raised by multiple client and WS connections. * - * **After the first call, the client configuration will not change if the key or options parameters change** + * **After the first call, the client configuration will not change if the key or options parameters change.** * - * @param {string} key - the api key - * @param {string} [secret] - the api secret - * @param {StreamChatOptions} [options] - additional options, here you can pass custom options to axios instance - * @param {boolean} [options.browser] - enforce the client to be in browser mode - * @param {boolean} [options.warmUp] - default to false, if true, client will open a connection as soon as possible to speed up following requests - * @param {Logger} [options.Logger] - custom logger - * @param {number} [options.timeout] - default to 3000 - * @param {httpsAgent} [options.httpsAgent] - custom httpsAgent, in node it's default to https.agent() * @example initialize the client in user mode * StreamChat.getInstance('api_key') * @example initialize the client in user mode with options - * StreamChat.getInstance('api_key', { timeout:5000 }) + * StreamChat.getInstance('api_key', { timeout: 5000 }) * @example secret is optional and only used in server side mode - * StreamChat.getInstance('api_key', "secret", { httpsAgent: customAgent }) - */ - public static getInstance(key: string, options?: StreamChatOptions): StreamChat; - public static getInstance( - key: string, - secret?: string, - options?: StreamChatOptions, - ): StreamChat; - public static getInstance( - key: string, - secretOrOptions?: StreamChatOptions | string, - options?: StreamChatOptions, - ): StreamChat { + * StreamChat.getInstance('api_key', 'secret', { httpsAgent: customAgent }) + * + * @param key - The API key. + * @param options - Additional options; here you can pass custom options to the axios instance (optional). + * @param options.browser - Enforce the client to be in browser mode (optional). + * @param options.warmUp - If `true`, the client will open a connection as soon as possible to speed up following requests (optional, defaults to `false`). + * @param options.logLevel - Minimum log level for the default sink (optional, defaults to `'info'`). + * @param options.logOptions - Per-scope sink/level overrides for `chatLoggerSystem` (optional). + * @param options.timeout - Request timeout (optional, defaults to `3000`). + * @param options.httpsAgent - Custom `httpsAgent` (optional, in Node defaults to `https.agent()`). + * @returns The shared client instance. + */ + public static getInstance(key: string, options?: StreamChatOptions): StreamChat { if (!StreamChat._instance) { - if (typeof secretOrOptions === 'string') { - StreamChat._instance = new StreamChat(key, secretOrOptions, options); - } else { - StreamChat._instance = new StreamChat(key, secretOrOptions); - } + StreamChat._instance = new StreamChat(key, options); } return StreamChat._instance as StreamChat; @@ -639,10 +377,6 @@ export class StreamChat { this.offlineDb = offlineDBInstance; } - devToken(userID: string) { - return DevToken(userID); - } - getAuthType() { return this.anonymous ? 'anonymous' : 'jwt'; } @@ -672,51 +406,44 @@ export class StreamChat { }; /** - * connectUser - Set the current user and open a WebSocket connection - * - * @param {OwnUserResponse | UserResponse} user Data about this user. IE {name: "john"} - * @param {TokenOrProvider} userTokenOrProvider Token or provider + * Sets the current user and opens a WebSocket connection. * - * @return {ConnectAPIResponse} Returns a promise that resolves when the connection is setup + * @param user - Data about this user, e.g. `{ name: 'john' }`. + * @param userTokenOrProvider - A token string or an async provider that returns one. + * @returns A promise that resolves when the connection is set up. */ - connectUser = async ( - user: OwnUserResponse | UserResponse, - userTokenOrProvider: TokenOrProvider, - ) => { + connectUser = async (user: ClientUser, userTokenOrProvider: TokenOrProvider) => { if (!user.id) { throw new Error('The "id" field on the user is missing'); } /** - * Calling connectUser multiple times is potentially the result of a bad integration, however, - * If the user id remains the same we don't throw error + * Calling connectUser multiple times is potentially the result of a bad integration; however, + * if the user ID remains the same we don't throw an error. */ - if (this.userID === user.id && this.setUserPromise) { - console.warn( - 'Consecutive calls to connectUser is detected, ideally you should only call this function once in your app.', - ); + if (this.userId === user.id && this.setUserPromise) { + logger + .withExtraTags('connectUser') + .warn( + 'Detected consecutive calls to connectUser. Ideally, this function should only be called once.', + ); return this.setUserPromise; } - if (this.userID) { + if (this.userId) { throw new Error( 'Use client.disconnect() before trying to connect as a different user. connectUser was called twice.', ); } - if ( - (this._isUsingServerAuth() || this.node) && - !this.options.allowServerSideConnect - ) { - console.warn( - 'Please do not use connectUser server side. connectUser impacts MAU and concurrent connection usage and thus your bill. If you have a valid use-case, add "allowServerSideConnect: true" to the client options to disable this warning.', - ); + if (this.node && !this.options.allowServerSideConnect) { + logger + .withExtraTags('connectUser') + .warn( + 'Do not use connectUser server-side. connectUser impacts MAU and concurrent connection usage, and therefore your bill. If you have a valid use case, set "allowServerSideConnect: true" in the client options to disable this warning.', + ); } - // we generate the client id client side - this.userID = user.id; - this.anonymous = false; - const setTokenPromise = this._setToken(user, userTokenOrProvider); this._setUser(user); @@ -740,43 +467,41 @@ export class StreamChat { }; /** - * @deprecated Please use connectUser() function instead. Its naming is more consistent with its functionality. + * Sets the current user and opens a WebSocket connection. * - * setUser - Set the current user and open a WebSocket connection + * @deprecated Use {@link StreamChat.connectUser} instead. Its naming is more consistent with its functionality. * - * @param {OwnUserResponse | UserResponse} user Data about this user. IE {name: "john"} - * @param {TokenOrProvider} userTokenOrProvider Token or provider - * - * @return {ConnectAPIResponse} Returns a promise that resolves when the connection is setup + * @param user - Data about this user, e.g. `{ name: 'john' }`. + * @param userTokenOrProvider - A token string or an async provider that returns one. + * @returns A promise that resolves when the connection is set up. */ setUser = this.connectUser; - _setToken = (user: UserResponse, userTokenOrProvider: TokenOrProvider) => + _setToken = (user: TokenManagerMinimalUser, userTokenOrProvider: TokenOrProvider) => this.tokenManager.setTokenOrProvider(userTokenOrProvider, user); - _setUser(user: OwnUserResponse | UserResponse) { + _setUser(user: TokenManagerMinimalUser) { /** * This one is used by the frontend. This is a copy of the current user object stored on backend. * It contains reserved properties and own user properties which are not present in `this._user`. */ this.user = user; - this.userID = user.id; // this one is actually used for requests. This is a copy of current user provided to `connectUser` function. this._user = { ...user }; } /** - * Disconnects the websocket connection, without removing the user set on client. + * Disconnects the WebSocket connection, without removing the user set on client. * client.closeConnection will not trigger default auto-retry mechanism for reconnection. You need - * to call client.openConnection to reconnect to websocket. + * to call `client.openConnection` to reconnect to the WebSocket. * * This is mainly useful on mobile side. You can only receive push notifications - * if you don't have active websocket connection. + * if you don't have an active WebSocket connection. * So when your app goes to background, you can call `client.closeConnection`. * And when app comes back to foreground, call `client.openConnection`. * - * @param timeout Max number of ms, to wait for close event of websocket, before forcefully assuming succesful disconnection. - * https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent + * @param timeout - Max number of milliseconds to wait for the WebSocket close event before forcefully assuming + * successful disconnection. See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent (optional). */ closeConnection = async (timeout?: number) => { if (this.cleaningIntervalRef != null) { @@ -791,9 +516,9 @@ export class StreamChat { this.offlineDb?.executeQuerySafely( async (db) => { - if (this.userID) { + if (this.userId) { await db.upsertUserSyncStatus({ - userId: this.userID, + userId: this.userId, lastSyncedAt: new Date().toString(), }); } @@ -805,12 +530,16 @@ export class StreamChat { }; /** - * Creates an instance of ChannelManager. + * Creates an instance of `ChannelManager`. * * @internal * - * @param eventHandlerOverrides - the overrides for event handlers to be used - * @param options - the options used for the channel manager + * @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). + * @returns A new `ChannelManager` instance. */ createChannelManager = ({ eventHandlerOverrides = {}, @@ -829,19 +558,21 @@ export class StreamChat { }); /** - * Creates a new WebSocket connection with the current user. Returns empty promise, if there is an active connection + * Creates a new WebSocket connection with the current user. + * + * @returns The WebSocket connect promise, or an empty resolved promise if a connection is already active. */ openConnection = () => { - if (!this.userID) { + if (!this.userId) { throw Error( 'User is not set on client, use client.connectUser or client.connectAnonymousUser instead', ); } if (this.wsConnection?.isConnecting && this.wsPromise) { - this.logger('info', 'client:openConnection() - connection already in progress', { - tags: ['connection', 'client'], - }); + logger + .withExtraTags('openConnection') + .debug('A connection attempt is already in progress.'); return this.wsPromise; } @@ -849,224 +580,64 @@ export class StreamChat { (this.wsConnection?.isHealthy || this.wsFallback?.isHealthy()) && this._hasConnectionID() ) { - this.logger( - 'info', - 'client:openConnection() - openConnection called twice, healthy connection already exists', - { - tags: ['connection', 'client'], - }, - ); + logger + .withExtraTags('openConnection') + .debug('openConnection was called twice; a healthy connection already exists.'); return; } - this.clientID = `${this.userID}--${randomId()}`; + this.clientId = `${this.userId}--${randomId()}`; this.wsPromise = this.connect(); this._startCleaning(); return this.wsPromise; }; - - /** - * @deprecated Please use client.openConnction instead. - * @private - * - * Creates a new websocket connection with current user. - */ - _setupConnection = this.openConnection; - /** - * updateAppSettings - updates application settings + * Revokes tokens for a connected user issued before the given time. * - * @param {AppSettings} options App settings. - * IE: { - 'apn_config': { - 'auth_type': 'token', - 'auth_key": fs.readFileSync( - './apn-push-auth-key.p8', - 'utf-8', - ), - 'key_id': 'keyid', - 'team_id': 'teamid', - 'notification_template": 'notification handlebars template', - 'bundle_id': 'com.apple.your.app', - 'development': true - }, - 'firebase_config': { - 'server_key': 'server key from fcm', - 'notification_template': 'notification handlebars template', - 'data_template': 'data handlebars template', - 'apn_template': 'apn notification handlebars template under v2' - }, - 'webhook_url': 'https://acme.com/my/awesome/webhook/', - 'event_hooks': [ - { - 'hook_type': 'webhook', - 'enabled': true, - 'event_types': ['message.new'], - 'webhook_url': 'https://acme.com/my/awesome/webhook/' - }, - { - 'hook_type': 'sqs', - 'enabled': true, - 'event_types': ['message.new'], - 'sqs_url': 'https://sqs.us-east-1.amazonaws.com/1234567890/my-queue', - 'sqs_auth_type': 'key', - 'sqs_key': 'my-access-key', - 'sqs_secret': 'my-secret-key' - } - ] - } - */ - async updateAppSettings(options: AppSettings) { - const apn_config = options.apn_config; - if (apn_config?.p12_cert) { - options = { - ...options, - apn_config: { - ...apn_config, - p12_cert: Buffer.from(apn_config.p12_cert).toString('base64'), - }, - }; - } - return await this.patch(this.baseURL + '/app', options); - } - - _normalizeDate = (before: Date | string | null): string | null => { - if (before instanceof Date) { - before = before.toISOString(); - } - - if (before === '') { - throw new Error( - "Don't pass blank string for since, use null instead if resetting the token revoke", - ); - } - - return before; - }; - - /** - * Revokes all tokens on application level issued before given time - */ - async revokeTokens(before: Date | string | null) { - return await this.updateAppSettings({ - revoke_tokens_issued_before: this._normalizeDate(before), - }); - } - - /** - * Revokes token for a user issued before given time - */ - async revokeUserToken(userID: string, before?: Date | string | null) { - return await this.revokeUsersToken([userID], before); - } - - /** - * Revokes tokens for a list of users issued before given time + * @param before - Cutoff date; tokens issued before this are revoked (optional, defaults to the current time). + * @returns The updated users response. */ - async revokeUsersToken(userIDs: string[], before?: Date | string | null) { - if (before === undefined) { - before = new Date().toISOString(); - } else { - before = this._normalizeDate(before); + async revokeTokens(before?: Date | null) { + if (!before) { + before = new Date(); } - const users: PartialUserUpdate[] = []; - for (const userID of userIDs) { - users.push({ - id: userID, - set: >{ + const users: UpdateUserPartialRequest[] = [ + { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + id: this.userId!, + set: { revoke_tokens_issued_before: before, }, - }); - } - - return await this.partialUpdateUsers(users); - } - - /** - * getAppSettings - retrieves application settings - */ - async getAppSettings() { - this.appSettingsPromise = this.get(this.baseURL + '/app'); - return await this.appSettingsPromise; - } - - /** - * testPushSettings - Tests the push settings for a user with a random chat message and the configured push templates - * - * @param {string} userID User ID. If user has no devices, it will error - * @param {TestPushDataInput} [data] Overrides for push templates/message used - * IE: { - messageID: 'id-of-message', // will error if message does not exist - apnTemplate: '{}', // if app doesn't have apn configured it will error - firebaseTemplate: '{}', // if app doesn't have firebase configured it will error - firebaseDataTemplate: '{}', // if app doesn't have firebase configured it will error - skipDevices: true, // skip config/device checks and sending to real devices - pushProviderName: 'staging' // one of your configured push providers - pushProviderType: 'apn' // one of supported provider types - } - */ - async testPushSettings(userID: string, data: TestPushDataInput = {}) { - return await this.post(this.baseURL + '/check_push', { - user_id: userID, - ...(data.messageID ? { message_id: data.messageID } : {}), - ...(data.apnTemplate ? { apn_template: data.apnTemplate } : {}), - ...(data.firebaseTemplate ? { firebase_template: data.firebaseTemplate } : {}), - ...(data.firebaseDataTemplate - ? { firebase_data_template: data.firebaseDataTemplate } - : {}), - ...(data.skipDevices ? { skip_devices: true } : {}), - ...(data.pushProviderName ? { push_provider_name: data.pushProviderName } : {}), - ...(data.pushProviderType ? { push_provider_type: data.pushProviderType } : {}), - }); - } + }, + ]; - /** - * testSQSSettings - Tests that the given or configured SQS configuration is valid - * - * @param {TestSQSDataInput} [data] Overrides SQS settings for testing if needed - * IE: { - sqs_key: 'auth_key', - sqs_secret: 'auth_secret', - sqs_url: 'url_to_queue', - } - */ - async testSQSSettings(data: TestSQSDataInput = {}) { - return await this.post(this.baseURL + '/check_sqs', data); + return await this.updateUsersPartial({ users }); } /** - * testSNSSettings - Tests that the given or configured SNS configuration is valid + * Retrieves application settings. * - * @param {TestSNSDataInput} [data] Overrides SNS settings for testing if needed - * IE: { - sns_key: 'auth_key', - sns_secret: 'auth_secret', - sns_topic_arn: 'topic_to_publish_to', - } + * @returns The application settings response. */ - async testSNSSettings(data: TestSNSDataInput = {}) { - return await this.post(this.baseURL + '/check_sns', data); + async getAppSettings() { + return await (this.appSettingsPromise = this.getApp()); } /** - * Disconnects the websocket and removes the user from client. + * Disconnects the WebSocket and removes the user from client. * - * @param timeout Max number of ms, to wait for close event of websocket, before forcefully assuming successful disconnection. - * https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent + * @param timeout - Max number of milliseconds to wait for the WebSocket close event before forcefully assuming + * successful disconnection. See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent (optional). + * @returns The close-connection promise. */ disconnectUser = (timeout?: number) => { - this.logger('info', 'client:disconnect() - Disconnecting the client', { - tags: ['connection', 'client'], - }); + logger.withExtraTags('disconnectUser').info('Disconnecting the client.'); // remove the user specific fields delete this.user; delete this._user; - delete this.userID; - - this.anonymous = false; const closePromise = this.closeConnection(timeout); @@ -1087,7 +658,11 @@ export class StreamChat { .finally(() => { this.tokenManager.reset(); }) - .catch((err) => console.error(err)); + .catch((err) => + logger + .withExtraTags('disconnectUser') + .error('The close promise rejected during disconnect.', { error: err }), + ); // close the WS connection return closePromise; @@ -1097,335 +672,148 @@ export class StreamChat { * * @deprecated Please use client.disconnectUser instead. * - * Disconnects the websocket and removes the user from client. + * Disconnects the WebSocket and removes the user from client. */ disconnect = this.disconnectUser; /** - * connectAnonymousUser - Set an anonymous user and open a WebSocket connection + * Sets an anonymous user and opens a WebSocket connection. + * + * @returns A promise that resolves when the connection is set up. */ connectAnonymousUser = () => { - if ( - (this._isUsingServerAuth() || this.node) && - !this.options.allowServerSideConnect - ) { - console.warn( - 'Please do not use connectUser server side. connectUser impacts MAU and concurrent connection usage and thus your bill. If you have a valid use-case, add "allowServerSideConnect: true" to the client options to disable this warning.', - ); + if (this.node && !this.options.allowServerSideConnect) { + logger + .withExtraTags('connectAnonymousUser') + .warn( + 'Do not use connectUser server-side. connectUser impacts MAU and concurrent connection usage, and therefore your bill. If you have a valid use case, set "allowServerSideConnect: true" in the client options to disable this warning.', + ); } - this.anonymous = true; - this.userID = randomId(); const anonymousUser = { - id: this.userID, + id: randomId(), anon: true, - } as UserResponse; + } satisfies TokenManagerMinimalUser; this._setToken(anonymousUser, ''); this._setUser(anonymousUser); - return this._setupConnection(); + return this.openConnection(); }; /** - * @deprecated Please use connectAnonymousUser. Its naming is more consistent with its functionality. - */ - setAnonymousUser = this.connectAnonymousUser; - - /** - * setGuestUser - Setup a temporary guest user - * - * @param {UserResponse} user Data about this user. IE {name: "john"} + * Sets up a temporary guest user. * - * @return {ConnectAPIResponse} Returns a promise that resolves when the connection is setup + * @param user - Data about this user, e.g. `{ name: 'john' }`. + * @returns A promise that resolves when the connection is set up. */ async setGuestUser(user: UserResponse) { - let response: { access_token: string; user: UserResponse } | undefined; - this.anonymous = true; - try { - response = await this.post< - APIResponse & { - access_token: string; - user: UserResponse; - } - >(this.baseURL + '/guest', { user }); - } catch (e) { - this.anonymous = false; - throw e; - } - this.anonymous = false; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { created_at, updated_at, last_active, online, ...guestUser } = response.user; - return await this.connectUser(guestUser as UserResponse, response.access_token); - } - - /** - * createToken - Creates a token to authenticate this user. This function is used server side. - * The resulting token should be passed to the client side when the users registers or logs in. - * - * @param {string} userID The User ID - * @param {number} [exp] The expiration time for the token expressed in the number of seconds since the epoch - * - * @return {string} Returns a token - */ - createToken(userID: string, exp?: number, iat?: number) { - if (this.secret == null) { - throw Error(`tokens can only be created server-side using the API Secret`); - } - const extra: { exp?: number; iat?: number } = {}; - - if (exp) { - extra.exp = exp; - } + const response = await this.createGuest({ user }); - if (iat) { - extra.iat = iat; - } + const { + created_at: _created_at, + updated_at: _updated_at, + last_active: _last_active, + online: _online, + ...guestUser + } = response.user; - return JWTUserToken(this.secret, userID, extra, {}); + return await this.connectUser(guestUser as UserResponse, response.access_token); } /** - * on - Listen to events on all channels and users your watching + * Listens to events on all channels and users you're watching. * - * client.on('message.new', event => {console.log("my new message", event, channel.messagePaginator.state.items)}) - * or - * client.on(event => {console.log(event.type)}) + * @example + * client.on('message.new', (event) => { + * console.log('my new message', event, channel.state.messages); + * }); * - * @param {EventHandler | string} callbackOrString The event type to listen for (optional) - * @param {EventHandler} [callbackOrNothing] The callback to call + * @example + * client.on((event) => { + * console.log(event.type); + * }); * - * @return {{ unsubscribe: () => void }} Description + * @param callbackOrString - The event type to listen for, or the callback when listening to all events. + * @param callbackOrNothing - The callback to call when an event type was provided (optional). + * @returns An object with an `unsubscribe()` method. */ on(callback: EventHandler): { unsubscribe: () => void }; - on(eventType: string, callback: EventHandler): { unsubscribe: () => void }; + on( + eventType: T, + callback: EventHandler, + ): { unsubscribe: () => void }; on( callbackOrString: EventHandler | string, callbackOrNothing?: EventHandler, ): { unsubscribe: () => void } { - const key = callbackOrNothing ? (callbackOrString as string) : 'all'; + const key = callbackOrNothing ? (callbackOrString as EventType) : 'all'; const callback = callbackOrNothing ? callbackOrNothing : (callbackOrString as EventHandler); - if (!(key in this.listeners)) { - this.listeners[key] = []; + + const set = this.listeners.get(key) ?? new Set(); + + logger.withExtraTags('on').debug(`Attaching a listener for the "${key}" event.`); + set.add(callback); + + if (!this.listeners.has(key)) { + this.listeners.set(key, set); } - this.logger('info', `Attaching listener for ${key} event`, { - tags: ['event', 'client'], - }); - this.listeners[key].push(callback); + return { unsubscribe: () => { - this.logger('info', `Removing listener for ${key} event`, { - tags: ['event', 'client'], - }); - this.listeners[key] = this.listeners[key].filter((el) => el !== callback); + logger.withExtraTags('on').debug(`Removing the listener for the "${key}" event.`); + set.delete(callback); + if (!set.size) { + this.listeners.delete(key); + } }, }; } /** - * off - Remove the event handler + * Removes the event handler. * + * @param callbackOrString - The event type, or the callback when removing an all-events listener. + * @param callbackOrNothing - The callback to remove when an event type was provided (optional). */ off(callback: EventHandler): void; off(eventType: string, callback: EventHandler): void; off(callbackOrString: EventHandler | string, callbackOrNothing?: EventHandler) { - const key = callbackOrNothing ? (callbackOrString as string) : 'all'; + const key = callbackOrNothing ? (callbackOrString as EventType) : 'all'; const callback = callbackOrNothing ? callbackOrNothing : (callbackOrString as EventHandler); - if (!(key in this.listeners)) { - this.listeners[key] = []; - } - - this.logger('info', `Removing listener for ${key} event`, { - tags: ['event', 'client'], - }); - this.listeners[key] = this.listeners[key].filter((value) => value !== callback); - } - - _logApiRequest( - type: string, - url: string, - data: unknown, - config: AxiosRequestConfig & { - config?: AxiosRequestConfig & { maxBodyLength?: number }; - }, - ) { - this.logger('info', `client: ${type} - Request - ${url}`, { - tags: ['api', 'api_request', 'client'], - url, - payload: data, - config, - }); - } - - _logApiResponse(type: string, url: string, response: AxiosResponse) { - this.logger( - 'info', - `client:${type} - Response - url: ${url} > status ${response.status}`, - { - tags: ['api', 'api_response', 'client'], - url, - response, - }, - ); - } - - _logApiError(type: string, url: string, error: unknown) { - this.logger('error', `client:${type} - Error - url: ${url}`, { - tags: ['api', 'api_response', 'client'], - url, - error, - }); - } - - doAxiosRequest = async ( - type: string, - url: string, - data?: unknown, - options: AxiosRequestConfig & { - config?: AxiosRequestConfig & { maxBodyLength?: number }; - } = {}, - ): Promise => { - await this.tokenManager.tokenReady(); - const requestConfig = this._enrichAxiosOptions(options); - try { - let response: AxiosResponse; - this._logApiRequest(type, url, data, requestConfig); - switch (type) { - case 'get': - response = await this.axiosInstance.get(url, requestConfig); - break; - case 'delete': - response = await this.axiosInstance.delete(url, requestConfig); - break; - case 'post': - response = await this.axiosInstance.post(url, data, requestConfig); - break; - case 'postForm': - response = await this.axiosInstance.postForm(url, data, requestConfig); - break; - case 'put': - response = await this.axiosInstance.put(url, data, requestConfig); - break; - case 'patch': - response = await this.axiosInstance.patch(url, data, requestConfig); - break; - case 'options': - response = await this.axiosInstance.options(url, requestConfig); - break; - default: - throw new Error('Invalid request type'); - } - this._logApiResponse(type, url, response); - this.consecutiveFailures = 0; - return this.handleResponse(response); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any /**TODO: generalize error types */) { - e.client_request_id = requestConfig.headers?.['x-client-request-id']; - this._logApiError(type, url, e); - this.consecutiveFailures += 1; - if (e.response) { - /** connection_fallback depends on this token expiration logic */ - if ( - e.response.data.code === chatCodes.TOKEN_EXPIRED && - !this.tokenManager.isStatic() - ) { - if (this.consecutiveFailures > 1) { - await sleep(retryInterval(this.consecutiveFailures)); - } - this.tokenManager.loadToken(); - return await this.doAxiosRequest(type, url, data, options); - } - return this.handleResponse(e.response); - } else { - throw e as AxiosError; - } - } - }; - - get(url: string, params?: AxiosRequestConfig['params']) { - return this.doAxiosRequest('get', url, null, { params }); - } - - put(url: string, data?: unknown) { - return this.doAxiosRequest('put', url, data); - } - - post(url: string, data?: unknown) { - return this.doAxiosRequest('post', url, data); - } - patch(url: string, data?: unknown) { - return this.doAxiosRequest('patch', url, data); - } - - delete(url: string, params?: AxiosRequestConfig['params']) { - return this.doAxiosRequest('delete', url, null, { params }); - } - - sendFile( - url: string, - uri: string | NodeJS.ReadableStream | Buffer | File, - name?: string, - contentType?: string, - user?: UserResponse, - axiosRequestConfig?: AxiosRequestConfig, - ) { - const data = addFileToFormData(uri, name, contentType || 'multipart/form-data'); - if (user != null) data.append('user', JSON.stringify(user)); - - return this.doAxiosRequest('postForm', url, data, { - headers: data.getHeaders ? data.getHeaders() : {}, // node vs browser - config: { - timeout: 0, - maxContentLength: Infinity, - maxBodyLength: Infinity, - ...axiosRequestConfig, - }, - }); - } + logger.withExtraTags('off').debug(`Removing the listener for the "${key}" event.`); - errorFromResponse(response: AxiosResponse) { - const message = - typeof response.data.code !== 'undefined' - ? `StreamChat error code ${response.data.code}: ${response.data.message}` - : `StreamChat error HTTP code: ${response.status}`; + const set = this.listeners.get(key); - return new ErrorFromResponse(message, { - code: response.data.code ?? null, - response, - status: response.status, - }); - } + set?.delete(callback); - handleResponse(response: AxiosResponse) { - const data = response.data; - if (isErrorResponse(response)) { - throw this.errorFromResponse(response); + if (!set?.size) { + this.listeners.delete(key); } - return data; } dispatchEvent = (event: Event) => { if (!event.received_at) event.received_at = new Date(); // client event handlers - const postListenerCallbacks = this._handleClientEvent(event); + const postListenerCallbacks = this._handleClientEvent(event as WSEvent); // channel event handlers - const cid = event.cid; + const cid = (event as Extract).cid; const channel = cid ? this.activeChannels[cid] : undefined; if (channel) { - channel._handleChannelEvent(event); + channel._handleChannelEvent(event as WSEvent); } this._callClientListeners(event); if (channel) { - channel._callChannelListeners(event); + channel._callChannelListeners(event as WSEvent); } postListenerCallbacks.forEach((c) => c()); @@ -1436,14 +824,14 @@ export class StreamChat { }; /** - * Updates the members, watchers and read references of the currently active channels that contain this user + * Updates the members, watchers and read references of the currently active channels that contain this user. * - * @param {UserResponse} user + * @param user - The updated user. */ _updateMemberWatcherReferences = (user: UserResponse) => { const refMap = this.state.userChannelReferences[user.id] || {}; - for (const channelID in refMap) { - const channel = this.activeChannels[channelID]; + for (const channelId in refMap) { + const channel = this.activeChannels[channelId]; if (channel?.state) { if (channel.state.members[user.id]) { channel.state.members[user.id].user = user; @@ -1459,21 +847,20 @@ export class StreamChat { }; /** - * @deprecated Please _updateMemberWatcherReferences instead. + * @deprecated Please use `_updateMemberWatcherReferences` instead. * @private */ _updateUserReferences = this._updateMemberWatcherReferences; /** - * @private + * Updates the messages from the currently active channels that contain this user, with the updated user object. * - * Updates the messages from the currently active channels that contain this user, - * with updated user object. + * @private * - * @param {UserResponse} user + * @param user - The updated user. */ _updateUserMessageReferences = (user: UserResponse) => { - // Scan all active channels rather than a user->channel reference map. Message authors are no + // Scan all active channels rather than a user->channel reference map. MessageRequest authors are no // longer registered as channel references (that registration was removed along with // `Channel._trackLatestMessage`); `reflectUserUpdate` filters by author id internally, so it is // a no-op on channels without this user's messages. @@ -1488,15 +875,16 @@ export class StreamChat { }; /** - * @private + * Deletes the messages from the currently active channels that contain this user. * - * Deletes the messages from the currently active channels that contain this user + * If `hardDelete` is `true`, all the content of the message will be stripped down. + * Otherwise, only `message.type` will be set as `'deleted'`. * - * If hardDelete is true, all the content of message will be stripped down. - * Otherwise, only 'message.type' will be set as 'deleted'. + * @private * - * @param {UserResponse} user - * @param {boolean} hardDelete + * @param user - The user whose messages should be deleted. + * @param hardDelete - Whether to fully strip the message content (optional, defaults to `false`). + * @param deletedAt - Timestamp to mark messages as deleted at (optional). */ _deleteUserMessageReference = ( user: UserResponse, @@ -1523,23 +911,28 @@ export class StreamChat { }; /** - * @private + * Handle the following user-related events: + * - `user.presence.changed` + * - `user.updated` + * - `user.deleted` * - * Handle following user related events: - * - user.presence.changed - * - user.updated - * - user.deleted + * @private * - * @param {Event} event + * @param event - The user event. */ - _handleUserEvent = (event: Event) => { + _handleUserEvent = ( + event: Extract< + WSEvent, + { type: 'user.presence.changed' | 'user.updated' | 'user.deleted' } + >, + ) => { if (!event.user) { return; } /** update the client.state with any changes to users */ if (event.type === 'user.presence.changed' || event.type === 'user.updated') { - if (event.user.id === this.userID) { + if (event.user.id === this.userId) { const user = { ...this.user } as NonNullable; const _user = { ...this._user } as NonNullable; @@ -1585,23 +978,18 @@ export class StreamChat { this._deleteUserMessageReference( event.user, event.hard_delete, - event.user.deleted_at ? new Date(event.user.deleted_at) : null, + event.user.deleted_at, ); } }; - _handleClientEvent(event: Event) { + _handleClientEvent(event: WSEvent) { // eslint-disable-next-line @typescript-eslint/no-this-alias const client = this; const postListenerCallbacks = []; - this.logger( - 'info', - `client:_handleClientEvent - Received event of type { ${event.type} }`, - { - tags: ['event', 'client'], - event, - }, - ); + logger + .withExtraTags('_handleClientEvent') + .debug(`Received an event of type "${event.type}".`, { event }); if ( event.type === 'user.presence.changed' || @@ -1612,11 +1000,7 @@ export class StreamChat { } if (event.type === 'user.messages.deleted' && !event.cid && event.user) { - this._deleteUserMessageReference( - event.user, - event.hard_delete, - event.created_at ? new Date(event.created_at) : null, - ); + this._deleteUserMessageReference(event.user, event.hard_delete, event.created_at); } if (event.type === 'health.check' && event.me) { @@ -1627,7 +1011,7 @@ export class StreamChat { client.blockedUsers.partialNext({ userIds: event.me.blocked_user_ids ?? [] }); } - if (event.channel && event.type === 'notification.message_new') { + if (event.type === 'notification.message_new' && event.channel) { const { channel } = event; this._addChannelConfig(channel); } @@ -1708,58 +1092,41 @@ export class StreamChat { } _callClientListeners = (event: Event) => { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const client = this; - // gather and call the listeners - const listeners: Array<(event: Event) => void> = []; - if (client.listeners.all) { - listeners.push(...client.listeners.all); - } - if (client.listeners[event.type]) { - listeners.push(...client.listeners[event.type]); - } + const allSet = this.listeners.get('all'); + const targetSet = this.listeners.get(event.type); - // call the event and send it to the listeners - for (const listener of listeners) { - listener(event); - } + [allSet, targetSet].forEach((set) => + set?.forEach((handleEvent) => handleEvent(event)), + ); }; recoverState = async () => { - this.logger( - 'info', - `client:recoverState() - Start of recoverState with connectionID ${this._getConnectionID()}`, - { - tags: ['connection'], - }, - ); + logger + .withExtraTags('recoverState') + .info(`Starting state recovery with connection ID ${this._getConnectionID()}.`); const cids = Object.keys(this.activeChannels); if (cids.length && this.recoverStateOnReconnect) { - this.logger( - 'info', - `client:recoverState() - Start the querying of ${cids.length} channels`, - { - tags: ['connection', 'client'], - }, - ); - - await this.queryChannels( - { cid: { $in: cids } } as ChannelFilters, - { last_message_at: -1 }, - { limit: 30 }, - ); + logger + .withExtraTags('recoverState') + .info(`Starting the query for ${cids.length} channel(s).`); - this.logger('info', 'client:recoverState() - Querying channels finished', { - tags: ['connection', 'client'], + await this.queryChannelsAndHydrate({ + filter_conditions: { + cid: { $in: cids }, + }, + limit: 30, + sort: [{ field: 'last_message_at', direction: -1 }], }); + + logger.withExtraTags('recoverState').info('Finished querying channels.'); this.dispatchEvent({ type: 'connection.recovered', - } as Event); + }); } else { this.dispatchEvent({ type: 'connection.recovered', - } as Event); + }); } this.wsPromise = Promise.resolve(); @@ -1770,16 +1137,16 @@ export class StreamChat { * @private */ async connect() { - if (!this.userID || !this._user) { + if (!this.userId || !this._user) { throw Error( 'Call connectUser or connectAnonymousUser before starting the connection', ); } if (!this.wsBaseURL) { - throw Error('Websocket base url not set'); + throw Error('Property wsBaseURL is not set'); } - if (!this.clientID) { - throw Error('clientID is not set'); + if (!this.clientId) { + throw Error('Property clientId is not set'); } if (!this.wsConnection && (this.options.warmUp || this.options.enableInsights)) { @@ -1808,14 +1175,13 @@ export class StreamChat { ? this.defaultWSTimeoutWithFallback : this.defaultWSTimeout, ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { // run fallback only if it's WS/Network error and not a normal API error // make sure browser is online before even trying the longpoll if (this.options.enableWSFallback && isWSFailure(error) && isOnline()) { - this.logger('info', 'client:connect() - WS failed, fallback to longpoll', { - tags: ['connection', 'client'], - }); + logger + .withExtraTags('connect') + .warn('The WebSocket connection failed; falling back to long-polling.'); this.dispatchEvent({ type: 'transport.changed', mode: 'longpoll' }); this.wsConnection._destroyCurrentWSConnection(); @@ -1831,14 +1197,14 @@ export class StreamChat { } /** - * Check the connectivity with server for warmup purpose. + * Checks connectivity with the server for warmup purposes. * * @private */ _sayHi() { const client_request_id = randomId(); const opts = { headers: { 'x-client-request-id': client_request_id } }; - this.doAxiosRequest('get', this.baseURL + '/hi', null, opts).catch((e) => { + this.api.doAxiosRequest('get', this.baseURL + '/hi', null, opts).catch((e) => { if (this.options.enableInsights) { postInsights('http_hi_failed', { api_key: this.key, @@ -1850,244 +1216,52 @@ export class StreamChat { } /** - * queryUsers - Query users and watch user presence + * Queries users and watches user presence. * - * @param {UserFilters} filterConditions MongoDB style filter conditions - * @param {UserSort} sort Sort options, for instance [{last_active: -1}]. - * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_active: -1}, {created_at: 1}] - * @param {UserOptions} options Option object, {presence: true} - * - * @return {Promise<{ users: Array }>} User Query Response + * @param request - The query users request payload (optional). The inner `payload` accepts + * MongoDB-style filter conditions, sort directions (e.g. `[{ field: 'last_active', direction: -1 }]`), + * and options such as `presence`. + * @returns The user query response. */ - async queryUsers( - filterConditions: UserFilters, - sort: UserSort = [], - options: UserOptions = {}, - ) { - const defaultOptions = { - presence: false, - }; - + override async queryUsers(request?: { payload?: Gen_QueryUsersPayload }) { // Make sure we wait for the connect promise if there is a pending one await this.wsPromise; - if (!this._hasConnectionID()) { - defaultOptions.presence = false; - } - - // Return a list of users - const data = await this.get }>( - this.baseURL + '/users', - { - payload: { - filter_conditions: filterConditions, - sort: normalizeQuerySort(sort), - ...defaultOptions, - ...options, - }, - }, - ); - + const data = await super.queryUsers(request); this.state.updateUsers(data.users); return data; } /** - * queryUserGroups - List user groups with cursor-based pagination. + * Queries user bans. * - * @param {QueryUserGroupsOptions} options The query options - * - * @return {Promise} User Group Query Response + * @param request - The query banned users request payload (optional). The inner `payload` + * accepts MongoDB-style filter conditions, sort directions + * (e.g. `[{ field: 'created_at', direction: 1 }]`), and options such as `limit`, `offset`, + * and `exclude_expired_bans`. + * @returns The ban query response. */ - async queryUserGroups(options: QueryUserGroupsOptions = {}) { - return await this.get(this.baseURL + '/usergroups', options); + async queryBannedUsers(request?: { payload?: QueryBannedUsersPayload }) { + // Return a list of user bans + return await super.queryBannedUsers(request); } /** - * createUserGroup - Create a user group + * Queries channels and returns the full API response including top-level metadata such as + * `predefined_filter`. * - * @param {CreateUserGroupOptions} options The create options + * This exists as a compatibility bridge, as changing `queryChannelsRequest()` to return + * `QueryChannelsResponse` would be a breaking change because it currently returns + * only the channel list. In the next major release, the request/response APIs should + * be consolidated so callers can access the full response through the primary API. * - * @return {Promise} User Group Create Response + * @param request - The query channels request payload (optional). Accepts MongoDB-style filter + * conditions, sort directions (e.g. `[{ field: 'created_at', direction: -1 }]`), and options + * such as `predefined_filter`, `filter_values`, and `sort_values`. + * @returns The full query channels response. */ - async createUserGroup(options: CreateUserGroupOptions) { - return await this.post( - this.baseURL + '/usergroups', - options, - ); - } - - /** - * getUserGroup - Get a user group by ID - * - * @param {string} id The user group ID - * @param {GetUserGroupOptions} options Optional query options - * - * @return {Promise} User Group Get Response - */ - async getUserGroup(id: string, options: GetUserGroupOptions = {}) { - return await this.get( - `${this.baseURL}/usergroups/${encodeURIComponent(id)}`, - options, - ); - } - - /** - * searchUserGroups - Search user groups by prefix for autocomplete - * - * @param {SearchUserGroupsOptions} options The search options - * - * @return {Promise} User Group Search Response - */ - async searchUserGroups(options: SearchUserGroupsOptions) { - return await this.get( - this.baseURL + '/usergroups/search', - options, - ); - } - - /** - * updateUserGroup - Update a user group by ID - * - * @param {string} id The user group ID - * @param {UpdateUserGroupOptions} options The update options - * - * @return {Promise} User Group Update Response - */ - async updateUserGroup(id: string, options: UpdateUserGroupOptions) { - return await this.put( - `${this.baseURL}/usergroups/${encodeURIComponent(id)}`, - options, - ); - } - - /** - * deleteUserGroup - Delete a user group by ID - * - * @param {string} id The user group ID - * @param {DeleteUserGroupOptions} options Optional query options - * - * @return {Promise} User Group Delete Response - */ - async deleteUserGroup(id: string, options: DeleteUserGroupOptions = {}) { - return await this.delete( - `${this.baseURL}/usergroups/${encodeURIComponent(id)}`, - options, - ); - } - - /** - * addUserGroupMembers - Add members to a user group - * - * @param {string} id The user group ID - * @param {AddUserGroupMembersOptions} options The add-members options - * - * @return {Promise} User Group Add Members Response - */ - async addUserGroupMembers(id: string, options: AddUserGroupMembersOptions) { - return await this.post( - `${this.baseURL}/usergroups/${encodeURIComponent(id)}/members`, - options, - ); - } - - /** - * removeUserGroupMembers - Remove members from a user group - * - * @param {string} id The user group ID - * @param {RemoveUserGroupMembersOptions} options The remove-members options - * - * @return {Promise} User Group Remove Members Response - */ - async removeUserGroupMembers(id: string, options: RemoveUserGroupMembersOptions) { - return await this.post( - `${this.baseURL}/usergroups/${encodeURIComponent(id)}/members/delete`, - options, - ); - } - - /** - * queryBannedUsers - Query user bans - * - * @param {BannedUsersFilters} filterConditions MongoDB style filter conditions - * @param {BannedUsersSort} sort Sort options [{created_at: 1}]. - * @param {BannedUsersPaginationOptions} options Option object, {limit: 10, offset:0, exclude_expired_bans: true} - * - * @return {Promise} Ban Query Response - */ - async queryBannedUsers( - filterConditions: BannedUsersFilters = {}, - sort: BannedUsersSort = [], - options: BannedUsersPaginationOptions = {}, - ) { - // Return a list of user bans - return await this.get(this.baseURL + '/query_banned_users', { - payload: { - filter_conditions: filterConditions, - sort: normalizeQuerySort(sort), - ...options, - }, - }); - } - - /** - * queryFutureChannelBans - Query future channel bans created by a user - * - * @param {QueryFutureChannelBansOptions} options Option object with user_id, exclude_expired_bans, limit, offset - * @returns {Promise} Future Channel Bans Response - */ - async queryFutureChannelBans(options: QueryFutureChannelBansOptions = {}) { - return await this.get( - this.baseURL + '/query_future_channel_bans', - { - payload: options, - }, - ); - } - - /** - * queryMessageFlags - Query message flags - * - * @param {MessageFlagsFilters} filterConditions MongoDB style filter conditions - * @param {MessageFlagsPaginationOptions} options Option object, {limit: 10, offset:0} - * - * @return {Promise} Message Flags Response - */ - async queryMessageFlags( - filterConditions: MessageFlagsFilters = {}, - options: MessageFlagsPaginationOptions = {}, - ) { - // Return a list of message flags - return await this.get( - this.baseURL + '/moderation/flags/message', - { - payload: { filter_conditions: filterConditions, ...options }, - }, - ); - } - - /** - * queryChannelsRequestWithResponse - Queries channels and returns the full API response - * including top-level metadata such as `predefined_filter`. - * - * This exists as a compatibility bridge, as changing `queryChannelsRequest()` to return - * `QueryChannelsAPIResponse` would be a breaking change because it currently returns - * only the channel list. In the next major release, the request/response APIs should - * be consolidated so callers can access the full response through the primary API. - * - * @param {ChannelFilters} filterConditions object MongoDB style filters. Can be empty object when using predefined_filter in options. - * @param {ChannelSort} [sort] Sort options, for instance {created_at: -1}. - * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_updated: -1}, {created_at: 1}] - * @param {ChannelOptions} [options] Options object. Can include predefined_filter, filter_values, and sort_values for using predefined filters. - * - * @return {Promise} full search channels response - */ - async queryChannelsRequestWithResponse( - filterConditions: ChannelFilters, - sort: ChannelSort = [], - options: ChannelOptions = {}, - ): Promise { + override async queryChannels(request?: QueryChannelsRequest) { const defaultOptions: ChannelOptions = { state: true, watch: true, @@ -2096,102 +1270,72 @@ export class StreamChat { // Make sure we wait for the connect promise if there is a pending one await this.wsPromise; + + // TODO: probably serverside only thing, remove at some point if (!this._hasConnectionID()) { defaultOptions.watch = false; } - const { predefined_filter, filter_values, sort_values, ...restOptions } = options; - const normalizedSort = normalizeQuerySort(sort); + const { + predefined_filter, + filter_values, + sort_values, + filter_conditions, + ...restOptions + } = request ?? {}; // Build payload based on whether we're using a predefined filter or traditional filters - const payload = predefined_filter + const payload: QueryChannelsRequest = predefined_filter ? { predefined_filter, filter_values, sort_values, - sort: normalizedSort, ...defaultOptions, ...restOptions, } : { - filter_conditions: filterConditions, - sort: normalizedSort, + filter_conditions, ...defaultOptions, ...restOptions, }; - return await this.post(this.baseURL + '/channels', payload); - } - - /** - * queryChannelsRequest - Queries channels and returns the raw channel response list. - * - * This preserves the historical return shape for backwards compatibility. Use - * `queryChannelsRequestWithResponse()` when response level metadata such as - * `predefined_filter` is needed. In the next major release these APIs should be - * consolidated into a single full-response API. - * - * @param {ChannelFilters} filterConditions object MongoDB style filters. Can be empty object when using predefined_filter in options. - * @param {ChannelSort} [sort] Sort options, for instance {created_at: -1}. - * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_updated: -1}, {created_at: 1}] - * @param {ChannelOptions} [options] Options object. Can include predefined_filter, filter_values, and sort_values for using predefined filters. - * - * @return {Promise>} search channels response - */ - async queryChannelsRequest( - filterConditions: ChannelFilters, - sort: ChannelSort = [], - options: ChannelOptions = {}, - ) { - const data = await this.queryChannelsRequestWithResponse( - filterConditions, - sort, - options, - ); - - // FIXME: In the next major release, return the full QueryChannelsAPIResponse - // instead of only `data.channels` so top-level metadata such as - // `predefined_filter` is not lost. - return data.channels; + return await super.queryChannels(payload); } /** - * queryChannels - Query channels + * Queries channels and hydrates them into `Channel` instances on this client. * - * @param {ChannelFilters} filterConditions object MongoDB style filters - * @param {ChannelSort} [sort] Sort options, for instance {created_at: -1}. - * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_updated: -1}, {created_at: 1}] - * @param {ChannelOptions} [options] Options object - * @param {ChannelStateOptions} [stateOptions] State options object. These options will only be used for state management and won't be sent in the request. - * - stateOptions.skipInitialization - Skips the initialization of the state for the channels matching the ids in the list. - * - stateOptions.skipHydration - Skips returning the channels as instances of the Channel class and rather returns the raw query response. - * - stateOptions.withResponse - Returns the full query response with hydrated channels. This is a compatibility bridge for internal callers that need response-level metadata while the default return value remains `Channel[]`. + * Use the inherited `queryChannels()` from `ChatApi` when only the raw API response + * is needed; this method wraps it with state hydration, `channels.queried` dispatch, + * and offline-db sync. * - * @return {Promise>} search channels response + * @param options - The query channels request payload (optional). Accepts MongoDB-style filter + * conditions, sort directions (e.g. `[{ field: 'created_at', direction: -1 }]`), and options + * such as `predefined_filter`, `filter_values`, and `sort_values`. + * @param stateOptions - Options that only affect state management and aren't sent in the request + * (optional, defaults to `{}`). + * @param stateOptions.skipInitialization - Skips the initialization of the state for the + * channels matching the IDs in the list (optional). + * @param stateOptions.skipHydration - Skips returning the channels as instances of the `Channel` + * class and instead returns the raw query response (optional). + * @param stateOptions.withResponse - Returns the full query response with hydrated channels. + * This is a compatibility bridge for internal callers that need response-level metadata while + * the default return value remains `Channel[]` (optional). + * @returns The hydrated channel list, or the full response when `withResponse` is `true`. */ - async queryChannels( - filterConditions: ChannelFilters, - sort: ChannelSort, - options: ChannelOptions, - stateOptions: ChannelStateOptions & { withResponse: true }, + async queryChannelsAndHydrate( + options?: QueryChannelsRequest, + stateOptions?: ChannelStateOptions & { withResponse: true }, ): Promise; - async queryChannels( - filterConditions?: ChannelFilters, - sort?: ChannelSort, - options?: ChannelOptions, + async queryChannelsAndHydrate( + options?: QueryChannelsRequest, stateOptions?: ChannelStateOptions, ): Promise; - async queryChannels( - filterConditions: ChannelFilters, - sort: ChannelSort = [], - options: ChannelOptions = {}, + async queryChannelsAndHydrate( + options?: QueryChannelsRequest, stateOptions: ChannelStateOptions = {}, ): Promise { - const queryChannelsResponse = await this.queryChannelsRequestWithResponse( - filterConditions, - sort, - options, - ); + const queryChannelsResponse = await this.queryChannels(options); const channels = queryChannelsResponse.channels; this.dispatchEvent({ @@ -2221,33 +1365,24 @@ export class StreamChat { } /** - * queryReactions - Query reactions + * Queries reactions for a message and hydrates any cached offline reactions before the network + * request. * - * @param {ReactionFilters} filter object MongoDB style filters - * @param {ReactionSort} [sort] Sort options, for instance {created_at: -1}. - * @param {QueryReactionsOptions} [options] Pagination object - * - * @return {Promise<{ QueryReactionsAPIResponse } search channels response + * @param request - The query reactions request payload, including the target message ID, + * MongoDB-style filters, sort directions (e.g. `[{ field: 'created_at', direction: -1 }]`), + * and pagination options. + * @returns The query reactions response. */ - async queryReactions( - messageID: string, - filter: ReactionFilters, - sort: ReactionSort = [], - options: QueryReactionsOptions = {}, - ) { - const payload = { - filter, - sort: normalizeQuerySort(sort), - ...options, - }; + async queryReactionsAndHydrate(request: QueryReactionsRequestWithId) { + const { filter, next, id: messageId, sort, limit } = request; - if (this.offlineDb?.getReactions && !options.next) { + if (this.offlineDb?.getReactions && !next) { try { const reactionsFromDb = await this.offlineDb.getReactions({ - messageId: messageID, + messageId, filters: filter, sort, - limit: options.limit, + limit, }); if (reactionsFromDb) { @@ -2257,23 +1392,20 @@ export class StreamChat { }); } } catch (e) { - this.logger('warn', 'An error has occurred while querying offline reactions', { - error: e, - }); + offlineDbLogger + .withExtraTags('queryReactionsAndHydrate') + .warn('An error occurred while querying offline reactions.', { error: e }); } } // Make sure we wait for the connect promise if there is a pending one await this.wsPromise; - return await this.post( - this.baseURL + '/messages/' + encodeURIComponent(messageID) + '/reactions', - payload, - ); + return await this.queryReactions(request); } hydrateActiveChannels( - channelsFromApi: ChannelAPIResponse[] = [], + channelsFromApi: ChannelStateResponseFields[] = [], stateOptions: ChannelStateOptions = {}, queryChannelsOptions?: ChannelOptions, ) { @@ -2281,6 +1413,8 @@ export class StreamChat { const channels: Channel[] = []; for (const channelState of channelsFromApi) { + if (!channelState.channel) continue; + this._addChannelConfig(channelState.channel); const c = this.channel(channelState.channel.type, channelState.channel.id); const previousData = c.data; @@ -2337,50 +1471,29 @@ export class StreamChat { } /** - * search - Query messages - * - * @param {ChannelFilters} filterConditions MongoDB style filter conditions - * @param {MessageFilters | string} query search query or object MongoDB style filters - * @param {SearchOptions} [options] Option object, {user_id: 'tommaso'} + * Queries messages. * - * @return {Promise} search messages response + * @param request - The search request payload (optional). The inner `payload` accepts + * MongoDB-style filter conditions, a search query, and options such as `user_id`. + * @returns The search messages response. */ - async search( - filterConditions: ChannelFilters, - query: string | MessageFilters, - options: SearchOptions = {}, - ) { - if (options.offset && options.next) { - throw Error(`Cannot specify offset with next`); - } - const payload: SearchPayload = { - filter_conditions: filterConditions, - ...options, - sort: options.sort - ? normalizeQuerySort(options.sort) - : undefined, - }; - if (typeof query === 'string') { - payload.query = query; - } else if (typeof query === 'object') { - payload.message_filter_conditions = query; - } else { - throw Error(`Invalid type ${typeof query} for query parameter`); + override async search(request?: { payload?: SearchPayload }) { + if (request?.payload?.offset && request?.payload?.next) { + throw Error(`Cannot specify "offset" with "next"`); } // Make sure we wait for the connect promise if there is a pending one await this.wsPromise; - return await this.get(this.baseURL + '/search', { payload }); + return await super.search(request); } /** - * setLocalDevice - Set the device info for the current client(device) that will be sent via WS connection automatically - * - * @param {BaseDeviceFields} device the device object - * @param {string} device.id device id - * @param {string} device.push_provider the push provider + * Sets the device info for the current client. It will be sent via the WS connection automatically. * + * @param device - The device object. + * @param device.id - Device ID. + * @param device.push_provider - The push provider. */ setLocalDevice(device: BaseDeviceFields) { if ( @@ -2388,140 +1501,12 @@ export class StreamChat { ((this.wsConnection?.isHealthy || this.wsFallback?.isHealthy()) && this._hasConnectionID()) ) { - throw new Error('you can only set device before opening a websocket connection'); + throw new Error('Device cannot be set before opening a WebSocket connection'); } this.options.device = device; } - /** - * addDevice - Adds a push device for a user. - * - * @param {string} id the device id - * @param {PushProvider} push_provider the push provider - * @param {string} [userID] the user id (defaults to current user) - * @param {string} [push_provider_name] user provided push provider name for multi bundle support - * - */ - async addDevice( - id: string, - push_provider: PushProvider, - userID?: string, - push_provider_name?: string, - ) { - return await this.post(this.baseURL + '/devices', { - id, - push_provider, - ...(userID != null ? { user_id: userID } : {}), - ...(push_provider_name != null ? { push_provider_name } : {}), - }); - } - - /** - * getDevices - Returns the devices associated with a current user - * - * @param {string} [userID] User ID. Only works on serverside - * - * @return {Device[]} Array of devices - */ - async getDevices(userID?: string) { - return await this.get( - this.baseURL + '/devices', - userID ? { user_id: userID } : {}, - ); - } - - /** - * getUnreadCount - Returns unread counts for a single user - * - * @param {string} [userID] User ID. - * - * @return {} - */ - async getUnreadCount(userID?: string) { - return await this.get( - this.baseURL + '/unread', - userID ? { user_id: userID } : {}, - ); - } - - /** - * getUnreadCountBatch - Returns unread counts for multiple users at once. Only works server side. - * - * @param {string[]} [userIDs] List of user IDs to fetch unread counts for. - * - * @return {} - */ - async getUnreadCountBatch(userIDs: string[]) { - return await this.post( - this.baseURL + '/unread_batch', - { user_ids: userIDs }, - ); - } - - /** - * setPushPreferences - Applies the list of push preferences. - * - * @param {PushPreference[]} A list of push preferences. - * - * @return {} - */ - async setPushPreferences(preferences: PushPreference[]) { - return await this.post( - this.baseURL + '/push_preferences', - { preferences }, - ); - } - - /** - * removeDevice - Removes the device with the given id. Clientside users can only delete their own devices - * - * @param {string} id The device id - * @param {string} [userID] The user id. Only specify this for serverside requests - * - */ - async removeDevice(id: string, userID?: string) { - return await this.delete(this.baseURL + '/devices', { - id, - ...(userID ? { user_id: userID } : {}), - }); - } - - /** - * getRateLimits - Returns the rate limits quota and usage for the current app, possibly filter for a specific platform and/or endpoints. - * Only available server-side. - * - * @param {object} [params] The params for the call. If none of the params are set, all limits for all platforms are returned. - * @returns {Promise} - */ - getRateLimits(params?: { - android?: boolean; - endpoints?: EndpointName[]; - ios?: boolean; - serverSide?: boolean; - web?: boolean; - }) { - const { serverSide, web, android, ios, endpoints } = params || {}; - return this.get(this.baseURL + '/rate_limits', { - server_side: serverSide, - web, - android, - ios, - endpoints: endpoints ? endpoints.join(',') : undefined, - }); - } - - /** - * getHookEvents - Get available events for hooks (webhook, SQS, and SNS) - * - * @param {Product[]} [products] Optional array of products to filter events by (e.g., [Product.Chat, Product.Video]) - * @returns {Promise} Response containing available hook events - */ - async getHookEvents(products?: Product[]) { - const params = products && products.length > 0 ? { product: products.join(',') } : {}; - return await this.get(this.baseURL + '/hook/events', params); - } - _addChannelConfig({ cid, config }: ChannelResponse) { if (this._cacheEnabled()) { this.configs = { @@ -2532,27 +1517,28 @@ export class StreamChat { } /** - * channel - Returns a new channel with the given type, id and custom data - * - * If you want to create a unique conversation between 2 or more users; you can leave out the ID parameter and provide the list of members. - * Make sure to await channel.create() or channel.watch() before accessing channel functions: - * ie. channel = client.channel("messaging", {members: ["tommaso", "thierry"]}) - * await channel.create() to assign an ID to channel + * Returns a new channel with the given type, ID and custom data. * - * @param {string} channelType The channel type - * @param {string | ChannelData | null} [channelIDOrCustom] The channel ID, you can leave this out if you want to create a conversation channel - * @param {object} [custom] Custom data to attach to the channel + * If you want to create a unique conversation between 2 or more users, you can leave out the ID + * parameter and provide the list of members. + * Make sure to await `channel.create()` or `channel.watch()` before accessing channel functions, + * i.e. `channel = client.channel('messaging', { members: ['tommaso', 'thierry'] })` then + * `await channel.create()` to assign an ID to the channel. * - * @return {channel} The channel object, initialize it using channel.watch() + * @param channelType - The channel type. + * @param channelIdOrCustom - The channel ID; you can leave this out if you want to create a + * conversation channel (optional). + * @param custom - Custom data to attach to the channel (optional, defaults to `{}`). + * @returns The channel object; initialize it using `channel.watch()`. */ - channel(channelType: string, channelID?: string | null, custom?: ChannelData): Channel; + channel(channelType: string, channelId?: string | null, custom?: ChannelData): Channel; channel(channelType: string, custom?: ChannelData): Channel; channel( channelType: string, - channelIDOrCustom?: string | ChannelData | null, + channelIdOrCustom?: string | ChannelData | null, custom: ChannelData = {}, ) { - if (!this.userID && !this._isUsingServerAuth()) { + if (!this.userId) { throw Error('Call connectUser or connectAnonymousUser before creating a channel'); } @@ -2563,28 +1549,28 @@ export class StreamChat { } // support channel("messaging", {options}) - if (channelIDOrCustom && typeof channelIDOrCustom === 'object') { - return this.getChannelByMembers(channelType, channelIDOrCustom); + if (channelIdOrCustom && typeof channelIdOrCustom === 'object') { + return this.getChannelByMembers(channelType, channelIdOrCustom); } // support channel("messaging", undefined, {options}) - if (!channelIDOrCustom && typeof custom === 'object' && custom.members?.length) { + if (!channelIdOrCustom && typeof custom === 'object' && custom.members?.length) { return this.getChannelByMembers(channelType, custom); } // support channel("messaging", null, {options}) // support channel("messaging", undefined, {options}) // support channel("messaging", "", {options}) - if (!channelIDOrCustom) { + if (!channelIdOrCustom) { return new Channel(this, channelType, undefined, custom); } - return this.getChannelById(channelType, channelIDOrCustom, custom); + return this.getChannelById(channelType, channelIdOrCustom, custom); } /** * It's a helper method for `client.channel()` method, used to create unique conversation or - * channel based on member list instead of id. + * channel based on member list instead of ID. * * If the channel already exists in `activeChannels` list, then we simply return it, since that * means the same channel was already requested or created. @@ -2593,16 +1579,15 @@ export class StreamChat { * * @private * - * @param {string} channelType The channel type - * @param {object} [custom] Custom data to attach to the channel - * - * @return {channel} The channel object, initialize it using channel.watch() + * @param channelType - The channel type. + * @param custom - Custom data to attach to the channel. + * @returns The channel object; initialize it using `channel.watch()`. */ getChannelByMembers = (channelType: string, custom: ChannelData) => { // Check if the channel already exists. // Only allow 1 channel object per cid - const memberIds = (custom.members ?? []).map((member: string | NewMemberPayload) => - typeof member === 'string' ? member : (member.user_id ?? ''), + const memberIds = (custom.members ?? []).map((member) => + typeof member === 'string' ? member : member.user_id, ); const membersStr = memberIds.sort().join(','); const tempCid = generateChannelTempCid(channelType, memberIds); @@ -2648,43 +1633,48 @@ export class StreamChat { }; /** - * Its a helper method for `client.channel()` method, used to channel given the id of channel. + * It's a helper method for `client.channel()`, used to retrieve a channel given its ID. * * If the channel already exists in `activeChannels` list, then we simply return it, since that * means the same channel was already requested or created. * - * Otherwise we create a new instance of Channel class and return it. + * Otherwise we create a new instance of `Channel` class and return it. * * @private * - * @param {string} channelType The channel type - * @param {string} [channelID] The channel ID - * @param {object} [custom] Custom data to attach to the channel - * - * @return {channel} The channel object, initialize it using channel.watch() + * @param channelType - The channel type. + * @param channelId - The channel ID. + * @param custom - Custom data to attach to the channel. + * @returns The channel object; initialize it using `channel.watch()`. */ - getChannelById = (channelType: string, channelID: string, custom: ChannelData) => { - if (typeof channelID === 'string' && ~channelID.indexOf(':')) { - throw Error(`Invalid channel id ${channelID}, can't contain the : character`); + getChannelById = (channelType: string, channelId: string, custom: ChannelData) => { + if (typeof channelId === 'string' && ~channelId.indexOf(':')) { + throw Error(`Invalid channel id ${channelId}, can't contain the : character`); } // only allow 1 channel object per cid - const cid = `${channelType}:${channelID}`; + const cid = `${channelType}:${channelId}`; if ( cid in this.activeChannels && this.activeChannels[cid] && !this.activeChannels[cid].disconnected ) { const channel = this.activeChannels[cid]; - if (Object.keys(custom).length > 0) { + // Only overwrite the existing channel's custom data when the caller actually provided some. + // A caller passing other fields (e.g. `{ members }`, or even `{ members: undefined }`) yields a + // non-empty object with no `.custom`; the previous `Object.keys(custom).length > 0` guard let + // that through and then set `custom: custom.custom` (undefined), wiping the channel's existing + // custom data (e.g. its name). Guarding on `custom.custom` keeps genuine custom updates while + // leaving the existing custom intact when the caller omits it. + if (custom.custom !== undefined) { const previousData = channel.data; - channel.data = { ...channel.data, ...custom }; + channel.data = { ...channel.data, custom: custom.custom }; channel._syncStateFromChannelData(channel.data, previousData); - channel._data = { ...channel._data, ...custom }; + channel._data = { ...channel._data, custom: custom.custom }; } return channel; } - const channel = new Channel(this, channelType, channelID, custom); + const channel = new Channel(this, channelType, channelId, custom); if (this._cacheEnabled()) { this.activeChannels[channel.cid] = channel; } @@ -2693,592 +1683,212 @@ export class StreamChat { }; /** - * partialUpdateUser - Update the given user object - * - * @param {PartialUserUpdate} partialUserObject which should contain id and any of "set" or "unset" params; - * example: {id: "user1", set:{field: value}, unset:["field2"]} + * Bans a user from all channels. * - * @return {Promise<{ users: { [key: string]: UserResponse } }>} list of updated users + * @param targetUserId - The user to ban. + * @param options - Ban options (optional). + * @returns The server response. */ - async partialUpdateUser(partialUserObject: PartialUserUpdate) { - return await this.partialUpdateUsers([partialUserObject]); + async banUser(targetUserId: string, options?: BanUserOptions) { + return await this.api.post(this.baseURL + '/moderation/ban', { + target_user_id: targetUserId, + ...options, + }); } /** - * upsertUsers - Batch upsert the list of users + * Revoke a global ban for a user. * - * @param {UserResponse[]} users list of users - * - * @return {Promise<{ users: { [key: string]: UserResponse } }>} + * @param targetUserId - The user to unban. + * @param options - Unban options (optional). + * @returns The server response. */ - async upsertUsers(users: UserResponse[]) { - const userMap: { [key: string]: UserResponse } = {}; - for (const userObject of users) { - if (!userObject.id) { - throw Error('User ID is required when updating a user'); - } - userMap[userObject.id] = userObject; - } - - return await this.post(this.baseURL + '/users', { - users: userMap, + async unbanUser(targetUserId: string, options?: UnBanUserOptions) { + return await this.api.delete(this.baseURL + '/moderation/ban', { + target_user_id: targetUserId, + ...options, }); } /** - * @deprecated Please use upsertUsers() function instead. - * - * updateUsers - Batch update the list of users - * - * @param {UserResponse[]} users list of users - * @return {Promise<{ users: { [key: string]: UserResponse } }>} - */ - updateUsers = this.upsertUsers; - - /** - * upsertUser - Update or Create the given user object + * Shadow bans a user from all channels. * - * @param {UserResponse} userObject user object, the only required field is the user id. IE {id: "myuser"} is valid - * - * @return {Promise<{ users: { [key: string]: UserResponse } }>} + * @param targetUserId - The user to shadow ban. + * @param options - Ban options (optional). + * @returns The server response. */ - upsertUser(userObject: UserResponse) { - return this.upsertUsers([userObject]); + async shadowBan(targetUserId: string, options?: BanUserOptions) { + return await this.banUser(targetUserId, { + shadow: true, + ...options, + }); } /** - * @deprecated Please use upsertUser() function instead. - * - * updateUser - Update or Create the given user object - * - * @param {UserResponse} userObject user object, the only required field is the user id. IE {id: "myuser"} is valid - * @return {Promise<{ users: { [key: string]: UserResponse } }>} - */ - updateUser = this.upsertUser; - - /** - * partialUpdateUsers - Batch partial update of users - * - * @param {PartialUserUpdate[]} users list of partial update requests + * Revoke a global shadow ban for a user. * - * @return {Promise<{ users: { [key: string]: UserResponse } }>} + * @param targetUserId - The user to remove the shadow ban for. + * @param options - Unban options (optional). + * @returns The server response. */ - async partialUpdateUsers(users: PartialUserUpdate[]) { - for (const userObject of users) { - if (!userObject.id) { - throw Error('User ID is required when updating a user'); - } + async removeShadowBan(targetUserId: string, options?: UnBanUserOptions) { + return await this.unbanUser(targetUserId, { + shadow: true, + ...options, + }); + } + async blockUser(blockedUserId: string) { + const result = await this.blockUsers({ + blocked_user_id: blockedUserId, + }); + if (this._cacheEnabled()) { + this.blockedUsers.next(({ userIds }) => ({ + userIds: userIds.concat(blockedUserId), + })); } - - return await this.patch(this.baseURL + '/users', { users }); + return result; } - async deleteUser( - userID: string, - params?: { - delete_conversation_channels?: boolean; - hard_delete?: boolean; - mark_messages_deleted?: boolean; - }, - ) { - return await this.delete< - APIResponse & { user: UserResponse } & { - task_id?: string; - } - >(this.baseURL + `/users/${encodeURIComponent(userID)}`, params); + override async getBlockedUsers() { + const result = await super.getBlockedUsers(); + if (this._cacheEnabled()) { + this.blockedUsers.partialNext({ + userIds: result.blocks.map(({ blocked_user_id }) => blocked_user_id), + }); + } + return result; } - /** - * restoreUsers - Restore soft deleted users - * - * @param {string[]} user_ids which users to restore - * - * @return {APIResponse} An API response - */ - async restoreUsers(user_ids: string[]) { - return await this.post(this.baseURL + `/users/restore`, { - user_ids, + async unblockUser(blockedUserId: string) { + const result = await this.unblockUsers({ + blocked_user_id: blockedUserId, }); + if (this._cacheEnabled()) { + this.blockedUsers.next(({ userIds }) => ({ + userIds: userIds.filter((id) => id !== blockedUserId), + })); + } + return result; } /** - * reactivateUser - Reactivate one user + * Mutes a user. * - * @param {string} userID which user to reactivate - * @param {ReactivateUserOptions} [options] - * - * @return {UserResponse} Reactivated user + * @param targetId - The user to mute. + * @param options - UserMuteResponse options (optional, defaults to `{}`). + * @returns The server response. */ - async reactivateUser(userID: string, options?: ReactivateUserOptions) { - return await this.post( - this.baseURL + `/users/${encodeURIComponent(userID)}/reactivate`, - { ...options }, - ); + async muteUser(targetId: string, options: MuteUserOptions = {}) { + return await this.api.post(this.baseURL + '/moderation/mute', { + target_id: targetId, + ...options, + }); } /** - * reactivateUsers - Reactivate many users asynchronously + * Unmutes a user. * - * @param {string[]} user_ids which users to reactivate - * @param {ReactivateUsersOptions} [options] - * - * @return {TaskResponse} A task ID + * @param targetId - The user to unmute. + * @returns The server response. */ - async reactivateUsers(user_ids: string[], options?: ReactivateUsersOptions) { - return await this.post( - this.baseURL + `/users/reactivate`, - { user_ids, ...options }, - ); + async unmuteUser(targetId: string) { + return await this.api.post(this.baseURL + '/moderation/unmute', { + target_id: targetId, + }); } /** - * deactivateUser - Deactivate one user - * - * @param {string} userID which user to deactivate - * @param {DeactivateUsersOptions} [options] + * Checks whether a user is muted. Can be used after `connectUser()` is called. * - * @return {UserResponse} Deactivated user + * @param targetId - The user ID to check. + * @returns `true` if the user is muted, otherwise `false`. */ - async deactivateUser(userID: string, options?: DeactivateUsersOptions) { - return await this.post( - this.baseURL + `/users/${encodeURIComponent(userID)}/deactivate`, - { ...options }, - ); + userMuteStatus(targetId: string) { + if (!this.user || !this.wsPromise) { + throw new Error('Make sure to await connectUser() first.'); + } + + for (let i = 0; i < this.mutedUsers.length; i += 1) { + if (this.mutedUsers[i].target?.id === targetId) return true; + } + return false; } /** - * deactivateUsers - Deactivate many users asynchronously + * Flag a message. * - * @param {string[]} user_ids which users to deactivate - * @param {DeactivateUsersOptions} [options] - * - * @return {TaskResponse} A task ID + * @param targetMessageId - The message to flag. + * @param options - Flag options (optional, defaults to `{}`). + * @param options.reason - Reason for flagging (optional). + * @returns The server response. */ - async deactivateUsers(user_ids: string[], options?: DeactivateUsersOptions) { - return await this.post( - this.baseURL + `/users/deactivate`, - { user_ids, ...options }, - ); - } - - async exportUser(userID: string, options?: Record) { - return await this.get< - APIResponse & { - messages: MessageResponse[]; - reactions: ReactionResponse[]; - user: UserResponse; - } - >(this.baseURL + `/users/${encodeURIComponent(userID)}/export`, { ...options }); + async flagMessage(targetMessageId: string, options: { reason?: string } = {}) { + return await this.api.post(this.baseURL + '/moderation/flag', { + target_message_id: targetMessageId, + ...options, + }); } - /** banUser - bans a user from all channels + /** + * Flag a user. * - * @param {string} targetUserID - * @param {BanUserOptions} [options] - * @returns {Promise} + * @param targetId - The user to flag. + * @param options - Flag options (optional, defaults to `{}`). + * @param options.reason - Reason for flagging (optional). + * @returns The server response. */ - async banUser(targetUserID: string, options?: BanUserOptions) { - return await this.post(this.baseURL + '/moderation/ban', { - target_user_id: targetUserID, + async flagUser(targetId: string, options: { reason?: string } = {}) { + return await this.api.post(this.baseURL + '/moderation/flag', { + target_user_id: targetId, ...options, }); } - /** unbanUser - revoke global ban for a user + /** + * Unflag a message. * - * @param {string} targetUserID - * @param {UnBanUserOptions} [options] - * @returns {Promise} + * @param targetMessageId - The message to unflag. + * @returns The server response. */ - async unbanUser(targetUserID: string, options?: UnBanUserOptions) { - return await this.delete(this.baseURL + '/moderation/ban', { - target_user_id: targetUserID, - ...options, + async unflagMessage(targetMessageId: string) { + return await this.api.post(this.baseURL + '/moderation/unflag', { + target_message_id: targetMessageId, }); } - /** shadowBan - shadow bans a user from all channels + /** + * Unflag a user. * - * @param {string} targetUserID - * @param {BanUserOptions} [options] - * @returns {Promise} + * @param targetId - The user to unflag. + * @returns The server response. */ - async shadowBan(targetUserID: string, options?: BanUserOptions) { - return await this.banUser(targetUserID, { - shadow: true, - ...options, + async unflagUser(targetId: string) { + return await this.api.post(this.baseURL + '/moderation/unflag', { + target_user_id: targetId, }); } - /** removeShadowBan - revoke global shadow ban for a user + /** + * Unblocks a message blocked by automod. * - * @param {string} targetUserID - * @param {UnBanUserOptions} [options] - * @returns {Promise} + * @param targetMessageId - The message to unblock. + * @returns The server response. */ - async removeShadowBan(targetUserID: string, options?: UnBanUserOptions) { - return await this.unbanUser(targetUserID, { - shadow: true, - ...options, - }); - } - async blockUser(blockedUserID: string, user_id?: string) { - const result = await this.post(this.baseURL + '/users/block', { - blocked_user_id: blockedUserID, - ...(user_id ? { user_id } : {}), - }); - if (this._cacheEnabled()) { - this.blockedUsers.next(({ userIds }) => ({ - userIds: userIds.concat(blockedUserID), - })); - } - return result; - } - - async getBlockedUsers(user_id?: string) { - const result = await this.get( - this.baseURL + '/users/block', - { - ...(user_id ? { user_id } : {}), - }, - ); - if (this._cacheEnabled()) { - this.blockedUsers.partialNext({ - userIds: result.blocks.map(({ blocked_user_id }) => blocked_user_id), - }); - } - return result; - } - - async unBlockUser(blockedUserID: string, userID?: string) { - const result = await this.post(this.baseURL + '/users/unblock', { - blocked_user_id: blockedUserID, - ...(userID ? { user_id: userID } : {}), - }); - if (this._cacheEnabled()) { - this.blockedUsers.next(({ userIds }) => ({ - userIds: userIds.filter((id) => id !== blockedUserID), - })); - } - return result; - } - - /** getSharedLocations - * - * @returns {Promise} The server response - * - */ - async getSharedLocations() { - return await this.get( - this.baseURL + `/users/live_locations`, - ); - } - - /** muteUser - mutes a user - * - * @param {string} targetID - * @param {string} [userID] Only used with serverside auth - * @param {MuteUserOptions} [options] - * @returns {Promise} - */ - async muteUser(targetID: string, userID?: string, options: MuteUserOptions = {}) { - return await this.post(this.baseURL + '/moderation/mute', { - target_id: targetID, - ...(userID ? { user_id: userID } : {}), - ...options, - }); - } - - /** unmuteUser - unmutes a user - * - * @param {string} targetID - * @param {string} [currentUserID] Only used with serverside auth - * @returns {Promise} - */ - async unmuteUser(targetID: string, currentUserID?: string) { - return await this.post(this.baseURL + '/moderation/unmute', { - target_id: targetID, - ...(currentUserID ? { user_id: currentUserID } : {}), - }); - } - - /** userMuteStatus - check if a user is muted or not, can be used after connectUser() is called - * - * @param {string} targetID - * @returns {boolean} - */ - userMuteStatus(targetID: string) { - if (!this.user || !this.wsPromise) { - throw new Error('Make sure to await connectUser() first.'); - } - - for (let i = 0; i < this.mutedUsers.length; i += 1) { - if (this.mutedUsers[i].target.id === targetID) return true; - } - return false; - } - - /** - * flagMessage - flag a message - * @param {string} targetMessageID - * @param {string} [options.user_id] currentUserID, only used with serverside auth - * @returns {Promise} - */ - async flagMessage( - targetMessageID: string, - options: { reason?: string; user_id?: string } = {}, - ) { - return await this.post(this.baseURL + '/moderation/flag', { - target_message_id: targetMessageID, - ...options, - }); - } - - /** - * flagUser - flag a user - * @param {string} targetID - * @param {string} [options.user_id] currentUserID, only used with serverside auth - * @returns {Promise} - */ - async flagUser(targetID: string, options: { reason?: string; user_id?: string } = {}) { - return await this.post(this.baseURL + '/moderation/flag', { - target_user_id: targetID, - ...options, - }); - } - - /** - * unflagMessage - unflag a message - * @param {string} targetMessageID - * @param {string} [options.user_id] currentUserID, only used with serverside auth - * @returns {Promise} - */ - async unflagMessage(targetMessageID: string, options: { user_id?: string } = {}) { - return await this.post(this.baseURL + '/moderation/unflag', { - target_message_id: targetMessageID, - ...options, - }); - } - - /** - * unflagUser - unflag a user - * @param {string} targetID - * @param {string} [options.user_id] currentUserID, only used with serverside auth - * @returns {Promise} - */ - async unflagUser(targetID: string, options: { user_id?: string } = {}) { - return await this.post(this.baseURL + '/moderation/unflag', { - target_user_id: targetID, - ...options, - }); - } - - /** - * _queryFlags - Query flags. - * - * Note: Do not use this. - * It is present for internal usage only. - * This function can, and will, break and/or be removed at any point in time. - * - * @private - * @param {FlagsFilters} filterConditions MongoDB style filter conditions - * @param {FlagsPaginationOptions} options Option object, {limit: 10, offset:0} - * - * @return {Promise} Flags Response - */ - async _queryFlags( - filterConditions: FlagsFilters = {}, - options: FlagsPaginationOptions = {}, - ) { - // Return a list of flags - return await this.post(this.baseURL + '/moderation/flags', { - filter_conditions: filterConditions, - ...options, - }); - } - - /** - * _queryFlagReports - Query flag reports. - * - * Note: Do not use this. - * It is present for internal usage only. - * This function can, and will, break and/or be removed at any point in time. - * - * @private - * @param {FlagReportsFilters} filterConditions MongoDB style filter conditions - * @param {FlagReportsPaginationOptions} options Option object, {limit: 10, offset:0} - * - * @return {Promise} Flag Reports Response - */ - async _queryFlagReports( - filterConditions: FlagReportsFilters = {}, - options: FlagReportsPaginationOptions = {}, - ) { - // Return a list of message flags - return await this.post(this.baseURL + '/moderation/reports', { - filter_conditions: filterConditions, - ...options, - }); - } - - /** - * _reviewFlagReport - review flag report - * - * Note: Do not use this. - * It is present for internal usage only. - * This function can, and will, break and/or be removed at any point in time. - * - * @private - * @param {string} [id] flag report to review - * @param {string} [reviewResult] flag report review result - * @param {string} [options.user_id] currentUserID, only used with serverside auth - * @param {string} [options.review_details] custom information about review result - * @returns {Promise>} - */ - async _reviewFlagReport( - id: string, - reviewResult: string, - options: ReviewFlagReportOptions = {}, - ) { - return await this.patch( - this.baseURL + `/moderation/reports/${encodeURIComponent(id)}`, + async unblockMessage(targetMessageId: string) { + return await this.api.post( + this.baseURL + '/moderation/unblock_message', { - review_result: reviewResult, - ...options, + target_message_id: targetMessageId, }, ); } /** - * unblockMessage - unblocks message blocked by automod - * - * - * @param {string} targetMessageID - * @param {string} [options.user_id] currentUserID, only used with serverside auth - * @returns {Promise} - */ - async unblockMessage(targetMessageID: string, options: { user_id?: string } = {}) { - return await this.post(this.baseURL + '/moderation/unblock_message', { - target_message_id: targetMessageID, - ...options, - }); - } - - // alias for backwards compatibility - _unblockMessage = this.unblockMessage; - - /** - * @deprecated use markChannelsRead instead - * - * markAllRead - marks all channels for this user as read - * @param {MarkAllReadOptions} [data] - * - * @return {Promise} - */ - markAllRead = this.markChannelsRead; - - /** - * markChannelsRead - marks channels read - - * it accepts a map of cid:messageid pairs, if messageid is empty, the whole channel will be marked as read - * - * @param {MarkChannelsReadOptions } [data] - * - * @return {Promise} - */ - async markChannelsRead(data: MarkChannelsReadOptions = {}) { - await this.post(this.baseURL + '/channels/read', { ...data }); - } - - createCommand(data: CreateCommandOptions) { - return this.post(this.baseURL + '/commands', data); - } - - getCommand(name: string) { - return this.get( - this.baseURL + `/commands/${encodeURIComponent(name)}`, - ); - } - - updateCommand(name: string, data: UpdateCommandOptions) { - return this.put( - this.baseURL + `/commands/${encodeURIComponent(name)}`, - data, - ); - } - - deleteCommand(name: string) { - return this.delete( - this.baseURL + `/commands/${encodeURIComponent(name)}`, - ); - } - - listCommands() { - return this.get(this.baseURL + `/commands`); - } - - createChannelType(data: CreateChannelOptions) { - const channelData = Object.assign({}, { commands: ['all'] }, data); - return this.post(this.baseURL + '/channeltypes', channelData); - } - - getChannelType(channelType: string) { - return this.get( - this.baseURL + `/channeltypes/${encodeURIComponent(channelType)}`, - ); - } - - updateChannelType(channelType: string, data: UpdateChannelTypeRequest) { - return this.put( - this.baseURL + `/channeltypes/${encodeURIComponent(channelType)}`, - data, - ); - } - - deleteChannelType(channelType: string) { - return this.delete( - this.baseURL + `/channeltypes/${encodeURIComponent(channelType)}`, - ); - } - - listChannelTypes() { - return this.get(this.baseURL + `/channeltypes`); - } - - /** - * translateMessage - adds the translation to the message - * - * @param {string} messageId - * @param {string} language - * - * @return {MessageResponse} Response that includes the message - */ - async translateMessage(messageId: string, language: string) { - return await this.post( - this.baseURL + `/messages/${encodeURIComponent(messageId)}/translate`, - { language }, - ); - } - - /** - * translate - translates the given text to provided language - * - * @param {string} text - * @param {string} destination_language - * @param {string} source_language + * Transforms an expiration value into an ISO string. * - * @return {TranslateResponse} Response that includes the message - */ - async translate(text: string, destination_language: string, source_language: string) { - return await this.post(this.baseURL + `/translate`, { - text, - source_language, - destination_language, - }); - } - - /** - * _normalizeExpiration - transforms expiration value into ISO string - * @param {undefined|null|number|string|Date} timeoutOrExpirationDate expiration date or timeout. Use number type to set timeout in seconds, string or Date to set exact expiration date + * @param timeoutOrExpirationDate - Expiration date or timeout. Use `number` to set the timeout + * in seconds, `string` or `Date` to set the exact expiration date (optional). + * @returns The expiration as an ISO string, or `null`. */ _normalizeExpiration(timeoutOrExpirationDate?: null | number | string | Date) { let pinExpires: null | string = null; @@ -3295,9 +1905,11 @@ export class StreamChat { } /** - * _messageId - extracts string message id from either message object or message id - * @param {string | { id: string }} messageOrMessageId message object or message id - * @param {string} errorText error message to report in case of message id absence + * Extracts a string message ID from either a message object or a message ID. + * + * @param messageOrMessageId - MessageRequest object or message ID. + * @param errorText - Error message to report in case of message ID absence. + * @returns The extracted message ID. */ _validateAndGetMessageId( messageOrMessageId: string | { id: string }, @@ -3316,328 +1928,145 @@ export class StreamChat { } /** - * pinMessage - pins the message - * @param {string | { id: string }} messageOrMessageId message object or message id - * @param {undefined|null|number|string|Date} timeoutOrExpirationDate expiration date or timeout. Use number type to set timeout in seconds, string or Date to set exact expiration date - * @param {undefined|string | { id: string }} [pinnedBy] who will appear as a user who pinned a message. Only for server-side use. Provide `undefined` when pinning message client-side - * @param {undefined|number|string|Date} pinnedAt date when message should be pinned. It affects the order of pinned messages. Use negative number to set relative time in the past, string or Date to set exact date of pin + * Pins the message. + * + * @param messageOrMessageId - MessageRequest object or message ID. + * @param timeoutOrExpirationDate - Expiration date or timeout. Use `number` to set the timeout + * in seconds, `string` or `Date` to set the exact expiration date (optional). + * @param pinnedAt - Date when the message should be pinned. It affects the order of pinned + * messages. Use a negative number to set relative time in the past, `string` or `Date` to + * set the exact date of pin (optional). + * @returns The updated message response. */ pinMessage( messageOrMessageId: string | { id: string }, timeoutOrExpirationDate?: null | number | string | Date, - pinnedBy?: string | { id: string }, pinnedAt?: number | string | Date, ) { - const messageId = this._validateAndGetMessageId( + const id = this._validateAndGetMessageId( messageOrMessageId, - 'Please specify the message id when calling unpinMessage', - ); - return this.partialUpdateMessage( - messageId, - { - set: { - pinned: true, - pin_expires: this._normalizeExpiration(timeoutOrExpirationDate), - pinned_at: this._normalizeExpiration(pinnedAt), - }, - } as unknown as PartialMessageUpdate, - pinnedBy, + 'Please specify the message id when calling pinMessage', ); + return this.updateMessagePartial({ + id, + set: { + pinned: true, + pin_expires: this._normalizeExpiration(timeoutOrExpirationDate), + pinned_at: this._normalizeExpiration(pinnedAt), + }, + }); } /** - * unpinMessage - unpins the message that was previously pinned - * @param {string | { id: string }} messageOrMessageId message object or message id - * @param {string | { id: string }} [userId] + * Unpins the message that was previously pinned. + * + * @param messageOrMessageId - MessageRequest object or message ID. + * @returns The updated message response. */ - unpinMessage( - messageOrMessageId: string | { id: string }, - userId?: string | { id: string }, - ) { - const messageId = this._validateAndGetMessageId( + unpinMessage(messageOrMessageId: string | { id: string }) { + const id = this._validateAndGetMessageId( messageOrMessageId, 'Please specify the message id when calling unpinMessage', ); - return this.partialUpdateMessage( - messageId, - { - set: { pinned: false }, - } as unknown as PartialMessageUpdate, - userId, - ); + return this.updateMessagePartial({ + id, + set: { pinned: false }, + }); } /** - * updateMessage - Update the given message - * - * @param {Omit & { mentioned_users?: string[] }} message object, id needs to be specified - * @param {string | { id: string }} [partialUserOrUserId] - * @param {boolean} [options.skip_enrich_url] Do not try to enrich the URLs within message - * - * @return {{ message: LocalMessage | MessageResponse }} Response that includes the message + * Updates the given message. When an `offlineDb` is registered the call is queued + * so it is replayed on reconnect. */ - async updateMessage( - message: LocalMessage | Partial, - partialUserOrUserId?: string | { id: string }, - options?: UpdateMessageOptions, + override async updateMessage( + request: Parameters[0] & { message: { cid?: string } }, ) { - if (!message.id) { - throw Error('Please specify the message.id when calling updateMessage'); - } - - const messageId = message.id as string; - try { if (this.offlineDb) { - return await this.offlineDb.queueTask({ + return await this.offlineDb.queueTask< + Awaited> + >({ task: { - ...getPendingTaskChannelData(message.cid), - messageId, - payload: [message, partialUserOrUserId, options], + ...getPendingTaskChannelData(request.message?.cid), + messageId: request.id, + payload: [request], type: 'update-message', }, }); } } catch (error) { - this.logger('error', `offlineDb:updateMessage`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('updateMessage') + .error('Updating the message failed.', { error }); } - return await this._updateMessage(message, partialUserOrUserId, options); + return await this._updateMessage(request); } - async _updateMessage( - message: LocalMessage | Partial, - partialUserOrUserId?: string | { id: string }, - options?: UpdateMessageOptions, - ) { - if (!message.id) { - throw Error('Please specify the message.id when calling updateMessage'); - } - - // should not include user object - const payload = toUpdatedMessagePayload(message); - - // add user_id (if exists) - if (typeof partialUserOrUserId === 'string') { - payload.user_id = partialUserOrUserId; - } else if (typeof partialUserOrUserId?.id === 'string') { - payload.user_id = partialUserOrUserId.id; - } - - return await this.post( - this.baseURL + `/messages/${encodeURIComponent(message.id as string)}`, - { - message: payload, - ...options, - }, - ); - } - - /** - * partialUpdateMessage - Update the given message id while retaining additional properties - * - * @param {string} id the message id - * - * @param {PartialUpdateMessage} partialMessageObject which should contain id and any of "set" or "unset" params; - * example: {id: "user1", set:{text: "hi"}, unset:["color"]} - * @param {string | { id: string }} [userId] - * - * @param {boolean} [options.skip_enrich_url] Do not try to enrich the URLs within message - * - * @return {{ message: MessageResponse }} Response that includes the updated message - */ - async partialUpdateMessage( - id: string, - partialMessageObject: PartialMessageUpdate, - partialUserOrUserId?: string | { id: string }, - options?: UpdateMessageOptions, - ) { - if (!id) { - throw Error('Please specify the message.id when calling partialUpdateMessage'); - } - - let user: { id: string } | undefined = undefined; - - if (typeof partialUserOrUserId === 'string') { - user = { id: partialUserOrUserId }; - } else if (typeof partialUserOrUserId?.id === 'string') { - user = { id: partialUserOrUserId.id }; - } - - return await this.put( - this.baseURL + `/messages/${encodeURIComponent(id)}`, - { - ...partialMessageObject, - ...options, - user, - }, - ); - } - - /** - * Updates message fields without storing them in the database, only sends update event. - * - * Available only on the server-side. - * - * @param messageId the message id to update. - * @param partialMessageObject the message payload. - * @param partialUserOrUserId the user id linked to this action. - * @param options additional options. - */ - async ephemeralUpdateMessage( - messageId: string, - partialMessageObject: PartialMessageUpdate, - partialUserOrUserId?: string | { id: string }, - options?: UpdateMessageOptions, - ) { - if (!messageId) throw Error('messageId is required'); - - let user: { id: string } | undefined = undefined; - if (typeof partialUserOrUserId === 'string') { - user = { id: partialUserOrUserId }; - } else if (typeof partialUserOrUserId?.id === 'string') { - user = { id: partialUserOrUserId.id }; - } - - return await this.patch( - `${this.baseURL}/messages/${encodeURIComponent(messageId)}/ephemeral`, - { - ...partialMessageObject, - ...options, - user, - }, - ); + async _updateMessage(request: Parameters[0]) { + return await super.updateMessage(request); } /** - * deleteMessage - Delete a message - * - * @param {string} messageID The id of the message to delete - * @param {boolean | DeleteMessageOptions | undefined} [optionsOrHardDelete] - * @return {Promise} The API response + * Deletes a message. When an `offlineDb` is registered the call is queued so it + * is replayed on reconnect. */ - // fixme: remove the signature with optionsOrHardDelete boolean with the next major release - async deleteMessage( - messageID: string, - optionsOrHardDelete?: DeleteMessageOptions | boolean, - ): Promise { - let options: DeleteMessageOptions = {}; - if (typeof optionsOrHardDelete === 'boolean') { - options = optionsOrHardDelete ? { hardDelete: true } : {}; - } else if (optionsOrHardDelete?.deleteForMe) { - options = { deleteForMe: true }; - } else if (optionsOrHardDelete?.hardDelete) { - options = { hardDelete: true }; - } - + override async deleteMessage(request: Parameters[0]) { try { if (this.offlineDb) { - if (options.hardDelete) { - await this.offlineDb.hardDeleteMessage({ id: messageID }); + if (request.hard) { + await this.offlineDb.hardDeleteMessage({ id: request.id }); } else { await this.offlineDb.softDeleteMessage({ - id: messageID, - deleteForMe: options.deleteForMe, + id: request.id, + deleteForMe: request.delete_for_me, }); } - return await this.offlineDb.queueTask( - { - task: { - messageId: messageID, - payload: [messageID, options], - type: 'delete-message', - }, + return await this.offlineDb.queueTask< + Awaited> + >({ + task: { + messageId: request.id, + payload: [request], + type: 'delete-message', }, - ); + }); } } catch (error) { - this.logger('error', `offlineDb:deleteMessage`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('deleteMessage') + .error('Deleting the message failed.', { error }); } - return this._deleteMessage(messageID, options); + return this._deleteMessage(request); } - // fixme: remove the signature with optionsOrHardDelete boolean with the next major release - async _deleteMessage( - messageID: string, - optionsOrHardDelete?: DeleteMessageOptions | boolean, - ): Promise { - // this is a API call method, we do not route hardDelete: true and deleteForMe: true to deleteForMe: true - // and expect to receive error response from the server - const { deleteForMe, hardDelete } = ( - typeof optionsOrHardDelete === 'boolean' - ? { hardDelete: optionsOrHardDelete } - : (optionsOrHardDelete ?? {}) - ) as DeleteMessageOptions; - - let params = {}; - if (hardDelete) { - params = { hard: true }; - } - if (deleteForMe) { - params = { ...params, delete_for_me: true }; - } - const result = await this.delete( - this.baseURL + `/messages/${encodeURIComponent(messageID)}`, - params, - ); + async _deleteMessage(request: Parameters[0]) { + const result = await super.deleteMessage(request); // necessary to populate the below values as the server does not return the message in the response as deleted - if (deleteForMe) { + if (request.delete_for_me) { result.message.deleted_for_me = true; result.message.type = 'deleted'; } - return result; - } - - /** - * undeleteMessage - Undelete a message - * - * undeletes a message that was previous soft deleted. Hard deleted messages - * cannot be undeleted. This is only allowed to be called from server-side - * clients. - * - * @param {string} messageID The id of the message to undelete - * @param {string} userID The id of the user who undeleted the message - * - * @return {{ message: MessageResponse }} Response that includes the message - */ - async undeleteMessage(messageID: string, userID: string) { - return await this.post( - this.baseURL + `/messages/${encodeURIComponent(messageID)}/undelete`, - { undeleted_by: userID }, - ); - } - async getMessage(messageID: string, options?: GetMessageOptions) { - return await this.get( - this.baseURL + `/messages/${encodeURIComponent(messageID)}`, - { - ...options, - }, - ); + return result; } /** - * queryThreads - returns the list of threads of current user. - * - * @param {QueryThreadsOptions} options Options object for pagination and limiting the participants and replies. - * @param {number} options.limit Limits the number of threads to be returned. - * @param {boolean} options.watch Subscribes the user to the channels of the threads. - * @param {number} options.participant_limit Limits the number of participants returned per threads. - * @param {number} options.reply_limit Limits the number of replies returned per threads. - * @param {ThreadFilters} options.filter MongoDB style filters for threads - * @param {ThreadSort} options.sort MongoDB style sort for threads + * Returns the list of threads of the current user. * - * @returns {{ threads: Thread[], next: string }} Returns the list of threads and the next cursor. + * @param options - Options object for pagination and limiting the participants and replies + * (optional, defaults to `{}`). + * @param options.limit - Limits the number of threads to be returned (optional). + * @param options.watch - Subscribes the user to the channels of the threads (optional). + * @param options.participant_limit - Limits the number of participants returned per thread (optional). + * @param options.reply_limit - Limits the number of replies returned per thread (optional). + * @param options.filter - MongoDB style filters for threads (optional). + * @param options.sort - MongoDB style sort for threads (optional). + * @returns The list of threads and the next cursor. */ - async queryThreads(options: QueryThreadsOptions = {}) { + async queryThreadsAndHydrate(options: QueryThreadsRequest = {}) { const optionsWithDefaults = { limit: 10, participant_limit: 10, @@ -3657,22 +2086,15 @@ export class StreamChat { requestBody.filter = optionsWithDefaults.filter; } - if ( - optionsWithDefaults.sort && - (Array.isArray(optionsWithDefaults.sort) - ? optionsWithDefaults.sort.length > 0 - : Object.keys(optionsWithDefaults.sort).length > 0) - ) { - requestBody.sort = normalizeQuerySort(optionsWithDefaults.sort); + if (optionsWithDefaults.sort && optionsWithDefaults.sort.length > 0) { + requestBody.sort = optionsWithDefaults.sort; } - const response = await this.post( - `${this.baseURL}/threads`, - requestBody, - ); + const response = await this.queryThreads(requestBody); // Hydrate the polls for the parent messages of the threads - const parentMessages = response.threads.map((thread) => thread.parent_message); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const parentMessages = response.threads.map((thread) => thread.parent_message!); this.polls.hydratePollCache(parentMessages); return { @@ -3684,19 +2106,19 @@ export class StreamChat { } /** - * getThread - returns the thread of a message by its id. - * - * @param {string} messageId The message id - * @param {GetThreadOptions} options Options object for pagination and limiting the participants and replies. - * @param {boolean} options.watch Subscribes the user to the channel of the thread. - * @param {number} options.participant_limit Limits the number of participants returned per threads. - * @param {number} options.reply_limit Limits the number of replies returned per threads. + * Returns the thread of a message by its ID, wrapped in a hydrated `Thread` instance. * - * @returns {Thread} Returns the thread. + * @param messageId - The message ID. + * @param options - Options object for pagination and limiting the participants and replies + * (optional, defaults to `{}`). + * @param options.watch - Subscribes the user to the channel of the thread (optional). + * @param options.participant_limit - Limits the number of participants returned per thread (optional). + * @param options.reply_limit - Limits the number of replies returned per thread (optional). + * @returns The thread. */ - async getThread(messageId: string, options: GetThreadOptions = {}) { + async getThreadAndHydrate(messageId: string, options: GetThreadOptions = {}) { if (!messageId) { - throw new Error('Please specify the messageId when calling getThread'); + throw new Error('Please specify the messageId when calling getThreadAndHydrate'); } const optionsWithDefaults = { @@ -3706,21 +2128,20 @@ export class StreamChat { ...options, }; - const response = await this.get( - `${this.baseURL}/threads/${encodeURIComponent(messageId)}`, - optionsWithDefaults, - ); + const response = await this.getThread({ + message_id: messageId, + ...optionsWithDefaults, + }); return new Thread({ client: this, threadData: response.thread }); } /** - * partialUpdateThread - updates the given thread - * - * @param {string} messageId The id of the thread message which needs to be updated. - * @param {PartialThreadUpdate} partialThreadObject should contain "set" or "unset" params for any of the thread's non-reserved fields. + * Updates the given thread. * - * @returns {GetThreadAPIResponse} Returns the updated thread. + * @param messageId - The ID of the thread message which needs to be updated. + * @param partialThreadObject - Should contain `set` or `unset` params for any of the thread's non-reserved fields. + * @returns The updated thread. */ async partialUpdateThread(messageId: string, partialThreadObject: PartialThreadUpdate) { if (!messageId) { @@ -3750,10 +2171,10 @@ export class StreamChat { } } - return await this.patch( - `${this.baseURL}/threads/${encodeURIComponent(messageId)}`, - partialThreadObject, - ); + return await this.updateThreadPartial({ + message_id: messageId, + ...partialThreadObject, + }); } getUserAgent = (): string => { @@ -3794,74 +2215,17 @@ export class StreamChat { }; /** - * @deprecated use sdkIdentifier instead - * @param userAgent + * Sets the user agent string. + * + * @deprecated Use `sdkIdentifier` instead. + * + * @param userAgent - The user agent string. */ setUserAgent(userAgent: string) { this.userAgent = userAgent; } - /** - * _isUsingServerAuth - Returns true if we're using server side auth - */ - _isUsingServerAuth = () => !!this.secret; - - _cacheEnabled = () => !this._isUsingServerAuth() || !this.options.disableCache; - - _enrichAxiosOptions( - options: AxiosRequestConfig & { config?: AxiosRequestConfig } = { - params: {}, - headers: {}, - config: {}, - }, - ): AxiosRequestConfig { - const token = this._getToken(); - const authorization = token ? { Authorization: token } : undefined; - let signal: AbortSignal | null = null; - if (this.nextRequestAbortController !== null) { - signal = this.nextRequestAbortController.signal; - this.nextRequestAbortController = null; - } - - if (!options.headers?.['x-client-request-id']) { - options.headers = { - ...options.headers, - 'x-client-request-id': randomId(), - }; - } - - const { - params: axiosRequestConfigParams, - headers: axiosRequestConfigHeaders, - ...axiosRequestConfigRest - } = this.options.axiosRequestConfig || {}; - - return { - params: { - user_id: this.userID, - connection_id: this._getConnectionID(), - api_key: this.key, - ...options.params, - ...(axiosRequestConfigParams || {}), - }, - headers: { - ...authorization, - 'stream-auth-type': this.getAuthType(), - 'X-Stream-Client': this.getUserAgent(), - ...options.headers, - ...(axiosRequestConfigHeaders || {}), - }, - ...(signal ? { signal } : {}), - ...options.config, - ...(axiosRequestConfigRest || {}), - }; - } - - _getToken() { - if (!this.tokenManager || this.anonymous) return null; - - return this.tokenManager.getToken(); - } + _cacheEnabled = () => !this.options.disableCache; _startCleaning() { // eslint-disable-next-line @typescript-eslint/no-this-alias @@ -3878,1560 +2242,110 @@ export class StreamChat { } /** - * encode ws url payload + * Encodes the WS URL payload. + * * @private - * @returns json string + * + * @param client_request_id - The client request ID (optional). + * @returns The JSON-encoded payload string. */ _buildWSPayload = (client_request_id?: string) => JSON.stringify({ - user_id: this.userID, + user_id: this.userId, user_details: this._user, device: this.options.device, client_request_id, }); /** - * checks signature of a request - * @param {string | Buffer} rawBody - * @param {string} signature from HTTP header - * @returns {boolean} - */ - verifyWebhook(requestBody: string | Buffer, xSignature: string) { - return !!this.secret && verifySignature(requestBody, xSignature, this.secret); - } - - /** - * Verify and parse an HTTP webhook event. - * - * Decompresses `rawBody` when gzipped (detected from the body bytes), - * verifies the `X-Signature` header against the app's API secret, and - * returns the parsed `Event`. Works whether or not Stream is currently - * compressing payloads for this app, and stays correct behind - * middleware that auto-decompresses the request. - * - * @param rawBody Raw HTTP request body bytes Stream signed - * @param signature Value of the `X-Signature` header - * @throws {InvalidWebhookError} When the signature does not match or - * the gzip envelope is malformed. - */ - verifyAndParseWebhook(rawBody: string | Buffer, signature: string) { - if (!this.secret) { - throw new InvalidWebhookError( - 'cannot verify webhook signature without an API secret on the client', - ); - } - return verifyAndParseWebhookHelper(rawBody, signature, this.secret); - } - - /** - * Parse an SQS firehose event: decodes the message `Body` (base64 + - * optional gzip) and returns the parsed `Event`. No HMAC verification - * (Stream does not sign SQS bodies). - * - * @param messageBody SQS message `Body` string - * @throws {InvalidWebhookError} When the base64 / gzip envelope is malformed. - */ - parseSqs(messageBody: string) { - return parseSqsHelper(messageBody); - } - - /** - * Parse an SNS-delivered event (unwraps envelope JSON when needed, then - * same decode path as SQS). No HMAC verification. - * - * @param notificationBody Raw SNS POST body or pre-extracted `Message` string - * @throws {InvalidWebhookError} When the envelope cannot be decoded. - */ - parseSns(notificationBody: string) { - return parseSnsHelper(notificationBody); - } - - /** getPermission - gets the definition for a permission - * - * @param {string} name - * @returns {Promise} - */ - getPermission(name: string) { - return this.get( - `${this.baseURL}/permissions/${encodeURIComponent(name)}`, - ); - } - - /** createPermission - creates a custom permission - * - * @param {CustomPermissionOptions} permissionData the permission data - * @returns {Promise} - */ - createPermission(permissionData: CustomPermissionOptions) { - return this.post(`${this.baseURL}/permissions`, { - ...permissionData, - }); - } - - /** updatePermission - updates an existing custom permission - * - * @param {string} id - * @param {Omit} permissionData the permission data - * @returns {Promise} - */ - updatePermission(id: string, permissionData: Omit) { - return this.put( - `${this.baseURL}/permissions/${encodeURIComponent(id)}`, - { - ...permissionData, + * Queries poll answers. + * + * @param request - The query poll answers request payload, including the poll ID, optional vote + * filter conditions, sort directions, and pagination options (`limit`, `offset`). + * @param request.poll_id - The poll ID. + * @param request.filter - Vote filter conditions. + * @returns The poll answers. + */ + async queryPollAnswers({ + poll_id, + filter, + ...options + }: Parameters[0]) { + return await this.queryPollVotes({ + poll_id, + filter: { + ...filter, + is_answer: true, }, - ); - } - - /** deletePermission - deletes a custom permission - * - * @param {string} name - * @returns {Promise} - */ - deletePermission(name: string) { - return this.delete( - `${this.baseURL}/permissions/${encodeURIComponent(name)}`, - ); - } - - /** listPermissions - returns the list of all permissions for this application - * - * @returns {Promise} - */ - listPermissions() { - return this.get(`${this.baseURL}/permissions`); - } - - /** createRole - creates a custom role - * - * @param {string} name the new role name - * @returns {Promise} - */ - createRole(name: string) { - return this.post(`${this.baseURL}/roles`, { name }); - } - - /** listRoles - returns the list of all roles for this application - * - * @returns {Promise} - */ - listRoles() { - return this.get(`${this.baseURL}/roles`); - } - - /** listRoles - returns the list of all roles for this application - * - * @returns {Promise} - */ - searchRoles(options: SearchRolesOptions) { - return this.get(`${this.baseURL}/roles/search`, options); - } - - /** deleteRole - deletes a custom role - * - * @param {string} name the role name - * @returns {Promise} - */ - deleteRole(name: string) { - return this.delete(`${this.baseURL}/roles/${encodeURIComponent(name)}`); - } - - /** sync - returns all events that happened for a list of channels since last sync - * @param {string[]} channel_cids list of channel CIDs - * @param {string} last_sync_at last time the user was online and in sync. RFC3339 ie. "2020-05-06T15:05:01.207Z" - * @param {SyncOptions} options See JSDoc in the type fields for more info - * - * @returns {Promise} - */ - sync(channel_cids: string[], last_sync_at: string, options: SyncOptions = {}) { - return this.post(`${this.baseURL}/sync`, { - channel_cids, - last_sync_at, ...options, }); } /** - * sendUserCustomEvent - Send a custom event to a user - * - * @param {string} targetUserID target user id - * @param {UserCustomEvent} event for example {type: 'friendship-request'} - * - * @return {Promise} The Server Response - */ - async sendUserCustomEvent(targetUserID: string, event: UserCustomEvent) { - return await this.post( - `${this.baseURL}/users/${encodeURIComponent(targetUserID)}/event`, - { - event, - }, - ); - } - - /** - * Creates a new block list - * - * @param {BlockList} blockList - The block list to create - * @param {string} blockList.name - The name of the block list - * @param {string[]} blockList.words - List of words to block - * @param {string} [blockList.team] - Team ID the block list belongs to - * - * @returns {Promise} The server response - */ - createBlockList(blockList: BlockList) { - return this.post(`${this.baseURL}/blocklists`, blockList); - } - - /** - * Lists all block lists - * - * @param {Object} [data] - Query parameters - * @param {string} [data.team] - Team ID to filter block lists by - * - * @returns {Promise} Response containing array of block lists - */ - listBlockLists(data?: { team?: string }) { - return this.get( - `${this.baseURL}/blocklists`, - data, - ); - } - - /** - * Gets a specific block list - * - * @param {string} name - The name of the block list to retrieve - * @param {Object} [data] - Query parameters - * @param {string} [data.team] - Team ID that blocklist belongs to - * - * @returns {Promise} Response containing the block list - */ - getBlockList(name: string, data?: { team?: string }) { - return this.get( - `${this.baseURL}/blocklists/${encodeURIComponent(name)}`, - data, - ); - } - - /** - * Updates an existing block list - * - * @param {string} name - The name of the block list to update - * @param {Object} data - The update data - * @param {string[]} data.words - New list of words to block - * @param {string} [data.team] - Team ID that blocklist belongs to - * - * @returns {Promise} The server response - */ - updateBlockList(name: string, data: { words: string[]; team?: string }) { - return this.put( - `${this.baseURL}/blocklists/${encodeURIComponent(name)}`, - data, - ); - } - - /** - * Deletes a block list - * - * @param {string} name - The name of the block list to delete - * @param {Object} [data] - Query parameters - * @param {string} [data.team] - Team ID that blocklist belongs to + * Uploads a file to the configured storage (defaults to Stream CDN). * - * @returns {Promise} The server response + * @param uri - The file to upload. + * @param name - The name of the file (optional). + * @param contentType - The content type of the file (optional). + * @param user - User information (optional). + * @param axiosRequestConfig - Axios config, e.g. `onUploadProgress` for progress tracking (optional). + * @returns Response containing the file URL. */ - deleteBlockList(name: string, data?: { team?: string }) { - return this.delete( - `${this.baseURL}/blocklists/${encodeURIComponent(name)}`, - data, - ); - } - - exportChannels( - request: Array, - options: ExportChannelOptions = {}, + uploadFile_( + uri: string | NodeJS.ReadableStream | Buffer | File, + name?: string, + contentType?: string, + user?: UserResponse, + axiosRequestConfig?: AxiosRequestConfig, ) { - const payload = { channels: request, ...options }; - return this.post( - `${this.baseURL}/export_channels`, - payload, - ); - } - - exportUsers(request: ExportUsersRequest) { - return this.post( - `${this.baseURL}/export/users`, - request, - ); - } - - exportChannel(request: ExportChannelRequest, options?: ExportChannelOptions) { - return this.exportChannels([request], options); - } - - getExportChannelStatus(id: string) { - return this.get( - `${this.baseURL}/export_channels/${encodeURIComponent(id)}`, - ); - } - - campaign(idOrData: string | CampaignData, data?: CampaignData) { - if (idOrData && typeof idOrData === 'object') { - return new Campaign(this, null, idOrData); - } - - return new Campaign(this, idOrData, data); - } - - /** - * channelBatchUpdater - Returns a ChannelBatchUpdater instance for batch channel operations - * - * @return {ChannelBatchUpdater} A ChannelBatchUpdater instance - */ - channelBatchUpdater() { - return new ChannelBatchUpdater(this); - } - - segment(type: SegmentType, idOrData: string | SegmentData, data?: SegmentData) { - if (typeof idOrData === 'string') { - return new Segment(this, type, idOrData, data); - } - - return new Segment(this, type, null, idOrData); - } - - validateServerSideAuth() { - if (!this.secret) { - throw new Error( - 'This feature can be used server-side only. Please initialize the client with a secret to use this feature.', - ); - } - } - - /** - * createSegment - Creates a segment - * - * @private - * @param {SegmentType} type Segment type - * @param {string} id Segment ID - * @param {string} name Segment name - * @param {SegmentData} params Segment data - * - * @return {{segment: SegmentResponse} & APIResponse} The created Segment - */ - createSegment(type: SegmentType, id: string | null, data?: SegmentData) { - this.validateServerSideAuth(); - const body = { - id, - type, - ...data, - }; - return this.post<{ segment: SegmentResponse }>(this.baseURL + `/segments`, body); - } - - /** - * createUserSegment - Creates a user segment - * - * @param {string} id Segment ID - * @param {string} name Segment name - * @param {SegmentData} data Segment data - * - * @return {Segment} The created Segment - */ - createUserSegment(id: string | null, data?: SegmentData) { - this.validateServerSideAuth(); - return this.createSegment('user', id, data); - } - - /** - * createChannelSegment - Creates a channel segment - * - * @param {string} id Segment ID - * @param {string} name Segment name - * @param {SegmentData} data Segment data - * - * @return {Segment} The created Segment - */ - createChannelSegment(id: string | null, data?: SegmentData) { - this.validateServerSideAuth(); - return this.createSegment('channel', id, data); - } - - getSegment(id: string) { - this.validateServerSideAuth(); - return this.get<{ segment: SegmentResponse } & APIResponse>( - this.baseURL + `/segments/${encodeURIComponent(id)}`, - ); - } - - /** - * updateSegment - Update a segment - * - * @param {string} id Segment ID - * @param {Partial} data Data to update - * - * @return {Segment} Updated Segment - */ - updateSegment(id: string, data: Partial) { - this.validateServerSideAuth(); - return this.put<{ segment: SegmentResponse }>( - this.baseURL + `/segments/${encodeURIComponent(id)}`, - data, + return this.api.sendFile( + `${this.baseURL}/uploads/file`, + uri, + name, + contentType, + user, + axiosRequestConfig, ); } /** - * addSegmentTargets - Add targets to a segment - * - * @param {string} id Segment ID - * @param {string[]} targets Targets to add to the segment + * Uploads an image to the configured storage (defaults to Stream CDN). * - * @return {APIResponse} API response + * @param uri - The image to upload. + * @param name - The name of the image (optional). + * @param contentType - The content type of the image (optional). + * @param user - User information (optional). + * @param axiosRequestConfig - Axios config, e.g. `onUploadProgress` for progress tracking (optional). + * @returns Response containing the image URL. */ - addSegmentTargets(id: string, targets: string[]) { - this.validateServerSideAuth(); - const body = { target_ids: targets }; - return this.post( - this.baseURL + `/segments/${encodeURIComponent(id)}/addtargets`, - body, - ); - } - - querySegmentTargets( - id: string, - filter: QuerySegmentTargetsFilter | null = {}, - sort: SortParam[] | null | [] = [], - options = {}, + uploadImage_( + uri: string | NodeJS.ReadableStream | File, + name?: string, + contentType?: string, + user?: UserResponse, + axiosRequestConfig?: AxiosRequestConfig, ) { - this.validateServerSideAuth(); - return this.post<{ targets: SegmentTargetsResponse[]; next?: string } & APIResponse>( - this.baseURL + `/segments/${encodeURIComponent(id)}/targets/query`, - { - filter: filter || {}, - sort: sort || [], - ...options, - }, - ); - } - /** - * removeSegmentTargets - Remove targets from a segment - * - * @param {string} id Segment ID - * @param {string[]} targets Targets to add to the segment - * - * @return {APIResponse} API response - */ - removeSegmentTargets(id: string, targets: string[]) { - this.validateServerSideAuth(); - const body = { target_ids: targets }; - return this.post( - this.baseURL + `/segments/${encodeURIComponent(id)}/deletetargets`, - body, - ); - } - - /** - * querySegments - Query Segments - * - * @param {filter} filter MongoDB style filter conditions - * @param {QuerySegmentsOptions} options Options for sorting/paginating the results - * - * @return {Segment[]} Segments - */ - querySegments(filter: {}, sort?: SortParam[], options: QuerySegmentsOptions = {}) { - this.validateServerSideAuth(); - return this.post< - { - segments: SegmentResponse[]; - next?: string; - prev?: string; - } & APIResponse - >(this.baseURL + `/segments/query`, { - filter, - sort, - ...options, - }); - } - - /** - * deleteSegment - Delete a Campaign Segment - * - * @param {string} id Segment ID - * - * @return {Promise} The Server Response - */ - deleteSegment(id: string) { - this.validateServerSideAuth(); - return this.delete(this.baseURL + `/segments/${encodeURIComponent(id)}`); - } - - /** - * segmentTargetExists - Check if a target exists in a segment - * - * @param {string} segmentId Segment ID - * @param {string} targetId Target ID - * - * @return {Promise} The Server Response - */ - segmentTargetExists(segmentId: string, targetId: string) { - this.validateServerSideAuth(); - return this.get( - this.baseURL + - `/segments/${encodeURIComponent(segmentId)}/target/${encodeURIComponent(targetId)}`, + return this.api.sendFile( + `${this.baseURL}/uploads/image`, + uri, + name, + contentType, + user, + axiosRequestConfig, ); } - /** - * createCampaign - Creates a Campaign + * Marks the channels as delivered for the given messages and the user. * - * @param {CampaignData} params Campaign data - * - * @return {Campaign} The Created Campaign + * @param request - Mark delivered options. + * @returns The server response, or `undefined` if there are no messages to mark. */ - createCampaign(params: CampaignData) { - this.validateServerSideAuth(); - return this.post< - { - campaign: CampaignResponse; - users: { - next?: string; - prev?: string; - }; - } & APIResponse - >(this.baseURL + `/campaigns`, { ...params }); - } - - getCampaign(id: string, options?: GetCampaignOptions) { - this.validateServerSideAuth(); - return this.get< - { - campaign: CampaignResponse; - users: { - next?: string; - prev?: string; - }; - } & APIResponse - >(this.baseURL + `/campaigns/${encodeURIComponent(id)}`, { ...options?.users }); - } - - startCampaign(id: string, options?: { scheduledFor?: string; stopAt?: string }) { - this.validateServerSideAuth(); - return this.post< - { - campaign: CampaignResponse; - users: { - next?: string; - prev?: string; - }; - } & APIResponse - >(this.baseURL + `/campaigns/${encodeURIComponent(id)}/start`, { - scheduled_for: options?.scheduledFor, - stop_at: options?.stopAt, - }); - } - - /** - * queryCampaigns - Query Campaigns - * - * - * @return {Campaign[]} Campaigns - */ - async queryCampaigns( - filter: CampaignFilters, - sort?: CampaignSort, - options?: CampaignQueryOptions, - ) { - this.validateServerSideAuth(); - return await this.post< - { - campaigns: CampaignResponse[]; - next?: string; - prev?: string; - } & APIResponse - >(this.baseURL + `/campaigns/query`, { - filter, - sort, - ...(options || {}), - }); - } - - /** - * updateCampaign - Update a Campaign - * - * @param {string} id Campaign ID - * @param {Partial} params Campaign data - * - * @return {Campaign} Updated Campaign - */ - updateCampaign(id: string, params: Partial) { - this.validateServerSideAuth(); - return this.put<{ - campaign: CampaignResponse; - users: { - next?: string; - prev?: string; - }; - }>(this.baseURL + `/campaigns/${encodeURIComponent(id)}`, params); - } - - /** - * deleteCampaign - Delete a Campaign - * - * @param {string} id Campaign ID - * - * @return {Promise} The Server Response - */ - deleteCampaign(id: string) { - this.validateServerSideAuth(); - return this.delete( - this.baseURL + `/campaigns/${encodeURIComponent(id)}`, - ); - } - - /** - * stopCampaign - Stop a Campaign - * - * @param {string} id Campaign ID - * - * @return {Campaign} Stopped Campaign - */ - stopCampaign(id: string) { - this.validateServerSideAuth(); - return this.post<{ campaign: CampaignResponse }>( - this.baseURL + `/campaigns/${encodeURIComponent(id)}/stop`, - ); - } - - /** - * enrichURL - Get OpenGraph data of the given link - * - * @param {string} url link - * @return {OGAttachment} OG Attachment - */ - enrichURL(url: string) { - return this.get(this.baseURL + `/og`, { url }); - } - - /** - * getTask - Gets status of a long running task - * - * @param {string} id Task ID - * - * @return {TaskStatus} The task status - */ - getTask(id: string) { - return this.get( - `${this.baseURL}/tasks/${encodeURIComponent(id)}`, - ); - } - - /** - * deleteChannels - Deletes a list of channel - * - * @param {string[]} cids Channel CIDs - * @param {boolean} [options.hard_delete] Defines if the channel is hard deleted or not - * - * @return {DeleteChannelsResponse} Result of the soft deletion, if server-side, it holds the task ID as well - */ - async deleteChannels(cids: string[], options: { hard_delete?: boolean } = {}) { - return await this.post( - this.baseURL + `/channels/delete`, - { - cids, - ...options, - }, - ); - } - - /** - * deleteUsers - Batch Delete Users - * - * @param {string[]} user_ids which users to delete - * @param {DeleteUserOptions} options Configuration how to delete users - * - * @return {TaskResponse} A task ID - */ - async deleteUsers(user_ids: string[], options: DeleteUserOptions = {}) { - if ( - typeof options.user !== 'undefined' && - !['soft', 'hard', 'pruning'].includes(options.user) - ) { - throw new Error( - 'Invalid delete user options. user must be one of [soft hard pruning]', - ); - } - if ( - typeof options.conversations !== 'undefined' && - !['soft', 'hard'].includes(options.conversations) - ) { - throw new Error( - 'Invalid delete user options. conversations must be one of [soft hard]', - ); - } - if ( - typeof options.messages !== 'undefined' && - !['soft', 'hard', 'pruning'].includes(options.messages) - ) { - throw new Error( - 'Invalid delete user options. messages must be one of [soft hard pruning]', - ); - } - return await this.post(this.baseURL + `/users/delete`, { - user_ids, - ...options, - }); - } - - /** - * _createImportURL - Create an Import upload url. - * - * Note: Do not use this. - * It is present for internal usage only. - * This function can, and will, break and/or be removed at any point in time. - * - * @private - * @param {string} filename filename of uploaded data - * @return {APIResponse & CreateImportResponse} An ImportTask - */ - async _createImportURL(filename: string) { - return await this.post( - this.baseURL + `/import_urls`, - { - filename, - }, - ); - } - - /** - * _createImport - Create an Import Task. - * - * Note: Do not use this. - * It is present for internal usage only. - * This function can, and will, break and/or be removed at any point in time. - * - * @private - * @param {string} path path of uploaded data - * @param {CreateImportOptions} options import options - * @return {APIResponse & CreateImportResponse} An ImportTask - */ - async _createImport(path: string, options: CreateImportOptions = { mode: 'upsert' }) { - return await this.post( - this.baseURL + `/imports`, - { - path, - ...options, - }, - ); - } - - /** - * _getImport - Get an Import Task. - * - * Note: Do not use this. - * It is present for internal usage only. - * This function can, and will, break and/or be removed at any point in time. - * - * @private - * @param {string} id id of Import Task - * - * @return {APIResponse & GetImportResponse} An ImportTask - */ - async _getImport(id: string) { - return await this.get( - this.baseURL + `/imports/${encodeURIComponent(id)}`, - ); - } - - /** - * _listImports - Lists Import Tasks. - * - * Note: Do not use this. - * It is present for internal usage only. - * This function can, and will, break and/or be removed at any point in time. - * - * @private - * @param {ListImportsPaginationOptions} options pagination options - * - * @return {APIResponse & ListImportsResponse} An ImportTask - */ - async _listImports(options: ListImportsPaginationOptions) { - return await this.get( - this.baseURL + `/imports`, - options, - ); - } - - /** - * upsertPushProvider - Create or Update a push provider - * - * Note: Works only for v2 push version is enabled on app settings. - * - * @param {PushProviderConfig} configuration of the provider you want to create or update - * - * @return {APIResponse & PushProviderUpsertResponse} A push provider - */ - async upsertPushProvider(pushProvider: PushProviderConfig) { - return await this.post( - this.baseURL + `/push_providers`, - { - push_provider: pushProvider, - }, - ); - } - - /** - * deletePushProvider - Delete a push provider - * - * Note: Works only for v2 push version is enabled on app settings. - * - * @param {PushProviderID} type and foreign id of the push provider to be deleted - * - * @return {APIResponse} An API response - */ - async deletePushProvider({ type, name }: PushProviderID) { - return await this.delete( - this.baseURL + - `/push_providers/${encodeURIComponent(type)}/${encodeURIComponent(name)}`, - ); - } - - /** - * listPushProviders - Get all push providers in the app - * - * Note: Works only for v2 push version is enabled on app settings. - * - * @return {APIResponse & PushProviderListResponse} A push provider - */ - async listPushProviders() { - return await this.get( - this.baseURL + `/push_providers`, - ); - } - - /** - * creates an abort controller that will be used by the next HTTP Request. - */ - createAbortControllerForNextRequest() { - return (this.nextRequestAbortController = new AbortController()); - } - - /** - * commits a pending message, making it visible in the channel and for other users - * @param id the message id - * - * @return {APIResponse & MessageResponse} The message - */ - async commitMessage(id: string) { - return await this.post( - this.baseURL + `/messages/${encodeURIComponent(id)}/commit`, - ); - } - - /** - * Creates a poll - * @param poll PollData The poll that will be created - * @param userId string The user id (only serverside) - * @returns {APIResponse & CreatePollAPIResponse} The poll - */ - async createPoll(poll: CreatePollData, userId?: string) { - return await this.post(this.baseURL + `/polls`, { - ...poll, - ...(userId ? { user_id: userId } : {}), - }); - } - - /** - * Retrieves a poll - * @param id string The poll id - * @param userId string The user id (only serverside) - * @returns {APIResponse & GetPollAPIResponse} The poll - */ - async getPoll(id: string, userId?: string): Promise { - return await this.get( - this.baseURL + `/polls/${encodeURIComponent(id)}`, - userId ? { user_id: userId } : {}, - ); - } - - /** - * Updates a poll - * @param poll PollData The poll that will be updated - * @param userId string The user id (only serverside) - * @returns {APIResponse & PollResponse} The poll - */ - async updatePoll(poll: PollData, userId?: string) { - return await this.put(this.baseURL + `/polls`, { - ...poll, - ...(userId ? { user_id: userId } : {}), - }); - } - - /** - * Partially updates a poll - * @param id string The poll id - * @param {PartialPollUpdate} partialPollObject which should contain id and any of "set" or "unset" params; - * @param userId string The user id (only serverside) - * example: {id: "44f26af5-f2be-4fa7-9dac-71cf893781de", set:{field: value}, unset:["field2"]} - * @returns {APIResponse & UpdatePollAPIResponse} The poll - */ - async partialUpdatePoll( - id: string, - partialPollObject: PartialPollUpdate, - userId?: string, - ): Promise { - return await this.patch( - this.baseURL + `/polls/${encodeURIComponent(id)}`, - { - ...partialPollObject, - ...(userId ? { user_id: userId } : {}), - }, - ); - } - - /** - * Delete a poll - * @param id string The poll id - * @param userId string The user id (only serverside) - * @returns - */ - async deletePoll(id: string, userId?: string): Promise { - return await this.delete( - this.baseURL + `/polls/${encodeURIComponent(id)}`, - { - ...(userId ? { user_id: userId } : {}), - }, - ); - } - - /** - * Close a poll - * @param id string The poll id - * @param userId string The user id (only serverside) - * @returns {APIResponse & UpdatePollAPIResponse} The poll - */ - closePoll(id: string, userId?: string): Promise { - return this.partialUpdatePoll( - id, - { - set: { - is_closed: true, - } as PartialPollUpdate['set'], - }, - userId, - ); - } - - /** - * Creates a poll option - * @param pollId string The poll id - * @param option PollOptionData The poll option that will be created - * @param userId string The user id (only serverside) - * @returns {APIResponse & PollOptionResponse} The poll option - */ - async createPollOption(pollId: string, option: PollOptionData, userId?: string) { - return await this.post( - this.baseURL + `/polls/${encodeURIComponent(pollId)}/options`, - { - ...option, - ...(userId ? { user_id: userId } : {}), - }, - ); - } - - /** - * Retrieves a poll option - * @param pollId string The poll id - * @param optionId string The poll option id - * @param userId string The user id (only serverside) - * @returns {APIResponse & PollOptionResponse} The poll option - */ - async getPollOption(pollId: string, optionId: string, userId?: string) { - return await this.get( - this.baseURL + - `/polls/${encodeURIComponent(pollId)}/options/${encodeURIComponent(optionId)}`, - userId ? { user_id: userId } : {}, - ); - } - - /** - * Updates a poll option - * @param pollId string The poll id - * @param option PollOptionData The poll option that will be updated - * @param userId string The user id (only serverside) - * @returns - */ - async updatePollOption(pollId: string, option: PollOptionData, userId?: string) { - return await this.put( - this.baseURL + `/polls/${encodeURIComponent(pollId)}/options`, - { - ...option, - ...(userId ? { user_id: userId } : {}), - }, - ); - } - - /** - * Delete a poll option - * @param pollId string The poll id - * @param optionId string The poll option id - * @param userId string The user id (only serverside) - * @returns {APIResponse} The poll option - */ - async deletePollOption(pollId: string, optionId: string, userId?: string) { - return await this.delete( - this.baseURL + - `/polls/${encodeURIComponent(pollId)}/options/${encodeURIComponent(optionId)}`, - userId ? { user_id: userId } : {}, - ); - } - - /** - * Cast vote on a poll - * @param messageId string The message id - * @param pollId string The poll id - * @param vote PollVoteData The vote that will be casted - * @param userId string The user id (only serverside) - * @returns {APIResponse & CastVoteAPIResponse} The poll vote - */ - async castPollVote( - messageId: string, - pollId: string, - vote: PollVoteData, - userId?: string, - ) { - return await this.post( - this.baseURL + - `/messages/${encodeURIComponent(messageId)}/polls/${encodeURIComponent(pollId)}/vote`, - { - vote, - ...(userId ? { user_id: userId } : {}), - }, - ); - } - - /** - * Add a poll answer - * @param messageId string The message id - * @param pollId string The poll id - * @param answerText string The answer text - * @param userId string The user id (only serverside) - */ - addPollAnswer(messageId: string, pollId: string, answerText: string, userId?: string) { - return this.castPollVote( - messageId, - pollId, - { - answer_text: answerText, - }, - userId, - ); - } - - async removePollVote( - messageId: string, - pollId: string, - voteId: string, - userId?: string, - ) { - return await this.delete( - this.baseURL + - `/messages/${encodeURIComponent(messageId)}/polls/${encodeURIComponent(pollId)}/vote/${encodeURIComponent( - voteId, - )}`, - { - ...(userId ? { user_id: userId } : {}), - }, - ); - } - - /** - * Queries polls - * @param filter - * @param sort - * @param options Option object, {limit: 10, offset:0} - * @param userId string The user id (only serverside) - * @returns {APIResponse & QueryPollsResponse} The polls - */ - async queryPolls( - filter: QueryPollsFilters = {}, - sort: PollSort = [], - options: QueryPollsOptions = {}, - userId?: string, - ): Promise { - const q = userId ? `?user_id=${userId}` : ''; - return await this.post( - this.baseURL + `/polls/query${q}`, - { - filter, - sort: normalizeQuerySort(sort), - ...options, - }, - ); - } - - /** - * Queries poll votes - * @param pollId - * @param filter - * @param sort - * @param options Option object, {limit: 10, offset:0} - * @param userId string The user id (only serverside) - * @returns {APIResponse & PollVotesAPIResponse} The poll votes - */ - async queryPollVotes( - pollId: string, - filter: QueryVotesFilters = {}, - sort: VoteSort = [], - options: QueryVotesOptions = {}, - userId?: string, - ): Promise { - const q = userId ? `?user_id=${userId}` : ''; - return await this.post( - this.baseURL + `/polls/${encodeURIComponent(pollId)}/votes${q}`, - { - filter, - sort: normalizeQuerySort(sort), - ...options, - }, - ); - } - - /** - * Queries poll answers - * @param pollId - * @param filter - * @param sort - * @param options Option object, {limit: 10, offset:0} - * @param userId string The user id (only serverside) - * @returns {APIResponse & PollAnswersAPIResponse} The poll votes - */ - async queryPollAnswers( - pollId: string, - filter: QueryVotesFilters = {}, - sort: VoteSort = [], - options: QueryVotesOptions = {}, - userId?: string, - ): Promise { - const q = userId ? `?user_id=${userId}` : ''; - return await this.post( - this.baseURL + `/polls/${encodeURIComponent(pollId)}/votes${q}`, - { - filter: { ...filter, is_answer: true }, - sort: normalizeQuerySort(sort), - ...options, - }, - ); - } - - /** - * Query message history - * @param filter - * @param sort - * @param options Option object, {limit: 10} - * @returns {APIResponse & QueryMessageHistoryResponse} The message histories - */ - async queryMessageHistory( - filter: QueryMessageHistoryFilters = {}, - sort: QueryMessageHistorySort = [], - options: QueryMessageHistoryOptions = {}, - ): Promise { - return await this.post( - this.baseURL + '/messages/history', - { - filter, - sort: normalizeQuerySort(sort), - ...options, - }, - ); - } - - /** - * updateFlags - reviews/unflags flagged message - * - * @param {string[]} message_ids list of message IDs - * @param {string} options Option object in case user ID is set to review all the flagged messages by the user - * @param {string} reviewed_by user ID who reviewed the flagged message - * @returns {APIResponse} - */ - async updateFlags( - message_ids: string[], - reviewed_by: string, - options: { user_id?: string } = {}, - ) { - return await this.post( - this.baseURL + '/automod/v1/moderation/update_flags', - { - message_ids, - reviewed_by, - ...options, - }, - ); - } - - /** - * queryDrafts - Queries drafts for the current user - * - * @param {object} [options] Query options - * @param {object} [options.filter] Filters for the query - * @param {number} [options.sort] Sort parameters - * @param {number} [options.limit] Limit the number of results - * @param {string} [options.next] Pagination parameter - * @param {string} [options.prev] Pagination parameter - * @param {string} [options.user_id] Has to be provided when called server-side - * - * @return {Promise} Response containing the drafts - */ - async queryDrafts( - options: Pager & { - filter?: DraftFilters; - sort?: DraftSort; - user_id?: string; - } = {}, - ) { - const payload = { - ...options, - sort: options.sort ? normalizeQuerySort(options.sort) : undefined, - }; - - return await this.post(this.baseURL + '/drafts/query', payload); - } - - /** - * createReminder - Creates a reminder for a message - * - * @param {CreateReminderOptions} options The options for creating the reminder - * @returns {Promise} - */ - async createReminder({ messageId, ...options }: CreateReminderOptions) { - return await this.post( - `${this.baseURL}/messages/${messageId}/reminders`, - options, - ); - } - - /** - * updateReminder - Updates an existing reminder for a message - * - * @param {UpdateReminderOptions} options The options for updating the reminder - * @returns {Promise} - */ - async updateReminder({ messageId, ...options }: UpdateReminderOptions) { - return await this.patch( - `${this.baseURL}/messages/${messageId}/reminders`, - options, - ); - } - - /** - * deleteReminder - Deletes a reminder for a message - * - * @param {string} messageId The ID of the message whose reminder to delete - * @param {string} [userId] Optional user ID, required for server-side operations - * @returns {Promise} - */ - async deleteReminder(messageId: string, userId?: string): Promise { - return await this.delete( - `${this.baseURL}/messages/${messageId}/reminders`, - userId ? { user_id: userId } : {}, - ); - } - - /** - * queryReminders - Queries reminders based on given filters - * - * @param {QueryRemindersOptions} options The options for querying reminders - * @returns {Promise} - */ - async queryReminders({ filter, sort, ...rest }: QueryRemindersOptions = {}) { - return await this.post(`${this.baseURL}/reminders/query`, { - filter, - sort: sort && normalizeQuerySort(sort), - ...rest, - }); - } - - /** - * queryTeamUsageStats - Queries team-level usage statistics from the warehouse database - * - * Returns all 16 metrics grouped by team with cursor-based pagination. - * - * Date Range Options (mutually exclusive): - * - Use 'month' parameter (YYYY-MM format) for monthly aggregated values - * - Use 'start_date'/'end_date' parameters (YYYY-MM-DD format) for daily breakdown - * - If neither provided, defaults to current month (monthly mode) - * - * This endpoint is server-side only. - * - * @param {QueryTeamUsageStatsOptions} options The options for querying team usage stats - * @returns {Promise} - */ - async queryTeamUsageStats(options: QueryTeamUsageStatsOptions = {}) { - return await this.post( - `${this.baseURL}/stats/team_usage`, - options, - ); - } - - /** - * updateLocation - Updates a location - * - * @param location SharedLocationRequest the location data to update - * - * @returns {Promise} The server response - */ - async updateLocation(location: UpdateLocationPayload) { - return await this.put( - this.baseURL + `/users/live_locations`, - location, - ); - } - - /** - * uploadFile - Uploads a file to the configured storage (defaults to Stream CDN) - * - * @param {string|NodeJS.ReadableStream|Buffer|File} uri The file to upload - * @param {string} [name] The name of the file - * @param {string} [contentType] The content type of the file - * @param {UserResponse} [user] Optional user information - * @param {AxiosRequestConfig} [axiosRequestConfig] Optional axios config (e.g. onUploadProgress for progress tracking) - * - * @return {Promise} Response containing the file URL - */ - uploadFile( - uri: string | NodeJS.ReadableStream | Buffer | File, - name?: string, - contentType?: string, - user?: UserResponse, - axiosRequestConfig?: AxiosRequestConfig, - ) { - return this.sendFile( - `${this.baseURL}/uploads/file`, - uri, - name, - contentType, - user, - axiosRequestConfig, - ); - } - - /** - * uploadImage - Uploads an image to the configured storage (defaults to Stream CDN) - * - * @param {string|NodeJS.ReadableStream|File} uri The image to upload - * @param {string} [name] The name of the image - * @param {string} [contentType] The content type of the image - * @param {UserResponse} [user] Optional user information - * @param {AxiosRequestConfig} [axiosRequestConfig] Optional axios config (e.g. onUploadProgress for progress tracking) - * - * @return {Promise} Response containing the image URL - */ - uploadImage( - uri: string | NodeJS.ReadableStream | File, - name?: string, - contentType?: string, - user?: UserResponse, - axiosRequestConfig?: AxiosRequestConfig, - ) { - return this.sendFile( - `${this.baseURL}/uploads/image`, - uri, - name, - contentType, - user, - axiosRequestConfig, - ); - } - - /** - * deleteFile - Deletes a file from the configured storage - * - * @param {string} url The URL of the file to delete - * - * @return {Promise} The server response - */ - deleteFile(url: string) { - return this.delete(`${this.baseURL}/uploads/file`, { url }); - } - - /** - * deleteImage - Deletes an image from the configured storage - * - * @param {string} url The URL of the image to delete - * - * @return {Promise} The server response - */ - deleteImage(url: string) { - return this.delete(`${this.baseURL}/uploads/image`, { url }); - } - - /** - * Mark the channels delivered for the given messages and the user - * - * @param {MarkDeliveredOptions} data - * @return {Promise} Description - */ - async markChannelsDelivered(data: MarkDeliveredOptions) { - if (!data?.latest_delivered_messages?.length) return; - return await this.post(this.baseURL + '/channels/delivered', data); + async markChannelsDelivered(request?: Gen_MarkDeliveredRequest) { + if (!request?.latest_delivered_messages?.length) return; + return await this.markDelivered(request); } syncDeliveredCandidates(collections: Channel[]) { this.messageDeliveryReporter.syncDeliveredCandidates(collections); } - - /** - * Update Channels Batch - * - * @param {UpdateChannelsBatchOptions} payload for updating channels in batch - * @return {Promise} The server response - */ - async updateChannelsBatch(payload: UpdateChannelsBatchOptions) { - return await this.put( - this.baseURL + `/channels/batch`, - payload, - ); - } - - /** - * createPredefinedFilter - Creates a new predefined filter (server-side only) - * - * @param {CreatePredefinedFilterOptions} options Predefined filter options - * - * @return {Promise} The created predefined filter - */ - async createPredefinedFilter< - F extends Record = Record, - >(options: CreatePredefinedFilterOptions) { - this.validateServerSideAuth(); - return await this.post>( - `${this.baseURL}/predefined_filters`, - options, - ); - } - - /** - * getPredefinedFilter - Gets a predefined filter by name (server-side only) - * - * @param {string} name Predefined filter name - * - * @return {Promise} The predefined filter - */ - async getPredefinedFilter = Record>( - name: string, - ) { - this.validateServerSideAuth(); - return await this.get>( - `${this.baseURL}/predefined_filters/${encodeURIComponent(name)}`, - ); - } - - /** - * updatePredefinedFilter - Updates a predefined filter (server-side only) - * - * @param {string} name Predefined filter name - * @param {UpdatePredefinedFilterOptions} options Predefined filter options - * - * @return {Promise} The updated predefined filter - */ - async updatePredefinedFilter< - F extends Record = Record, - >(name: string, options: UpdatePredefinedFilterOptions) { - this.validateServerSideAuth(); - return await this.put>( - `${this.baseURL}/predefined_filters/${encodeURIComponent(name)}`, - options, - ); - } - - /** - * deletePredefinedFilter - Deletes a predefined filter (server-side only) - * - * @param {string} name Predefined filter name - * - * @return {Promise} The server response - */ - async deletePredefinedFilter(name: string) { - this.validateServerSideAuth(); - return await this.delete( - `${this.baseURL}/predefined_filters/${encodeURIComponent(name)}`, - ); - } - - /** - * listPredefinedFilters - Lists all predefined filters (server-side only) - * - * @param {ListPredefinedFiltersOptions} options Query options - * - * @return {Promise} The list of predefined filters - */ - async listPredefinedFilters< - F extends Record = Record, - >(options: ListPredefinedFiltersOptions = {}) { - this.validateServerSideAuth(); - const { sort, ...paginationOptions } = options; - return await this.get>( - `${this.baseURL}/predefined_filters`, - { - ...paginationOptions, - ...(sort ? { sort: JSON.stringify(sort) } : {}), - }, - ); - } - - /** - * setRetentionPolicy - Creates or updates a retention policy for the app. - * Server-side only. - * - * @param {string} policy The policy type ('old-messages' or 'inactive-channels') - * @param {number} maxAgeHours Max age in hours (24-43800) - * @returns {Promise} - */ - async setRetentionPolicy(policy: string, maxAgeHours: number) { - this.validateServerSideAuth(); - return await this.post( - this.baseURL + '/retention_policy', - { policy, max_age_hours: maxAgeHours }, - ); - } - - /** - * deleteRetentionPolicy - Deletes a retention policy for the app. - * Server-side only. - * - * @param {string} policy The policy type ('old-messages' or 'inactive-channels') - * @returns {Promise} - */ - async deleteRetentionPolicy(policy: string) { - this.validateServerSideAuth(); - return await this.post( - this.baseURL + '/retention_policy/delete', - { policy }, - ); - } - - /** - * getRetentionPolicy - Returns all retention policies configured for the app. - * Server-side only. - * - * @returns {Promise} - */ - async getRetentionPolicy() { - this.validateServerSideAuth(); - return await this.get(this.baseURL + '/retention_policy'); - } - - /** - * getRetentionPolicyRuns - Returns filtered and sorted retention cleanup run history. - * Supports filter_conditions on 'policy' and 'date' fields. - * Server-side only. - * - * @param {GetRetentionPolicyRunsOptions} options Filter, sort, and pagination options - * @returns {Promise} - */ - async getRetentionPolicyRuns(options: GetRetentionPolicyRunsOptions = {}) { - this.validateServerSideAuth(); - return await this.post( - this.baseURL + '/retention_policy/runs', - options, - ); - } } diff --git a/src/client_state.ts b/src/client_state.ts index 2bcf1a3cbd..76c412caa0 100644 --- a/src/client_state.ts +++ b/src/client_state.ts @@ -1,13 +1,13 @@ -import type { UserResponse } from './types'; +import type { OwnUserResponse, UserResponse } from './types'; import type { StreamChat } from './client'; /** - * ClientState - A container class for the client state. + * Container class for the client state. */ export class ClientState { private client: StreamChat; users: { - [key: string]: UserResponse; + [key: string]: UserResponse | OwnUserResponse; }; userChannelReferences: { [key: string]: { [key: string]: boolean } }; constructor({ client }: { client: StreamChat }) { @@ -25,7 +25,7 @@ export class ClientState { } } - updateUser(user?: UserResponse) { + updateUser(user?: UserResponse | OwnUserResponse) { if (user != null && this.client._cacheEnabled()) { this.users[user.id] = user; } diff --git a/src/connection.ts b/src/connection.ts index 567ad0f787..6ad1de09c0 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -13,9 +13,14 @@ import { buildWsSuccessAfterFailureInsight, postInsights, } from './insights'; -import type { ConnectAPIResponse, ConnectionOpen, LogLevel, UR } from './types'; +import { chatLoggerSystem } from './logger'; +import type { ConnectAPIResponse, ConnectionOpen, EventPayload } from './types'; import type { StreamChat } from './client'; import type { APIError } from './errors'; +import { decodeWSEvent } from './gen/model-decoders/event-decoder-mapping'; +import type { WSEvent } from './gen/models'; + +const logger = chatLoggerSystem.getLogger('connection'); // Type guards to check WebSocket error type const isCloseEvent = ( @@ -27,15 +32,16 @@ const isErrorEvent = ( ): res is WebSocket.ErrorEvent => (res as WebSocket.ErrorEvent).error !== undefined; /** - * StableWSConnection - A WS connection that reconnects upon failure. + * A WS connection that reconnects upon failure. + * * - the browser will sometimes report that you're online or offline * - the WS connection can break and fail (there is a 30s health check) * - sometimes your WS connection will seem to work while the user is in fact offline - * - to speed up online/offline detection you can use the window.addEventListener('offline'); + * - to speed up online/offline detection you can use the `window.addEventListener('offline')` * * There are 4 ways in which a connection can become unhealthy: - * - websocket.onerror is called - * - websocket.onclose is called + * - WebSocket.onerror is called + * - WebSocket.onclose is called * - the health check fails and no event is received for ~40 seconds * - the browser indicates the connection is now offline * @@ -99,18 +105,15 @@ export class StableWSConnection { addConnectionEventListeners(this.onlineStatusChanged); } - _log(msg: string, extra: UR = {}, level: LogLevel = 'info') { - this.client.logger(level, 'connection:' + msg, { tags: ['connection'], ...extra }); - } - setClient(client: StreamChat) { this.client = client; } /** - * connect - Connect to the WS URL - * the default 15s timeout allows between 2~3 tries - * @return {ConnectAPIResponse} Promise that completes once the first health check message is received + * Connects to the WS URL. The default 15s timeout allows between 2 and 3 tries. + * + * @param timeout - Connect timeout in milliseconds (optional, defaults to `15000`). + * @returns A promise that resolves once the first health check message is received. */ async connect(timeout = 15000) { if (this.isConnecting) { @@ -125,8 +128,9 @@ export class StableWSConnection { const healthCheck = await this._connect(); this.consecutiveFailures = 0; - this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`); - // eslint-disable-next-line @typescript-eslint/no-explicit-any + logger + .withExtraTags('connect') + .info(`Established a WebSocket connection. Health check: ${healthCheck}.`); } catch (error: any) { this.isHealthy = false; this.consecutiveFailures += 1; @@ -134,9 +138,11 @@ export class StableWSConnection { const e = error as APIError; if (e.code === chatCodes.TOKEN_EXPIRED && !this.client.tokenManager.isStatic()) { - this._log( - 'connect() - WS failure due to expired token, so going to try to reload token and reconnect', - ); + logger + .withExtraTags('connect') + .warn( + 'WebSocket connection failed due to an expired token. Reloading the token and reconnecting.', + ); this._reconnect({ refreshToken: true }); } else if (!e.isWSFailure) { // API rejected the connection and we should not retry @@ -157,7 +163,8 @@ export class StableWSConnection { /** * _waitForHealthy polls the promise connection to see if its resolved until it times out * the default 15s timeout allows between 2~3 tries - * @param timeout duration(ms) + * + * @param timeout - duration (ms) */ _waitForHealthy(timeout = 15000) { return Promise.race([ @@ -166,7 +173,6 @@ export class StableWSConnection { for (let i = 0; i <= timeout; i += interval) { try { return await this.connectionOpen; - // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { if (i === timeout) { throw new Error( @@ -198,7 +204,8 @@ export class StableWSConnection { } /** - * Builds and returns the url for websocket. + * Builds and returns the URL for the WebSocket connection. + * * @private * @returns url string */ @@ -220,11 +227,14 @@ export class StableWSConnection { }; /** - * disconnect - Disconnect the connection and doesn't recover... + * Disconnects the connection without attempting to recover. * + * @param timeout - Optional timeout in milliseconds to wait for the close frame from the server. */ disconnect(timeout?: number) { - this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`); + logger + .withExtraTags('disconnect') + .info(`Closing the WebSocket connection for wsID ${this.wsID}.`); this.wsID += 1; this.isConnecting = false; @@ -255,29 +265,33 @@ export class StableWSConnection { if (ws && ws.close && ws.readyState === ws.OPEN) { isClosedPromise = new Promise((resolve) => { const onclose = (event: WebSocket.CloseEvent) => { - this._log( - `disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`, - { event }, - ); + logger + .withExtraTags('disconnect') + .debug( + `Resolving the close promise ${event ? 'with' : 'without'} a close frame.`, + { event }, + ); resolve(); }; ws.onclose = onclose; - // In case we don't receive close frame websocket server in time, + // In case we don't receive a close frame from the WebSocket server in time, // lets not wait for more than 1 seconds. setTimeout(onclose, timeout != null ? timeout : 1000); }); - this._log( - `disconnect() - Manually closed connection by calling client.disconnect()`, - ); + logger + .withExtraTags('disconnect') + .debug('Manually closing the connection via client.disconnect().'); ws.close( chatCodes.WS_CLOSED_SUCCESS, 'Manually closed connection by calling client.disconnect()', ); } else { - this._log(`disconnect() - ws connection doesn't exist or it is already closed.`); + logger + .withExtraTags('disconnect') + .debug('The WebSocket connection does not exist or is already closed.'); isClosedPromise = Promise.resolve(); } @@ -287,9 +301,9 @@ export class StableWSConnection { } /** - * _connect - Connect to the WS endpoint + * Connects to the WS endpoint. * - * @return {ConnectAPIResponse} Promise that completes once the first health check message is received + * @returns A promise that resolves once the first health check message is received. */ async _connect() { if ( @@ -302,7 +316,7 @@ export class StableWSConnection { this.client.insightMetrics.connectionStartTimestamp = new Date().getTime(); let isTokenReady = false; try { - this._log(`_connect() - waiting for token`); + logger.withExtraTags('_connect').debug('Waiting for the auth token.'); await this.client.tokenManager.tokenReady(); isTokenReady = true; } catch (e) { @@ -311,13 +325,15 @@ export class StableWSConnection { try { if (!isTokenReady) { - this._log(`_connect() - tokenProvider failed before, so going to retry`); + logger + .withExtraTags('_connect') + .warn('The token provider failed previously. Retrying.'); await this.client.tokenManager.loadToken(); } this._setupConnectionPromise(); const wsURL = this._buildUrl(); - this._log(`_connect() - Connecting to ${wsURL}`, { + logger.withExtraTags('_connect').info(`Connecting to ${wsURL}.`, { wsURL, requestID: this.requestID, }); @@ -343,10 +359,11 @@ export class StableWSConnection { } return response; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { this.isConnecting = false; - this._log(`_connect() - Error - `, error); + logger + .withExtraTags('_connect') + .warn('An error occurred while connecting.', { error }); if (this.client.options.enableInsights) { this.client.insightMetrics.wsConsecutiveFailures++; this.client.insightMetrics.wsTotalFailures++; @@ -362,21 +379,22 @@ export class StableWSConnection { } /** - * _reconnect - Retry the connection to WS endpoint - * - * @param {{ interval?: number; refreshToken?: boolean }} options Following options are available + * Retries the connection to the WS endpoint. * - * - `interval` {int} number of ms that function should wait before reconnecting - * - `refreshToken` {boolean} reload/refresh user token be refreshed before attempting reconnection. + * @param options - Reconnect options. + * @param options.interval - Number of milliseconds to wait before reconnecting. + * @param options.refreshToken - Reload/refresh the user token before attempting to reconnect. */ async _reconnect( options: { interval?: number; refreshToken?: boolean } = {}, ): Promise { - this._log('_reconnect() - Initiating the reconnect'); + logger.withExtraTags('_reconnect').info('Initiating a reconnect.'); // only allow 1 connection at the time if (this.isConnecting || this.isHealthy) { - this._log('_reconnect() - Abort (1) since already connecting or healthy'); + logger + .withExtraTags('_reconnect') + .debug('Aborting reconnect: already connecting or healthy (check 1).'); return; } @@ -392,16 +410,22 @@ export class StableWSConnection { // Check once again if by some other call to _reconnect is active or connection is // already restored, then no need to proceed. if (this.isConnecting || this.isHealthy) { - this._log('_reconnect() - Abort (2) since already connecting or healthy'); + logger + .withExtraTags('_reconnect') + .debug('Aborting reconnect: already connecting or healthy (check 2).'); return; } if (this.isDisconnected && this.client.options.enableWSFallback) { - this._log('_reconnect() - Abort (3) since disconnect() is called'); + logger + .withExtraTags('_reconnect') + .debug('Aborting reconnect: disconnect() was called.'); return; } - this._log('_reconnect() - Destroying current WS connection'); + logger + .withExtraTags('_reconnect') + .info('Destroying the current WebSocket connection.'); // cleanup the old connection this._destroyCurrentWSConnection(); @@ -412,12 +436,11 @@ export class StableWSConnection { try { await this._connect(); - this._log('_reconnect() - Waiting for recoverCallBack'); + logger.withExtraTags('_reconnect').debug('Waiting for the recover callback.'); await this.client.recoverState(); - this._log('_reconnect() - Finished recoverCallBack'); + logger.withExtraTags('_reconnect').debug('Finished the recover callback.'); this.consecutiveFailures = 0; - // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { this.isHealthy = false; this.consecutiveFailures += 1; @@ -425,60 +448,71 @@ export class StableWSConnection { error.code === chatCodes.TOKEN_EXPIRED && !this.client.tokenManager.isStatic() ) { - this._log( - '_reconnect() - WS failure due to expired token, so going to try to reload token and reconnect', - ); + logger + .withExtraTags('_reconnect') + .warn( + 'WebSocket connection failed due to an expired token. Reloading the token and reconnecting.', + ); return this._reconnect({ refreshToken: true }); } // reconnect on WS failures, don't reconnect if there is a code bug if (error.isWSFailure) { - this._log('_reconnect() - WS failure, so going to try to reconnect'); + logger + .withExtraTags('_reconnect') + .warn('WebSocket connection failed. Retrying the reconnect.'); this._reconnect(); } } - this._log('_reconnect() - == END =='); + logger.withExtraTags('_reconnect').debug('Reconnect attempt finished.'); } /** - * onlineStatusChanged - this function is called when the browser connects or disconnects from the internet. - * - * @param {Event} event Event with type online or offline + * Called when the browser connects or disconnects from the internet. * + * @param event - The DOM event whose `type` is `'online'` or `'offline'`. */ onlineStatusChanged = (event: Event) => { if (event.type === 'offline') { // mark the connection as down - this._log('onlineStatusChanged() - Status changing to offline'); + logger + .withExtraTags('onlineStatusChanged') + .info('Network status changed to offline.'); this._setHealth(false); } else if (event.type === 'online') { // retry right now... // We check this.isHealthy, not sure if it's always // smart to create a new WS connection if the old one is still up and running. // it's possible we didn't miss any messages, so this process is just expensive and not needed. - this._log( - `onlineStatusChanged() - Status changing to online. isHealthy: ${this.isHealthy}`, - ); + logger + .withExtraTags('onlineStatusChanged') + .info(`Network status changed to online. isHealthy: ${this.isHealthy}.`); if (!this.isHealthy) { this._reconnect({ interval: 10 }); } } }; - onopen = (wsID: number) => { - if (this.wsID !== wsID) return; + onopen = (wsId: number) => { + if (this.wsID !== wsId) return; - this._log('onopen() - onopen callback', { wsID }); + logger.withExtraTags('onopen').debug('WebSocket onopen callback fired.', { + wsID: wsId, + }); }; - onmessage = (wsID: number, event: WebSocket.MessageEvent) => { - if (this.wsID !== wsID) return; + onmessage = (wsId: number, event: WebSocket.MessageEvent) => { + if (this.wsID !== wsId) return; - this._log('onmessage() - onmessage callback', { event, wsID }); + logger.withExtraTags('onmessage').trace('WebSocket onmessage callback fired.', { + event, + wsID: wsId, + }); if (typeof event.data !== 'string') return; const data = JSON.parse(event.data); + const decodedData = decodeWSEvent(data) as WSEvent; // we wait till the first message before we consider the connection open.. // the reason for this is that auth errors and similar errors trigger a ws.onopen and immediately @@ -490,7 +524,7 @@ export class StableWSConnection { return; } - this.resolvePromise?.(data); + this.resolvePromise?.(decodedData as EventPayload<'health.check'>); this._setHealth(true); } @@ -501,14 +535,19 @@ export class StableWSConnection { this.scheduleNextPing(); } - this.client.dispatchEvent(data); + this.client.dispatchEvent(decodedData); this.scheduleConnectionCheck(); }; - onclose = (wsID: number, event: WebSocket.CloseEvent) => { - if (this.wsID !== wsID) return; + onclose = (wsId: number, event: WebSocket.CloseEvent) => { + if (this.wsID !== wsId) return; - this._log('onclose() - onclose callback - ' + event.code, { event, wsID }); + logger + .withExtraTags('onclose') + .debug(`WebSocket onclose callback fired with code ${event.code}.`, { + event, + wsID: wsId, + }); if (event.code === chatCodes.WS_CLOSED_SUCCESS) { // this is a permanent error raised by stream.. @@ -523,7 +562,9 @@ export class StableWSConnection { error.target = event.target; this.rejectPromise?.(error); - this._log(`onclose() - WS connection reject with error ${event.reason}`, { event }); + logger + .withExtraTags('onclose') + .warn(`The WebSocket connection was rejected: ${event.reason}.`, { event }); } else { this.consecutiveFailures += 1; this.totalFailures += 1; @@ -532,15 +573,19 @@ export class StableWSConnection { this.rejectPromise?.(this._errorFromWSEvent(event)); - this._log(`onclose() - WS connection closed. Calling reconnect ...`, { event }); + logger + .withExtraTags('onclose') + .warn('The WebSocket connection was closed. Attempting to reconnect.', { + event, + }); // reconnect if its an abnormal failure this._reconnect(); } }; - onerror = (wsID: number, event: WebSocket.ErrorEvent) => { - if (this.wsID !== wsID) return; + onerror = (wsId: number, event: WebSocket.ErrorEvent) => { + if (this.wsID !== wsId) return; this.consecutiveFailures += 1; this.totalFailures += 1; @@ -548,17 +593,17 @@ export class StableWSConnection { this.isConnecting = false; this.rejectPromise?.(this._errorFromWSEvent(event)); - this._log(`onerror() - WS connection resulted into error`, { event }); + logger + .withExtraTags('onerror') + .warn('The WebSocket connection raised an error.', { event }); this._reconnect(); }; /** - * _setHealth - Sets the connection to healthy or unhealthy. - * Broadcasts an event in case the connection status changed. - * - * @param {boolean} healthy boolean indicating if the connection is healthy or not + * Sets the connection to healthy or unhealthy. Broadcasts an event if the connection status changed. * + * @param healthy - Whether the connection is healthy. */ _setHealth = (healthy: boolean) => { if (healthy === this.isHealthy) return; @@ -578,8 +623,11 @@ export class StableWSConnection { }; /** - * _errorFromWSEvent - Creates an error object for the WS event + * Creates an error object for the WS event. * + * @param event - The raw WebSocket close / data / error event. + * @param isWSFailure - Whether the underlying cause is a WebSocket failure (optional, defaults to `true`). + * @returns A normalized error describing the WS failure. */ _errorFromWSEvent = ( event: WebSocket.CloseEvent | WebSocket.Data | WebSocket.ErrorEvent, @@ -601,7 +649,9 @@ export class StableWSConnection { } // Keeping this `warn` level log, to avoid cluttering of error logs from ws failures. - this._log(`_errorFromWSEvent() - WS failed with code ${code}`, { event }, 'warn'); + logger + .withExtraTags('_errorFromWSEvent') + .warn(`The WebSocket failed with code ${code}.`, { event }); const error = new Error( `WS failed with code ${code} and reason - ${message}`, @@ -621,8 +671,7 @@ export class StableWSConnection { }; /** - * _destroyCurrentWSConnection - Removes the current WS connection - * + * Removes the current WS connection. */ _destroyCurrentWSConnection() { // increment the ID, meaning we will ignore all messages from the old @@ -638,7 +687,7 @@ export class StableWSConnection { } /** - * _setupPromise - sets up the this.connectOpen promise + * Sets up the `this.connectionOpen` promise. */ _setupConnectionPromise = () => { this.isResolved = false; @@ -650,7 +699,7 @@ export class StableWSConnection { }; /** - * Schedules a next health check ping for websocket. + * Schedules the next health check ping for the WebSocket connection. */ scheduleNextPing = () => { if (this.healthCheckTimeoutRef) { @@ -660,7 +709,7 @@ export class StableWSConnection { // 30 seconds is the recommended interval (messenger uses this) this.healthCheckTimeoutRef = setTimeout(() => { // send the healthcheck.., server replies with a health check event - const data = [{ type: 'health.check', client_id: this.client.clientID }]; + const data = [{ type: 'health.check', client_id: this.client.clientId }]; // try to send on the connection try { this.ws?.send(JSON.stringify(data)); @@ -671,9 +720,9 @@ export class StableWSConnection { }; /** - * scheduleConnectionCheck - schedules a check for time difference between last received event and now. - * If the difference is more than 35 seconds, it means our health check logic has failed and websocket needs - * to be reconnected. + * Schedules a check for the time difference between the last received event and now. If the + * difference is more than 35 seconds, it means our health check logic has failed and the + * WebSocket needs to be reconnected. */ scheduleConnectionCheck = () => { if (this.connectionCheckTimeoutRef) { @@ -686,7 +735,9 @@ export class StableWSConnection { this.lastEvent && now.getTime() - this.lastEvent.getTime() > this.connectionCheckTimeout ) { - this._log('scheduleConnectionCheck - going to reconnect'); + logger + .withExtraTags('scheduleConnectionCheck') + .warn('No events received within the health-check window. Reconnecting.'); this._setHealth(false); this._reconnect(); } diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index c0552b1a18..98c8864aa3 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -8,7 +8,11 @@ import { sleep, } from './utils'; import { isAPIError, isConnectionIDError, isErrorRetryable } from './errors'; -import type { ConnectionOpen, Event, LogLevel, UR } from './types'; +import { chatLoggerSystem } from './logger'; +import type { ConnectionOpen, UR } from './types'; +import type { WSEvent } from './gen/models'; + +const logger = chatLoggerSystem.getLogger('connection-fallback'); export enum ConnectionState { Closed = 'CLOSED', @@ -33,15 +37,8 @@ export class WSConnectionFallback { addConnectionEventListeners(this._onlineStatusChanged); } - _log(msg: string, extra: UR = {}, level: LogLevel = 'info') { - this.client.logger(level, 'WSConnectionFallback:' + msg, { - tags: ['connection_fallback', 'connection'], - ...extra, - }); - } - _setState(state: ConnectionState) { - this._log(`_setState() - ${state}`); + logger.withExtraTags('_setState').debug(`Transitioning to state: ${state}.`); // transition from connecting => connected if ( @@ -60,7 +57,9 @@ export class WSConnectionFallback { /** @private */ _onlineStatusChanged = (event: { type: string }) => { - this._log(`_onlineStatusChanged() - ${event.type}`); + logger + .withExtraTags('_onlineStatusChanged') + .info(`Network status changed to ${event.type}.`); if (event.type === 'offline') { this._setState(ConnectionState.Closed); @@ -85,24 +84,22 @@ export class WSConnectionFallback { } try { - const res = await this.client.doAxiosRequest( + const res = await this.client.api.doAxiosRequest( 'get', (this.client.baseURL as string).replace(':3030', ':8900') + '/longpoll', // replace port if present for testing with local API undefined, - { - config: { ...config, cancelToken: this.cancelToken?.token }, - params, - }, + { ...config, cancelToken: this.cancelToken?.token, params }, ); this.consecutiveFailures = 0; // always reset in case of no error return res; - // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { this.consecutiveFailures += 1; if (retry && isErrorRetryable(error)) { - this._log(`_req() - Retryable error, retrying request`); + logger + .withExtraTags('_req') + .debug('Encountered a retryable error. Retrying the request.'); await sleep(retryInterval(this.consecutiveFailures)); return this._req(params, config, retry); } @@ -116,7 +113,7 @@ export class WSConnectionFallback { while (this.state === ConnectionState.Connected) { try { const data = await this._req<{ - events: Event[]; + events: WSEvent[]; }>({}, { timeout: 30000 }, true); // 30s => API responds in 20s if there is no event if (data.events?.length) { @@ -124,17 +121,18 @@ export class WSConnectionFallback { this.client.dispatchEvent(data.events[i]); } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { if (axios.isCancel(error)) { - this._log(`_poll() - axios canceled request`); + logger.withExtraTags('_poll').debug('Axios canceled the request.'); return; } /** client.doAxiosRequest will take care of TOKEN_EXPIRED error */ if (isConnectionIDError(error)) { - this._log(`_poll() - ConnectionID error, connecting without ID...`); + logger + .withExtraTags('_poll') + .warn('Received a connection ID error. Reconnecting without an ID.'); this._setState(ConnectionState.Disconnected); this.connect(true); return; @@ -152,15 +150,20 @@ export class WSConnectionFallback { /** * connect try to open a longpoll request - * @param reconnect should be false for first call and true for subsequent calls to keep the connection alive and call recoverState + * + * @param reconnect - should be false for first call and true for subsequent calls to keep the connection alive and call recoverState */ connect = async (reconnect = false) => { if (this.state === ConnectionState.Connecting) { - this._log('connect() - connecting already in progress', { reconnect }, 'warn'); + logger + .withExtraTags('connect') + .warn('A connection attempt is already in progress.', { reconnect }); return; } if (this.state === ConnectionState.Connected) { - this._log('connect() - already connected and polling', { reconnect }, 'warn'); + logger + .withExtraTags('connect') + .warn('Already connected and polling.', { reconnect }); return; } @@ -175,7 +178,6 @@ export class WSConnectionFallback { this._setState(ConnectionState.Connected); this.connectionID = event.connection_id; - // @ts-expect-error type mismatch this.client.dispatchEvent(event); this._poll(); if (reconnect) { @@ -205,9 +207,9 @@ export class WSConnectionFallback { try { await this._req({ close: true, connection_id }, { timeout }, false); - this._log(`disconnect() - Closed connectionID`); + logger.withExtraTags('disconnect').info('Closed the connection ID.'); } catch (err) { - this._log(`disconnect() - Failed`, { err }, 'error'); + logger.withExtraTags('disconnect').error('Disconnect failed.', { error: err }); } }; } diff --git a/src/constants.ts b/src/constants.ts index a503997efb..15515100cc 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -19,7 +19,7 @@ export const RESERVED_UPDATED_MESSAGE_FIELDS = Object.freeze({ own_reactions: true, reaction_counts: true, reply_count: true, - // Message text related fields that shouldn't be in update + // MessageRequest text related fields that shouldn't be in update i18n: true, type: true, html: true, diff --git a/src/custom_types.ts b/src/custom_types.ts index b787867de9..713bff1fca 100644 --- a/src/custom_types.ts +++ b/src/custom_types.ts @@ -1,4 +1,7 @@ -export interface CustomAttachmentData {} +export interface CustomAttachmentData { + mime_type?: string; + file_size?: number; +} export interface CustomChannelData {} export interface CustomCommandData {} export interface CustomEventData {} diff --git a/src/errors.ts b/src/errors.ts index 3688a656c1..432a1c2e7b 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,5 +1,5 @@ import type { AxiosResponse } from 'axios'; -import type { APIErrorResponse } from './types'; +import type { APIError as Gen_APIError } from './types'; export const APIErrorCodes: Record = { '-1': { name: 'InternalSystemError', retryable: true }, @@ -66,6 +66,6 @@ export function isWSFailure(err: APIError): boolean { export function isErrorResponse( res: AxiosResponse, -): res is AxiosResponse { +): res is AxiosResponse { return !res.status || res.status < 200 || 300 <= res.status; } diff --git a/src/events.ts b/src/events.ts deleted file mode 100644 index 7f8805cd8d..0000000000 --- a/src/events.ts +++ /dev/null @@ -1,75 +0,0 @@ -export const EVENT_MAP = { - 'channel.created': true, - 'channel.deleted': true, - 'channel.hidden': true, - 'channel.kicked': true, - 'channel.muted': true, - 'channel.truncated': true, - 'channel.unmuted': true, - 'channel.updated': true, - 'channel.visible': true, - 'draft.deleted': true, - 'draft.updated': true, - 'health.check': true, - 'member.added': true, - 'member.removed': true, - 'member.updated': true, - 'message.deleted': true, - 'message.new': true, - 'message.read': true, - 'message.updated': true, - 'message.undeleted': true, - 'notification.added_to_channel': true, - 'notification.channel_deleted': true, - 'message.delivered': true, - 'notification.channel_mutes_updated': true, - 'notification.channel_truncated': true, - 'notification.invite_accepted': true, - 'notification.invite_rejected': true, - 'notification.invited': true, - 'notification.mark_read': true, - 'notification.mark_unread': true, - 'notification.message_new': true, - 'notification.mutes_updated': true, - 'notification.reminder_due': true, - 'notification.removed_from_channel': true, - 'notification.thread_message_new': true, - 'poll.closed': true, - 'poll.updated': true, - 'poll.vote_casted': true, - 'poll.vote_changed': true, - 'poll.vote_removed': true, - 'reaction.deleted': true, - 'reaction.new': true, - 'reaction.updated': true, - 'reminder.created': true, - 'reminder.deleted': true, - 'reminder.updated': true, - 'thread.updated': true, - 'typing.start': true, - 'typing.stop': true, - 'user.banned': true, - 'user.deleted': true, - 'user.messages.deleted': true, - 'user.presence.changed': true, - 'user.unbanned': true, - 'user.unread_message_reminder': true, - 'user.updated': true, - 'user.watching.start': true, - 'user.watching.stop': true, - // AI events - 'ai_indicator.update': true, - 'ai_indicator.stop': true, - 'ai_indicator.clear': true, - - // local events - 'message.read_locally': true, - 'channels.queried': true, - 'offline_reactions.queried': true, - 'connection.changed': true, - 'connection.recovered': true, - 'transport.changed': true, - 'capabilities.changed': true, - 'live_location_sharing.started': true, - 'live_location_sharing.stopped': true, -}; diff --git a/src/gen-imports.ts b/src/gen-imports.ts new file mode 100644 index 0000000000..47107fae60 --- /dev/null +++ b/src/gen-imports.ts @@ -0,0 +1,3 @@ +export { ChatApi } from './gen/chat/ChatApi'; +export type { StreamResponse } from './types'; +export { ApiClient } from './api-client'; diff --git a/src/gen/chat/ChannelApi.ts b/src/gen/chat/ChannelApi.ts new file mode 100644 index 0000000000..836c04163f --- /dev/null +++ b/src/gen/chat/ChannelApi.ts @@ -0,0 +1,275 @@ +import type { ChatApi, StreamResponse } from '../../gen-imports'; +import type { + ChannelGetOrCreateRequest, + ChannelStateResponse, + ChannelStopWatchingRequest, + CreateDraftRequest, + CreateDraftResponse, + DeleteChannelResponse, + EventResponse, + GetDraftResponse, + GetManyMessagesResponse, + HideChannelRequest, + HideChannelResponse, + MarkReadRequest, + MarkReadResponse, + MarkUnreadRequest, + Response, + SendEventRequest, + SendMessageRequest, + SendMessageResponse, + ShowChannelRequest, + ShowChannelResponse, + TruncateChannelRequest, + TruncateChannelResponse, + UpdateChannelPartialRequest, + UpdateChannelPartialResponse, + UpdateChannelRequest, + UpdateChannelResponse, + UpdateMemberPartialRequest, + UpdateMemberPartialResponse, + UploadChannelFileRequest, + UploadChannelFileResponse, + UploadChannelRequest, + UploadChannelResponse, +} from '../models'; + +export class ChannelApi { + constructor( + protected chatApi: ChatApi, + public readonly type: string, + public id: string | undefined, + ) {} + + delete(request?: { + hard_delete?: boolean; + }): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.deleteChannel({ id: this.id, type: this.type, ...request }); + } + + updateChannelPartial( + request?: UpdateChannelPartialRequest, + ): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.updateChannelPartial({ + id: this.id, + type: this.type, + ...request, + }); + } + + update(request?: UpdateChannelRequest): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.updateChannel({ id: this.id, type: this.type, ...request }); + } + + deleteDraft(request?: { parent_id?: string }): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.deleteDraft({ id: this.id, type: this.type, ...request }); + } + + getDraft(request?: { parent_id?: string }): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.getDraft({ id: this.id, type: this.type, ...request }); + } + + createDraft(request: CreateDraftRequest): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.createDraft({ id: this.id, type: this.type, ...request }); + } + + sendEvent(request: SendEventRequest): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.sendEvent({ id: this.id, type: this.type, ...request }); + } + + deleteChannelFile(request?: { url?: string }): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.deleteChannelFile({ id: this.id, type: this.type, ...request }); + } + + uploadChannelFile( + request?: UploadChannelFileRequest, + ): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.uploadChannelFile({ id: this.id, type: this.type, ...request }); + } + + hide(request?: HideChannelRequest): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.hideChannel({ id: this.id, type: this.type, ...request }); + } + + deleteChannelImage(request?: { url?: string }): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.deleteChannelImage({ id: this.id, type: this.type, ...request }); + } + + uploadChannelImage( + request?: UploadChannelRequest, + ): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.uploadChannelImage({ id: this.id, type: this.type, ...request }); + } + + updateMemberPartial( + request?: UpdateMemberPartialRequest, + ): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.updateMemberPartial({ id: this.id, type: this.type, ...request }); + } + + sendMessage(request: SendMessageRequest): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.sendMessage({ id: this.id, type: this.type, ...request }); + } + + getManyMessages(request: { + ids: Array; + }): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.getManyMessages({ id: this.id, type: this.type, ...request }); + } + + getOrCreate( + request?: ChannelGetOrCreateRequest & { connection_id?: string }, + ): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.getOrCreateChannel({ id: this.id, type: this.type, ...request }); + } + + markRead(request?: MarkReadRequest): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.markRead({ id: this.id, type: this.type, ...request }); + } + + show(request?: ShowChannelRequest): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.showChannel({ id: this.id, type: this.type, ...request }); + } + + stopWatching( + request?: ChannelStopWatchingRequest & { connection_id?: string }, + ): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.stopWatchingChannel({ id: this.id, type: this.type, ...request }); + } + + truncate( + request?: TruncateChannelRequest, + ): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.truncateChannel({ id: this.id, type: this.type, ...request }); + } + + markUnread(request?: MarkUnreadRequest): Promise> { + if (!this.id) { + throw new Error( + `Channel isn't yet created, call getOrCreateDistinctChannel() before this operation`, + ); + } + + return this.chatApi.markUnread({ id: this.id, type: this.type, ...request }); + } +} diff --git a/src/gen/chat/ChatApi.ts b/src/gen/chat/ChatApi.ts new file mode 100644 index 0000000000..30b0fb0021 --- /dev/null +++ b/src/gen/chat/ChatApi.ts @@ -0,0 +1,2554 @@ +import type { ApiClient, StreamResponse } from '../../gen-imports'; +import type { + AddUserGroupMembersRequest, + AddUserGroupMembersResponse, + BlockUsersRequest, + BlockUsersResponse, + CastPollVoteRequest, + ChannelGetOrCreateRequest, + ChannelStateResponse, + ChannelStopWatchingRequest, + CreateBlockListRequest, + CreateBlockListResponse, + CreateDeviceRequest, + CreateDraftRequest, + CreateDraftResponse, + CreateGuestRequest, + CreateGuestResponse, + CreatePollOptionRequest, + CreatePollRequest, + CreateReminderRequest, + CreateUserGroupRequest, + CreateUserGroupResponse, + DeleteChannelResponse, + DeleteChannelsRequest, + DeleteChannelsResponse, + DeleteMessageResponse, + DeleteReactionResponse, + DeleteReminderResponse, + EventResponse, + FileUploadRequest, + FileUploadResponse, + GetApplicationResponse, + GetBlockedUsersResponse, + GetDraftResponse, + GetManyMessagesResponse, + GetMessageResponse, + GetOGResponse, + GetReactionsResponse, + GetRepliesResponse, + GetThreadResponse, + GetUserGroupResponse, + GroupedQueryChannelsRequest, + GroupedQueryChannelsResponse, + HideChannelRequest, + HideChannelResponse, + ImageUploadRequest, + ImageUploadResponse, + ListBlockListResponse, + ListDevicesResponse, + ListUserGroupsResponse, + MarkChannelsReadRequest, + MarkDeliveredRequest, + MarkDeliveredResponse, + MarkReadRequest, + MarkReadResponse, + MarkUnreadRequest, + MembersResponse, + MessageActionRequest, + MessageActionResponse, + MuteChannelRequest, + MuteChannelResponse, + PollOptionResponse, + PollResponse, + PollVoteResponse, + PollVotesResponse, + QueryBannedUsersPayload, + QueryBannedUsersResponse, + QueryChannelsRequest, + QueryChannelsResponse, + QueryDraftsRequest, + QueryDraftsResponse, + QueryFutureChannelBansPayload, + QueryFutureChannelBansResponse, + QueryMembersPayload, + QueryMessageFlagsPayload, + QueryMessageFlagsResponse, + QueryPollsRequest, + QueryPollsResponse, + QueryPollVotesRequest, + QueryReactionsRequest, + QueryReactionsResponse, + QueryRemindersRequest, + QueryRemindersResponse, + QueryThreadsRequest, + QueryThreadsResponse, + QueryUsersPayload, + QueryUsersResponse, + ReminderResponseData, + RemoveUserGroupMembersRequest, + RemoveUserGroupMembersResponse, + Response, + SearchPayload, + SearchResponse, + SearchRolesResponse, + SearchUserGroupsResponse, + SendEventRequest, + SendMessageRequest, + SendMessageResponse, + SendReactionRequest, + SendReactionResponse, + SharedLocationResponse, + SharedLocationsResponse, + ShowChannelRequest, + ShowChannelResponse, + SortParamRequest, + SyncRequest, + SyncResponse, + TranslateMessageRequest, + TruncateChannelRequest, + TruncateChannelResponse, + UnblockUsersRequest, + UnblockUsersResponse, + UnmuteChannelRequest, + UnmuteResponse, + UpdateBlockListRequest, + UpdateBlockListResponse, + UpdateChannelPartialRequest, + UpdateChannelPartialResponse, + UpdateChannelRequest, + UpdateChannelResponse, + UpdateLiveLocationRequest, + UpdateMemberPartialRequest, + UpdateMemberPartialResponse, + UpdateMessagePartialRequest, + UpdateMessagePartialResponse, + UpdateMessageRequest, + UpdateMessageResponse, + UpdatePollOptionRequest, + UpdatePollPartialRequest, + UpdatePollRequest, + UpdateReminderRequest, + UpdateReminderResponse, + UpdateThreadPartialRequest, + UpdateThreadPartialResponse, + UpdateUserGroupRequest, + UpdateUserGroupResponse, + UpdateUsersPartialRequest, + UpdateUsersRequest, + UpdateUsersResponse, + UploadChannelFileRequest, + UploadChannelFileResponse, + UploadChannelRequest, + UploadChannelResponse, + UpsertPushPreferencesRequest, + UpsertPushPreferencesResponse, + WrappedUnreadCountsResponse, + WSAuthMessage, +} from '../models'; +import { decoders } from '../model-decoders/decoders'; + +export class ChatApi { + constructor(public readonly apiClient: ApiClient) {} + + async getApp(): Promise> { + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/app', undefined, undefined); + + decoders['GetApplicationResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async listBlockLists(request?: { + team?: string; + }): Promise> { + const queryParams = { + team: request?.team, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/blocklists', undefined, queryParams); + + decoders['ListBlockListResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createBlockList( + request: CreateBlockListRequest, + ): Promise> { + const body = { + name: request?.name, + words: request?.words, + is_confusable_folding_enabled: request?.is_confusable_folding_enabled, + is_leet_check_enabled: request?.is_leet_check_enabled, + is_plural_check_enabled: request?.is_plural_check_enabled, + is_substring_matching_enabled: request?.is_substring_matching_enabled, + team: request?.team, + type: request?.type, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/blocklists', undefined, undefined, body, 'application/json'); + + decoders['CreateBlockListResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteBlockList(request: { + name: string; + team?: string; + }): Promise> { + const queryParams = { + team: request?.team, + }; + const pathParams = { + name: request?.name, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/blocklists/{name}', + pathParams, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateBlockList( + request: UpdateBlockListRequest & { name: string }, + ): Promise> { + const pathParams = { + name: request?.name, + }; + const body = { + is_confusable_folding_enabled: request?.is_confusable_folding_enabled, + is_leet_check_enabled: request?.is_leet_check_enabled, + is_plural_check_enabled: request?.is_plural_check_enabled, + is_substring_matching_enabled: request?.is_substring_matching_enabled, + team: request?.team, + words: request?.words, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'PUT', + '/api/v2/blocklists/{name}', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['UpdateBlockListResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryChannels( + request?: QueryChannelsRequest & { connection_id?: string }, + ): Promise> { + const queryParams = { + connection_id: request?.connection_id, + }; + const body = { + limit: request?.limit, + member_limit: request?.member_limit, + message_limit: request?.message_limit, + offset: request?.offset, + predefined_filter: request?.predefined_filter, + presence: request?.presence, + state: request?.state, + watch: request?.watch, + sort: request?.sort, + filter_conditions: request?.filter_conditions, + filter_values: request?.filter_values, + sort_values: request?.sort_values, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/chat/channels', undefined, queryParams, body, 'application/json'); + + decoders['QueryChannelsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteChannels( + request: DeleteChannelsRequest, + ): Promise> { + const body = { + cids: request?.cids, + hard_delete: request?.hard_delete, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/delete', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['DeleteChannelsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async markDelivered( + request?: MarkDeliveredRequest, + ): Promise> { + const body = { + latest_delivered_messages: request?.latest_delivered_messages, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/delivered', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['MarkDeliveredResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async groupedQueryChannels( + request?: GroupedQueryChannelsRequest & { connection_id?: string }, + ): Promise> { + const queryParams = { + connection_id: request?.connection_id, + }; + const body = { + limit: request?.limit, + presence: request?.presence, + watch: request?.watch, + groups: request?.groups, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/grouped', + undefined, + queryParams, + body, + 'application/json', + ); + + decoders['GroupedQueryChannelsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async markChannelsRead( + request?: MarkChannelsReadRequest, + ): Promise> { + const body = { + read_by_channel: request?.read_by_channel, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/chat/channels/read', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['MarkReadResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getOrCreateDistinctChannel( + request: ChannelGetOrCreateRequest & { type: string; connection_id?: string }, + ): Promise> { + const queryParams = { + connection_id: request?.connection_id, + }; + const pathParams = { + type: request?.type, + }; + const body = { + hide_for_creator: request?.hide_for_creator, + presence: request?.presence, + state: request?.state, + thread_unread_counts: request?.thread_unread_counts, + watch: request?.watch, + data: request?.data, + members: request?.members, + messages: request?.messages, + watchers: request?.watchers, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/query', + pathParams, + queryParams, + body, + 'application/json', + ); + + decoders['ChannelStateResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteChannel(request: { + type: string; + id: string; + hard_delete?: boolean; + }): Promise> { + const queryParams = { + hard_delete: request?.hard_delete, + }; + const pathParams = { + type: request?.type, + id: request?.id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('DELETE', '/api/v2/chat/channels/{type}/{id}', pathParams, queryParams); + + decoders['DeleteChannelResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateChannelPartial( + request: UpdateChannelPartialRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + unset: request?.unset, + set: request?.set, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'PATCH', + '/api/v2/chat/channels/{type}/{id}', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['UpdateChannelPartialResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateChannel( + request: UpdateChannelRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + accept_invite: request?.accept_invite, + cooldown: request?.cooldown, + hide_history: request?.hide_history, + hide_history_before: request?.hide_history_before, + reject_invite: request?.reject_invite, + skip_push: request?.skip_push, + add_filter_tags: request?.add_filter_tags, + add_members: request?.add_members, + add_moderators: request?.add_moderators, + assign_roles: request?.assign_roles, + demote_moderators: request?.demote_moderators, + invites: request?.invites, + remove_filter_tags: request?.remove_filter_tags, + remove_members: request?.remove_members, + data: request?.data, + message: request?.message, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['UpdateChannelResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteDraft(request: { + type: string; + id: string; + parent_id?: string; + }): Promise> { + const queryParams = { + parent_id: request?.parent_id, + }; + const pathParams = { + type: request?.type, + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/chat/channels/{type}/{id}/draft', + pathParams, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getDraft(request: { + type: string; + id: string; + parent_id?: string; + }): Promise> { + const queryParams = { + parent_id: request?.parent_id, + }; + const pathParams = { + type: request?.type, + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/chat/channels/{type}/{id}/draft', + pathParams, + queryParams, + ); + + decoders['GetDraftResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createDraft( + request: CreateDraftRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + message: request?.message, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}/draft', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['CreateDraftResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async sendEvent( + request: SendEventRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + event: request?.event, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/chat/channels/{type}/{id}/event', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['EventResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteChannelFile(request: { + type: string; + id: string; + url?: string; + }): Promise> { + const queryParams = { + url: request?.url, + }; + const pathParams = { + type: request?.type, + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/chat/channels/{type}/{id}/file', + pathParams, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async uploadChannelFile( + request: UploadChannelFileRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + file: request?.file, + user: request?.user, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}/file', + pathParams, + undefined, + body, + 'multipart/form-data', + ); + + decoders['UploadChannelFileResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async hideChannel( + request: HideChannelRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + clear_history: request?.clear_history, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}/hide', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['HideChannelResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteChannelImage(request: { + type: string; + id: string; + url?: string; + }): Promise> { + const queryParams = { + url: request?.url, + }; + const pathParams = { + type: request?.type, + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/chat/channels/{type}/{id}/image', + pathParams, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async uploadChannelImage( + request: UploadChannelRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + file: request?.file, + upload_sizes: request?.upload_sizes, + user: request?.user, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}/image', + pathParams, + undefined, + body, + 'multipart/form-data', + ); + + decoders['UploadChannelResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateMemberPartial( + request: UpdateMemberPartialRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + unset: request?.unset, + set: request?.set, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'PATCH', + '/api/v2/chat/channels/{type}/{id}/member', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['UpdateMemberPartialResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async sendMessage( + request: SendMessageRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + message: request?.message, + keep_channel_hidden: request?.keep_channel_hidden, + skip_enrich_url: request?.skip_enrich_url, + skip_push: request?.skip_push, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}/message', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['SendMessageResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getManyMessages(request: { + type: string; + id: string; + ids: Array; + }): Promise> { + const queryParams = { + ids: request?.ids, + }; + const pathParams = { + type: request?.type, + id: request?.id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/chat/channels/{type}/{id}/messages', pathParams, queryParams); + + decoders['GetManyMessagesResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getOrCreateChannel( + request: ChannelGetOrCreateRequest & { + type: string; + id: string; + connection_id?: string; + }, + ): Promise> { + const queryParams = { + connection_id: request?.connection_id, + }; + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + hide_for_creator: request?.hide_for_creator, + presence: request?.presence, + state: request?.state, + thread_unread_counts: request?.thread_unread_counts, + watch: request?.watch, + data: request?.data, + members: request?.members, + messages: request?.messages, + watchers: request?.watchers, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}/query', + pathParams, + queryParams, + body, + 'application/json', + ); + + decoders['ChannelStateResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async markRead( + request: MarkReadRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + message_id: request?.message_id, + thread_id: request?.thread_id, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/chat/channels/{type}/{id}/read', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['MarkReadResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async showChannel( + request: ShowChannelRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = {}; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}/show', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['ShowChannelResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async stopWatchingChannel( + request: ChannelStopWatchingRequest & { + type: string; + id: string; + connection_id?: string; + }, + ): Promise> { + const queryParams = { + connection_id: request?.connection_id, + }; + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = {}; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/chat/channels/{type}/{id}/stop-watching', + pathParams, + queryParams, + body, + 'application/json', + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async truncateChannel( + request: TruncateChannelRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + hard_delete: request?.hard_delete, + skip_push: request?.skip_push, + truncated_at: request?.truncated_at, + member_ids: request?.member_ids, + message: request?.message, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/channels/{type}/{id}/truncate', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['TruncateChannelResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async markUnread( + request: MarkUnreadRequest & { type: string; id: string }, + ): Promise> { + const pathParams = { + type: request?.type, + id: request?.id, + }; + const body = { + message_id: request?.message_id, + message_timestamp: request?.message_timestamp, + thread_id: request?.thread_id, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/chat/channels/{type}/{id}/unread', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryDrafts( + request?: QueryDraftsRequest, + ): Promise> { + const body = { + limit: request?.limit, + next: request?.next, + prev: request?.prev, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/drafts/query', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['QueryDraftsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryMembers(request?: { + payload?: QueryMembersPayload; + }): Promise> { + const queryParams = { + payload: request?.payload, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/chat/members', + undefined, + queryParams, + ); + + decoders['MembersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteMessage(request: { + id: string; + hard?: boolean; + deleted_by?: string; + delete_for_me?: boolean; + }): Promise> { + const queryParams = { + hard: request?.hard, + deleted_by: request?.deleted_by, + delete_for_me: request?.delete_for_me, + }; + const pathParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('DELETE', '/api/v2/chat/messages/{id}', pathParams, queryParams); + + decoders['DeleteMessageResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getMessage(request: { id: string }): Promise> { + const pathParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/chat/messages/{id}', + pathParams, + undefined, + ); + + decoders['GetMessageResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateMessage( + request: UpdateMessageRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + message: request?.message, + skip_enrich_url: request?.skip_enrich_url, + skip_push: request?.skip_push, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/messages/{id}', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['UpdateMessageResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateMessagePartial( + request: UpdateMessagePartialRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + skip_enrich_url: request?.skip_enrich_url, + skip_push: request?.skip_push, + unset: request?.unset, + set: request?.set, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'PUT', + '/api/v2/chat/messages/{id}', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['UpdateMessagePartialResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async runMessageAction( + request: MessageActionRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + form_data: request?.form_data, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/messages/{id}/action', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['MessageActionResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async sendReaction( + request: SendReactionRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + reaction: request?.reaction, + enforce_unique: request?.enforce_unique, + skip_push: request?.skip_push, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/messages/{id}/reaction', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['SendReactionResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteReaction(request: { + id: string; + type: string; + user_id?: string; + }): Promise> { + const queryParams = { + user_id: request?.user_id, + }; + const pathParams = { + id: request?.id, + type: request?.type, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('DELETE', '/api/v2/chat/messages/{id}/reaction/{type}', pathParams, queryParams); + + decoders['DeleteReactionResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getReactions(request: { + id: string; + limit?: number; + offset?: number; + }): Promise> { + const queryParams = { + limit: request?.limit, + offset: request?.offset, + }; + const pathParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/chat/messages/{id}/reactions', pathParams, queryParams); + + decoders['GetReactionsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryReactions( + request: QueryReactionsRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + limit: request?.limit, + next: request?.next, + prev: request?.prev, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/messages/{id}/reactions', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['QueryReactionsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async translateMessage( + request: TranslateMessageRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + language: request?.language, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/messages/{id}/translate', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['MessageActionResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async castPollVote( + request: CastPollVoteRequest & { message_id: string; poll_id: string }, + ): Promise> { + const pathParams = { + message_id: request?.message_id, + poll_id: request?.poll_id, + }; + const body = { + vote: request?.vote, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/chat/messages/{message_id}/polls/{poll_id}/vote', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['PollVoteResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deletePollVote(request: { + message_id: string; + poll_id: string; + vote_id: string; + user_id?: string; + }): Promise> { + const queryParams = { + user_id: request?.user_id, + }; + const pathParams = { + message_id: request?.message_id, + poll_id: request?.poll_id, + vote_id: request?.vote_id, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/chat/messages/{message_id}/polls/{poll_id}/vote/{vote_id}', + pathParams, + queryParams, + ); + + decoders['PollVoteResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteReminder(request: { + message_id: string; + }): Promise> { + const pathParams = { + message_id: request?.message_id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('DELETE', '/api/v2/chat/messages/{message_id}/reminders', pathParams, undefined); + + decoders['DeleteReminderResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateReminder( + request: UpdateReminderRequest & { message_id: string }, + ): Promise> { + const pathParams = { + message_id: request?.message_id, + }; + const body = { + remind_at: request?.remind_at, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'PATCH', + '/api/v2/chat/messages/{message_id}/reminders', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['UpdateReminderResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createReminder( + request: CreateReminderRequest & { message_id: string }, + ): Promise> { + const pathParams = { + message_id: request?.message_id, + }; + const body = { + remind_at: request?.remind_at, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/messages/{message_id}/reminders', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['ReminderResponseData']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getReplies(request: { + parent_id: string; + limit?: number; + id_gte?: string; + id_gt?: string; + id_lte?: string; + id_lt?: string; + id_around?: string; + sort?: Array; + }): Promise> { + const queryParams = { + limit: request?.limit, + id_gte: request?.id_gte, + id_gt: request?.id_gt, + id_lte: request?.id_lte, + id_lt: request?.id_lt, + id_around: request?.id_around, + sort: request?.sort, + }; + const pathParams = { + parent_id: request?.parent_id, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/chat/messages/{parent_id}/replies', + pathParams, + queryParams, + ); + + decoders['GetRepliesResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryMessageFlags(request?: { + payload?: QueryMessageFlagsPayload; + }): Promise> { + const queryParams = { + payload: request?.payload, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/chat/moderation/flags/message', undefined, queryParams); + + decoders['QueryMessageFlagsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async muteChannel( + request?: MuteChannelRequest, + ): Promise> { + const body = { + expiration: request?.expiration, + channel_cids: request?.channel_cids, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/moderation/mute/channel', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['MuteChannelResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async unmuteChannel( + request?: UnmuteChannelRequest, + ): Promise> { + const body = { + expiration: request?.expiration, + channel_cids: request?.channel_cids, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/chat/moderation/unmute/channel', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['UnmuteResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryBannedUsers(request?: { + payload?: QueryBannedUsersPayload; + }): Promise> { + const queryParams = { + payload: request?.payload, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/chat/query_banned_users', undefined, queryParams); + + decoders['QueryBannedUsersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryFutureChannelBans(request?: { + payload?: QueryFutureChannelBansPayload; + }): Promise> { + const queryParams = { + payload: request?.payload, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/chat/query_future_channel_bans', undefined, queryParams); + + decoders['QueryFutureChannelBansResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryReminders( + request?: QueryRemindersRequest, + ): Promise> { + const body = { + limit: request?.limit, + next: request?.next, + prev: request?.prev, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/chat/reminders/query', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['QueryRemindersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async search(request?: { + payload?: SearchPayload; + }): Promise> { + const queryParams = { + payload: request?.payload, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/chat/search', + undefined, + queryParams, + ); + + decoders['SearchResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async sync( + request: SyncRequest & { + with_inaccessible_cids?: boolean; + watch?: boolean; + connection_id?: string; + }, + ): Promise> { + const queryParams = { + with_inaccessible_cids: request?.with_inaccessible_cids, + watch: request?.watch, + connection_id: request?.connection_id, + }; + const body = { + last_sync_at: request?.last_sync_at, + channel_cids: request?.channel_cids, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/chat/sync', + undefined, + queryParams, + body, + 'application/json', + ); + + decoders['SyncResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryThreads( + request?: QueryThreadsRequest & { connection_id?: string }, + ): Promise> { + const queryParams = { + connection_id: request?.connection_id, + }; + const body = { + limit: request?.limit, + member_limit: request?.member_limit, + next: request?.next, + participant_limit: request?.participant_limit, + prev: request?.prev, + reply_limit: request?.reply_limit, + watch: request?.watch, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/chat/threads', undefined, queryParams, body, 'application/json'); + + decoders['QueryThreadsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getThread(request: { + message_id: string; + watch?: boolean; + connection_id?: string; + reply_limit?: number; + participant_limit?: number; + member_limit?: number; + }): Promise> { + const queryParams = { + watch: request?.watch, + connection_id: request?.connection_id, + reply_limit: request?.reply_limit, + participant_limit: request?.participant_limit, + member_limit: request?.member_limit, + }; + const pathParams = { + message_id: request?.message_id, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/chat/threads/{message_id}', + pathParams, + queryParams, + ); + + decoders['GetThreadResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateThreadPartial( + request: UpdateThreadPartialRequest & { message_id: string }, + ): Promise> { + const pathParams = { + message_id: request?.message_id, + }; + const body = { + unset: request?.unset, + set: request?.set, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'PATCH', + '/api/v2/chat/threads/{message_id}', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['UpdateThreadPartialResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async unreadCounts(): Promise> { + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/chat/unread', undefined, undefined); + + decoders['WrappedUnreadCountsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteDevice(request: { id: string }): Promise> { + const queryParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/devices', + undefined, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async listDevices(): Promise> { + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/devices', undefined, undefined); + + decoders['ListDevicesResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createDevice(request: CreateDeviceRequest): Promise> { + const body = { + id: request?.id, + push_provider: request?.push_provider, + hardware_id: request?.hardware_id, + push_provider_name: request?.push_provider_name, + voip_token: request?.voip_token, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/devices', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createGuest( + request: CreateGuestRequest, + ): Promise> { + const body = { + user: request?.user, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/guest', undefined, undefined, body, 'application/json'); + + decoders['CreateGuestResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async longPoll(request?: { + connection_id?: string; + json?: WSAuthMessage; + }): Promise> { + const queryParams = { + connection_id: request?.connection_id, + json: request?.json, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/longpoll', + undefined, + queryParams, + ); + + decoders['{}']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getOG(request: { url: string }): Promise> { + const queryParams = { + url: request?.url, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/og', + undefined, + queryParams, + ); + + decoders['GetOGResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createPoll(request: CreatePollRequest): Promise> { + const body = { + name: request?.name, + allow_answers: request?.allow_answers, + allow_user_suggested_options: request?.allow_user_suggested_options, + description: request?.description, + enforce_unique_vote: request?.enforce_unique_vote, + id: request?.id, + is_closed: request?.is_closed, + max_votes_allowed: request?.max_votes_allowed, + voting_visibility: request?.voting_visibility, + options: request?.options, + custom: request?.custom, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/polls', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['PollResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updatePoll(request: UpdatePollRequest): Promise> { + const body = { + id: request?.id, + name: request?.name, + allow_answers: request?.allow_answers, + allow_user_suggested_options: request?.allow_user_suggested_options, + description: request?.description, + enforce_unique_vote: request?.enforce_unique_vote, + is_closed: request?.is_closed, + max_votes_allowed: request?.max_votes_allowed, + voting_visibility: request?.voting_visibility, + options: request?.options, + custom: request?.custom, + }; + + const response = await this.apiClient.sendRequest>( + 'PUT', + '/api/v2/polls', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['PollResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryPolls( + request?: QueryPollsRequest & { user_id?: string }, + ): Promise> { + const queryParams = { + user_id: request?.user_id, + }; + const body = { + limit: request?.limit, + next: request?.next, + prev: request?.prev, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/polls/query', + undefined, + queryParams, + body, + 'application/json', + ); + + decoders['QueryPollsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deletePoll(request: { + poll_id: string; + user_id?: string; + }): Promise> { + const queryParams = { + user_id: request?.user_id, + }; + const pathParams = { + poll_id: request?.poll_id, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/polls/{poll_id}', + pathParams, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getPoll(request: { + poll_id: string; + user_id?: string; + }): Promise> { + const queryParams = { + user_id: request?.user_id, + }; + const pathParams = { + poll_id: request?.poll_id, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/polls/{poll_id}', + pathParams, + queryParams, + ); + + decoders['PollResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updatePollPartial( + request: UpdatePollPartialRequest & { poll_id: string }, + ): Promise> { + const pathParams = { + poll_id: request?.poll_id, + }; + const body = { + unset: request?.unset, + set: request?.set, + }; + + const response = await this.apiClient.sendRequest>( + 'PATCH', + '/api/v2/polls/{poll_id}', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['PollResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createPollOption( + request: CreatePollOptionRequest & { poll_id: string }, + ): Promise> { + const pathParams = { + poll_id: request?.poll_id, + }; + const body = { + text: request?.text, + custom: request?.custom, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/polls/{poll_id}/options', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['PollOptionResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updatePollOption( + request: UpdatePollOptionRequest & { poll_id: string }, + ): Promise> { + const pathParams = { + poll_id: request?.poll_id, + }; + const body = { + id: request?.id, + text: request?.text, + custom: request?.custom, + }; + + const response = await this.apiClient.sendRequest>( + 'PUT', + '/api/v2/polls/{poll_id}/options', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['PollOptionResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deletePollOption(request: { + poll_id: string; + option_id: string; + user_id?: string; + }): Promise> { + const queryParams = { + user_id: request?.user_id, + }; + const pathParams = { + poll_id: request?.poll_id, + option_id: request?.option_id, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/polls/{poll_id}/options/{option_id}', + pathParams, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getPollOption(request: { + poll_id: string; + option_id: string; + user_id?: string; + }): Promise> { + const queryParams = { + user_id: request?.user_id, + }; + const pathParams = { + poll_id: request?.poll_id, + option_id: request?.option_id, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/polls/{poll_id}/options/{option_id}', + pathParams, + queryParams, + ); + + decoders['PollOptionResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryPollVotes( + request: QueryPollVotesRequest & { poll_id: string; user_id?: string }, + ): Promise> { + const queryParams = { + user_id: request?.user_id, + }; + const pathParams = { + poll_id: request?.poll_id, + }; + const body = { + limit: request?.limit, + next: request?.next, + prev: request?.prev, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/polls/{poll_id}/votes', + pathParams, + queryParams, + body, + 'application/json', + ); + + decoders['PollVotesResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updatePushNotificationPreferences( + request: UpsertPushPreferencesRequest, + ): Promise> { + const body = { + preferences: request?.preferences, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/push_preferences', undefined, undefined, body, 'application/json'); + + decoders['UpsertPushPreferencesResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async searchRoles(request: { + query: string; + limit?: number; + name_gt?: string; + role_type?: string; + include_global_roles?: boolean; + }): Promise> { + const queryParams = { + query: request?.query, + limit: request?.limit, + name_gt: request?.name_gt, + role_type: request?.role_type, + include_global_roles: request?.include_global_roles, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/roles/search', undefined, queryParams); + + decoders['SearchRolesResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteFile(request?: { url?: string }): Promise> { + const queryParams = { + url: request?.url, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/uploads/file', + undefined, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async uploadFile( + request?: FileUploadRequest, + ): Promise> { + const body = { + file: request?.file, + user: request?.user, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/uploads/file', + undefined, + undefined, + body, + 'multipart/form-data', + ); + + decoders['FileUploadResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteImage(request?: { url?: string }): Promise> { + const queryParams = { + url: request?.url, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/uploads/image', + undefined, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async uploadImage( + request?: ImageUploadRequest, + ): Promise> { + const body = { + file: request?.file, + upload_sizes: request?.upload_sizes, + user: request?.user, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/uploads/image', undefined, undefined, body, 'multipart/form-data'); + + decoders['ImageUploadResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async listUserGroups(request?: { + limit?: number; + id_gt?: string; + created_at_gt?: string; + team_id?: string; + }): Promise> { + const queryParams = { + limit: request?.limit, + id_gt: request?.id_gt, + created_at_gt: request?.created_at_gt, + team_id: request?.team_id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/usergroups', undefined, queryParams); + + decoders['ListUserGroupsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createUserGroup( + request: CreateUserGroupRequest, + ): Promise> { + const body = { + name: request?.name, + description: request?.description, + id: request?.id, + team_id: request?.team_id, + member_ids: request?.member_ids, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/usergroups', undefined, undefined, body, 'application/json'); + + decoders['CreateUserGroupResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async searchUserGroups(request: { + query: string; + limit?: number; + name_gt?: string; + id_gt?: string; + team_id?: string; + }): Promise> { + const queryParams = { + query: request?.query, + limit: request?.limit, + name_gt: request?.name_gt, + id_gt: request?.id_gt, + team_id: request?.team_id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/usergroups/search', undefined, queryParams); + + decoders['SearchUserGroupsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteUserGroup(request: { + id: string; + team_id?: string; + }): Promise> { + const queryParams = { + team_id: request?.team_id, + }; + const pathParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'DELETE', + '/api/v2/usergroups/{id}', + pathParams, + queryParams, + ); + + decoders['Response']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getUserGroup(request: { + id: string; + team_id?: string; + }): Promise> { + const queryParams = { + team_id: request?.team_id, + }; + const pathParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/usergroups/{id}', pathParams, queryParams); + + decoders['GetUserGroupResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateUserGroup( + request: UpdateUserGroupRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + description: request?.description, + name: request?.name, + team_id: request?.team_id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('PUT', '/api/v2/usergroups/{id}', pathParams, undefined, body, 'application/json'); + + decoders['UpdateUserGroupResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async addUserGroupMembers( + request: AddUserGroupMembersRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + member_ids: request?.member_ids, + as_admin: request?.as_admin, + team_id: request?.team_id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/usergroups/{id}/members', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['AddUserGroupMembersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async removeUserGroupMembers( + request: RemoveUserGroupMembersRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + member_ids: request?.member_ids, + team_id: request?.team_id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/usergroups/{id}/members/delete', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['RemoveUserGroupMembersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryUsers(request?: { + payload?: QueryUsersPayload; + }): Promise> { + const queryParams = { + payload: request?.payload, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/users', + undefined, + queryParams, + ); + + decoders['QueryUsersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateUsersPartial( + request: UpdateUsersPartialRequest, + ): Promise> { + const body = { + users: request?.users, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('PATCH', '/api/v2/users', undefined, undefined, body, 'application/json'); + + decoders['UpdateUsersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateUsers( + request: UpdateUsersRequest, + ): Promise> { + const body = { + users: request?.users, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/users', undefined, undefined, body, 'application/json'); + + decoders['UpdateUsersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getBlockedUsers(): Promise> { + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/users/block', undefined, undefined); + + decoders['GetBlockedUsersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async blockUsers( + request: BlockUsersRequest, + ): Promise> { + const body = { + blocked_user_id: request?.blocked_user_id, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/users/block', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['BlockUsersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getUserLiveLocations(): Promise> { + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/users/live_locations', undefined, undefined); + + decoders['SharedLocationsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateLiveLocation( + request: UpdateLiveLocationRequest, + ): Promise> { + const body = { + message_id: request?.message_id, + end_at: request?.end_at, + latitude: request?.latitude, + longitude: request?.longitude, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'PUT', + '/api/v2/users/live_locations', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['SharedLocationResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async unblockUsers( + request: UnblockUsersRequest, + ): Promise> { + const body = { + blocked_user_id: request?.blocked_user_id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/users/unblock', undefined, undefined, body, 'application/json'); + + decoders['UnblockUsersResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } +} diff --git a/src/gen/model-decoders/decoders.ts b/src/gen/model-decoders/decoders.ts new file mode 100644 index 0000000000..b58e1c8cec --- /dev/null +++ b/src/gen/model-decoders/decoders.ts @@ -0,0 +1,2680 @@ +type Decoder = (i: any) => any; + +type TypeMapping = Record; + +export const decoders: Record = {}; + +const decodeDatetimeType = (input: number | string) => + typeof input === 'number' ? new Date(Math.floor(input / 1000000)) : new Date(input); + +decoders.DatetimeType = decodeDatetimeType; + +const decode = (typeMappings: TypeMapping, input?: Record) => { + if (!input || Object.keys(typeMappings).length === 0) return input; + + Object.keys(typeMappings).forEach((key) => { + if (input[key] != null) { + if (typeMappings[key]) { + const decoder = decoders[typeMappings[key].type]; + if (decoder) { + if (typeMappings[key].isSingle) { + input[key] = decoder(input[key]); + } else { + Object.keys(input[key]).forEach((k) => { + input[key][k] = decoder(input[key][k]); + }); + } + } + } + } + }); + + return input; +}; + +decoders['AIIndicatorClearEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['AIIndicatorStopEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['AIIndicatorUpdateEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ActionLogResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + review_queue_item: { type: 'ReviewQueueItemResponse', isSingle: true }, + + target_user: { type: 'UserResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['AddUserGroupMembersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user_group: { type: 'UserGroupResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['AppUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['AppealItemResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + actions: { type: 'ActionLogResponse', isSingle: false }, + + flags: { type: 'ModerationFlagResponse', isSingle: false }, + + moderation_action: { type: 'ActionLogResponse', isSingle: true }, + + original_moderation_action: { type: 'ActionLogResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['AutomodDetailsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + result: { type: 'MessageModerationResult', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['BanInfoResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + expires: { type: 'DatetimeType', isSingle: true }, + + created_by: { type: 'UserResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['BanResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + expires: { type: 'DatetimeType', isSingle: true }, + + banned_by: { type: 'UserResponse', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['BlockListResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['BlockUsersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['BlockedUserResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + blocked_user: { type: 'UserResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['BulkActionAppealsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + results: { type: 'BulkAppealResult', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['BulkAppealResult'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + appeal_item: { type: 'AppealItemResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['CallResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + ended_at: { type: 'DatetimeType', isSingle: true }, + + starts_at: { type: 'DatetimeType', isSingle: true }, + + created_by: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelConfigWithInfo'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + commands: { type: 'Command', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelCreatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelFrozenEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelHiddenEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelKickedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelMemberResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + archived_at: { type: 'DatetimeType', isSingle: true }, + + ban_expires: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + invite_accepted_at: { type: 'DatetimeType', isSingle: true }, + + invite_rejected_at: { type: 'DatetimeType', isSingle: true }, + + pinned_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelMute'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + expires: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelPushPreferencesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + disabled_until: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + hide_messages_before: { type: 'DatetimeType', isSingle: true }, + + last_message_at: { type: 'DatetimeType', isSingle: true }, + + mute_expires_at: { type: 'DatetimeType', isSingle: true }, + + truncated_at: { type: 'DatetimeType', isSingle: true }, + + members: { type: 'ChannelMemberResponse', isSingle: false }, + + config: { type: 'ChannelConfigWithInfo', isSingle: true }, + + created_by: { type: 'UserResponse', isSingle: true }, + + truncated_by: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelStateResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + members: { type: 'ChannelMemberResponse', isSingle: false }, + + messages: { type: 'MessageResponse', isSingle: false }, + + pinned_messages: { type: 'MessageResponse', isSingle: false }, + + threads: { type: 'ThreadStateResponse', isSingle: false }, + + hide_messages_before: { type: 'DatetimeType', isSingle: true }, + + active_live_locations: { type: 'SharedLocationResponseData', isSingle: false }, + + pending_messages: { type: 'PendingMessageResponse', isSingle: false }, + + read: { type: 'ReadStateResponse', isSingle: false }, + + watchers: { type: 'UserResponse', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + draft: { type: 'DraftResponse', isSingle: true }, + + membership: { type: 'ChannelMemberResponse', isSingle: true }, + + push_preferences: { type: 'ChannelPushPreferencesResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelStateResponseFields'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + members: { type: 'ChannelMemberResponse', isSingle: false }, + + messages: { type: 'MessageResponse', isSingle: false }, + + pinned_messages: { type: 'MessageResponse', isSingle: false }, + + threads: { type: 'ThreadStateResponse', isSingle: false }, + + hide_messages_before: { type: 'DatetimeType', isSingle: true }, + + active_live_locations: { type: 'SharedLocationResponseData', isSingle: false }, + + pending_messages: { type: 'PendingMessageResponse', isSingle: false }, + + read: { type: 'ReadStateResponse', isSingle: false }, + + watchers: { type: 'UserResponse', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + draft: { type: 'DraftResponse', isSingle: true }, + + membership: { type: 'ChannelMemberResponse', isSingle: true }, + + push_preferences: { type: 'ChannelPushPreferencesResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelTruncatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelUnFrozenEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChannelVisibleEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChatDraftPayloadResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + mentioned_users: { type: 'UserResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['ChatDraftResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'ChatDraftPayloadResponse', isSingle: true }, + + parent_message: { type: 'ChatMessageResponse', isSingle: true }, + + quoted_message: { type: 'ChatMessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChatMessageResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + latest_reactions: { type: 'ChatReactionResponse', isSingle: false }, + + mentioned_users: { type: 'UserResponse', isSingle: false }, + + own_reactions: { type: 'ChatReactionResponse', isSingle: false }, + + user: { type: 'UserResponse', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + message_text_updated_at: { type: 'DatetimeType', isSingle: true }, + + pin_expires: { type: 'DatetimeType', isSingle: true }, + + pinned_at: { type: 'DatetimeType', isSingle: true }, + + mentioned_groups: { type: 'UserGroupResponse', isSingle: false }, + + thread_participants: { type: 'UserResponse', isSingle: false }, + + draft: { type: 'ChatDraftResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + pinned_by: { type: 'UserResponse', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + quoted_message: { type: 'ChatMessageResponse', isSingle: true }, + + reaction_groups: { type: 'ChatReactionGroupResponse', isSingle: false }, + + reminder: { type: 'ChatReminderResponseData', isSingle: true }, + + shared_location: { type: 'ChatSharedLocationResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChatReactionGroupResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + first_reaction_at: { type: 'DatetimeType', isSingle: true }, + + last_reaction_at: { type: 'DatetimeType', isSingle: true }, + + latest_reactions_by: { type: 'ChatReactionGroupUserResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['ChatReactionGroupUserResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChatReactionResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChatReminderResponseData'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + remind_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'ChatMessageResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ChatSharedLocationResponseData'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + end_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'ChatMessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['Command'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ConfigResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['CreateBlockListResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + blocklist: { type: 'BlockListResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['CreateDraftResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + draft: { type: 'DraftResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['CreateGuestResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['CreateUserGroupResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user_group: { type: 'UserGroupResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['CustomEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['DeleteChannelResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + channel: { type: 'ChannelResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['DeleteMessageResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['DeleteReactionResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'MessageResponse', isSingle: true }, + + reaction: { type: 'ReactionResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['DeviceResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['DraftDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + draft: { type: 'DraftResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['DraftPayloadResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + mentioned_users: { type: 'UserResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['DraftResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'DraftPayloadResponse', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + parent_message: { type: 'MessageResponse', isSingle: true }, + + quoted_message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['DraftUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + draft: { type: 'DraftResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['EntityCreatorResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + deactivated_at: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + last_active: { type: 'DatetimeType', isSingle: true }, + + revoke_tokens_issued_before: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FeedsBookmarkResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FeedsEnrichedCollectionResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FeedsFeedResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + created_by: { type: 'UserResponse', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FeedsReactionGroupResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + first_reaction_at: { type: 'DatetimeType', isSingle: true }, + + last_reaction_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FeedsReactionResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FeedsV3ActivityResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + comments: { type: 'FeedsV3CommentResponse', isSingle: false }, + + latest_reactions: { type: 'FeedsReactionResponse', isSingle: false }, + + mentioned_users: { type: 'UserResponse', isSingle: false }, + + own_bookmarks: { type: 'FeedsBookmarkResponse', isSingle: false }, + + own_reactions: { type: 'FeedsReactionResponse', isSingle: false }, + + collections: { type: 'FeedsEnrichedCollectionResponse', isSingle: false }, + + reaction_groups: { type: 'FeedsReactionGroupResponse', isSingle: false }, + + user: { type: 'UserResponse', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + edited_at: { type: 'DatetimeType', isSingle: true }, + + expires_at: { type: 'DatetimeType', isSingle: true }, + + friend_reactions: { type: 'FeedsReactionResponse', isSingle: false }, + + current_feed: { type: 'FeedsFeedResponse', isSingle: true }, + + parent: { type: 'FeedsV3ActivityResponse', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FeedsV3CommentResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + mentioned_users: { type: 'UserResponse', isSingle: false }, + + own_reactions: { type: 'FeedsReactionResponse', isSingle: false }, + + user: { type: 'UserResponse', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + edited_at: { type: 'DatetimeType', isSingle: true }, + + latest_reactions: { type: 'FeedsReactionResponse', isSingle: false }, + + reaction_groups: { type: 'FeedsReactionGroupResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['FlagDetailsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + automod: { type: 'AutomodDetailsResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FlagFeedbackResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FullUserResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + channel_mutes: { type: 'ChannelMute', isSingle: false }, + + devices: { type: 'DeviceResponse', isSingle: false }, + + mutes: { type: 'UserMuteResponse', isSingle: false }, + + ban_expires: { type: 'DatetimeType', isSingle: true }, + + deactivated_at: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + last_active: { type: 'DatetimeType', isSingle: true }, + + revoke_tokens_issued_before: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['FutureChannelBanResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + expires: { type: 'DatetimeType', isSingle: true }, + + banned_by: { type: 'UserResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['GetAppealResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + item: { type: 'AppealItemResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['GetBlockedUsersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + blocks: { type: 'BlockedUserResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['GetConfigResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + config: { type: 'ConfigResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['GetDraftResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + draft: { type: 'DraftResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['GetManyMessagesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + messages: { type: 'MessageResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['GetMessageResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'MessageWithChannelResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['GetReactionsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + reactions: { type: 'ReactionResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['GetRepliesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + messages: { type: 'MessageResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['GetThreadResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + thread: { type: 'ThreadStateResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['GetUserGroupResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user_group: { type: 'UserGroupResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['GroupedChannelsBucket'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + channels: { type: 'ChannelStateResponseFields', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['GroupedQueryChannelsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + groups: { type: 'GroupedChannelsBucket', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['HealthCheckEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + me: { type: 'OwnUserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ListBlockListResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + blocklists: { type: 'BlockListResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['ListDevicesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + devices: { type: 'DeviceResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['ListQueuesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + queues: { type: 'ModerationQueueResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['ListUserGroupsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user_groups: { type: 'UserGroupResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['MarkReadResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + event: { type: 'MarkReadResponseEvent', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MarkReadResponseEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel_last_message_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + thread: { type: 'ThreadResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MaxStreakChangedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MemberAddedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MemberRemovedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MemberUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MembersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + members: { type: 'ChannelMemberResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageActionResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageDeliveredEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageFlagResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + approved_at: { type: 'DatetimeType', isSingle: true }, + + rejected_at: { type: 'DatetimeType', isSingle: true }, + + reviewed_at: { type: 'DatetimeType', isSingle: true }, + + details: { type: 'FlagDetailsResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + moderation_feedback: { type: 'FlagFeedbackResponse', isSingle: true }, + + moderation_result: { type: 'MessageModerationResult', isSingle: true }, + + reviewed_by: { type: 'UserResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageModerationResult'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageNewEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + thread_participants: { type: 'UserResponseCommonFields', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageReadEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + thread: { type: 'ThreadResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + latest_reactions: { type: 'ReactionResponse', isSingle: false }, + + mentioned_users: { type: 'UserResponse', isSingle: false }, + + own_reactions: { type: 'ReactionResponse', isSingle: false }, + + user: { type: 'UserResponse', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + message_text_updated_at: { type: 'DatetimeType', isSingle: true }, + + pin_expires: { type: 'DatetimeType', isSingle: true }, + + pinned_at: { type: 'DatetimeType', isSingle: true }, + + mentioned_groups: { type: 'UserGroupResponse', isSingle: false }, + + thread_participants: { type: 'UserResponse', isSingle: false }, + + draft: { type: 'DraftResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + pinned_by: { type: 'UserResponse', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + quoted_message: { type: 'MessageResponse', isSingle: true }, + + reaction_groups: { type: 'ReactionGroupResponse', isSingle: false }, + + reminder: { type: 'ReminderResponseData', isSingle: true }, + + shared_location: { type: 'SharedLocationResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageUndeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MessageWithChannelResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + latest_reactions: { type: 'ReactionResponse', isSingle: false }, + + mentioned_users: { type: 'UserResponse', isSingle: false }, + + own_reactions: { type: 'ReactionResponse', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + message_text_updated_at: { type: 'DatetimeType', isSingle: true }, + + pin_expires: { type: 'DatetimeType', isSingle: true }, + + pinned_at: { type: 'DatetimeType', isSingle: true }, + + mentioned_groups: { type: 'UserGroupResponse', isSingle: false }, + + thread_participants: { type: 'UserResponse', isSingle: false }, + + draft: { type: 'DraftResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + pinned_by: { type: 'UserResponse', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + quoted_message: { type: 'MessageResponse', isSingle: true }, + + reaction_groups: { type: 'ReactionGroupResponse', isSingle: false }, + + reminder: { type: 'ReminderResponseData', isSingle: true }, + + shared_location: { type: 'SharedLocationResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ModerationCustomActionEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + review_queue_item: { type: 'ReviewQueueItemResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ModerationFlagResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + review_queue_item: { type: 'ReviewQueueItemResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ModerationFlaggedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ModerationMarkReviewedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + item: { type: 'ReviewQueueItemResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ModerationQueueResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MuteChannelResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + channel_mutes: { type: 'ChannelMute', isSingle: false }, + + channel_mute: { type: 'ChannelMute', isSingle: true }, + + own_user: { type: 'OwnUserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['MuteResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + mutes: { type: 'UserMuteResponse', isSingle: false }, + + own_user: { type: 'OwnUserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationAddedToChannelEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationChannelDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationChannelMutesUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + me: { type: 'OwnUserResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationChannelTruncatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationInviteAcceptedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationInviteRejectedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationInvitedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationMarkReadEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + thread: { type: 'ThreadResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationMarkUnreadEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + last_read_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationMutesUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + me: { type: 'OwnUserResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationNewMessageEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + thread_participants: { type: 'UserResponseCommonFields', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationRemovedFromChannelEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['NotificationThreadMessageNewEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + thread_participants: { type: 'UserResponseCommonFields', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['OwnUserResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + channel_mutes: { type: 'ChannelMute', isSingle: false }, + + devices: { type: 'DeviceResponse', isSingle: false }, + + mutes: { type: 'UserMuteResponse', isSingle: false }, + + deactivated_at: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + last_active: { type: 'DatetimeType', isSingle: true }, + + revoke_tokens_issued_before: { type: 'DatetimeType', isSingle: true }, + + push_preferences: { type: 'PushPreferencesResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PendingMessageEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PendingMessageResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollClosedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + poll: { type: 'PollResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollResponseData'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + latest_answers: { type: 'PollVoteResponseData', isSingle: false }, + + own_votes: { type: 'PollVoteResponseData', isSingle: false }, + + created_by: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollVoteCastedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + poll_vote: { type: 'PollVoteResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollVoteChangedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + poll_vote: { type: 'PollVoteResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollVoteRemovedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + poll_vote: { type: 'PollVoteResponseData', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollVoteResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + poll: { type: 'PollResponseData', isSingle: true }, + + vote: { type: 'PollVoteResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollVoteResponseData'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['PollVotesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + votes: { type: 'PollVoteResponseData', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['PushPreferencesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + disabled_until: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryAppealsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + items: { type: 'AppealItemResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryBannedUsersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + bans: { type: 'BanResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryChannelsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + channels: { type: 'ChannelStateResponseFields', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryDraftsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + drafts: { type: 'DraftResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryFutureChannelBansResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + bans: { type: 'FutureChannelBanResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryMessageFlagsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + flags: { type: 'MessageFlagResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryModerationConfigsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + configs: { type: 'ConfigResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryPollsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + polls: { type: 'PollResponseData', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryReactionsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + reactions: { type: 'ReactionResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryRemindersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + reminders: { type: 'ReminderResponseData', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryReviewQueueResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + items: { type: 'ReviewQueueItemResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryThreadsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + threads: { type: 'ThreadStateResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueryUsersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + users: { type: 'FullUserResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['QueueResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + queue: { type: 'ModerationQueueResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['Reaction'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReactionDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + thread_participants: { type: 'UserResponseCommonFields', isSingle: false }, + + message: { type: 'MessageResponse', isSingle: true }, + + reaction: { type: 'ReactionResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReactionGroupResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + first_reaction_at: { type: 'DatetimeType', isSingle: true }, + + last_reaction_at: { type: 'DatetimeType', isSingle: true }, + + latest_reactions_by: { type: 'ReactionGroupUserResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['ReactionGroupUserResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReactionNewEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + thread_participants: { type: 'UserResponseCommonFields', isSingle: false }, + + message: { type: 'MessageResponse', isSingle: true }, + + reaction: { type: 'ReactionResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReactionResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReactionUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + reaction: { type: 'ReactionResponse', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReadStateResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + last_read: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + + last_delivered_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReminderCreatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + reminder: { type: 'ReminderResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReminderDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + reminder: { type: 'ReminderResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReminderNotificationEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + reminder: { type: 'ReminderResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReminderResponseData'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + remind_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReminderUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + reminder: { type: 'ReminderResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['RemoveUserGroupMembersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user_group: { type: 'UserGroupResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ReviewQueueItemResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + actions: { type: 'ActionLogResponse', isSingle: false }, + + bans: { type: 'BanInfoResponse', isSingle: false }, + + flags: { type: 'ModerationFlagResponse', isSingle: false }, + + completed_at: { type: 'DatetimeType', isSingle: true }, + + escalated_at: { type: 'DatetimeType', isSingle: true }, + + reviewed_at: { type: 'DatetimeType', isSingle: true }, + + appeal: { type: 'AppealItemResponse', isSingle: true }, + + assigned_to: { type: 'UserResponse', isSingle: true }, + + call: { type: 'CallResponse', isSingle: true }, + + entity_creator: { type: 'EntityCreatorResponse', isSingle: true }, + + feeds_v2_reaction: { type: 'Reaction', isSingle: true }, + + feeds_v3_activity: { type: 'FeedsV3ActivityResponse', isSingle: true }, + + feeds_v3_comment: { type: 'FeedsV3CommentResponse', isSingle: true }, + + message: { type: 'ChatMessageResponse', isSingle: true }, + + reaction: { type: 'Reaction', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['Role'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['SearchResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + results: { type: 'SearchResult', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['SearchResult'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'SearchResultMessage', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['SearchResultMessage'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + latest_reactions: { type: 'ReactionResponse', isSingle: false }, + + mentioned_users: { type: 'UserResponse', isSingle: false }, + + own_reactions: { type: 'ReactionResponse', isSingle: false }, + + user: { type: 'UserResponse', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + message_text_updated_at: { type: 'DatetimeType', isSingle: true }, + + pin_expires: { type: 'DatetimeType', isSingle: true }, + + pinned_at: { type: 'DatetimeType', isSingle: true }, + + mentioned_groups: { type: 'UserGroupResponse', isSingle: false }, + + thread_participants: { type: 'UserResponse', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + draft: { type: 'DraftResponse', isSingle: true }, + + member: { type: 'ChannelMemberResponse', isSingle: true }, + + pinned_by: { type: 'UserResponse', isSingle: true }, + + poll: { type: 'PollResponseData', isSingle: true }, + + quoted_message: { type: 'MessageResponse', isSingle: true }, + + reaction_groups: { type: 'ReactionGroupResponse', isSingle: false }, + + reminder: { type: 'ReminderResponseData', isSingle: true }, + + shared_location: { type: 'SharedLocationResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['SearchRolesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + roles: { type: 'Role', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['SearchUserGroupsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user_groups: { type: 'UserGroupResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['SendMessageResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['SendReactionResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'MessageResponse', isSingle: true }, + + reaction: { type: 'ReactionResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['SharedLocationResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + end_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['SharedLocationResponseData'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + end_at: { type: 'DatetimeType', isSingle: true }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['SharedLocationsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + active_live_locations: { type: 'SharedLocationResponseData', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['SubmitActionResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + appeal_item: { type: 'AppealItemResponse', isSingle: true }, + + item: { type: 'ReviewQueueItemResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ThreadParticipant'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + last_read_at: { type: 'DatetimeType', isSingle: true }, + + last_thread_message_at: { type: 'DatetimeType', isSingle: true }, + + left_thread_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ThreadResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + last_message_at: { type: 'DatetimeType', isSingle: true }, + + thread_participants: { type: 'ThreadParticipant', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + created_by: { type: 'UserResponse', isSingle: true }, + + parent_message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ThreadStateResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + latest_replies: { type: 'MessageResponse', isSingle: false }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + last_message_at: { type: 'DatetimeType', isSingle: true }, + + read: { type: 'ReadStateResponse', isSingle: false }, + + thread_participants: { type: 'ThreadParticipant', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + created_by: { type: 'UserResponse', isSingle: true }, + + draft: { type: 'DraftResponse', isSingle: true }, + + parent_message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['ThreadUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + thread: { type: 'ThreadResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['TruncateChannelResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['TypingStartEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['TypingStopEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UnreadCountsChannel'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + last_read: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UnreadCountsThread'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + last_read: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateBlockListResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + blocklist: { type: 'BlockListResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateChannelPartialResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + members: { type: 'ChannelMemberResponse', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateChannelResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + members: { type: 'ChannelMemberResponse', isSingle: false }, + + channel: { type: 'ChannelResponse', isSingle: true }, + + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateMemberPartialResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + channel_member: { type: 'ChannelMemberResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateMessagePartialResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateMessageResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + message: { type: 'MessageResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateReminderResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + reminder: { type: 'ReminderResponseData', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateThreadPartialResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + thread: { type: 'ThreadResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateUserGroupResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user_group: { type: 'UserGroupResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpdateUsersResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + users: { type: 'FullUserResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['UpsertConfigResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + config: { type: 'ConfigResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UpsertPushPreferencesResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + user_preferences: { type: 'PushPreferencesResponse', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['UserBannedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + expiration: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + created_by: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserDeactivatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + created_by: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserGroupCreatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserGroupDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserGroupMember'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserGroupMemberAddedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserGroupMemberRemovedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserGroupResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + members: { type: 'UserGroupMember', isSingle: false }, + }; + return decode(typeMappings, input); +}; + +decoders['UserGroupUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserMessagesDeletedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserMuteResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + expires: { type: 'DatetimeType', isSingle: true }, + + target: { type: 'UserResponse', isSingle: true }, + + user: { type: 'UserResponse', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserMutedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + target_users: { type: 'UserResponseCommonFields', isSingle: false }, + + target_user: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserPresenceChangedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserReactivatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + created_by: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + deactivated_at: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + last_active: { type: 'DatetimeType', isSingle: true }, + + revoke_tokens_issued_before: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserResponseCommonFields'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + updated_at: { type: 'DatetimeType', isSingle: true }, + + deactivated_at: { type: 'DatetimeType', isSingle: true }, + + deleted_at: { type: 'DatetimeType', isSingle: true }, + + last_active: { type: 'DatetimeType', isSingle: true }, + + revoke_tokens_issued_before: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserUnbannedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + + created_by: { type: 'UserResponseCommonFields', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserUpdatedEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserWatchingStartEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['UserWatchingStopEvent'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + created_at: { type: 'DatetimeType', isSingle: true }, + + user: { type: 'UserResponseCommonFields', isSingle: true }, + + received_at: { type: 'DatetimeType', isSingle: true }, + }; + return decode(typeMappings, input); +}; + +decoders['WrappedUnreadCountsResponse'] = (input?: { [key: string]: any }) => { + const typeMappings: TypeMapping = { + channels: { type: 'UnreadCountsChannel', isSingle: false }, + + threads: { type: 'UnreadCountsThread', isSingle: false }, + }; + return decode(typeMappings, input); +}; diff --git a/src/gen/model-decoders/event-decoder-mapping.ts b/src/gen/model-decoders/event-decoder-mapping.ts new file mode 100644 index 0000000000..c0dc4826a4 --- /dev/null +++ b/src/gen/model-decoders/event-decoder-mapping.ts @@ -0,0 +1,198 @@ +import type { WSEvent } from '../models'; +import { decoders } from '../model-decoders/decoders'; + +const eventDecoderMapping: { + [key in WSEvent['type']]: (data: Record) => WSEvent; +} = { + '*': (data: Record) => decoders.CustomEvent(data), + + 'ai_indicator.clear': (data: Record) => + decoders.AIIndicatorClearEvent(data), + + 'ai_indicator.stop': (data: Record) => decoders.AIIndicatorStopEvent(data), + + 'ai_indicator.update': (data: Record) => + decoders.AIIndicatorUpdateEvent(data), + + 'app.updated': (data: Record) => decoders.AppUpdatedEvent(data), + + 'channel.created': (data: Record) => decoders.ChannelCreatedEvent(data), + + 'channel.deleted': (data: Record) => decoders.ChannelDeletedEvent(data), + + 'channel.frozen': (data: Record) => decoders.ChannelFrozenEvent(data), + + 'channel.hidden': (data: Record) => decoders.ChannelHiddenEvent(data), + + 'channel.kicked': (data: Record) => decoders.ChannelKickedEvent(data), + + 'channel.max_streak_changed': (data: Record) => + decoders.MaxStreakChangedEvent(data), + + 'channel.truncated': (data: Record) => + decoders.ChannelTruncatedEvent(data), + + 'channel.unfrozen': (data: Record) => decoders.ChannelUnFrozenEvent(data), + + 'channel.updated': (data: Record) => decoders.ChannelUpdatedEvent(data), + + 'channel.visible': (data: Record) => decoders.ChannelVisibleEvent(data), + + 'draft.deleted': (data: Record) => decoders.DraftDeletedEvent(data), + + 'draft.updated': (data: Record) => decoders.DraftUpdatedEvent(data), + + 'health.check': (data: Record) => decoders.HealthCheckEvent(data), + + 'member.added': (data: Record) => decoders.MemberAddedEvent(data), + + 'member.removed': (data: Record) => decoders.MemberRemovedEvent(data), + + 'member.updated': (data: Record) => decoders.MemberUpdatedEvent(data), + + 'message.deleted': (data: Record) => decoders.MessageDeletedEvent(data), + + 'message.delivered': (data: Record) => + decoders.MessageDeliveredEvent(data), + + 'message.new': (data: Record) => decoders.MessageNewEvent(data), + + 'message.pending': (data: Record) => decoders.PendingMessageEvent(data), + + 'message.read': (data: Record) => decoders.MessageReadEvent(data), + + 'message.undeleted': (data: Record) => + decoders.MessageUndeletedEvent(data), + + 'message.updated': (data: Record) => decoders.MessageUpdatedEvent(data), + + 'moderation.custom_action': (data: Record) => + decoders.ModerationCustomActionEvent(data), + + 'moderation.flagged': (data: Record) => + decoders.ModerationFlaggedEvent(data), + + 'moderation.mark_reviewed': (data: Record) => + decoders.ModerationMarkReviewedEvent(data), + + 'notification.added_to_channel': (data: Record) => + decoders.NotificationAddedToChannelEvent(data), + + 'notification.channel_deleted': (data: Record) => + decoders.NotificationChannelDeletedEvent(data), + + 'notification.channel_mutes_updated': (data: Record) => + decoders.NotificationChannelMutesUpdatedEvent(data), + + 'notification.channel_truncated': (data: Record) => + decoders.NotificationChannelTruncatedEvent(data), + + 'notification.invite_accepted': (data: Record) => + decoders.NotificationInviteAcceptedEvent(data), + + 'notification.invite_rejected': (data: Record) => + decoders.NotificationInviteRejectedEvent(data), + + 'notification.invited': (data: Record) => + decoders.NotificationInvitedEvent(data), + + 'notification.mark_read': (data: Record) => + decoders.NotificationMarkReadEvent(data), + + 'notification.mark_unread': (data: Record) => + decoders.NotificationMarkUnreadEvent(data), + + 'notification.message_new': (data: Record) => + decoders.NotificationNewMessageEvent(data), + + 'notification.mutes_updated': (data: Record) => + decoders.NotificationMutesUpdatedEvent(data), + + 'notification.reminder_due': (data: Record) => + decoders.ReminderNotificationEvent(data), + + 'notification.removed_from_channel': (data: Record) => + decoders.NotificationRemovedFromChannelEvent(data), + + 'notification.thread_message_new': (data: Record) => + decoders.NotificationThreadMessageNewEvent(data), + + 'poll.closed': (data: Record) => decoders.PollClosedEvent(data), + + 'poll.deleted': (data: Record) => decoders.PollDeletedEvent(data), + + 'poll.updated': (data: Record) => decoders.PollUpdatedEvent(data), + + 'poll.vote_casted': (data: Record) => decoders.PollVoteCastedEvent(data), + + 'poll.vote_changed': (data: Record) => decoders.PollVoteChangedEvent(data), + + 'poll.vote_removed': (data: Record) => decoders.PollVoteRemovedEvent(data), + + 'reaction.deleted': (data: Record) => decoders.ReactionDeletedEvent(data), + + 'reaction.new': (data: Record) => decoders.ReactionNewEvent(data), + + 'reaction.updated': (data: Record) => decoders.ReactionUpdatedEvent(data), + + 'reminder.created': (data: Record) => decoders.ReminderCreatedEvent(data), + + 'reminder.deleted': (data: Record) => decoders.ReminderDeletedEvent(data), + + 'reminder.updated': (data: Record) => decoders.ReminderUpdatedEvent(data), + + 'thread.updated': (data: Record) => decoders.ThreadUpdatedEvent(data), + + 'typing.start': (data: Record) => decoders.TypingStartEvent(data), + + 'typing.stop': (data: Record) => decoders.TypingStopEvent(data), + + 'user.banned': (data: Record) => decoders.UserBannedEvent(data), + + 'user.deactivated': (data: Record) => decoders.UserDeactivatedEvent(data), + + 'user.deleted': (data: Record) => decoders.UserDeletedEvent(data), + + 'user.messages.deleted': (data: Record) => + decoders.UserMessagesDeletedEvent(data), + + 'user.muted': (data: Record) => decoders.UserMutedEvent(data), + + 'user.presence.changed': (data: Record) => + decoders.UserPresenceChangedEvent(data), + + 'user.reactivated': (data: Record) => decoders.UserReactivatedEvent(data), + + 'user.unbanned': (data: Record) => decoders.UserUnbannedEvent(data), + + 'user.updated': (data: Record) => decoders.UserUpdatedEvent(data), + + 'user.watching.start': (data: Record) => + decoders.UserWatchingStartEvent(data), + + 'user.watching.stop': (data: Record) => + decoders.UserWatchingStopEvent(data), + + 'user_group.created': (data: Record) => + decoders.UserGroupCreatedEvent(data), + + 'user_group.deleted': (data: Record) => + decoders.UserGroupDeletedEvent(data), + + 'user_group.member_added': (data: Record) => + decoders.UserGroupMemberAddedEvent(data), + + 'user_group.member_removed': (data: Record) => + decoders.UserGroupMemberRemovedEvent(data), + + 'user_group.updated': (data: Record) => + decoders.UserGroupUpdatedEvent(data), +}; + +export const decodeWSEvent = (data: { type: string } & Record) => { + if (Object.hasOwn(eventDecoderMapping, data.type)) { + return eventDecoderMapping[data.type as WSEvent['type']](data); + } else { + return data; + } +}; diff --git a/src/gen/models/index.ts b/src/gen/models/index.ts new file mode 100644 index 0000000000..c493ed7851 --- /dev/null +++ b/src/gen/models/index.ts @@ -0,0 +1,12673 @@ +import type { + CustomAttachmentData, + CustomChannelData, + CustomEventData, + CustomMemberData, + CustomMessageData, + CustomPollData, + CustomPollOptionData, + CustomReactionData, + CustomThreadData, + CustomUserData, +} from '../../custom_types'; + +type Filters> = + QueryFilters<{ + [Property in keyof FilterConditions]: FilterConditions[Property]['operators'] extends string + ? + | RequireAtLeastOne<{ + [Operator in FilterConditions[Property]['operators']]: + | (Operator extends '$in' | '$nin' + ? Array + : Operator extends '$exists' + ? boolean + : FilterConditions[Property]['type']) + | null; + }> + | FilterConditions[Property]['type'] + | null + : undefined; + }>; + +export type QueryFilters = { + [Key in keyof Operators]?: Operators[Key]; +} & QueryLogicalOperators; + +export type QueryLogicalOperators = { + $and?: ArrayOneOrMore>; + $nor?: ArrayOneOrMore>; + $or?: ArrayTwoOrMore>; +}; + +export type ArrayOneOrMore = { + 0: T; +} & Array; + +export type ArrayTwoOrMore = { + 0: T; + 1: T; +} & Array; + +export type RequireAtLeastOne = { + [K in keyof T]-?: Required> & Partial>; +}[keyof T]; + +export interface AIImageConfig { + enabled: boolean; + + ocr_rules: Array; + + rules: Array; + + async?: boolean; +} + +export interface AIImageLabelDefinition { + description: string; + + group: string; + + key: string; + + label: string; +} + +export interface AIIndicatorClearEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "ai_indicator.clear" in this case + */ + type: string; + + /** + * The ID of the channel + */ + channel_id?: string; + + /** + * The type of the channel + */ + channel_type?: string; + + /** + * The CID of the channel + */ + cid?: string; + + received_at?: Date; +} + +export interface AIIndicatorStopEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "ai_indicator.stop" in this case + */ + type: string; + + /** + * The ID of the channel + */ + channel_id?: string; + + /** + * The type of the channel + */ + channel_type?: string; + + /** + * The CID of the channel + */ + cid?: string; + + received_at?: Date; +} + +export interface AIIndicatorUpdateEvent { + /** + * The state of the AI indicator + */ + ai_state: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The ID of the message + */ + message_id: string; + + custom: CustomEventData; + + /** + * The type of event: "ai_indicator.update" in this case + */ + type: string; + + /** + * Optional message from the AI + */ + ai_message?: string; + + /** + * The ID of the channel + */ + channel_id?: string; + + /** + * The type of the channel + */ + channel_type?: string; + + /** + * The CID of the channel + */ + cid?: string; + + received_at?: Date; +} + +export interface AITextConfig { + enabled: boolean; + + profile: string; + + rules: Array; + + severity_rules: Array; + + async?: boolean; +} + +export interface AIVideoConfig { + enabled: boolean; + + rules: Array; + + async?: boolean; +} + +export interface APIError { + /** + * API error code + */ + code: number; + + /** + * Request duration + */ + duration: string; + + /** + * Message describing an error + */ + message: string; + + /** + * URL with additional information + */ + more_info: string; + + /** + * Response HTTP status code + */ + status_code: number; + + /** + * Additional error-specific information + */ + details: Array; + + /** + * Flag that indicates if the error is unrecoverable, requests that return unrecoverable errors should not be retried, this error only applies to the request that caused it + */ + unrecoverable?: boolean; + + /** + * Additional error info + */ + exception_fields?: Record; +} + +export interface AWSRekognitionRule { + action: 'flag' | 'shadow' | 'remove' | 'bounce' | 'bounce_flag' | 'bounce_remove'; + + label: string; + + min_confidence: number; + + subclassifications?: Record; +} + +export interface Action { + name: string; + + text: string; + + type: string; + + style?: string; + + value?: string; +} + +export interface ActionLogResponse { + /** + * Timestamp when the action was taken + */ + created_at: Date; + + /** + * Unique identifier of the action log + */ + id: string; + + /** + * Reason for the moderation action + */ + reason: string; + + /** + * Classification of who triggered the action (e.g. user, moderator, automod, api_integration) + */ + reporter_type: string; + + /** + * ID of the user who was the target of the action + */ + target_user_id: string; + + /** + * Type of moderation action + */ + type: string; + + /** + * ID of the user who performed the action + */ + user_id: string; + + ai_providers: Array; + + /** + * Additional metadata about the action + */ + custom: Record; + + review_queue_item?: ReviewQueueItemResponse; + + target_user?: UserResponse; + + user?: UserResponse; +} + +export interface ActionSequence { + action: string; + + blur: boolean; + + cooldown_period: number; + + threshold: number; + + time_window: number; + + warning: boolean; + + warning_text: string; +} + +export interface AddUserGroupMembersRequest { + /** + * List of user IDs to add as members + */ + member_ids: Array; + + /** + * Whether to add the members as group admins. Defaults to false + */ + as_admin?: boolean; + + team_id?: string; +} + +export interface AddUserGroupMembersResponse { + duration: string; + + user_group?: UserGroupResponse; +} + +export interface AppEventResponse { + /** + * boolean + */ + auto_translation_enabled: boolean; + + /** + * string + */ + name: string; + + /** + * boolean + */ + async_url_enrich_enabled?: boolean; + + file_upload_config?: FileUploadConfig; + + image_upload_config?: FileUploadConfig; +} + +export interface AppResponseFields { + async_url_enrich_enabled: boolean; + + auto_translation_enabled: boolean; + + id: number; + + name: string; + + placement: string; + + file_upload_config: FileUploadConfig; + + image_upload_config: FileUploadConfig; +} + +export interface AppUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + app: AppEventResponse; + + custom: CustomEventData; + + /** + * The type of event: "app.updated" in this case + */ + type: string; + + received_at?: Date; +} + +export interface AppealItemResponse { + /** + * Reason Text of the Appeal Item + */ + appeal_reason: string; + + /** + * When the flag was created + */ + created_at: Date; + + /** + * ID of the entity + */ + entity_id: string; + + /** + * Type of entity + */ + entity_type: string; + + id: string; + + /** + * Status of the Appeal Item + */ + status: string; + + /** + * When the flag was last updated + */ + updated_at: Date; + + /** + * Text severity level assigned by the AI provider + */ + ai_text_severity?: string; + + /** + * CID of the channel the entity belongs to, if applicable + */ + channel_cid?: string; + + /** + * Moderation policy key that was applied + */ + config_key?: string; + + /** + * Decision Reason of the Appeal Item + */ + decision_reason?: string; + + /** + * Action recommended by the automated moderation system (e.g. flag, remove, shadow) + */ + recommended_action?: string; + + /** + * ID of the review queue item linked to this appeal, if the appeal was submitted with one + */ + review_queue_item_id?: string; + + /** + * Overall content severity score (1–100) + */ + severity?: number; + + /** + * Full chronological history of all moderation actions on the review queue item + */ + actions?: Array; + + /** + * Attachments(e.g. Images) of the Appeal Item + */ + attachments?: Array; + + /** + * Classification labels from automated and manual review + */ + flag_labels?: Array; + + /** + * Types of flags applied to the entity (e.g. user_report, bodyguard) + */ + flag_types?: Array; + + /** + * Per-provider flag records explaining why the action was taken + */ + flags?: Array; + + entity_content?: ModerationPayload; + + moderation_action?: ActionLogResponse; + + original_moderation_action?: ActionLogResponse; + + user?: UserResponse; +} + +export interface AppealRequest { + /** + * Explanation for why the content is being appealed + */ + appeal_reason: string; + + /** + * Unique identifier of the entity being appealed + */ + entity_id: string; + + /** + * Type of entity being appealed (e.g., message, user) + */ + entity_type: string; + + /** + * ID of the review queue item (flagged message) that triggered the ban. Applicable only for user ban appeals. + */ + review_queue_item_id?: string; + + /** + * Array of Attachment URLs(e.g., images) + */ + attachments?: Array; +} + +export interface AppealResponse { + /** + * Unique identifier of the created Appeal item + */ + appeal_id: string; + + duration: string; +} + +export interface Attachment { + custom: CustomAttachmentData; + + asset_url?: string; + + author_icon?: string; + + author_link?: string; + + author_name?: string; + + color?: string; + + fallback?: string; + + footer?: string; + + footer_icon?: string; + + image_url?: string; + + og_scrape_url?: string; + + original_height?: number; + + original_width?: number; + + pretext?: string; + + text?: string; + + thumb_url?: string; + + title?: string; + + title_link?: string; + + /** + * Attachment type (e.g. image, video, url) + */ + type?: string; + + actions?: Array; + + fields?: Array; + + giphy?: Images; +} + +export interface AutomodDetailsResponse { + action?: string; + + original_message_type?: string; + + image_labels?: Array; + + message_details?: FlagMessageDetailsResponse; + + result?: MessageModerationResult; +} + +export interface AutomodPlatformCircumventionConfig { + enabled: boolean; + + rules: Array; + + async?: boolean; +} + +export interface AutomodRule { + action: 'flag' | 'shadow' | 'remove' | 'bounce' | 'bounce_flag' | 'bounce_remove'; + + label: string; + + threshold: number; +} + +export interface AutomodSemanticFiltersConfig { + enabled: boolean; + + rules: Array; + + async?: boolean; +} + +export interface AutomodSemanticFiltersRule { + action: 'flag' | 'shadow' | 'remove' | 'bounce' | 'bounce_flag' | 'bounce_remove'; + + name: string; + + threshold: number; +} + +export interface AutomodToxicityConfig { + enabled: boolean; + + rules: Array; + + async?: boolean; +} + +export interface BanActionRequestPayload { + /** + * Also ban user from all channels this moderator creates in the future + */ + ban_from_future_channels?: boolean; + + /** + * Ban only from specific channel + */ + channel_ban_only?: boolean; + + channel_cid?: string; + + /** + * Message deletion mode: soft, pruning, or hard + */ + + delete_messages?: 'soft' | 'pruning' | 'hard'; + + /** + * Whether to ban by IP address + */ + ip_ban?: boolean; + + /** + * Reason for the ban + */ + reason?: string; + + /** + * Whether this is a shadow ban + */ + shadow?: boolean; + + /** + * Optional: ban user directly without review item + */ + target_user_id?: string; + + /** + * Duration of ban in minutes + */ + timeout?: number; +} + +export interface BanInfoResponse { + /** + * When the ban was created + */ + created_at: Date; + + /** + * When the ban expires + */ + expires?: Date; + + /** + * Reason for the ban + */ + reason?: string; + + /** + * Whether this is a shadow ban + */ + shadow?: boolean; + + created_by?: UserResponse; + + user?: UserResponse; +} + +export interface BanOptions { + delete_messages?: 'soft' | 'pruning' | 'hard'; + + duration?: number; + + ip_ban?: boolean; + + reason?: string; + + shadow_ban?: boolean; +} + +export interface BanRequest { + /** + * ID of the user to ban + */ + target_user_id: string; + + /** + * ID of the user performing the ban + */ + banned_by_id?: string; + + /** + * Channel where the ban applies + */ + channel_cid?: string; + + delete_messages?: 'soft' | 'pruning' | 'hard'; + + /** + * Whether to ban the user's IP address + */ + ip_ban?: boolean; + + /** + * Optional explanation for the ban + */ + reason?: string; + + /** + * Whether this is a shadow ban + */ + shadow?: boolean; + + /** + * Duration of the ban in minutes + */ + timeout?: number; + + banned_by?: UserRequest; +} + +export interface BanResponse { + created_at: Date; + + expires?: Date; + + reason?: string; + + shadow?: boolean; + + banned_by?: UserResponse; + + channel?: ChannelResponse; + + user?: UserResponse; +} + +export interface BlockActionRequestPayload { + /** + * Reason for blocking + */ + reason?: string; +} + +export interface BlockListConfig { + enabled: boolean; + + rules: Array; + + async?: boolean; + + match_substring?: boolean; +} + +export interface BlockListOptions { + /** + * Blocklist behavior. One of: flag, block, shadow_block + */ + + behavior: 'flag' | 'block' | 'shadow_block'; + + /** + * Blocklist name + */ + blocklist: string; +} + +export interface BlockListResponse { + is_confusable_folding_enabled: boolean; + + is_leet_check_enabled: boolean; + + is_plural_check_enabled: boolean; + + is_substring_matching_enabled: boolean; + + /** + * Block list name + */ + name: string; + + /** + * Block list type. One of: regex, domain, domain_allowlist, email, email_allowlist, word + */ + type: string; + + /** + * List of words to block + */ + words: Array; + + /** + * Date/time of creation + */ + created_at?: Date; + + id?: string; + + team?: string; + + /** + * Date/time of the last update + */ + updated_at?: Date; +} + +export interface BlockListRule { + action: + | 'flag' + | 'mask' + | 'mask_flag' + | 'shadow' + | 'remove' + | 'bounce' + | 'bounce_flag' + | 'bounce_remove'; + + name: string; + + team: string; +} + +export interface BlockUsersRequest { + /** + * User id to block + */ + blocked_user_id: string; +} + +export interface BlockUsersResponse { + /** + * User id who blocked another user + */ + blocked_by_user_id: string; + + /** + * User id who got blocked + */ + blocked_user_id: string; + + /** + * Timestamp when the user was blocked + */ + created_at: Date; + + /** + * Duration of the request in milliseconds + */ + duration: string; +} + +export interface BlockedUserResponse { + /** + * ID of the user who got blocked + */ + blocked_user_id: string; + + created_at: Date; + + /** + * ID of the user who blocked another user + */ + user_id: string; + + blocked_user: UserResponse; + + user: UserResponse; +} + +export interface BodyguardProfileSummary { + name: string; + + display_name?: string; + + text_type?: string; +} + +export interface BodyguardRule { + action: + | 'keep' + | 'flag' + | 'mask' + | 'mask_flag' + | 'shadow' + | 'remove' + | 'bounce' + | 'bounce_flag' + | 'bounce_remove'; + + label: string; + + severity_rules: Array; +} + +export interface BodyguardSeverityRule { + action: + | 'keep' + | 'flag' + | 'mask' + | 'shadow' + | 'remove' + | 'bounce' + | 'bounce_flag' + | 'bounce_remove'; + + severity: 'low' | 'medium' | 'high' | 'critical'; +} + +export interface BulkActionAppealsRequest { + /** + * Action to apply: unban, restore, unblock, mark_reviewed, or reject_appeal + */ + + action_type: 'unban' | 'restore' | 'unblock' | 'mark_reviewed' | 'reject_appeal'; + + /** + * List of appeal UUIDs to process + */ + appeal_ids: Array; + + mark_reviewed?: MarkReviewedRequestPayload; + + reject_appeal?: RejectAppealRequestPayload; + + restore?: RestoreActionRequestPayload; + + unban?: UnbanActionRequestPayload; + + unblock?: UnblockActionRequestPayload; +} + +export interface BulkActionAppealsResponse { + duration: string; + + /** + * Appeals that could not be processed, with per-item error messages + */ + errors: Array; + + /** + * Successfully processed appeals + */ + results: Array; +} + +export interface BulkAppealError { + appeal_id: string; + + error: string; +} + +export interface BulkAppealResult { + appeal_id: string; + + appeal_item?: AppealItemResponse; +} + +export interface BulkDeleteActionConfigRequest { + /** + * UUIDs of the action configs to delete + */ + ids: Array; +} + +export interface BulkDeleteActionConfigResponse { + /** + * Number of action configs deleted + */ + deleted: number; + + duration: string; +} + +export interface BulkUpsertActionConfigRequest { + /** + * List of action configs to create or update + */ + action_configs: Array; +} + +export interface BulkUpsertActionConfigResponse { + duration: string; + + /** + * The created or updated action configs in the same order as the request + */ + action_configs: Array; +} + +export interface BypassActionRequest { + enabled?: boolean; +} + +export interface CallActionOptions { + duration?: number; + + flag_reason?: string; + + kick_reason?: string; + + mute_audio?: boolean; + + mute_video?: boolean; + + reason?: string; + + warning_text?: string; +} + +export interface CallCustomPropertyParameters { + operator?: string; + + property_key?: string; +} + +export interface CallResponse { + backstage: boolean; + + captioning: boolean; + + cid: string; + + created_at: Date; + + current_session_id: string; + + id: string; + + recording: boolean; + + transcribing: boolean; + + translating: boolean; + + type: string; + + updated_at: Date; + + blocked_user_ids: Array; + + custom: Record; + + channel_cid?: string; + + ended_at?: Date; + + join_ahead_time_seconds?: number; + + routing_number?: string; + + starts_at?: Date; + + team?: string; + + created_by?: UserResponse; +} + +export interface CallRuleActionSequence { + violation_number?: number; + + actions?: Array; + + call_options?: CallActionOptions; +} + +export interface CallTypeRuleParameters { + call_type?: string; +} + +export interface CallViolationCountParameters { + threshold?: number; + + time_window?: string; +} + +export interface CastPollVoteRequest { + vote?: VoteData; +} + +export interface ChannelConfigOverrides { + blocklist?: string; + + blocklist_behavior?: 'flag' | 'block'; + + /** + * Enable/disable message counting + */ + count_messages?: boolean; + + /** + * Overrides max message length + */ + max_message_length?: number; + + /** + * Overrides the push notification level for this channel + */ + + push_level?: 'all' | 'all_mentions' | 'mentions' | 'direct_mentions' | 'none'; + + /** + * Enables message quotes + */ + quotes?: boolean; + + /** + * Enables or disables reactions + */ + reactions?: boolean; + + /** + * Enables message replies (threads) + */ + replies?: boolean; + + /** + * Enable/disable shared locations + */ + shared_locations?: boolean; + + /** + * Enables or disables typing events + */ + typing_events?: boolean; + + /** + * Enables or disables file uploads + */ + uploads?: boolean; + + /** + * Enables or disables URL enrichment + */ + url_enrichment?: boolean; + + /** + * Enable/disable user message reminders + */ + user_message_reminders?: boolean; + + /** + * List of commands that channel supports + */ + commands?: Array; + + chat_preferences?: ChatPreferences; + + grants?: Record>; +} + +export interface ChannelConfigWithInfo { + automod: 'disabled' | 'simple' | 'AI'; + + automod_behavior: 'flag' | 'block' | 'shadow_block'; + + connect_events: boolean; + + count_messages: boolean; + + created_at: Date; + + custom_events: boolean; + + delivery_events: boolean; + + mark_messages_pending: boolean; + + max_message_length: number; + + mutes: boolean; + + name: string; + + polls: boolean; + + push_notifications: boolean; + + quotes: boolean; + + reactions: boolean; + + read_events: boolean; + + reminders: boolean; + + replies: boolean; + + search: boolean; + + shared_locations: boolean; + + skip_last_msg_update_for_system_msgs: boolean; + + typing_events: boolean; + + updated_at: Date; + + uploads: boolean; + + url_enrichment: boolean; + + user_message_reminders: boolean; + + commands: Array; + + blocklist?: string; + + blocklist_behavior?: 'flag' | 'block' | 'shadow_block'; + + partition_size?: number; + + partition_ttl?: string; + + push_level?: 'all' | 'all_mentions' | 'mentions' | 'direct_mentions' | 'none'; + + allowed_flag_reasons?: Array; + + blocklists?: Array; + + automod_thresholds?: Thresholds; + + chat_preferences?: ChatPreferences; + + grants?: Record>; +} + +export interface ChannelCreatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "channel.created" in this case + */ + type: string; + + /** + * The ID of the channel which was created + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was created + */ + channel_type?: string; + + /** + * The CID of the channel which was created + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface ChannelDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "channel.deleted" in this case + */ + type: string; + + /** + * The ID of the channel which was deleted + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was deleted + */ + channel_type?: string; + + /** + * The CID of the channel which was deleted + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface ChannelFrozenEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "channel.frozen" in this case + */ + type: string; + + /** + * The ID of the channel which was frozen + */ + channel_id?: string; + + /** + * The type of the channel which was frozen + */ + channel_type?: string; + + /** + * The CID of the channel which was frozen + */ + cid?: string; + + received_at?: Date; +} + +export interface ChannelGetOrCreateRequest { + /** + * Whether this channel will be hidden for the user who created the channel or not + */ + hide_for_creator?: boolean; + + /** + * Fetch user presence info + */ + presence?: boolean; + + /** + * Refresh channel state + */ + state?: boolean; + + thread_unread_counts?: boolean; + + /** + * Start watching the channel + */ + watch?: boolean; + + data?: ChannelInput; + + members?: PaginationParams; + + messages?: MessagePaginationParams; + + watchers?: PaginationParams; +} + +export interface ChannelHiddenEvent { + /** + * Whether the history was cleared + */ + clear_history: boolean; + + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "channel.hidden" in this case + */ + type: string; + + /** + * The ID of the channel which was hidden + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was hidden + */ + channel_type?: string; + + /** + * The CID of the channel which was hidden + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface ChannelInput { + /** + * Enable or disable auto translation + */ + auto_translation_enabled?: boolean; + + /** + * Switch auto translation language + */ + auto_translation_language?: string; + + created_by_id?: string; + + disabled?: boolean; + + /** + * Freeze or unfreeze the channel + */ + frozen?: boolean; + + /** + * Team the channel belongs to (if multi-tenant mode is enabled) + */ + team?: string; + + truncated_by_id?: string; + + filter_tags?: Array; + + invites?: Array; + + members?: Array; + + config_overrides?: ChannelConfigOverrides; + + created_by?: UserRequest; + + custom?: CustomChannelData; +} + +export interface ChannelInputRequest { + auto_translation_enabled?: boolean; + + auto_translation_language?: string; + + disabled?: boolean; + + frozen?: boolean; + + team?: string; + + invites?: Array; + + members?: Array; + + config_overrides?: ConfigOverridesRequest; + + created_by?: UserRequest; + + custom?: CustomChannelData; +} + +export interface ChannelKickedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "channel.kicked" in this case + */ + type: string; + + /** + * The ID of the channel which was kicked + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was kicked + */ + channel_type?: string; + + /** + * The CID of the channel which was kicked + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; +} + +export interface ChannelMemberRequest { + user_id: string; + + /** + * Role of the member in the channel + */ + channel_role?: string; + + custom?: CustomMemberData; + + user?: UserResponse; +} + +export interface ChannelMemberResponse { + /** + * Whether member is banned this channel or not + */ + banned: boolean; + + /** + * Role of the member in the channel + */ + channel_role: string; + + /** + * Date/time of creation + */ + created_at: Date; + + notifications_muted: boolean; + + /** + * Whether member is shadow banned in this channel or not + */ + shadow_banned: boolean; + + /** + * Date/time of the last update + */ + updated_at: Date; + + custom: CustomMemberData; + + archived_at?: Date; + + /** + * Expiration date of the ban + */ + ban_expires?: Date; + + deleted_at?: Date; + + /** + * Date when invite was accepted + */ + invite_accepted_at?: Date; + + /** + * Date when invite was rejected + */ + invite_rejected_at?: Date; + + /** + * Whether member was invited or not + */ + invited?: boolean; + + /** + * Whether member is channel moderator or not + */ + is_moderator?: boolean; + + pinned_at?: Date; + + /** + * Permission level of the member in the channel (DEPRECATED: use channel_role instead). One of: member, moderator, admin, owner + */ + role?: string; + + status?: string; + + user_id?: string; + + deleted_messages?: Array; + + user?: UserResponse; +} + +export interface ChannelMessageCountRuleParameters { + operator?: string; + + threshold?: number; +} + +export interface ChannelMute { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * Date/time of mute expiration + */ + expires?: Date; + + channel?: ChannelResponse; + + user?: UserResponse; +} + +export const ChannelOwnCapability = { + BAN_CHANNEL_MEMBERS: 'ban-channel-members', + CAST_POLL_VOTE: 'cast-poll-vote', + CONNECT_EVENTS: 'connect-events', + CREATE_ATTACHMENT: 'create-attachment', + CREATE_MENTION: 'create-mention', + DELETE_ANY_MESSAGE: 'delete-any-message', + DELETE_CHANNEL: 'delete-channel', + DELETE_OWN_MESSAGE: 'delete-own-message', + DELIVERY_EVENTS: 'delivery-events', + FLAG_MESSAGE: 'flag-message', + FREEZE_CHANNEL: 'freeze-channel', + JOIN_CHANNEL: 'join-channel', + LEAVE_CHANNEL: 'leave-channel', + MUTE_CHANNEL: 'mute-channel', + NOTIFY_CHANNEL: 'notify-channel', + NOTIFY_GROUP: 'notify-group', + NOTIFY_HERE: 'notify-here', + NOTIFY_ROLE: 'notify-role', + PIN_MESSAGE: 'pin-message', + QUERY_POLL_VOTES: 'query-poll-votes', + QUOTE_MESSAGE: 'quote-message', + READ_EVENTS: 'read-events', + SEARCH_MESSAGES: 'search-messages', + SEND_CUSTOM_EVENTS: 'send-custom-events', + SEND_LINKS: 'send-links', + SEND_MESSAGE: 'send-message', + SEND_POLL: 'send-poll', + SEND_REACTION: 'send-reaction', + SEND_REPLY: 'send-reply', + SEND_RESTRICTED_VISIBILITY_MESSAGE: 'send-restricted-visibility-message', + SEND_TYPING_EVENTS: 'send-typing-events', + SET_CHANNEL_COOLDOWN: 'set-channel-cooldown', + SHARE_LOCATION: 'share-location', + SKIP_SLOW_MODE: 'skip-slow-mode', + SLOW_MODE: 'slow-mode', + TYPING_EVENTS: 'typing-events', + UPDATE_ANY_MESSAGE: 'update-any-message', + UPDATE_CHANNEL: 'update-channel', + UPDATE_CHANNEL_MEMBERS: 'update-channel-members', + UPDATE_OWN_MESSAGE: 'update-own-message', + UPDATE_THREAD: 'update-thread', + UPLOAD_FILE: 'upload-file', +} as const; + +export type ChannelOwnCapability = + (typeof ChannelOwnCapability)[keyof typeof ChannelOwnCapability]; + +export interface ChannelPushPreferencesResponse { + chat_level?: string; + + disabled_until?: Date; + + chat_preferences?: ChatPreferencesResponse; +} + +export interface ChannelResponse { + /** + * Channel CID (:) + */ + cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + disabled: boolean; + + /** + * Whether channel is frozen or not + */ + frozen: boolean; + + /** + * Channel unique ID + */ + id: string; + + /** + * Type of the channel + */ + type: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * Custom data for this object + */ + custom: CustomChannelData; + + /** + * Whether auto translation is enabled or not + */ + auto_translation_enabled?: boolean; + + /** + * Language to translate to when auto translation is active + */ + auto_translation_language?: string; + + /** + * Whether this channel is blocked by current user or not + */ + blocked?: boolean; + + /** + * Cooldown period after sending each message + */ + cooldown?: number; + + /** + * Date/time of deletion + */ + deleted_at?: Date; + + /** + * Whether this channel is hidden by current user or not + */ + hidden?: boolean; + + /** + * Date since when the message history is accessible + */ + hide_messages_before?: Date; + + /** + * Date of the last message sent + */ + last_message_at?: Date; + + /** + * Number of members in the channel + */ + member_count?: number; + + /** + * Number of messages in the channel + */ + message_count?: number; + + /** + * Date of mute expiration + */ + mute_expires_at?: Date; + + /** + * Whether this channel is muted or not + */ + muted?: boolean; + + /** + * Team the channel belongs to (multi-tenant only) + */ + team?: string; + + /** + * Date of the latest truncation of the channel + */ + truncated_at?: Date; + + /** + * List of filter tags associated with the channel + */ + filter_tags?: Array; + + /** + * List of channel members (max 100) + */ + members?: Array; + + /** + * List of channel capabilities of authenticated user + */ + own_capabilities?: Array; + + config?: ChannelConfigWithInfo; + + created_by?: UserResponse; + + truncated_by?: UserResponse; +} + +export interface ChannelStateResponse { + duration: string; + + members: Array; + + messages: Array; + + pinned_messages: Array; + + threads: Array; + + hidden?: boolean; + + hide_messages_before?: Date; + + watcher_count?: number; + + active_live_locations?: Array; + + pending_messages?: Array; + + read?: Array; + + watchers?: Array; + + channel?: ChannelResponse; + + draft?: DraftResponse; + + membership?: ChannelMemberResponse; + + push_preferences?: ChannelPushPreferencesResponse; +} + +export interface ChannelStateResponseFields { + /** + * List of channel members + */ + members: Array; + + /** + * List of channel messages + */ + messages: Array; + + /** + * List of pinned messages in the channel + */ + pinned_messages: Array; + + threads: Array; + + /** + * Whether this channel is hidden or not + */ + hidden?: boolean; + + /** + * Messages before this date are hidden from the user + */ + hide_messages_before?: Date; + + /** + * Number of channel watchers + */ + watcher_count?: number; + + /** + * Active live locations in the channel + */ + active_live_locations?: Array; + + /** + * Pending messages that this user has sent + */ + pending_messages?: Array; + + /** + * List of read states + */ + read?: Array; + + /** + * List of user who is watching the channel + */ + watchers?: Array; + + channel?: ChannelResponse; + + draft?: DraftResponse; + + membership?: ChannelMemberResponse; + + push_preferences?: ChannelPushPreferencesResponse; +} + +export interface ChannelStopWatchingRequest {} + +export interface ChannelTruncatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "channel.truncated" in this case + */ + type: string; + + /** + * The ID of the channel which was truncated + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was truncated + */ + channel_type?: string; + + /** + * The CID of the channel which was truncated + */ + cid?: string; + + message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + message?: MessageResponse; + + user?: UserResponseCommonFields; +} + +export interface ChannelUnFrozenEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "channel.unfrozen" in this case + */ + type: string; + + /** + * The ID of the channel which was unfrozen + */ + channel_id?: string; + + /** + * The type of the channel which was unfrozen + */ + channel_type?: string; + + /** + * The CID of the channel which was unfrozen + */ + cid?: string; + + received_at?: Date; +} + +export interface ChannelUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "channel.updated" in this case + */ + type: string; + + /** + * The ID of the channel which was updated + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was updated + */ + channel_type?: string; + + /** + * The CID of the channel which was updated + */ + cid?: string; + + message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + message?: MessageResponse; + + user?: UserResponseCommonFields; +} + +export interface ChannelVisibleEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "channel.visible" in this case + */ + type: string; + + /** + * The ID of the channel which was shown + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was shown + */ + channel_type?: string; + + /** + * The CID of the channel which was shown + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface ChatDraftPayloadResponse { + id: string; + + text: string; + + custom: CustomMessageData; + + html?: string; + + mml?: string; + + parent_id?: string; + + poll_id?: string; + + quoted_message_id?: string; + + show_in_channel?: boolean; + + silent?: boolean; + + type?: string; + + attachments?: Array; + + mentioned_users?: Array; +} + +export interface ChatDraftResponse { + channel_cid: string; + + created_at: Date; + + message: ChatDraftPayloadResponse; + + parent_id?: string; + + parent_message?: ChatMessageResponse; + + quoted_message?: ChatMessageResponse; +} + +export interface ChatMessageResponse { + cid: string; + + created_at: Date; + + deleted_reply_count: number; + + html: string; + + id: string; + + mentioned_channel: boolean; + + mentioned_here: boolean; + + pinned: boolean; + + reply_count: number; + + shadowed: boolean; + + silent: boolean; + + text: string; + + type: string; + + updated_at: Date; + + attachments: Array; + + latest_reactions: Array; + + mentioned_users: Array; + + own_reactions: Array; + + restricted_visibility: Array; + + custom: CustomMessageData; + + reaction_counts: Record; + + reaction_scores: Record; + + user: UserResponse; + + command?: string; + + deleted_at?: Date; + + deleted_for_me?: boolean; + + message_text_updated_at?: Date; + + mml?: string; + + parent_id?: string; + + pin_expires?: Date; + + pinned_at?: Date; + + poll_id?: string; + + quoted_message_id?: string; + + show_in_channel?: boolean; + + mentioned_group_ids?: Array; + + mentioned_groups?: Array; + + mentioned_roles?: Array; + + thread_participants?: Array; + + draft?: ChatDraftResponse; + + i18n?: Record; + + image_labels?: Record>; + + member?: ChannelMemberResponse; + + moderation?: ChatModerationV2Response; + + pinned_by?: UserResponse; + + poll?: PollResponseData; + + quoted_message?: ChatMessageResponse; + + reaction_groups?: Record; + + reminder?: ChatReminderResponseData; + + shared_location?: ChatSharedLocationResponseData; +} + +export interface ChatModerationV2Response { + action: string; + + original_text: string; + + blocklist_matched?: string; + + platform_circumvented?: boolean; + + semantic_filter_matched?: string; + + blocklists_matched?: Array; + + image_harms?: Array; + + text_harms?: Array; +} + +export interface ChatPreferences { + channel_mentions?: string; + + default_preference?: string; + + direct_mentions?: string; + + distinct_channel_messages?: string; + + group_mentions?: string; + + here_mentions?: string; + + role_mentions?: string; + + thread_replies?: string; +} + +export interface ChatPreferencesInput { + channel_mentions?: 'all' | 'none'; + + default_preference?: 'all' | 'none'; + + direct_mentions?: 'all' | 'none'; + + group_mentions?: 'all' | 'none'; + + here_mentions?: 'all' | 'none'; + + role_mentions?: 'all' | 'none'; + + thread_replies?: 'all' | 'none'; +} + +export interface ChatPreferencesResponse { + channel_mentions?: string; + + default_preference?: string; + + direct_mentions?: string; + + group_mentions?: string; + + here_mentions?: string; + + role_mentions?: string; + + thread_replies?: string; +} + +export interface ChatReactionGroupResponse { + count: number; + + first_reaction_at: Date; + + last_reaction_at: Date; + + sum_scores: number; + + latest_reactions_by: Array; +} + +export interface ChatReactionGroupUserResponse { + created_at: Date; + + user_id: string; + + user?: UserResponse; +} + +export interface ChatReactionResponse { + created_at: Date; + + message_id: string; + + score: number; + + type: string; + + updated_at: Date; + + user_id: string; + + custom: CustomReactionData; + + user: UserResponse; +} + +export interface ChatReminderResponseData { + channel_cid: string; + + created_at: Date; + + message_id: string; + + updated_at: Date; + + user_id: string; + + remind_at?: Date; + + message?: ChatMessageResponse; + + user?: UserResponse; +} + +export interface ChatSharedLocationResponseData { + channel_cid: string; + + created_at: Date; + + created_by_device_id: string; + + latitude: number; + + longitude: number; + + message_id: string; + + updated_at: Date; + + user_id: string; + + end_at?: Date; + + message?: ChatMessageResponse; +} + +export interface ClosedCaptionRuleParameters { + threshold?: number; + + time_window?: string; + + harm_labels?: Array; + + llm_harm_labels?: Record; +} + +export interface Command { + /** + * Arguments help text, shown in commands auto-completion + */ + args: string; + + /** + * Description, shown in commands auto-completion + */ + description: string; + + /** + * Unique command name + */ + name: string; + + /** + * Set name used for grouping commands + */ + set: string; + + /** + * Date/time of creation + */ + created_at?: Date; + + /** + * Date/time of the last update + */ + updated_at?: Date; +} + +export interface ConfigOverridesRequest { + /** + * Blocklist name + */ + blocklist?: string; + + /** + * Blocklist behavior. One of: flag, block + */ + + blocklist_behavior?: 'flag' | 'block'; + + /** + * Enable/disable message counting + */ + count_messages?: boolean; + + /** + * Maximum message length + */ + max_message_length?: number; + + push_level?: 'all' | 'all_mentions' | 'mentions' | 'direct_mentions' | 'none'; + + /** + * Enable/disable quotes + */ + quotes?: boolean; + + /** + * Enable/disable reactions + */ + reactions?: boolean; + + /** + * Enable/disable replies + */ + replies?: boolean; + + /** + * Enable/disable shared locations + */ + shared_locations?: boolean; + + /** + * Enable/disable typing events + */ + typing_events?: boolean; + + /** + * Enable/disable uploads + */ + uploads?: boolean; + + /** + * Enable/disable URL enrichment + */ + url_enrichment?: boolean; + + /** + * Enable/disable user message reminders + */ + user_message_reminders?: boolean; + + /** + * List of available commands + */ + commands?: Array; + + chat_preferences?: ChatPreferences; + + /** + * Permission grants modifiers + */ + grants?: Record>; +} + +export interface ConfigResponse { + /** + * Whether moderation should be performed asynchronously + */ + async: boolean; + + /** + * When the configuration was created + */ + created_at: Date; + + /** + * Unique identifier for the moderation configuration + */ + key: string; + + /** + * Team associated with the configuration + */ + team: string; + + /** + * When the configuration was last updated + */ + updated_at: Date; + + supported_video_call_harm_types: Array; + + /** + * Configurable image moderation label definitions for dashboard rendering + */ + ai_image_label_definitions?: Array; + + /** + * Names of Bodyguard credential profiles registered on this app. The dashboard uses this list to render the profile picker on the AI Text section. + */ + available_bodyguard_profiles?: Array; + + ai_image_config?: AIImageConfig; + + /** + * Available L2 subclassifications per L1 image moderation label, based on the active provider + */ + ai_image_subclassifications?: Record>; + + ai_text_config?: AITextConfig; + + ai_video_config?: AIVideoConfig; + + automod_platform_circumvention_config?: AutomodPlatformCircumventionConfig; + + automod_semantic_filters_config?: AutomodSemanticFiltersConfig; + + automod_toxicity_config?: AutomodToxicityConfig; + + block_list_config?: BlockListConfig; + + flood_config?: FloodConfig; + + llm_config?: LLMConfig; + + velocity_filter_config?: VelocityFilterConfig; + + video_call_rule_config?: VideoCallRuleConfig; +} + +export interface ConnectUserDetailsRequest { + id: string; + + image?: string; + + invisible?: boolean; + + language?: string; + + name?: string; + + custom?: CustomUserData; + + privacy_settings?: PrivacySettingsResponse; +} + +export interface ContentCountRuleParameters { + threshold?: number; + + time_window?: string; +} + +export interface ContentCustomPropertyCountParameters { + operator?: string; + + property_key?: string; + + threshold?: number; + + time_window?: string; +} + +export interface ContentCustomPropertyParameters { + operator?: string; + + property_key?: string; +} + +export interface CreateBlockListRequest { + /** + * Block list name + */ + name: string; + + /** + * List of words to block + */ + words: Array; + + is_confusable_folding_enabled?: boolean; + + is_leet_check_enabled?: boolean; + + is_plural_check_enabled?: boolean; + + is_substring_matching_enabled?: boolean; + + team?: string; + + /** + * Block list type. One of: regex, domain, domain_allowlist, email, email_allowlist, word + */ + + type?: 'regex' | 'domain' | 'domain_allowlist' | 'email' | 'email_allowlist' | 'word'; +} + +export interface CreateBlockListResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + blocklist?: BlockListResponse; +} + +export interface CreateDeviceRequest { + /** + * Device ID + */ + id: string; + + /** + * Push provider + */ + + push_provider: 'firebase' | 'apn' | 'huawei' | 'xiaomi'; + + /** + * Stable physical device identifier used to deduplicate pushes across push providers (e.g. APNs VoIP and Firebase on the same iOS device). Distinct from 'id', which is the push token. + */ + hardware_id?: string; + + /** + * Push provider name + */ + push_provider_name?: string; + + /** + * When true the token is for Apple VoIP push notifications + */ + voip_token?: boolean; +} + +export interface CreateDraftRequest { + message: MessageRequest; +} + +export interface CreateDraftResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + draft: DraftResponse; +} + +export interface CreateGuestRequest { + user: UserRequest; +} + +export interface CreateGuestResponse { + /** + * the access token to authenticate the user + */ + access_token: string; + + /** + * Duration of the request in milliseconds + */ + duration: string; + + user: UserResponse; +} + +export interface CreatePollOptionRequest { + /** + * Option text + */ + text: string; + + custom?: CustomPollOptionData; +} + +export interface CreatePollRequest { + /** + * The name of the poll + */ + name: string; + + /** + * Indicates whether users can suggest user defined answers + */ + allow_answers?: boolean; + + allow_user_suggested_options?: boolean; + + /** + * A description of the poll + */ + description?: string; + + /** + * Indicates whether users can cast multiple votes + */ + enforce_unique_vote?: boolean; + + id?: string; + + /** + * Indicates whether the poll is open for voting + */ + is_closed?: boolean; + + /** + * Indicates the maximum amount of votes a user can cast + */ + max_votes_allowed?: number; + + voting_visibility?: 'anonymous' | 'public'; + + options?: Array; + + custom?: CustomPollData; +} + +export interface CreateQueueRequest { + name: string; + + type: 'personal_view' | 'operational_queue'; + + description?: string; + + sort?: Array>; + + filters?: Record; +} + +export interface CreateReminderRequest { + remind_at?: Date; +} + +export interface CreateUserGroupRequest { + /** + * The user friendly name of the user group + */ + name: string; + + /** + * An optional description for the group + */ + description?: string; + + /** + * Optional user group ID. If not provided, a UUID v7 will be generated + */ + id?: string; + + /** + * Optional team ID to scope the group to a team + */ + team_id?: string; + + /** + * Optional initial list of user IDs to add as members + */ + member_ids?: Array; +} + +export interface CreateUserGroupResponse { + duration: string; + + user_group?: UserGroupResponse; +} + +export interface CustomActionRequestPayload { + /** + * Custom action identifier + */ + id?: string; + + /** + * Custom action options + */ + options?: Record; +} + +export interface CustomEvent { + created_at: Date; + + custom: CustomEventData; + + type: string; + + received_at?: Date; +} + +export interface Data { + id: string; +} + +export interface DeleteActionConfigResponse { + /** + * Number of action configs deleted (0 or 1) + */ + deleted: number; + + duration: string; +} + +export interface DeleteActivityRequestPayload { + /** + * ID of the activity to delete (alternative to item_id) + */ + entity_id?: string; + + /** + * Type of the entity (required for delete_activity to distinguish v2 vs v3) + */ + entity_type?: string; + + /** + * Whether to permanently delete the activity + */ + hard_delete?: boolean; + + /** + * Reason for deletion + */ + reason?: string; +} + +export interface DeleteChannelResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + channel?: ChannelResponse; +} + +export interface DeleteChannelsRequest { + /** + * All channels that should be deleted + */ + cids: Array; + + /** + * Specify if channels and all ressources should be hard deleted + */ + hard_delete?: boolean; +} + +export interface DeleteChannelsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + task_id?: string; + + /** + * Map of channel IDs and their deletion results + */ + result?: Record; +} + +export interface DeleteChannelsResultResponse { + status: string; + + error?: string; +} + +export interface DeleteCommentRequestPayload { + /** + * ID of the comment to delete (alternative to item_id) + */ + entity_id?: string; + + /** + * Type of the entity + */ + entity_type?: string; + + /** + * Whether to permanently delete the comment + */ + hard_delete?: boolean; + + /** + * Reason for deletion + */ + reason?: string; +} + +export interface DeleteMessageRequestPayload { + /** + * ID of the message to delete (alternative to item_id) + */ + entity_id?: string; + + /** + * Type of the entity + */ + entity_type?: string; + + /** + * Whether to permanently delete the message + */ + hard_delete?: boolean; + + /** + * Reason for deletion + */ + reason?: string; +} + +export interface DeleteMessageResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + message: MessageResponse; +} + +export interface DeleteModerationConfigResponse { + duration: string; +} + +export interface DeleteQueueRequest {} + +export interface DeleteReactionRequestPayload { + /** + * ID of the reaction to delete (alternative to item_id) + */ + entity_id?: string; + + /** + * Type of the entity + */ + entity_type?: string; + + /** + * Whether to permanently delete the reaction + */ + hard_delete?: boolean; + + /** + * Reason for deletion + */ + reason?: string; +} + +export interface DeleteReactionResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + message: MessageResponse; + + reaction: ReactionResponse; +} + +export interface DeleteReminderResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; +} + +export interface DeleteUserRequestPayload { + /** + * Also delete all user conversations + */ + delete_conversation_channels?: boolean; + + /** + * Delete flagged feeds content + */ + delete_feeds_content?: boolean; + + /** + * ID of the user to delete (alternative to item_id) + */ + entity_id?: string; + + /** + * Type of the entity + */ + entity_type?: string; + + /** + * Whether to permanently delete the user + */ + hard_delete?: boolean; + + /** + * Also delete all user messages + */ + mark_messages_deleted?: boolean; + + /** + * Reason for deletion + */ + reason?: string; +} + +export interface DeliveredMessagePayload { + cid?: string; + + id?: string; +} + +export interface DeliveryReceiptsResponse { + enabled: boolean; +} + +export interface DeviceResponse { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Device ID + */ + id: string; + + /** + * Push provider + */ + push_provider: string; + + /** + * User ID + */ + user_id: string; + + /** + * Whether device is disabled or not + */ + disabled?: boolean; + + /** + * Reason explaining why device had been disabled + */ + disabled_reason?: string; + + /** + * Stable physical device identifier used to deduplicate pushes across push providers + */ + hardware_id?: string; + + /** + * Push provider name + */ + push_provider_name?: string; + + /** + * When true the token is for Apple VoIP push notifications + */ + voip?: boolean; +} + +export interface DraftDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "draft.deleted" in this case + */ + type: string; + + /** + * The CID of the channel where the draft was created + */ + cid?: string; + + /** + * The ID of the parent message + */ + parent_id?: string; + + received_at?: Date; + + draft?: DraftResponse; +} + +export interface DraftPayloadResponse { + /** + * Message ID is unique string identifier of the message + */ + id: string; + + /** + * Text of the message + */ + text: string; + + custom: CustomMessageData; + + /** + * Contains HTML markup of the message + */ + html?: string; + + /** + * MML content of the message + */ + mml?: string; + + /** + * ID of parent message (thread) + */ + parent_id?: string; + + /** + * Identifier of the poll to include in the message + */ + poll_id?: string; + + quoted_message_id?: string; + + /** + * Whether thread reply should be shown in the channel as well + */ + show_in_channel?: boolean; + + /** + * Whether message is silent or not + */ + silent?: boolean; + + /** + * Contains type of the message. One of: regular, system + */ + type?: string; + + /** + * Array of message attachments + */ + attachments?: Array; + + /** + * List of mentioned users + */ + mentioned_users?: Array; +} + +export interface DraftResponse { + channel_cid: string; + + created_at: Date; + + message: DraftPayloadResponse; + + parent_id?: string; + + channel?: ChannelResponse; + + parent_message?: MessageResponse; + + quoted_message?: MessageResponse; +} + +export interface DraftUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "draft.updated" in this case + */ + type: string; + + /** + * The CID of the channel where the draft was created/updated + */ + cid?: string; + + /** + * The ID of the parent message + */ + parent_id?: string; + + received_at?: Date; + + draft?: DraftResponse; +} + +export interface EnrichedActivity { + foreign_id?: string; + + id?: string; + + score?: number; + + verb?: string; + + to?: Array; + + actor?: Data; + + latest_reactions?: Record>; + + object?: Data; + + origin?: Data; + + own_reactions?: Record>; + + reaction_counts?: Record; + + target?: Data; +} + +export interface EnrichedReaction { + activity_id: string; + + kind: string; + + user_id: string; + + id?: string; + + parent?: string; + + target_feeds?: Array; + + children_counts?: Record; + + created_at?: Time; + + data?: Record; + + latest_children?: Record>; + + own_children?: Record>; + + updated_at?: Time; + + user?: Data; +} + +export interface EntityCreatorResponse { + /** + * Number of minor actions performed on the user + */ + ban_count: number; + + banned: boolean; + + created_at: Date; + + /** + * Number of major actions performed on the user + */ + deleted_content_count: number; + + /** + * Number of flag actions performed on the user + */ + flagged_count: number; + + id: string; + + language: string; + + online: boolean; + + role: string; + + updated_at: Date; + + blocked_user_ids: Array; + + teams: Array; + + custom: CustomUserData; + + avg_response_time?: number; + + deactivated_at?: Date; + + deleted_at?: Date; + + image?: string; + + last_active?: Date; + + name?: string; + + revoke_tokens_issued_before?: Date; + + teams_role?: Record; +} + +export interface EscalatePayload { + /** + * Additional context for the reviewer + */ + notes?: string; + + /** + * Priority of the escalation (low, medium, high) + */ + priority?: string; + + /** + * Reason for the escalation (from configured escalation_reasons) + */ + reason?: string; +} + +export interface EscalationMetadata { + notes?: string; + + priority?: string; + + reason?: string; +} + +export interface EventRequest { + type: string; + + parent_id?: string; + + custom?: CustomEventData; +} + +export interface EventResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + event: WSEvent; +} + +export interface FeedsActivityLocation { + lat: number; + + lng: number; +} + +export interface FeedsBookmarkResponse { + created_at: Date; + + object_id: string; + + object_type: string; + + updated_at: Date; + + user: UserResponse; + + activity_id?: string; + + custom?: Record; +} + +export interface FeedsEnrichedCollectionResponse { + created_at: Date; + + id: string; + + name: string; + + status: string; + + updated_at: Date; + + user_id: string; + + custom: Record; +} + +export interface FeedsFeedResponse { + activity_count: number; + + created_at: Date; + + description: string; + + feed: string; + + follower_count: number; + + following_count: number; + + group_id: string; + + id: string; + + member_count: number; + + name: string; + + pin_count: number; + + updated_at: Date; + + created_by: UserResponse; + + deleted_at?: Date; + + visibility?: string; + + filter_tags?: Array; + + custom?: Record; + + location?: FeedsActivityLocation; +} + +export interface FeedsNotificationComment { + comment: string; + + id: string; + + user_id: string; + + attachments?: Array; +} + +export interface FeedsNotificationContext { + target?: FeedsNotificationTarget; + + trigger?: FeedsNotificationTrigger; +} + +export interface FeedsNotificationParentActivity { + id: string; + + text?: string; + + type?: string; + + user_id?: string; + + attachments?: Array; +} + +export interface FeedsNotificationTarget { + id: string; + + name?: string; + + text?: string; + + type?: string; + + user_id?: string; + + attachments?: Array; + + comment?: FeedsNotificationComment; + + custom?: Record; + + parent_activity?: FeedsNotificationParentActivity; +} + +export interface FeedsNotificationTrigger { + text: string; + + type: string; + + comment?: FeedsNotificationComment; + + custom?: Record; +} + +export interface FeedsPreferences { + /** + * Push notification preference for comments on user's activities. One of: all, none + */ + + comment?: 'all' | 'none'; + + /** + * Push notification preference for mentions in comments. One of: all, none + */ + + comment_mention?: 'all' | 'none'; + + /** + * Push notification preference for reactions on comments. One of: all, none + */ + + comment_reaction?: 'all' | 'none'; + + /** + * Push notification preference for replies to comments. One of: all, none + */ + + comment_reply?: 'all' | 'none'; + + /** + * Push notification preference for new followers. One of: all, none + */ + + follow?: 'all' | 'none'; + + /** + * Push notification preference for mentions in activities. One of: all, none + */ + + mention?: 'all' | 'none'; + + /** + * Push notification preference for reactions on user's activities or comments. One of: all, none + */ + + reaction?: 'all' | 'none'; + + /** + * Push notification preferences for custom activity types. Map of activity type to preference (all or none) + */ + custom_activity_types?: Record; +} + +export interface FeedsPreferencesResponse { + comment?: string; + + comment_mention?: string; + + comment_reaction?: string; + + comment_reply?: string; + + follow?: string; + + mention?: string; + + reaction?: string; + + custom_activity_types?: Record; +} + +export interface FeedsReactionGroupResponse { + count: number; + + first_reaction_at: Date; + + last_reaction_at: Date; +} + +export interface FeedsReactionResponse { + activity_id: string; + + created_at: Date; + + type: string; + + updated_at: Date; + + user: UserResponse; + + comment_id?: string; + + custom?: Record; +} + +export interface FeedsV3ActivityResponse { + bookmark_count: number; + + comment_count: number; + + created_at: Date; + + hidden: boolean; + + id: string; + + popularity: number; + + preview: boolean; + + reaction_count: number; + + restrict_replies: string; + + score: number; + + share_count: number; + + type: string; + + updated_at: Date; + + visibility: string; + + attachments: Array; + + comments: Array; + + feeds: Array; + + filter_tags: Array; + + interest_tags: Array; + + latest_reactions: Array; + + mentioned_users: Array; + + own_bookmarks: Array; + + own_reactions: Array; + + collections: Record; + + custom: Record; + + reaction_groups: Record; + + search_data: Record; + + user: UserResponse; + + deleted_at?: Date; + + edited_at?: Date; + + expires_at?: Date; + + friend_reaction_count?: number; + + is_read?: boolean; + + is_seen?: boolean; + + is_watched?: boolean; + + moderation_action?: string; + + selector_source?: string; + + text?: string; + + visibility_tag?: string; + + friend_reactions?: Array; + + current_feed?: FeedsFeedResponse; + + location?: FeedsActivityLocation; + + metrics?: Record; + + moderation?: ModerationV2Response; + + notification_context?: FeedsNotificationContext; + + parent?: FeedsV3ActivityResponse; + + poll?: PollResponseData; + + score_vars?: Record; +} + +export interface FeedsV3CommentResponse { + bookmark_count: number; + + confidence_score: number; + + created_at: Date; + + downvote_count: number; + + id: string; + + object_id: string; + + object_type: string; + + reaction_count: number; + + reply_count: number; + + score: number; + + status: string; + + updated_at: Date; + + upvote_count: number; + + mentioned_users: Array; + + own_reactions: Array; + + user: UserResponse; + + controversy_score?: number; + + deleted_at?: Date; + + edited_at?: Date; + + parent_id?: string; + + text?: string; + + attachments?: Array; + + latest_reactions?: Array; + + custom?: Record; + + moderation?: ModerationV2Response; + + reaction_groups?: Record; +} + +export interface Field { + short: boolean; + + title: string; + + value: string; +} + +export interface FileUploadConfig { + size_limit: number; + + allowed_file_extensions: Array; + + allowed_mime_types: Array; + + blocked_file_extensions: Array; + + blocked_mime_types: Array; +} + +export interface FileUploadRequest { + /** + * file field + */ + file?: string; + + user?: OnlyUserID; +} + +export interface FileUploadResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * URL to the uploaded asset. Should be used to put to `asset_url` attachment field + */ + file?: string; + + /** + * URL of the file thumbnail for supported file formats. Should be put to `thumb_url` attachment field + */ + thumb_url?: string; +} + +export interface FilterConfigResponse { + /** + * LLM moderation labels available as filter values + */ + llm_labels: Array; + + /** + * AI text moderation labels available as filter values + */ + ai_text_labels?: Array; + + /** + * Moderation config keys present in the queue, available as filter values + */ + config_keys?: Array; + + /** + * The moderation_payload.custom keys the app has configured as review-queue filter chips (via moderation_dashboard_preferences.filterable_custom_keys). Discovery hint for the dashboard only — the filter accepts any custom key regardless of this list. + */ + filterable_custom_keys?: Array; +} + +export interface FlagCountRuleParameters { + threshold?: number; +} + +export interface FlagDetailsResponse { + original_text: string; + + automod?: AutomodDetailsResponse; + + extra?: Record; +} + +export interface FlagFeedbackResponse { + created_at: Date; + + message_id: string; + + labels: Array; +} + +export interface FlagItemResponse { + duration: string; + + /** + * Unique identifier of the created moderation item + */ + item_id: string; +} + +export interface FlagMessageDetailsResponse { + pin_changed?: boolean; + + should_enrich?: boolean; + + skip_push?: boolean; + + updated_by_id?: string; +} + +export interface FlagRequest { + /** + * Unique identifier of the entity being flagged + */ + entity_id: string; + + /** + * Type of entity being flagged (e.g., message, user) + */ + entity_type: string; + + /** + * ID of the user who created the flagged entity + */ + entity_creator_id?: string; + + /** + * Optional explanation for why the content is being flagged + */ + reason?: string; + + /** + * Additional metadata about the flag + */ + custom?: Record; + + moderation_payload?: ModerationPayload; +} + +export interface FlagUserOptions { + reason?: string; +} + +export interface FloodConfig { + identical?: FloodIdenticalConfig; + + similar?: FloodSimilarConfig; +} + +export interface FloodIdenticalConfig { + action: string; + + enabled: boolean; + + threshold: number; + + time_window: string; +} + +export interface FloodSimilarConfig { + action: string; + + enabled: boolean; + + similarity_distance: number; + + threshold: number; + + time_window: string; +} + +export interface FullUserResponse { + banned: boolean; + + created_at: Date; + + id: string; + + invisible: boolean; + + language: string; + + online: boolean; + + role: string; + + shadow_banned: boolean; + + total_unread_count: number; + + unread_channels: number; + + unread_count: number; + + unread_threads: number; + + updated_at: Date; + + blocked_user_ids: Array; + + channel_mutes: Array; + + devices: Array; + + mutes: Array; + + teams: Array; + + custom: CustomUserData; + + avg_response_time?: number; + + ban_expires?: Date; + + deactivated_at?: Date; + + deleted_at?: Date; + + image?: string; + + last_active?: Date; + + name?: string; + + revoke_tokens_issued_before?: Date; + + latest_hidden_channels?: Array; + + privacy_settings?: PrivacySettingsResponse; + + teams_role?: Record; +} + +export interface FutureChannelBanResponse { + created_at: Date; + + expires?: Date; + + reason?: string; + + shadow?: boolean; + + banned_by?: UserResponse; + + user?: UserResponse; +} + +export interface GetActionConfigResponse { + duration: string; + + /** + * Moderation action configs grouped by entity type, sorted by order ascending + */ + action_config: Record>; +} + +export interface GetAppealResponse { + duration: string; + + item?: AppealItemResponse; +} + +export interface GetApplicationResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + app: AppResponseFields; +} + +export interface GetBlockedUsersResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * Array of blocked user object + */ + blocks: Array; +} + +export interface GetConfigResponse { + duration: string; + + config?: ConfigResponse; +} + +export interface GetDraftResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + draft: DraftResponse; +} + +export interface GetManyMessagesResponse { + duration: string; + + /** + * List of messages + */ + messages: Array; +} + +export interface GetMessageResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + message: MessageWithChannelResponse; + + pending_message_metadata?: Record; +} + +export interface GetOGResponse { + duration: string; + + custom: CustomAttachmentData; + + /** + * URL of detected video or audio + */ + asset_url?: string; + + author_icon?: string; + + /** + * og:site + */ + author_link?: string; + + /** + * og:site_name + */ + author_name?: string; + + color?: string; + + fallback?: string; + + footer?: string; + + footer_icon?: string; + + /** + * URL of detected image + */ + image_url?: string; + + /** + * extracted url from the text + */ + og_scrape_url?: string; + + original_height?: number; + + original_width?: number; + + pretext?: string; + + /** + * og:description + */ + text?: string; + + /** + * URL of detected thumb image + */ + thumb_url?: string; + + /** + * og:title + */ + title?: string; + + /** + * og:url + */ + title_link?: string; + + /** + * Attachment type, could be empty, image, audio or video + */ + type?: string; + + actions?: Array; + + fields?: Array; + + giphy?: Images; +} + +export interface GetReactionsResponse { + duration: string; + + /** + * List of reactions + */ + reactions: Array; +} + +export interface GetRepliesResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + messages: Array; +} + +export interface GetThreadResponse { + duration: string; + + thread: ThreadStateResponse; +} + +export interface GetUserGroupResponse { + duration: string; + + user_group?: UserGroupResponse; +} + +export interface GoogleVisionConfig { + enabled?: boolean; +} + +export interface GroupedChannelsBucket { + /** + * Channels returned for this bucket + */ + channels: Array; + + /** + * Cursor for the next page of this group + */ + next?: string; + + /** + * Cursor for the previous page of this group + */ + prev?: string; + + /** + * Unread channels currently classified into this bucket + */ + unread_channels?: number; +} + +export interface GroupedChannelsGroupRequest { + limit?: number; + + next?: string; + + prev?: string; +} + +export interface GroupedQueryChannelsRequest { + /** + * Default max channels per group (default 10) + */ + limit?: number; + + /** + * Whether to subscribe to presence events for channel members + */ + presence?: boolean; + + /** + * Whether to start watching found channels or not + */ + watch?: boolean; + + /** + * Groups to return, keyed by group name. Each group can define limit, next, or prev. 'next' and 'prev' cursors are only allowed when the request contains exactly one group; multi-group pagination is rejected. + */ + groups?: Record; +} + +export interface GroupedQueryChannelsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * Predefined channel groups keyed by group name + */ + groups: Record; +} + +export interface HarmConfig { + cooldown_period: number; + + severity: number; + + threshold: number; + + action_sequences: Array; + + harm_types: Array; +} + +export interface HealthCheckEvent { + connection_id: string; + + created_at: Date; + + custom: CustomEventData; + + type: string; + + cid?: string; + + received_at?: Date; + + me?: OwnUserResponse; +} + +export interface HideChannelRequest { + /** + * Whether to clear message history of the channel or not + */ + clear_history?: boolean; +} + +export interface HideChannelResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; +} + +export interface ImageContentParameters { + label_operator?: string; + + min_confidence?: number; + + harm_labels?: Array; +} + +export interface ImageData { + frames: string; + + height: string; + + size: string; + + url: string; + + width: string; +} + +export interface ImageRuleParameters { + min_confidence?: number; + + threshold?: number; + + time_window?: string; + + harm_labels?: Array; +} + +export interface ImageSize { + /** + * Crop mode. One of: top, bottom, left, right, center + */ + crop?: string; + + /** + * Target image height + */ + height?: number; + + /** + * Resize method. One of: clip, crop, scale, fill + */ + resize?: string; + + /** + * Target image width + */ + width?: number; +} + +export interface ImageUploadRequest { + file?: string; + + /** + * field with JSON-encoded array of image size configurations + */ + upload_sizes?: Array; + + user?: OnlyUserID; +} + +export interface ImageUploadResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + file?: string; + + thumb_url?: string; + + /** + * Array of image size configurations + */ + upload_sizes?: Array; +} + +export interface Images { + fixed_height: ImageData; + + fixed_height_downsampled: ImageData; + + fixed_height_still: ImageData; + + fixed_width: ImageData; + + fixed_width_downsampled: ImageData; + + fixed_width_still: ImageData; + + original: ImageData; +} + +export interface KeyframeOCRRuleParameters { + threshold?: number; + + time_window?: string; + + harm_labels?: Array; +} + +export interface KeyframeRuleParameters { + min_confidence?: number; + + threshold?: number; + + time_window?: string; + + harm_labels?: Array; +} + +export interface LLMConfig { + enabled: boolean; + + rules: Array; + + app_context?: string; + + async?: boolean; + + severity_descriptions?: Record; +} + +export interface LLMRule { + action: + | 'flag' + | 'shadow' + | 'remove' + | 'bounce' + | 'bounce_flag' + | 'bounce_remove' + | 'keep'; + + description: string; + + label: string; + + severity_rules: Array; +} + +export interface LabelResponse { + name: string; + + harm_labels?: Array; + + phrase_list_ids?: Array; +} + +export interface LabelThresholds { + /** + * Threshold for automatic message block + */ + block?: number; + + /** + * Threshold for automatic message flag + */ + flag?: number; +} + +export interface ListBlockListResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + blocklists: Array; +} + +export interface ListDevicesResponse { + duration: string; + + /** + * List of devices + */ + devices: Array; +} + +export interface ListQueuesResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + queues: Array; +} + +export interface ListUserGroupsResponse { + duration: string; + + /** + * List of user groups + */ + user_groups: Array; +} + +export interface MarkChannelsReadRequest { + /** + * Map of channel ID to last read message ID + */ + read_by_channel?: Record; +} + +export interface MarkDeliveredRequest { + latest_delivered_messages?: Array; +} + +export interface MarkDeliveredResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; +} + +export interface MarkReadRequest { + /** + * ID of the message that is considered last read by client + */ + message_id?: string; + + /** + * Optional Thread ID to specifically mark a given thread as read + */ + thread_id?: string; +} + +export interface MarkReadResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + event?: MarkReadResponseEvent; +} + +export interface MarkReadResponseEvent { + channel_id: string; + + channel_type: string; + + cid: string; + + created_at: Date; + + type: string; + + channel_last_message_at?: Date; + + last_read_message_id?: string; + + team?: string; + + channel?: ChannelResponse; + + thread?: ThreadResponse; + + user?: UserResponseCommonFields; +} + +export interface MarkReviewedRequestPayload { + /** + * Maximum content items to mark as reviewed + */ + content_to_mark_as_reviewed_limit?: number; + + /** + * Reason for the appeal decision + */ + decision_reason?: string; + + /** + * Skip marking content as reviewed + */ + disable_marking_content_as_reviewed?: boolean; +} + +export interface MarkUnreadRequest { + /** + * ID of the message from where the channel is marked unread + */ + message_id?: string; + + /** + * Timestamp of the message from where the channel is marked unread + */ + message_timestamp?: Date; + + /** + * Mark a thread unread, specify one of the thread, message timestamp, or message id + */ + thread_id?: string; +} + +export interface MaxStreakChangedEvent { + created_at: Date; + + custom: CustomEventData; + + type: string; + + received_at?: Date; +} + +export interface MemberAddedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + member: ChannelMemberResponse; + + /** + * The type of event: "member.added" in this case + */ + type: string; + + /** + * The ID of the channel to which the member was added + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel to which the member was added + */ + channel_type?: string; + + /** + * The CID of the channel to which the member was added + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface MemberRemovedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + member: ChannelMemberResponse; + + /** + * The type of event: "member.removed" in this case + */ + type: string; + + /** + * The ID of the channel from which the member was removed + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel from which the member was removed + */ + channel_type?: string; + + /** + * The CID of the channel from which the member was removed + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface MemberUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + member: ChannelMemberResponse; + + /** + * The type of event: "member.updated" in this case + */ + type: string; + + /** + * The ID of the channel in which the member was updated + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel in which the member was updated + */ + channel_type?: string; + + /** + * The CID of the channel in which the member was updated + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface MembersResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * List of found members + */ + members: Array; +} + +export interface MessageActionRequest { + /** + * ReadOnlyData to execute command with + */ + form_data: Record; +} + +export interface MessageActionResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + message?: MessageResponse; +} + +export interface MessageChangeSet { + attachments: boolean; + + custom: boolean; + + html: boolean; + + mentioned_user_ids: boolean; + + mml: boolean; + + pin: boolean; + + quoted_message_id: boolean; + + silent: boolean; + + text: boolean; +} + +export interface MessageDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Whether the message was hard deleted + */ + hard_delete: boolean; + + message_id: string; + + custom: CustomEventData; + + message: MessageResponse; + + /** + * The type of event: "message.deleted" in this case + */ + type: string; + + /** + * The ID of the channel where the message was sent + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel where the message was sent + */ + channel_type?: string; + + /** + * The CID of the channel where the message was sent + */ + cid?: string; + + /** + * Whether the message was deleted only for the current user + */ + deleted_for_me?: boolean; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface MessageDeliveredEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "message.delivered" in this case + */ + type: string; + + /** + * The ID of the channel where the message was read + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel where the message was read + */ + channel_type?: string; + + /** + * The CID of the channel where the message was read + */ + cid?: string; + + /** + * The time when the message was delivered + */ + last_delivered_at?: string; + + /** + * The ID of the last delivered message + */ + last_delivered_message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel?: ChannelResponse; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface MessageFlagResponse { + created_at: Date; + + created_by_automod: boolean; + + updated_at: Date; + + approved_at?: Date; + + reason?: string; + + rejected_at?: Date; + + reviewed_at?: Date; + + custom?: Record; + + details?: FlagDetailsResponse; + + message?: MessageResponse; + + moderation_feedback?: FlagFeedbackResponse; + + moderation_result?: MessageModerationResult; + + reviewed_by?: UserResponse; + + user?: UserResponse; +} + +export interface MessageModerationResult { + /** + * Action taken by automod + */ + action: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * ID of the message + */ + message_id: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * Whether user has bad karma + */ + user_bad_karma: boolean; + + /** + * Karma of the user + */ + user_karma: number; + + /** + * Word that was blocked + */ + blocked_word?: string; + + /** + * Name of the blocklist + */ + blocklist_name?: string; + + /** + * User who moderated the message + */ + moderated_by?: string; + + ai_moderation_response?: ModerationResponse; + + moderation_thresholds?: Thresholds; +} + +export interface MessageNewEvent { + /** + * Date/time of creation + */ + created_at: Date; + + message_id: string; + + /** + * The number of watchers + */ + watcher_count: number; + + custom: CustomEventData; + + message: MessageResponse; + + /** + * The type of event: "message.new" in this case + */ + type: string; + + /** + * The ID of the channel where the message was sent + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel where the message was sent + */ + channel_type?: string; + + /** + * The CID of the channel where the message was sent + */ + cid?: string; + + /** + * The author of the parent message + */ + parent_author?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + total_unread_count?: number; + + unread_channels?: number; + + /** + * The number of unread messages + */ + unread_count?: number; + + /** + * The participants of the thread + */ + thread_participants?: Array; + + channel?: ChannelResponse; + + channel_custom?: CustomChannelData; + + grouped_unread_channels?: Record; + + user?: UserResponseCommonFields; +} + +export interface MessageOptions { + include_thread_participants?: boolean; +} + +export interface MessagePaginationParams { + /** + * The timestamp to get messages with a created_at timestamp greater than + */ + created_at_after?: Date; + + /** + * The timestamp to get messages with a created_at timestamp greater than or equal to + */ + created_at_after_or_equal?: Date; + + /** + * The result will be a set of messages, that are both older and newer than the created_at timestamp provided, distributed evenly around the timestamp + */ + created_at_around?: Date; + + /** + * The timestamp to get messages with a created_at timestamp smaller than + */ + created_at_before?: Date; + + /** + * The timestamp to get messages with a created_at timestamp smaller than or equal to + */ + created_at_before_or_equal?: Date; + + /** + * The result will be a set of messages, that are both older and newer than the message with the provided ID, and the message with the ID provided will be in the middle of the set + */ + id_around?: string; + + /** + * The ID of the message to get messages with a timestamp greater than + */ + id_gt?: string; + + /** + * The ID of the message to get messages with a timestamp greater than or equal to + */ + id_gte?: string; + + /** + * The ID of the message to get messages with a timestamp smaller than + */ + id_lt?: string; + + /** + * The ID of the message to get messages with a timestamp smaller than or equal to + */ + id_lte?: string; + + /** + * The maximum number of messages to return (max limit + */ + limit?: number; +} + +export interface MessageReadEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "message.read" in this case + */ + type: string; + + /** + * The ID of the channel where the message was read + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel where the message was read + */ + channel_type?: string; + + /** + * The CID of the channel where the message was read + */ + cid?: string; + + /** + * The ID of the last read message + */ + last_read_message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel?: ChannelResponse; + + channel_custom?: CustomChannelData; + + thread?: ThreadResponse; + + user?: UserResponseCommonFields; +} + +export interface MessageRequest { + /** + * Message ID is unique string identifier of the message + */ + id?: string; + + mentioned_channel?: boolean; + + mentioned_here?: boolean; + + /** + * Should be empty if `text` is provided. Can only be set when using server-side API + */ + mml?: string; + + /** + * ID of parent message (thread) + */ + parent_id?: string; + + /** + * Date when pinned message expires + */ + pin_expires?: Date; + + /** + * Whether message is pinned or not + */ + pinned?: boolean; + + /** + * Date when message got pinned + */ + pinned_at?: Date; + + /** + * Identifier of the poll to include in the message + */ + poll_id?: string; + + quoted_message_id?: string; + + /** + * Whether thread reply should be shown in the channel as well + */ + show_in_channel?: boolean; + + /** + * Whether message is silent or not + */ + silent?: boolean; + + /** + * Text of the message. Should be empty if `mml` is provided + */ + text?: string; + + /** + * Contains type of the message. One of: regular, system + */ + + type?: "''" | 'regular' | 'system'; + + /** + * Array of message attachments + */ + attachments?: Array; + + /** + * List of user group IDs to mention. Group members who are also channel members will receive push notifications. Max 10 groups + */ + mentioned_group_ids?: Array; + + mentioned_roles?: Array; + + /** + * Array of user IDs to mention + */ + mentioned_users?: Array; + + /** + * A list of user ids that have restricted visibility to the message + */ + restricted_visibility?: Array; + + custom?: CustomMessageData; + + shared_location?: SharedLocation; +} + +export interface MessageResponse { + /** + * Channel unique identifier in : format + */ + cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + deleted_reply_count: number; + + /** + * Contains HTML markup of the message. Can only be set when using server-side API + */ + html: string; + + /** + * Message ID is unique string identifier of the message + */ + id: string; + + /** + * Whether the message mentioned the channel tag + */ + mentioned_channel: boolean; + + /** + * Whether the message mentioned online users with @here tag + */ + mentioned_here: boolean; + + /** + * Whether message is pinned or not + */ + pinned: boolean; + + /** + * Number of replies to this message + */ + reply_count: number; + + /** + * Whether the message was shadowed or not + */ + shadowed: boolean; + + /** + * Whether message is silent or not + */ + silent: boolean; + + /** + * Text of the message. Should be empty if `mml` is provided + */ + text: string; + + /** + * Contains type of the message. One of: regular, ephemeral, error, reply, system, deleted + */ + type: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * Array of message attachments + */ + attachments: Array; + + /** + * List of 10 latest reactions to this message + */ + latest_reactions: Array; + + /** + * List of mentioned users + */ + mentioned_users: Array; + + /** + * List of 10 latest reactions of authenticated user to this message + */ + own_reactions: Array; + + /** + * A list of user ids that have restricted visibility to the message, if the list is not empty, the message is only visible to the users in the list + */ + restricted_visibility: Array; + + custom: CustomMessageData; + + /** + * An object containing number of reactions of each type. Key: reaction type (string), value: number of reactions (int) + */ + reaction_counts: Record; + + /** + * An object containing scores of reactions of each type. Key: reaction type (string), value: total score of reactions (int) + */ + reaction_scores: Record; + + user: UserResponse; + + /** + * Contains provided slash command + */ + command?: string; + + /** + * Date/time of deletion + */ + deleted_at?: Date; + + deleted_for_me?: boolean; + + message_text_updated_at?: Date; + + /** + * Should be empty if `text` is provided. Can only be set when using server-side API + */ + mml?: string; + + /** + * ID of parent message (thread) + */ + parent_id?: string; + + /** + * Date when pinned message expires + */ + pin_expires?: Date; + + /** + * Date when message got pinned + */ + pinned_at?: Date; + + /** + * Identifier of the poll to include in the message + */ + poll_id?: string; + + quoted_message_id?: string; + + /** + * Whether thread reply should be shown in the channel as well + */ + show_in_channel?: boolean; + + /** + * List of user group IDs mentioned in the message. Group members who are also channel members will receive push notifications based on their push preferences. Max 10 groups + */ + mentioned_group_ids?: Array; + + /** + * List of mentioned user group objects. + */ + mentioned_groups?: Array; + + /** + * List of roles mentioned in the message (e.g. admin, channel_moderator, custom roles). Members with matching roles will receive push notifications based on their push preferences. Max 10 roles + */ + mentioned_roles?: Array; + + /** + * List of users who participate in thread + */ + thread_participants?: Array; + + draft?: DraftResponse; + + /** + * Object with translations. Key `language` contains the original language key. Other keys contain translations + */ + i18n?: Record; + + /** + * Contains image moderation information + */ + image_labels?: Record>; + + member?: ChannelMemberResponse; + + moderation?: ModerationV2Response; + + pinned_by?: UserResponse; + + poll?: PollResponseData; + + quoted_message?: MessageResponse; + + reaction_groups?: Record; + + reminder?: ReminderResponseData; + + shared_location?: SharedLocationResponseData; +} + +export interface MessageUndeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + message_id: string; + + custom: CustomEventData; + + message: MessageResponse; + + /** + * The type of event: "message.undeleted" in this case + */ + type: string; + + /** + * The ID of the channel where the message was sent + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel where the message was sent + */ + channel_type?: string; + + /** + * The CID of the channel where the message was sent + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; +} + +export interface MessageUpdate { + old_text?: string; + + change_set?: MessageChangeSet; +} + +export interface MessageUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + message_id: string; + + custom: CustomEventData; + + message: MessageResponse; + + /** + * The type of event: "message.updated" in this case + */ + type: string; + + /** + * The ID of the channel where the message was sent + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel where the message was sent + */ + channel_type?: string; + + /** + * The CID of the channel where the message was sent + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + message_update?: MessageUpdate; + + user?: UserResponseCommonFields; +} + +export interface MessageWithChannelResponse { + /** + * Channel unique identifier in : format + */ + cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + deleted_reply_count: number; + + /** + * Contains HTML markup of the message. Can only be set when using server-side API + */ + html: string; + + /** + * Message ID is unique string identifier of the message + */ + id: string; + + /** + * Whether the message mentioned the channel tag + */ + mentioned_channel: boolean; + + /** + * Whether the message mentioned online users with @here tag + */ + mentioned_here: boolean; + + /** + * Whether message is pinned or not + */ + pinned: boolean; + + /** + * Number of replies to this message + */ + reply_count: number; + + /** + * Whether the message was shadowed or not + */ + shadowed: boolean; + + /** + * Whether message is silent or not + */ + silent: boolean; + + /** + * Text of the message. Should be empty if `mml` is provided + */ + text: string; + + /** + * Contains type of the message. One of: regular, ephemeral, error, reply, system, deleted + */ + type: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * Array of message attachments + */ + attachments: Array; + + /** + * List of 10 latest reactions to this message + */ + latest_reactions: Array; + + /** + * List of mentioned users + */ + mentioned_users: Array; + + /** + * List of 10 latest reactions of authenticated user to this message + */ + own_reactions: Array; + + /** + * A list of user ids that have restricted visibility to the message, if the list is not empty, the message is only visible to the users in the list + */ + restricted_visibility: Array; + + channel: ChannelResponse; + + custom: CustomMessageData; + + /** + * An object containing number of reactions of each type. Key: reaction type (string), value: number of reactions (int) + */ + reaction_counts: Record; + + /** + * An object containing scores of reactions of each type. Key: reaction type (string), value: total score of reactions (int) + */ + reaction_scores: Record; + + user: UserResponse; + + /** + * Contains provided slash command + */ + command?: string; + + /** + * Date/time of deletion + */ + deleted_at?: Date; + + deleted_for_me?: boolean; + + message_text_updated_at?: Date; + + /** + * Should be empty if `text` is provided. Can only be set when using server-side API + */ + mml?: string; + + /** + * ID of parent message (thread) + */ + parent_id?: string; + + /** + * Date when pinned message expires + */ + pin_expires?: Date; + + /** + * Date when message got pinned + */ + pinned_at?: Date; + + /** + * Identifier of the poll to include in the message + */ + poll_id?: string; + + quoted_message_id?: string; + + /** + * Whether thread reply should be shown in the channel as well + */ + show_in_channel?: boolean; + + /** + * List of user group IDs mentioned in the message. Group members who are also channel members will receive push notifications based on their push preferences. Max 10 groups + */ + mentioned_group_ids?: Array; + + /** + * List of mentioned user group objects. + */ + mentioned_groups?: Array; + + /** + * List of roles mentioned in the message (e.g. admin, channel_moderator, custom roles). Members with matching roles will receive push notifications based on their push preferences. Max 10 roles + */ + mentioned_roles?: Array; + + /** + * List of users who participate in thread + */ + thread_participants?: Array; + + draft?: DraftResponse; + + /** + * Object with translations. Key `language` contains the original language key. Other keys contain translations + */ + i18n?: Record; + + /** + * Contains image moderation information + */ + image_labels?: Record>; + + member?: ChannelMemberResponse; + + moderation?: ModerationV2Response; + + pinned_by?: UserResponse; + + poll?: PollResponseData; + + quoted_message?: MessageResponse; + + reaction_groups?: Record; + + reminder?: ReminderResponseData; + + shared_location?: SharedLocationResponseData; +} + +export interface ModerationActionConfigResponse { + /** + * The action to take + */ + action: string; + + /** + * Description of what this action does + */ + description: string; + + /** + * Type of entity this action applies to + */ + entity_type: string; + + /** + * Icon for the dashboard + */ + icon: string; + + /** + * Display order (lower numbers shown first) + */ + order: number; + + id?: string; + + /** + * Queue type this action config belongs to + */ + queue_type?: string; + + /** + * Custom data for the action + */ + custom?: Record; +} + +export interface ModerationBanResponse { + duration: string; +} + +export interface ModerationCustomActionEvent { + /** + * The ID of the custom action that was executed + */ + action_id: string; + + created_at: Date; + + custom: CustomEventData; + + review_queue_item: ReviewQueueItemResponse; + + type: string; + + received_at?: Date; + + /** + * Additional options passed to the custom action + */ + action_options?: Record; + + message?: MessageResponse; +} + +export interface ModerationFlagResponse { + created_at: Date; + + entity_id: string; + + entity_type: string; + + type: string; + + updated_at: Date; + + user_id: string; + + result: Array>; + + entity_creator_id?: string; + + reason?: string; + + review_queue_item_id?: string; + + labels?: Array; + + custom?: Record; + + moderation_payload?: ModerationPayloadResponse; + + review_queue_item?: ReviewQueueItemResponse; + + user?: UserResponse; +} + +export interface ModerationFlaggedEvent { + /** + * The type of content that was flagged + */ + content_type: string; + + created_at: Date; + + /** + * The ID of the flagged content + */ + object_id: string; + + custom: CustomEventData; + + type: string; + + received_at?: Date; +} + +export interface ModerationMarkReviewedEvent { + created_at: Date; + + custom: CustomEventData; + + item: ReviewQueueItemResponse; + + type: string; + + received_at?: Date; + + message?: MessageResponse; +} + +export interface ModerationPayload { + image_ordered_keys?: Array; + + images?: Array; + + text_ordered_keys?: Array; + + texts?: Array; + + videos?: Array; + + custom?: Record; + + image_ids?: Record; + + text_ids?: Record; +} + +export interface ModerationPayloadResponse { + /** + * Caller-supplied keys for images, index-aligned with images[] + */ + image_ordered_keys?: Array; + + /** + * Image URLs to moderate + */ + images?: Array; + + /** + * Caller-supplied keys for texts (e.g. "title", "description"), index-aligned with texts[] + */ + text_ordered_keys?: Array; + + /** + * Text content to moderate + */ + texts?: Array; + + /** + * Video URLs to moderate + */ + videos?: Array; + + /** + * Custom data for moderation + */ + custom?: Record; + + /** + * Caller-supplied content IDs per image key (from content_ids on /analyze) + */ + image_ids?: Record; + + /** + * Caller-supplied content IDs per text key (from content_ids on /analyze) + */ + text_ids?: Record; +} + +export interface ModerationQueueResponse { + created_at: Date; + + created_by: string; + + description: string; + + id: string; + + item_count: number; + + name: string; + + type: string; + + updated_at: Date; + + sort: Array>; + + filters: Record; +} + +export interface ModerationResponse { + action: string; + + explicit: number; + + spam: number; + + toxic: number; +} + +export interface ModerationV2Response { + action: string; + + original_text: string; + + blocklist_matched?: string; + + platform_circumvented?: boolean; + + semantic_filter_matched?: string; + + blocklists_matched?: Array; + + image_harms?: Array; + + text_harms?: Array; +} + +export interface MuteChannelRequest { + /** + * Duration of mute in milliseconds + */ + expiration?: number; + + /** + * Channel CIDs to mute (if multiple channels) + */ + channel_cids?: Array; +} + +export interface MuteChannelResponse { + duration: string; + + /** + * Object with mutes (if multiple channels were muted) + */ + channel_mutes?: Array; + + channel_mute?: ChannelMute; + + own_user?: OwnUserResponse; +} + +export interface MuteRequest { + /** + * User IDs to mute (if multiple users) + */ + target_ids: Array; + + /** + * Duration of mute in minutes + */ + timeout?: number; +} + +export interface MuteResponse { + duration: string; + + /** + * Object with mutes (if multiple users were muted) + */ + mutes?: Array; + + /** + * A list of users that can't be found. Common cause for this is deleted users + */ + non_existing_users?: Array; + + own_user?: OwnUserResponse; +} + +export interface NotificationAddedToChannelEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + member: ChannelMemberResponse; + + /** + * The type of event: "notification.added_to_channel" in this case + */ + type: string; + + /** + * The ID of the channel to which the user was added + */ + channel_id?: string; + + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel to which the user was added + */ + channel_type?: string; + + /** + * The CID of the channel to which the user was added + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; +} + +export interface NotificationChannelDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "notification.channel_deleted" in this case + */ + type: string; + + /** + * The ID of the channel which was deleted + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was deleted + */ + channel_type?: string; + + /** + * The CID of the channel which was deleted + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + /** + * The total number of unread messages + */ + total_unread_count?: number; + + /** + * The number of channels with unread messages + */ + unread_channels?: number; + + /** + * The number of unread messages in the channel + */ + unread_count?: number; + + channel_custom?: CustomChannelData; + + grouped_unread_channels?: Record; +} + +export interface NotificationChannelMutesUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + me: OwnUserResponse; + + /** + * The type of event: "notification.channel_mutes_updated" in this case + */ + type: string; + + received_at?: Date; +} + +export interface NotificationChannelTruncatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "notification.channel_truncated" in this case + */ + type: string; + + /** + * The ID of the channel which was truncated + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was truncated + */ + channel_type?: string; + + /** + * The CID of the channel which was truncated + */ + cid?: string; + + message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + /** + * The total number of unread messages + */ + total_unread_count?: number; + + /** + * The number of channels with unread messages + */ + unread_channels?: number; + + /** + * The number of unread messages in the channel + */ + unread_count?: number; + + channel_custom?: CustomChannelData; + + grouped_unread_channels?: Record; + + message?: MessageResponse; +} + +export interface NotificationInviteAcceptedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + member: ChannelMemberResponse; + + /** + * The type of event: "notification.invite_accepted" in this case + */ + type: string; + + /** + * The ID of the channel to which the user accepted the invite + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel to which the user accepted the invite + */ + channel_type?: string; + + /** + * The CID of the channel to which the user accepted the invite + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface NotificationInviteRejectedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + member: ChannelMemberResponse; + + /** + * The type of event: "notification.invite_rejected" in this case + */ + type: string; + + /** + * The ID of the channel to which the user rejected the invite + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel to which the user rejected the invite + */ + channel_type?: string; + + /** + * The CID of the channel to which the user rejected the invite + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface NotificationInvitedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + member: ChannelMemberResponse; + + /** + * The type of event: "notification.invited" in this case + */ + type: string; + + /** + * The ID of the channel to which the user was invited + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel to which the user was invited + */ + channel_type?: string; + + /** + * The CID of the channel to which the user was invited + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface NotificationMarkReadEvent { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The total number of unread messages + */ + total_unread_count: number; + + /** + * The number of channels with unread messages + */ + unread_channels: number; + + /** + * The total number of unread messages + */ + unread_count: number; + + custom: CustomEventData; + + /** + * The type of event: "notification.mark_read" in this case + */ + type: string; + + /** + * The ID of the channel which was marked as read + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was marked as read + */ + channel_type?: string; + + /** + * The CID of the channel which was marked as read + */ + cid?: string; + + /** + * The ID of the last read message + */ + last_read_message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + /** + * The ID of the thread which was marked as read + */ + thread_id?: string; + + /** + * The total number of unread messages in the threads + */ + unread_thread_messages?: number; + + /** + * The number of unread threads + */ + unread_threads?: number; + + channel?: ChannelResponse; + + channel_custom?: CustomChannelData; + + grouped_unread_channels?: Record; + + thread?: ThreadResponse; + + user?: UserResponseCommonFields; +} + +export interface NotificationMarkUnreadEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "notification.mark_unread" in this case + */ + type: string; + + /** + * The ID of the channel which was marked as unread + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel which was marked as unread + */ + channel_type?: string; + + /** + * The CID of the channel which was marked as unread + */ + cid?: string; + + /** + * The ID of the first unread message + */ + first_unread_message_id?: string; + + /** + * The time when the channel/thread was marked as unread + */ + last_read_at?: Date; + + /** + * The ID of the last read message + */ + last_read_message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + /** + * The ID of the thread which was marked as unread + */ + thread_id?: string; + + /** + * The total number of unread messages + */ + total_unread_count?: number; + + /** + * The number of channels with unread messages + */ + unread_channels?: number; + + /** + * The total number of unread messages + */ + unread_count?: number; + + /** + * The number of unread messages in the channel/thread after first_unread_message_id + */ + unread_messages?: number; + + /** + * The total number of unread messages in the threads + */ + unread_thread_messages?: number; + + /** + * The number of unread threads + */ + unread_threads?: number; + + channel?: ChannelResponse; + + channel_custom?: CustomChannelData; + + grouped_unread_channels?: Record; + + user?: UserResponseCommonFields; +} + +export interface NotificationMutesUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + me: OwnUserResponse; + + /** + * The type of event: "notification.mutes_updated" in this case + */ + type: string; + + received_at?: Date; +} + +export interface NotificationNewMessageEvent { + /** + * Date/time of creation + */ + created_at: Date; + + message_id: string; + + /** + * The number of watchers + */ + watcher_count: number; + + channel: ChannelResponse; + + custom: CustomEventData; + + message: MessageResponse; + + /** + * The type of event: "notification.message_new" in this case + */ + type: string; + + /** + * The ID of the channel where the message was sent + */ + channel_id?: string; + + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel where the message was sent + */ + channel_type?: string; + + /** + * The CID of the channel where the message was sent + */ + cid?: string; + + parent_author?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + total_unread_count?: number; + + unread_channels?: number; + + unread_count?: number; + + /** + * The participants of the thread + */ + thread_participants?: Array; + + channel_custom?: CustomChannelData; + + grouped_unread_channels?: Record; +} + +export interface NotificationRemovedFromChannelEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + member: ChannelMemberResponse; + + /** + * The type of event: "notification.removed_from_channel" in this case + */ + type: string; + + /** + * The ID of the channel from which the user was removed + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel from which the user was removed + */ + channel_type?: string; + + /** + * The CID of the channel from which the user was removed + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + user?: UserResponseCommonFields; +} + +export interface NotificationThreadMessageNewEvent { + /** + * Date/time of creation + */ + created_at: Date; + + message_id: string; + + /** + * The ID of the thread + */ + thread_id: string; + + /** + * The number of watchers + */ + watcher_count: number; + + channel: ChannelResponse; + + custom: CustomEventData; + + message: MessageResponse; + + /** + * The type of event: "notification.message_new" in this case + */ + type: string; + + /** + * The ID of the channel where the message was sent + */ + channel_id?: string; + + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel where the message was sent + */ + channel_type?: string; + + /** + * The CID of the channel where the message was sent + */ + cid?: string; + + parent_author?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + unread_thread_messages?: number; + + unread_threads?: number; + + /** + * The participants of the thread + */ + thread_participants?: Array; + + channel_custom?: CustomChannelData; +} + +export interface OCRRule { + action: 'flag' | 'shadow' | 'remove' | 'bounce' | 'bounce_flag' | 'bounce_remove'; + + label: string; +} + +export interface OnlyUserID { + id: string; +} + +export interface OwnUserResponse { + banned: boolean; + + created_at: Date; + + id: string; + + invisible: boolean; + + language: string; + + online: boolean; + + role: string; + + total_unread_count: number; + + unread_channels: number; + + unread_count: number; + + unread_threads: number; + + updated_at: Date; + + channel_mutes: Array; + + devices: Array; + + mutes: Array; + + teams: Array; + + custom: CustomUserData; + + avg_response_time?: number; + + deactivated_at?: Date; + + deleted_at?: Date; + + image?: string; + + last_active?: Date; + + name?: string; + + revoke_tokens_issued_before?: Date; + + blocked_user_ids?: Array; + + latest_hidden_channels?: Array; + + privacy_settings?: PrivacySettingsResponse; + + push_preferences?: PushPreferencesResponse; + + teams_role?: Record; + + total_unread_count_by_team?: Record; +} + +export interface PaginationParams { + limit?: number; + + offset?: number; +} + +export interface ParsedPredefinedFilterResponse { + name: string; + + filter: Record; + + sort?: Array; +} + +export interface PendingMessageEvent { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The method used for the pending message + */ + method: string; + + custom: CustomEventData; + + /** + * The type of event: "message.pending" in this case + */ + type: string; + + received_at?: Date; + + channel?: ChannelResponse; + + message?: MessageResponse; + + /** + * Metadata attached to the pending message + */ + metadata?: Record; + + user?: UserResponse; +} + +export interface PendingMessageResponse { + channel?: ChannelResponse; + + message?: MessageResponse; + + metadata?: Record; + + user?: UserResponse; +} + +export interface PollClosedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + poll: PollResponseData; + + /** + * The type of event: "poll.closed" in this case + */ + type: string; + + activity_id?: string; + + /** + * The CID of the channel containing the poll + */ + cid?: string; + + /** + * The ID of the message containing the poll + */ + message_id?: string; + + received_at?: Date; +} + +export interface PollDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + poll: PollResponseData; + + /** + * The type of event: "poll.deleted" in this case + */ + type: string; + + activity_id?: string; + + /** + * The CID of the channel containing the poll + */ + cid?: string; + + /** + * The ID of the message containing the poll + */ + message_id?: string; + + received_at?: Date; +} + +export interface PollOptionInput { + text?: string; + + custom?: CustomPollOptionData; +} + +export interface PollOptionRequest { + id: string; + + text?: string; + + custom?: CustomPollOptionData; +} + +export interface PollOptionResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + poll_option: PollOptionResponseData; +} + +export interface PollOptionResponseData { + id: string; + + text: string; + + custom: CustomPollOptionData; +} + +export interface PollResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + poll: PollResponseData; +} + +export interface PollResponseData { + allow_answers: boolean; + + allow_user_suggested_options: boolean; + + answers_count: number; + + created_at: Date; + + created_by_id: string; + + description: string; + + enforce_unique_vote: boolean; + + id: string; + + name: string; + + updated_at: Date; + + vote_count: number; + + voting_visibility: string; + + latest_answers: Array; + + options: Array; + + own_votes: Array; + + custom: CustomPollData; + + latest_votes_by_option: Record>; + + vote_counts_by_option: Record; + + is_closed?: boolean; + + max_votes_allowed?: number; + + created_by?: UserResponse; +} + +export interface PollUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + poll: PollResponseData; + + /** + * The type of event: "poll.updated" in this case + */ + type: string; + + activity_id?: string; + + /** + * The CID of the channel containing the poll + */ + cid?: string; + + /** + * The ID of the message containing the poll + */ + message_id?: string; + + received_at?: Date; +} + +export interface PollVoteCastedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + poll: PollResponseData; + + poll_vote: PollVoteResponseData; + + /** + * The type of event: "poll.vote_casted" in this case + */ + type: string; + + activity_id?: string; + + /** + * The CID of the channel containing the poll + */ + cid?: string; + + /** + * The ID of the message containing the poll + */ + message_id?: string; + + received_at?: Date; +} + +export interface PollVoteChangedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + poll: PollResponseData; + + poll_vote: PollVoteResponseData; + + /** + * The type of event: "poll.vote_changed" in this case + */ + type: string; + + activity_id?: string; + + /** + * The CID of the channel containing the poll + */ + cid?: string; + + /** + * The ID of the message containing the poll + */ + message_id?: string; + + received_at?: Date; +} + +export interface PollVoteRemovedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + poll: PollResponseData; + + poll_vote: PollVoteResponseData; + + /** + * The type of event: "poll.vote_removed" in this case + */ + type: string; + + activity_id?: string; + + /** + * The CID of the channel containing the poll + */ + cid?: string; + + /** + * The ID of the message containing the poll + */ + message_id?: string; + + received_at?: Date; +} + +export interface PollVoteResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + poll?: PollResponseData; + + vote?: PollVoteResponseData; +} + +export interface PollVoteResponseData { + created_at: Date; + + id: string; + + option_id: string; + + poll_id: string; + + updated_at: Date; + + answer_text?: string; + + is_answer?: boolean; + + user_id?: string; + + user?: UserResponse; +} + +export interface PollVotesResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * Poll votes + */ + votes: Array; + + next?: string; + + prev?: string; +} + +export interface PrivacySettingsResponse { + delivery_receipts?: DeliveryReceiptsResponse; + + read_receipts?: ReadReceiptsResponse; + + typing_indicators?: TypingIndicatorsResponse; +} + +export interface PushPreferenceInput { + /** + * Set the level of call push notifications for the user. One of: all, none, default + */ + + call_level?: 'all' | 'none' | 'default'; + + /** + * Set the push preferences for a specific channel. If empty it sets the default for the user + */ + channel_cid?: string; + + /** + * Set the level of chat push notifications for the user. Note: "mentions" is deprecated in favor of "direct_mentions". One of: all, mentions, direct_mentions, all_mentions, none, default + */ + + chat_level?: + | 'all' + | 'mentions' + | 'direct_mentions' + | 'all_mentions' + | 'none' + | 'default'; + + /** + * Disable push notifications till a certain time + */ + disabled_until?: Date; + + /** + * Set the level of feeds push notifications for the user. One of: all, none, default + */ + + feeds_level?: 'all' | 'none' | 'default'; + + /** + * Remove the disabled until time. (IE stop snoozing notifications) + */ + remove_disable?: boolean; + + /** + * The user id for which to set the push preferences. Required when using server side auths, defaults to current user with client side auth. + */ + user_id?: string; + + chat_preferences?: ChatPreferencesInput; + + feeds_preferences?: FeedsPreferences; +} + +export interface PushPreferencesResponse { + call_level?: string; + + chat_level?: string; + + disabled_until?: Date; + + feeds_level?: string; + + chat_preferences?: ChatPreferencesResponse; + + feeds_preferences?: FeedsPreferencesResponse; +} + +export interface QueryAppealsRequest { + limit?: number; + + next?: string; + + prev?: string; + + /** + * Sorting parameters for appeals + */ + sort?: Array; + + /** + * Filter conditions for appeals + */ + filter?: Record; +} + +export interface QueryAppealsResponse { + duration: string; + + /** + * List of Appeal Items + */ + items: Array; + + next?: string; + + prev?: string; +} + +export interface QueryBannedUsersPayload { + /** + * Filter conditions to apply to the query + */ + filter_conditions: Filters<{ + banned_by_id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + channel_cid: { + type: string; + operators: '$eq' | '$in'; + }; + + created_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + reason: { + type: string; + operators: + | '$autocomplete' + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + user_id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + }>; + + /** + * Whether to exclude expired bans or not + */ + exclude_expired_bans?: boolean; + + /** + * Number of records to return + */ + limit?: number; + + /** + * Number of records to offset + */ + offset?: number; + + /** + * Array of sort parameters + */ + sort?: Array; +} + +export interface QueryBannedUsersResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * List of found bans + */ + bans: Array; +} + +export interface QueryChannelsRequest { + /** + * Number of channels to limit + */ + limit?: number; + + /** + * Number of members to limit + */ + member_limit?: number; + + /** + * Number of messages to limit + */ + message_limit?: number; + + /** + * Channel pagination offset + */ + offset?: number; + + /** + * ID of a predefined filter to use instead of filter_conditions + */ + predefined_filter?: string; + + presence?: boolean; + + /** + * Whether to update channel state or not + */ + state?: boolean; + + /** + * Whether to start watching found channels or not + */ + watch?: boolean; + + /** + * List of sort parameters + */ + sort?: Array; + + /** + * Filter conditions to apply to the query + */ + filter_conditions?: Filters<{ + app_banned: { + type: string; + operators: '$eq'; + }; + + archived: { + type: boolean; + operators: '$eq'; + }; + + blocked: { + type: boolean; + operators: '$eq'; + }; + + channel_role: { + type: string; + operators: '$eq' | '$in'; + }; + + cid: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + created_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + created_by_id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + disabled: { + type: boolean; + operators: '$eq'; + }; + + distinct: { + type: boolean; + operators: '$eq'; + }; + + filter_tags: { + type: string; + operators: '$eq' | '$in'; + }; + + frozen: { + type: boolean; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + has_unread: { + type: boolean; + operators: '$eq'; + }; + + hidden: { + type: boolean; + operators: '$eq'; + }; + + id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + invite: { + type: string; + operators: '$eq'; + }; + + joined: { + type: boolean; + operators: '$eq'; + }; + + last_message_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + last_updated: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + 'member.user.name': { + type: string; + operators: '$autocomplete' | '$eq' | '$ne'; + }; + + member_count: { + type: number; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + members: { + type: string; + operators: '$eq' | '$in' | '$nin'; + }; + + message_count: { + type: number; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + muted: { + type: boolean; + operators: '$eq'; + }; + + name: { + type: string; + operators: + | '$autocomplete' + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin' + | '$q'; + }; + + pinned: { + type: boolean; + operators: '$eq'; + }; + + team: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + type: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + updated_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + }>; + + /** + * Values to interpolate into the predefined filter template + */ + filter_values?: Record; + + sort_values?: Record; +} + +export interface QueryChannelsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * List of channels + */ + channels: Array; + + predefined_filter?: ParsedPredefinedFilterResponse; +} + +export interface QueryDraftsRequest { + limit?: number; + + next?: string; + + prev?: string; + + /** + * Array of sort parameters + */ + sort?: Array; + + /** + * Filter to apply to the query + */ + filter?: Record; +} + +export interface QueryDraftsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * Drafts + */ + drafts: Array; + + next?: string; + + prev?: string; +} + +export interface QueryFutureChannelBansPayload { + /** + * Whether to exclude expired bans or not + */ + exclude_expired_bans?: boolean; + + /** + * Number of records to return + */ + limit?: number; + + /** + * Number of records to offset + */ + offset?: number; + + /** + * Filter by the target user ID. For server-side requests only. + */ + target_user_id?: string; +} + +export interface QueryFutureChannelBansResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * List of found future channel bans + */ + bans: Array; +} + +export interface QueryMembersPayload { + type: string; + + /** + * Filter conditions to apply to the query + */ + filter_conditions: Filters<{ + banned: { + type: boolean; + operators: '$eq'; + }; + + channel_role: { + type: string; + operators: '$eq' | '$in'; + }; + + cid: { + type: string; + operators: '$eq'; + }; + + created_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + invite: { + type: string; + operators: '$eq'; + }; + + is_moderator: { + type: boolean; + operators: '$eq' | '$ne'; + }; + + joined: { + type: boolean; + operators: '$eq'; + }; + + last_active: { + type: Date; + operators: '$eq' | '$gt' | '$gte' | '$lt' | '$lte' | '$ne'; + }; + + name: { + type: string; + operators: '$autocomplete' | '$eq' | '$in' | '$ne' | '$nin' | '$q'; + }; + + notifications_muted: { + type: boolean; + operators: '$eq'; + }; + + updated_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + 'user.email': { + type: string; + operators: '$autocomplete' | '$eq' | '$in' | '$ne' | '$nin' | '$q'; + }; + + 'user.nd_deactivated': { + type: boolean; + operators: '$eq'; + }; + + user_id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + }>; + + id?: string; + + limit?: number; + + offset?: number; + + members?: Array; + + /** + * Array of sort parameters + */ + sort?: Array; +} + +export interface QueryMessageFlagsPayload { + limit?: number; + + offset?: number; + + /** + * Whether to include deleted messages in the results + */ + show_deleted_messages?: boolean; + + /** + * Array of sort parameters + */ + sort?: Array; + + /** + * Filter conditions to apply to the query + */ + filter_conditions?: Filters<{ + action: { + type: string; + operators: '$eq'; + }; + + blocklist_name: { + type: string; + operators: '$eq'; + }; + + channel_cid: { + type: string; + operators: '$eq' | '$in'; + }; + + date_range: { + type: string; + operators: '$eq'; + }; + + harm_label: { + type: string; + operators: '$eq'; + }; + + harm_type: { + type: string; + operators: '$eq'; + }; + + image_labels: { + type: string; + operators: '$eq'; + }; + + is_reviewed: { + type: boolean; + operators: '$eq'; + }; + + keyword: { + type: string; + operators: '$eq'; + }; + + matched_phrase: { + type: string; + operators: '$eq'; + }; + + message_id: { + type: string; + operators: '$eq' | '$in'; + }; + + phrase_list_ids: { + type: number; + operators: '$eq'; + }; + + reason: { + type: string; + operators: '$eq' | '$in'; + }; + + reporter_id: { + type: string; + operators: '$eq'; + }; + + reporter_type: { + type: string; + operators: '$eq'; + }; + + team: { + type: string; + operators: '$eq' | '$in'; + }; + + user_id: { + type: string; + operators: '$eq' | '$in'; + }; + }>; +} + +export interface QueryMessageFlagsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * The flags that match the query + */ + flags: Array; +} + +export interface QueryModerationConfigsRequest { + limit?: number; + + next?: string; + + prev?: string; + + /** + * Sorting parameters for the results + */ + sort?: Array; + + /** + * Filter conditions for moderation configs + */ + filter?: Record; +} + +export interface QueryModerationConfigsResponse { + duration: string; + + /** + * List of moderation configurations + */ + configs: Array; + + next?: string; + + prev?: string; +} + +export interface QueryPollVotesRequest { + limit?: number; + + next?: string; + + prev?: string; + + /** + * Array of sort parameters + */ + sort?: Array; + + /** + * Filter to apply to the query + */ + filter?: Record; +} + +export interface QueryPollsRequest { + limit?: number; + + next?: string; + + prev?: string; + + /** + * Array of sort parameters + */ + sort?: Array; + + /** + * Filter to apply to the query + */ + filter?: Record; +} + +export interface QueryPollsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * Polls data returned by the query + */ + polls: Array; + + next?: string; + + prev?: string; +} + +export interface QueryReactionsRequest { + limit?: number; + + next?: string; + + prev?: string; + + /** + * Array of sort parameters + */ + sort?: Array; + + /** + * Filter to apply to the query + */ + filter?: Filters<{ + created_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + type: { + type: string; + operators: '$eq' | '$in'; + }; + + user_id: { + type: string; + operators: '$eq' | '$in'; + }; + }>; +} + +export interface QueryReactionsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + reactions: Array; + + next?: string; + + prev?: string; +} + +export interface QueryRemindersRequest { + limit?: number; + + next?: string; + + prev?: string; + + /** + * Array of sort parameters + */ + sort?: Array; + + /** + * Filter to apply to the query + */ + filter?: Record; +} + +export interface QueryRemindersResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * MessageReminders data returned by the query + */ + reminders: Array; + + next?: string; + + prev?: string; +} + +export interface QueryReviewQueueRequest { + exclude_default_action_config?: boolean; + + limit?: number; + + /** + * Number of items to lock (1-25) + */ + lock_count?: number; + + /** + * Duration for which items should be locked + */ + lock_duration?: number; + + /** + * Whether to lock items for review (true), unlock items (false), or just fetch (nil) + */ + lock_items?: boolean; + + next?: string; + + prev?: string; + + /** + * Whether to return only statistics + */ + stats_only?: boolean; + + /** + * Sorting parameters for the results + */ + sort?: Array; + + /** + * Filter conditions for review queue items. Accepts built-in fields (e.g. status, channel_cid, severity, recommended_action) and customer-supplied moderation_payload.custom keys: any key that is not a built-in field is matched against the item's custom moderation data (e.g. {"location_id": "loc-42"}). Use filter_config.filterable_custom_keys to discover which custom keys the app exposes as chips. + */ + filter?: Record; +} + +export interface QueryReviewQueueResponse { + duration: string; + + /** + * List of review queue items + */ + items: Array; + + /** + * Configuration for moderation actions + */ + action_config: Record>; + + /** + * Statistics about the review queue + */ + stats: Record; + + next?: string; + + prev?: string; + + default_action_config?: Record>; + + filter_config?: FilterConfigResponse; +} + +export interface QueryThreadsRequest { + limit?: number; + + member_limit?: number; + + next?: string; + + /** + * Limit the number of participants returned per each thread + */ + participant_limit?: number; + + prev?: string; + + /** + * Limit the number of replies returned per each thread + */ + reply_limit?: number; + + /** + * Start watching the channel this thread belongs to + */ + watch?: boolean; + + /** + * Array of sort parameters + */ + sort?: Array; + + /** + * Filter to apply to the query + */ + filter?: Filters<{ + active_participant_count: { + type: number; + operators: '$eq' | '$gt' | '$gte' | '$lt' | '$lte'; + }; + + 'channel.disabled': { + type: boolean; + operators: '$eq'; + }; + + 'channel.team': { + type: string; + operators: '$eq' | '$in'; + }; + + channel_cid: { + type: string; + operators: '$eq' | '$in'; + }; + + created_at: { + type: Date; + operators: '$eq' | '$gt' | '$gte' | '$lt' | '$lte'; + }; + + created_by_user_id: { + type: string; + operators: '$eq' | '$in'; + }; + + has_unread: { + type: boolean; + operators: '$eq'; + }; + + last_message_at: { + type: Date; + operators: '$eq' | '$gt' | '$gte' | '$lt' | '$lte'; + }; + + parent_message_id: { + type: string; + operators: '$eq' | '$in'; + }; + + participant_count: { + type: number; + operators: '$eq' | '$gt' | '$gte' | '$lt' | '$lte'; + }; + + reply_count: { + type: number; + operators: '$eq' | '$gt' | '$gte' | '$lt' | '$lte'; + }; + + updated_at: { + type: Date; + operators: '$eq' | '$gt' | '$gte' | '$lt' | '$lte'; + }; + }>; +} + +export interface QueryThreadsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * List of enriched thread states + */ + threads: Array; + + next?: string; + + prev?: string; +} + +export interface QueryUsersPayload { + /** + * Filter conditions to apply to the query + */ + filter_conditions: Filters<{ + banned: { + type: boolean; + operators: '$eq' | '$ne'; + }; + + bypass_moderation: { + type: boolean; + operators: '$eq' | '$ne'; + }; + + created_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + email: { + type: string; + operators: '$eq' | '$in'; + }; + + id: { + type: string; + operators: + | '$autocomplete' + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + language: { + type: string; + operators: '$eq' | '$ne'; + }; + + last_active: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + name: { + type: string; + operators: + | '$autocomplete' + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + role: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + shadow_banned: { + type: boolean; + operators: '$eq' | '$ne'; + }; + + teams: { + type: string; + operators: '$_none' | '$contains' | '$eq' | '$in'; + }; + + updated_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + username: { + type: string; + operators: '$autocomplete' | '$eq'; + }; + }>; + + include_deactivated_users?: boolean; + + limit?: number; + + offset?: number; + + presence?: boolean; + + /** + * Array of sort parameters + */ + sort?: Array; +} + +export interface QueryUsersResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * Array of users as result of filters applied. + */ + users: Array; +} + +export interface QueueResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + queue?: ModerationQueueResponse; +} + +export interface Reaction { + activity_id: string; + + created_at: Date; + + kind: string; + + updated_at: Date; + + user_id: string; + + deleted_at?: Date; + + id?: string; + + parent?: string; + + score?: number; + + target_feeds?: Array; + + children_counts?: Record; + + data?: Record; + + latest_children?: Record>; + + moderation?: Record; + + own_children?: Record>; + + target_feeds_extra_data?: Record; + + user?: User; +} + +export interface ReactionDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "reaction.deleted" in this case + */ + type: string; + + /** + * The ID of the channel containing the message + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel containing the message + */ + channel_type?: string; + + /** + * The CID of the channel containing the message + */ + cid?: string; + + message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + /** + * The participants of the thread + */ + thread_participants?: Array; + + channel_custom?: CustomChannelData; + + message?: MessageResponse; + + reaction?: ReactionResponse; + + user?: UserResponseCommonFields; +} + +export interface ReactionGroupResponse { + /** + * Count is the number of reactions of this type. + */ + count: number; + + /** + * FirstReactionAt is the time of the first reaction of this type. This is the same also if all reaction of this type are deleted, because if someone will react again with the same type, will be preserved the sorting. + */ + first_reaction_at: Date; + + /** + * LastReactionAt is the time of the last reaction of this type. + */ + last_reaction_at: Date; + + /** + * SumScores is the sum of all scores of reactions of this type. Medium allows you to clap articles more than once and shows the sum of all claps from all users. For example, you can send `clap` x5 using `score: 5`. + */ + sum_scores: number; + + /** + * The most recent users who reacted with this type, ordered by most recent first. + */ + latest_reactions_by: Array; +} + +export interface ReactionGroupUserResponse { + /** + * The time when the user reacted. + */ + created_at: Date; + + /** + * The ID of the user who reacted. + */ + user_id: string; + + user?: UserResponse; +} + +export interface ReactionNewEvent { + /** + * Date/time of creation + */ + created_at: Date; + + channel: ChannelResponse; + + custom: CustomEventData; + + /** + * The type of event: "reaction.new" in this case + */ + type: string; + + /** + * The ID of the channel containing the message + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel containing the message + */ + channel_type?: string; + + /** + * The CID of the channel containing the message + */ + cid?: string; + + message_id?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + /** + * The participants of the thread + */ + thread_participants?: Array; + + channel_custom?: CustomChannelData; + + message?: MessageResponse; + + reaction?: ReactionResponse; + + user?: UserResponseCommonFields; +} + +export interface ReactionRequest { + /** + * The type of reaction (e.g. 'like', 'laugh', 'wow') + */ + type: string; + + /** + * Date/time of creation + */ + created_at?: Date; + + /** + * Reaction score. If not specified reaction has score of 1 + */ + score?: number; + + /** + * Date/time of the last update + */ + updated_at?: Date; + + custom?: CustomReactionData; +} + +export interface ReactionResponse { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Message ID + */ + message_id: string; + + /** + * Score of the reaction + */ + score: number; + + /** + * Type of reaction + */ + type: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * User ID + */ + user_id: string; + + /** + * Custom data for this object + */ + custom: CustomReactionData; + + user: UserResponse; +} + +export interface ReactionUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + message_id: string; + + channel: ChannelResponse; + + custom: CustomEventData; + + message: MessageResponse; + + /** + * The type of event: "reaction.updated" in this case + */ + type: string; + + /** + * The ID of the channel containing the message + */ + channel_id?: string; + + /** + * The number of members in the channel + */ + channel_member_count?: number; + + /** + * The number of messages in the channel + */ + channel_message_count?: number; + + /** + * The type of the channel containing the message + */ + channel_type?: string; + + /** + * The CID of the channel containing the message + */ + cid?: string; + + received_at?: Date; + + /** + * The team ID + */ + team?: string; + + channel_custom?: CustomChannelData; + + reaction?: ReactionResponse; + + user?: UserResponseCommonFields; +} + +export interface ReadReceiptsResponse { + enabled: boolean; +} + +export interface ReadStateResponse { + last_read: Date; + + unread_messages: number; + + user: UserResponse; + + last_delivered_at?: Date; + + last_delivered_message_id?: string; + + last_read_message_id?: string; +} + +export interface RejectAppealRequestPayload { + /** + * Reason for rejecting the appeal + */ + decision_reason: string; +} + +export interface ReminderCreatedEvent { + /** + * The CID of the Channel for which the reminder was created + */ + cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The ID of the message for which the reminder was created + */ + message_id: string; + + /** + * The ID of the user for whom the reminder was created + */ + user_id: string; + + custom: CustomEventData; + + /** + * The type of event: "reminder.created" in this case + */ + type: string; + + /** + * The ID of the parent message, if the reminder is for a thread message + */ + parent_id?: string; + + received_at?: Date; + + reminder?: ReminderResponseData; +} + +export interface ReminderDeletedEvent { + /** + * The CID of the Channel for which the reminder was created + */ + cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The ID of the message for which the reminder was created + */ + message_id: string; + + /** + * The ID of the user for whom the reminder was created + */ + user_id: string; + + custom: CustomEventData; + + /** + * The type of event: "reminder.deleted" in this case + */ + type: string; + + /** + * The ID of the parent message, if the reminder is for a thread message + */ + parent_id?: string; + + received_at?: Date; + + reminder?: ReminderResponseData; +} + +export interface ReminderNotificationEvent { + /** + * The CID of the Channel for which the reminder was created + */ + cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The ID of the message for which the reminder was created + */ + message_id: string; + + /** + * The ID of the user for whom the reminder was created + */ + user_id: string; + + custom: CustomEventData; + + /** + * The type of event: "notification.reminder_due" in this case + */ + type: string; + + parent_id?: string; + + received_at?: Date; + + reminder?: ReminderResponseData; +} + +export interface ReminderResponseData { + channel_cid: string; + + created_at: Date; + + message_id: string; + + updated_at: Date; + + user_id: string; + + remind_at?: Date; + + channel?: ChannelResponse; + + message?: MessageResponse; + + user?: UserResponse; +} + +export interface ReminderUpdatedEvent { + /** + * The CID of the Channel for which the reminder was created + */ + cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The ID of the message for which the reminder was created + */ + message_id: string; + + /** + * The ID of the user for whom the reminder was created + */ + user_id: string; + + custom: CustomEventData; + + /** + * The type of event: "reminder.updated" in this case + */ + type: string; + + /** + * The ID of the parent message, if the reminder is for a thread message + */ + parent_id?: string; + + received_at?: Date; + + reminder?: ReminderResponseData; +} + +export interface RemoveUserGroupMembersRequest { + /** + * List of user IDs to remove + */ + member_ids: Array; + + team_id?: string; +} + +export interface RemoveUserGroupMembersResponse { + duration: string; + + user_group?: UserGroupResponse; +} + +export interface Response { + /** + * Duration of the request in milliseconds + */ + duration: string; +} + +export interface RestoreActionRequestPayload { + /** + * Reason for the appeal decision + */ + decision_reason?: string; +} + +export interface ReviewQueueItemResponse { + /** + * AI-determined text severity + */ + ai_text_severity: string; + + /** + * When the item was created + */ + created_at: Date; + + /** + * ID of the entity being reviewed + */ + entity_id: string; + + /** + * Type of entity being reviewed + */ + entity_type: string; + + /** + * Whether the item has been escalated + */ + escalated: boolean; + + flags_count: number; + + /** + * Unique identifier of the review queue item + */ + id: string; + + latest_moderator_action: string; + + /** + * Suggested moderation action + */ + recommended_action: string; + + /** + * ID of the moderator who reviewed the item + */ + reviewed_by: string; + + /** + * Severity level of the content + */ + severity: number; + + /** + * Current status of the review + */ + status: string; + + /** + * When the item was last updated + */ + updated_at: Date; + + /** + * Moderation actions taken + */ + actions: Array; + + /** + * Associated ban records + */ + bans: Array; + + /** + * Associated flag records + */ + flags: Array; + + /** + * Detected languages in the content + */ + languages: Array; + + /** + * When the review was completed + */ + completed_at?: Date; + + config_key?: string; + + /** + * ID of who created the entity + */ + entity_creator_id?: string; + + /** + * When the item was escalated + */ + escalated_at?: Date; + + /** + * ID of the moderator who escalated the item + */ + escalated_by?: string; + + /** + * When the item was reviewed + */ + reviewed_at?: Date; + + /** + * Teams associated with this item + */ + teams?: Array; + + activity?: EnrichedActivity; + + appeal?: AppealItemResponse; + + assigned_to?: UserResponse; + + call?: CallResponse; + + entity_creator?: EntityCreatorResponse; + + escalation_metadata?: EscalationMetadata; + + feeds_v2_activity?: EnrichedActivity; + + feeds_v2_reaction?: Reaction; + + feeds_v3_activity?: FeedsV3ActivityResponse; + + feeds_v3_comment?: FeedsV3CommentResponse; + + message?: ChatMessageResponse; + + moderation_payload?: ModerationPayloadResponse; + + reaction?: Reaction; +} + +export interface Role { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Whether this is a custom role or built-in + */ + custom: boolean; + + /** + * Unique role name + */ + name: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * List of scopes where this role is currently present. `.app` means that role is present in app-level grants + */ + scopes: Array; +} + +export interface RuleBuilderAction { + reason?: string; + + skip_inbox?: boolean; + + type?: + | 'ban_user' + | 'flag_user' + | 'flag_content' + | 'block_content' + | 'shadow_content' + | 'bounce_flag_content' + | 'bounce_content' + | 'bounce_remove_content' + | 'mute_video' + | 'mute_audio' + | 'blur' + | 'call_blur' + | 'end_call' + | 'kick_user' + | 'warning' + | 'call_warning' + | 'webhook_only'; + + ban_options?: BanOptions; + + call_options?: CallActionOptions; + + flag_user_options?: FlagUserOptions; +} + +export interface RuleBuilderCondition { + confidence?: number; + + type?: string; + + call_custom_property_params?: CallCustomPropertyParameters; + + call_type_rule_params?: CallTypeRuleParameters; + + call_violation_count_params?: CallViolationCountParameters; + + channel_message_count_rule_params?: ChannelMessageCountRuleParameters; + + closed_caption_rule_params?: ClosedCaptionRuleParameters; + + content_count_rule_params?: ContentCountRuleParameters; + + content_custom_property_count_params?: ContentCustomPropertyCountParameters; + + content_custom_property_params?: ContentCustomPropertyParameters; + + content_flag_count_rule_params?: FlagCountRuleParameters; + + image_content_params?: ImageContentParameters; + + image_rule_params?: ImageRuleParameters; + + keyframe_ocr_rule_params?: KeyframeOCRRuleParameters; + + keyframe_rule_params?: KeyframeRuleParameters; + + text_content_params?: TextContentParameters; + + text_rule_params?: TextRuleParameters; + + user_created_within_params?: UserCreatedWithinParameters; + + user_custom_property_params?: UserCustomPropertyParameters; + + user_flag_count_rule_params?: FlagCountRuleParameters; + + user_identical_content_count_params?: UserIdenticalContentCountParameters; + + user_role_params?: UserRoleParameters; + + user_rule_params?: UserRuleParameters; + + video_content_params?: VideoContentParameters; + + video_rule_params?: VideoRuleParameters; +} + +export interface RuleBuilderConditionGroup { + logic?: string; + + conditions?: Array; +} + +export interface RuleBuilderConfig { + async?: boolean; + + rules?: Array; +} + +export interface RuleBuilderRule { + rule_type: string; + + cooldown_period?: string; + + id?: string; + + logic?: string; + + action_sequences?: Array; + + conditions?: Array; + + groups?: Array; + + action?: RuleBuilderAction; +} + +export interface SearchPayload { + /** + * Channel filter conditions + */ + filter_conditions: Filters<{ + app_banned: { + type: string; + operators: '$eq'; + }; + + archived: { + type: boolean; + operators: '$eq'; + }; + + blocked: { + type: boolean; + operators: '$eq'; + }; + + channel_role: { + type: string; + operators: '$eq' | '$in'; + }; + + cid: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + created_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + created_by_id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + disabled: { + type: boolean; + operators: '$eq'; + }; + + distinct: { + type: boolean; + operators: '$eq'; + }; + + filter_tags: { + type: string; + operators: '$eq' | '$in'; + }; + + frozen: { + type: boolean; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + has_unread: { + type: boolean; + operators: '$eq'; + }; + + hidden: { + type: boolean; + operators: '$eq'; + }; + + id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + invite: { + type: string; + operators: '$eq'; + }; + + joined: { + type: boolean; + operators: '$eq'; + }; + + last_message_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + last_updated: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + 'member.user.name': { + type: string; + operators: '$autocomplete' | '$eq' | '$ne'; + }; + + member_count: { + type: number; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + members: { + type: string; + operators: '$eq' | '$in' | '$nin'; + }; + + message_count: { + type: number; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + muted: { + type: boolean; + operators: '$eq'; + }; + + name: { + type: string; + operators: + | '$autocomplete' + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin' + | '$q'; + }; + + pinned: { + type: boolean; + operators: '$eq'; + }; + + team: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + type: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + updated_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + }>; + + force_default_search?: boolean; + + force_sql_v2_backend?: boolean; + + /** + * Number of messages to return + */ + limit?: number; + + /** + * Pagination parameter. Cannot be used with non-zero offset. + */ + next?: string; + + /** + * Pagination offset. Cannot be used with sort or next. + */ + offset?: number; + + /** + * Search phrase + */ + query?: string; + + /** + * Sort parameters. Cannot be used with non-zero offset + */ + sort?: Array; + + /** + * Message filter conditions + */ + message_filter_conditions?: Filters<{ + attachments: { + type: boolean; + operators: '$exists'; + }; + + 'attachments.type': { + type: string; + operators: '$eq' | '$in'; + }; + + created_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + 'mentioned_users.id': { + type: string; + operators: '$contains'; + }; + + parent_id: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + pinned: { + type: boolean; + operators: '$eq'; + }; + + reply_count: { + type: number; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + text: { + type: string; + operators: + | '$any' + | '$autocomplete' + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin' + | '$q'; + }; + + type: { + type: string; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + updated_at: { + type: Date; + operators: + | '$eq' + | '$exists' + | '$gt' + | '$gte' + | '$in' + | '$lt' + | '$lte' + | '$ne' + | '$nin'; + }; + + 'user.id': { + type: string; + operators: '$eq' | '$in' | '$ne' | '$nin'; + }; + + user_id: { + type: string; + operators: '$eq' | '$in' | '$ne' | '$nin'; + }; + }>; + + message_options?: MessageOptions; +} + +export interface SearchResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * Search results + */ + results: Array; + + /** + * Value to pass to the next search query in order to paginate + */ + next?: string; + + /** + * Value that points to the previous page. Pass as the next value in a search query to paginate backwards + */ + previous?: string; + + results_warning?: SearchWarning; +} + +export interface SearchResult { + message?: SearchResultMessage; +} + +export interface SearchResultMessage { + cid: string; + + created_at: Date; + + deleted_reply_count: number; + + html: string; + + id: string; + + mentioned_channel: boolean; + + mentioned_here: boolean; + + pinned: boolean; + + reply_count: number; + + shadowed: boolean; + + silent: boolean; + + text: string; + + type: string; + + updated_at: Date; + + attachments: Array; + + latest_reactions: Array; + + mentioned_users: Array; + + own_reactions: Array; + + restricted_visibility: Array; + + custom: CustomMessageData; + + reaction_counts: Record; + + reaction_scores: Record; + + user: UserResponse; + + command?: string; + + deleted_at?: Date; + + deleted_for_me?: boolean; + + message_text_updated_at?: Date; + + mml?: string; + + parent_id?: string; + + pin_expires?: Date; + + pinned_at?: Date; + + poll_id?: string; + + quoted_message_id?: string; + + show_in_channel?: boolean; + + mentioned_group_ids?: Array; + + mentioned_groups?: Array; + + mentioned_roles?: Array; + + thread_participants?: Array; + + channel?: ChannelResponse; + + draft?: DraftResponse; + + i18n?: Record; + + image_labels?: Record>; + + member?: ChannelMemberResponse; + + moderation?: ModerationV2Response; + + pinned_by?: UserResponse; + + poll?: PollResponseData; + + quoted_message?: MessageResponse; + + reaction_groups?: Record; + + reminder?: ReminderResponseData; + + shared_location?: SharedLocationResponseData; +} + +export interface SearchRolesResponse { + duration: string; + + /** + * Matching roles, sorted ascending by name + */ + roles: Array; +} + +export interface SearchUserGroupsResponse { + duration: string; + + /** + * List of matching user groups + */ + user_groups: Array; +} + +export interface SearchWarning { + /** + * Code corresponding to the warning + */ + warning_code: number; + + /** + * Description of the warning + */ + warning_description: string; + + /** + * Number of channels searched + */ + channel_search_count?: number; + + /** + * Channel CIDs for the searched channels + */ + channel_search_cids?: Array; +} + +export interface SendEventRequest { + event: EventRequest; +} + +export interface SendMessageRequest { + message: MessageRequest; + + keep_channel_hidden?: boolean; + + skip_enrich_url?: boolean; + + skip_push?: boolean; +} + +export interface SendMessageResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + message: MessageResponse; + + /** + * Pending message metadata + */ + pending_message_metadata?: Record; +} + +export interface SendReactionRequest { + reaction: ReactionRequest; + + /** + * Whether to replace all existing user reactions + */ + enforce_unique?: boolean; + + /** + * Skips any mobile push notifications + */ + skip_push?: boolean; +} + +export interface SendReactionResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + message: MessageResponse; + + reaction: ReactionResponse; +} + +export interface ShadowBlockActionRequestPayload { + /** + * Reason for shadow blocking + */ + reason?: string; +} + +export interface SharedLocation { + latitude: number; + + longitude: number; + + created_by_device_id?: string; + + end_at?: Date; +} + +export interface SharedLocationResponse { + /** + * Channel CID + */ + channel_cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Device ID that created the live location + */ + created_by_device_id: string; + + duration: string; + + /** + * Latitude coordinate + */ + latitude: number; + + /** + * Longitude coordinate + */ + longitude: number; + + /** + * Message ID + */ + message_id: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * User ID + */ + user_id: string; + + /** + * Time when the live location expires + */ + end_at?: Date; + + channel?: ChannelResponse; + + message?: MessageResponse; +} + +export interface SharedLocationResponseData { + channel_cid: string; + + created_at: Date; + + created_by_device_id: string; + + latitude: number; + + longitude: number; + + message_id: string; + + updated_at: Date; + + user_id: string; + + end_at?: Date; + + channel?: ChannelResponse; + + message?: MessageResponse; +} + +export interface SharedLocationsResponse { + duration: string; + + active_live_locations: Array; +} + +export interface ShowChannelRequest {} + +export interface ShowChannelResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; +} + +export interface SortParamRequest { + /** + * Direction of sorting, 1 for Ascending, -1 for Descending, default is 1. One of: -1, 1 + */ + direction?: number; + + /** + * Name of field to sort by + */ + field?: string; + + /** + * Type of field to sort by. Empty string or omitted means string type (default). One of: number, boolean + */ + type?: string; +} + +export interface SubmitActionRequest { + /** + * Type of moderation action to perform. One of: mark_reviewed, delete_message, delete_activity, delete_comment, delete_reaction, ban, custom, unban, restore, delete_user, unblock, block, shadow_block, unmask, kick_user, end_call, escalate, de_escalate + */ + + action_type: + | 'flag' + | 'mark_reviewed' + | 'delete_message' + | 'delete_activity' + | 'delete_comment' + | 'delete_reaction' + | 'ban' + | 'custom' + | 'unban' + | 'restore' + | 'delete_user' + | 'unblock' + | 'block' + | 'shadow_block' + | 'unmask' + | 'kick_user' + | 'end_call' + | 'reject_appeal' + | 'escalate' + | 'de_escalate' + | 'bypass'; + + /** + * UUID of the appeal to act on (required for reject_appeal, optional for other actions) + */ + appeal_id?: string; + + /** + * UUID of the review queue item to act on + */ + item_id?: string; + + ban?: BanActionRequestPayload; + + block?: BlockActionRequestPayload; + + bypass?: BypassActionRequest; + + custom?: CustomActionRequestPayload; + + delete_activity?: DeleteActivityRequestPayload; + + delete_comment?: DeleteCommentRequestPayload; + + delete_message?: DeleteMessageRequestPayload; + + delete_reaction?: DeleteReactionRequestPayload; + + delete_user?: DeleteUserRequestPayload; + + escalate?: EscalatePayload; + + flag?: FlagRequest; + + mark_reviewed?: MarkReviewedRequestPayload; + + reject_appeal?: RejectAppealRequestPayload; + + restore?: RestoreActionRequestPayload; + + shadow_block?: ShadowBlockActionRequestPayload; + + unban?: UnbanActionRequestPayload; + + unblock?: UnblockActionRequestPayload; +} + +export interface SubmitActionResponse { + duration: string; + + /** + * Present when the appeal was accepted but the entity could not be restored automatically. The moderator should restore it manually. + */ + auto_restore_warning?: string; + + appeal_item?: AppealItemResponse; + + item?: ReviewQueueItemResponse; +} + +export interface SyncRequest { + /** + * Date from which synchronization should happen + */ + last_sync_at: Date; + + /** + * List of channel CIDs to sync + */ + channel_cids: Array; +} + +export interface SyncResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * List of events + */ + events: Array; + + /** + * List of CIDs that user can't access + */ + inaccessible_cids?: Array; +} + +export interface TextContentParameters { + contains_url?: boolean; + + label_operator?: string; + + severity?: string; + + text_length?: number; + + text_length_operator?: string; + + blocklist_match?: Array; + + harm_labels?: Array; + + llm_harm_labels?: Record; +} + +export interface TextRuleParameters { + contains_url?: boolean; + + semantic_filter_min_threshold?: number; + + severity?: string; + + threshold?: number; + + time_window?: string; + + blocklist_match?: Array; + + harm_labels?: Array; + + semantic_filter_names?: Array; + + llm_harm_labels?: Record; +} + +export interface ThreadParticipant { + app_pk: number; + + channel_cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + last_read_at: Date; + + custom: CustomThreadData; + + last_thread_message_at?: Date; + + /** + * Left Thread At is the time when the user left the thread + */ + left_thread_at?: Date; + + /** + * Thead ID is unique string identifier of the thread + */ + thread_id?: string; + + /** + * User ID is unique string identifier of the user + */ + user_id?: string; + + user?: UserResponse; +} + +export interface ThreadResponse { + /** + * Active Participant Count + */ + active_participant_count: number; + + /** + * Channel CID + */ + channel_cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Created By User ID + */ + created_by_user_id: string; + + /** + * Parent Message ID + */ + parent_message_id: string; + + /** + * Participant Count + */ + participant_count: number; + + /** + * Title + */ + title: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + /** + * Custom data for this object + */ + custom: CustomThreadData; + + /** + * Deleted At + */ + deleted_at?: Date; + + /** + * Last Message At + */ + last_message_at?: Date; + + /** + * Reply Count + */ + reply_count?: number; + + /** + * Thread Participants + */ + thread_participants?: Array; + + channel?: ChannelResponse; + + created_by?: UserResponse; + + parent_message?: MessageResponse; +} + +export interface ThreadStateResponse { + /** + * Active Participant Count + */ + active_participant_count: number; + + /** + * Channel CID + */ + channel_cid: string; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Created By User ID + */ + created_by_user_id: string; + + /** + * Parent Message ID + */ + parent_message_id: string; + + /** + * Participant Count + */ + participant_count: number; + + /** + * Title + */ + title: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + latest_replies: Array; + + /** + * Custom data for this object + */ + custom: CustomThreadData; + + /** + * Deleted At + */ + deleted_at?: Date; + + /** + * Last Message At + */ + last_message_at?: Date; + + /** + * Reply Count + */ + reply_count?: number; + + read?: Array; + + /** + * Thread Participants + */ + thread_participants?: Array; + + channel?: ChannelResponse; + + created_by?: UserResponse; + + draft?: DraftResponse; + + parent_message?: MessageResponse; +} + +export interface ThreadUpdatedEvent { + created_at: Date; + + custom: CustomEventData; + + type: string; + + channel_id?: string; + + channel_type?: string; + + cid?: string; + + received_at?: Date; + + thread?: ThreadResponse; +} + +export interface Thresholds { + explicit?: LabelThresholds; + + spam?: LabelThresholds; + + toxic?: LabelThresholds; +} + +export interface Time {} + +export interface TranslateMessageRequest { + /** + * Language to translate message to + */ + + language: + | 'af' + | 'sq' + | 'am' + | 'ar' + | 'az' + | 'bn' + | 'bs' + | 'bg' + | 'zh' + | 'zh-TW' + | 'hr' + | 'cs' + | 'da' + | 'fa-AF' + | 'nl' + | 'en' + | 'et' + | 'fi' + | 'fr' + | 'fr-CA' + | 'ka' + | 'de' + | 'el' + | 'ha' + | 'he' + | 'hi' + | 'hu' + | 'id' + | 'it' + | 'ja' + | 'ko' + | 'lv' + | 'ms' + | 'no' + | 'fa' + | 'ps' + | 'pl' + | 'pt' + | 'ro' + | 'ru' + | 'sr' + | 'sk' + | 'sl' + | 'so' + | 'es' + | 'es-MX' + | 'sw' + | 'sv' + | 'tl' + | 'ta' + | 'th' + | 'tr' + | 'uk' + | 'ur' + | 'vi' + | 'lt' + | 'ht'; +} + +export interface TruncateChannelRequest { + /** + * Permanently delete channel data (messages, reactions, etc.) + */ + hard_delete?: boolean; + + /** + * When `message` is set disables all push notifications for it + */ + skip_push?: boolean; + + /** + * Truncate channel data up to `truncated_at`. The system message (if provided) creation time is always greater than `truncated_at` + */ + truncated_at?: Date; + + /** + * List of member IDs to hide message history for. If empty, truncates the channel for all members + */ + member_ids?: Array; + + message?: MessageRequest; +} + +export interface TruncateChannelResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + channel?: ChannelResponse; + + message?: MessageResponse; +} + +export interface TypingIndicatorsResponse { + enabled: boolean; +} + +export interface TypingStartEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "typing.start" in this case + */ + type: string; + + /** + * The ID of the channel where the user started typing + */ + channel_id?: string; + + /** + * The type of the channel where the user started typing + */ + channel_type?: string; + + /** + * The CID of the channel where the user started typing + */ + cid?: string; + + /** + * The parent ID if the user started typing in a thread + */ + parent_id?: string; + + received_at?: Date; + + user?: UserResponseCommonFields; +} + +export interface TypingStopEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "typing.stop" in this case + */ + type: string; + + /** + * The ID of the channel where the user stopped typing + */ + channel_id?: string; + + /** + * The type of the channel where the user stopped typing + */ + channel_type?: string; + + /** + * The CID of the channel where the user stopped typing + */ + cid?: string; + + /** + * The parent ID if the user stopped typing in a thread + */ + parent_id?: string; + + received_at?: Date; + + user?: UserResponseCommonFields; +} + +export interface UnbanActionRequestPayload { + /** + * Channel CID for channel-specific unban + */ + channel_cid?: string; + + /** + * Reason for the appeal decision + */ + decision_reason?: string; + + /** + * Also remove the future channels ban for this user + */ + remove_future_channels_ban?: boolean; +} + +export interface UnblockActionRequestPayload { + /** + * Reason for the appeal decision + */ + decision_reason?: string; +} + +export interface UnblockUsersRequest { + blocked_user_id: string; +} + +export interface UnblockUsersResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; +} + +export interface UnmuteChannelRequest { + /** + * Duration of mute in milliseconds + */ + expiration?: number; + + /** + * Channel CIDs to mute (if multiple channels) + */ + channel_cids?: Array; +} + +export interface UnmuteResponse { + duration: string; + + /** + * A list of users that can't be found. Common cause for this is deleted users + */ + non_existing_users?: Array; +} + +export interface UnreadCountsChannel { + channel_id: string; + + last_read: Date; + + unread_count: number; +} + +export interface UnreadCountsChannelType { + channel_count: number; + + channel_type: string; + + unread_count: number; +} + +export interface UnreadCountsThread { + last_read: Date; + + last_read_message_id: string; + + parent_message_id: string; + + unread_count: number; +} + +export interface UpdateBlockListRequest { + is_confusable_folding_enabled?: boolean; + + is_leet_check_enabled?: boolean; + + is_plural_check_enabled?: boolean; + + is_substring_matching_enabled?: boolean; + + team?: string; + + /** + * List of words to block + */ + words?: Array; +} + +export interface UpdateBlockListResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + blocklist?: BlockListResponse; +} + +export interface UpdateChannelPartialRequest { + unset?: Array; + + set?: Record; +} + +export interface UpdateChannelPartialResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * List of updated members + */ + members: Array; + + channel?: ChannelResponse; +} + +export interface UpdateChannelRequest { + /** + * Set to `true` to accept the invite + */ + accept_invite?: boolean; + + /** + * Sets cool down period for the channel in seconds + */ + cooldown?: number; + + /** + * Set to `true` to hide channel's history when adding new members + */ + hide_history?: boolean; + + /** + * If set, hides channel's history before this time when adding new members. Takes precedence over `hide_history` when both are provided. Must be in RFC3339 format (e.g., "2024-01-01T10:00:00Z") and in the past. + */ + hide_history_before?: Date; + + /** + * Set to `true` to reject the invite + */ + reject_invite?: boolean; + + /** + * When `message` is set disables all push notifications for it + */ + skip_push?: boolean; + + /** + * List of filter tags to add to the channel + */ + add_filter_tags?: Array; + + /** + * List of user IDs to add to the channel + */ + add_members?: Array; + + /** + * List of user IDs to make channel moderators + */ + add_moderators?: Array; + + /** + * List of channel member role assignments. If any specified user is not part of the channel, the request will fail + */ + assign_roles?: Array; + + /** + * List of user IDs to take away moderators status from + */ + demote_moderators?: Array; + + /** + * List of user IDs to invite to the channel + */ + invites?: Array; + + /** + * List of filter tags to remove from the channel + */ + remove_filter_tags?: Array; + + /** + * List of user IDs to remove from the channel + */ + remove_members?: Array; + + data?: ChannelInputRequest; + + message?: MessageRequest; +} + +export interface UpdateChannelResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * List of channel members + */ + members: Array; + + channel?: ChannelResponse; + + message?: MessageResponse; +} + +export interface UpdateLiveLocationRequest { + /** + * Live location ID + */ + message_id: string; + + /** + * Time when the live location expires + */ + end_at?: Date; + + /** + * Latitude coordinate + */ + latitude?: number; + + /** + * Longitude coordinate + */ + longitude?: number; +} + +export interface UpdateMemberPartialRequest { + unset?: Array; + + set?: Record; +} + +export interface UpdateMemberPartialResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + channel_member?: ChannelMemberResponse; +} + +export interface UpdateMessagePartialRequest { + /** + * Skip enriching the URL in the message + */ + skip_enrich_url?: boolean; + + skip_push?: boolean; + + /** + * Array of field names to unset + */ + unset?: Array; + + /** + * Sets new field values + */ + set?: Record; +} + +export interface UpdateMessagePartialResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + message?: MessageResponse; + + /** + * Pending message metadata + */ + pending_message_metadata?: Record; +} + +export interface UpdateMessageRequest { + message: MessageRequest; + + /** + * Skip enrich URL + */ + skip_enrich_url?: boolean; + + skip_push?: boolean; +} + +export interface UpdateMessageResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + message: MessageResponse; + + pending_message_metadata?: Record; +} + +export interface UpdatePollOptionRequest { + /** + * Option ID + */ + id: string; + + /** + * Option text + */ + text: string; + + custom?: CustomPollOptionData; +} + +export interface UpdatePollPartialRequest { + /** + * Array of field names to unset + */ + unset?: Array; + + /** + * Sets new field values + */ + set?: Record; +} + +export interface UpdatePollRequest { + /** + * Poll ID + */ + id: string; + + /** + * Poll name + */ + name: string; + + /** + * Allow answers + */ + allow_answers?: boolean; + + /** + * Allow user suggested options + */ + allow_user_suggested_options?: boolean; + + /** + * Poll description + */ + description?: string; + + /** + * Enforce unique vote + */ + enforce_unique_vote?: boolean; + + /** + * Is closed + */ + is_closed?: boolean; + + /** + * Max votes allowed + */ + max_votes_allowed?: number; + + /** + * Voting visibility + */ + + voting_visibility?: 'anonymous' | 'public'; + + /** + * Poll options + */ + options?: Array; + + custom?: CustomPollData; +} + +export interface UpdateQueueRequest { + description?: string; + + name?: string; + + sort?: Array>; + + filters?: Record; +} + +export interface UpdateReminderRequest { + remind_at?: Date; +} + +export interface UpdateReminderResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + reminder: ReminderResponseData; +} + +export interface UpdateThreadPartialRequest { + /** + * Array of field names to unset + */ + unset?: Array; + + /** + * Sets new field values + */ + set?: Record; +} + +export interface UpdateThreadPartialResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + thread: ThreadResponse; +} + +export interface UpdateUserGroupRequest { + /** + * The new description for the group + */ + description?: string; + + /** + * The new name of the user group + */ + name?: string; + + team_id?: string; +} + +export interface UpdateUserGroupResponse { + duration: string; + + user_group?: UserGroupResponse; +} + +export interface UpdateUserPartialRequest { + /** + * User ID to update + */ + id: string; + + unset?: Array; + + set?: Record; +} + +export interface UpdateUsersPartialRequest { + users: Array; +} + +export interface UpdateUsersRequest { + /** + * Object containing users + */ + users: Record; +} + +export interface UpdateUsersResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + membership_deletion_task_id: string; + + /** + * Object containing users + */ + users: Record; +} + +export interface UploadChannelFileRequest { + /** + * file field + */ + file?: string; + + user?: OnlyUserID; +} + +export interface UploadChannelFileResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * URL to the uploaded asset. Should be used to put to `asset_url` attachment field + */ + file?: string; + + moderation_action?: string; + + /** + * URL of the file thumbnail for supported file formats. Should be put to `thumb_url` attachment field + */ + thumb_url?: string; +} + +export interface UploadChannelRequest { + file?: string; + + /** + * field with JSON-encoded array of image size configurations + */ + upload_sizes?: Array; + + user?: OnlyUserID; +} + +export interface UploadChannelResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + file?: string; + + moderation_action?: string; + + thumb_url?: string; + + /** + * Array of image size configurations + */ + upload_sizes?: Array; +} + +export interface UpsertActionConfigItem { + action: string; + + entity_type: string; + + order: number; + + description?: string; + + icon?: string; + + id?: string; + + queue_type?: string; + + custom?: Record; +} + +export interface UpsertActionConfigRequest { + /** + * The action to perform (e.g. ban, delete_message, custom) + */ + action: string; + + /** + * Type of entity this action applies to (e.g. stream:chat:v1:message) + */ + entity_type: string; + + /** + * Display order in the dashboard (0–100, lower numbers shown first) + */ + order: number; + + /** + * Human-readable label for the dashboard button + */ + description?: string; + + /** + * Icon identifier for the dashboard button + */ + icon?: string; + + /** + * UUID of an existing action config to update; omit to create a new record + */ + id?: string; + + /** + * Queue this config belongs to; null means the default queue + */ + queue_type?: string; + + /** + * Action-specific parameters passed to the action handler + */ + custom?: Record; +} + +export interface UpsertActionConfigResponse { + duration: string; + + action_config?: ModerationActionConfigResponse; +} + +export interface UpsertConfigRequest { + /** + * Unique identifier for the moderation configuration + */ + key: string; + + /** + * Whether moderation should be performed asynchronously + */ + async?: boolean; + + /** + * Team associated with the configuration + */ + team?: string; + + ai_image_config?: AIImageConfig; + + ai_text_config?: AITextConfig; + + ai_video_config?: AIVideoConfig; + + automod_platform_circumvention_config?: AutomodPlatformCircumventionConfig; + + automod_semantic_filters_config?: AutomodSemanticFiltersConfig; + + automod_toxicity_config?: AutomodToxicityConfig; + + aws_rekognition_config?: AIImageConfig; + + block_list_config?: BlockListConfig; + + bodyguard_config?: AITextConfig; + + flood_config?: FloodConfig; + + google_vision_config?: GoogleVisionConfig; + + llm_config?: LLMConfig; + + rule_builder_config?: RuleBuilderConfig; + + velocity_filter_config?: VelocityFilterConfig; + + video_call_rule_config?: VideoCallRuleConfig; +} + +export interface UpsertConfigResponse { + duration: string; + + config?: ConfigResponse; +} + +export interface UpsertPushPreferencesRequest { + /** + * A list of push preferences for channels, calls, or the user. + */ + preferences: Array; +} + +export interface UpsertPushPreferencesResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + /** + * The channel specific push notification preferences, only returned for channels you've edited. + */ + user_channel_preferences: Record< + string, + Record + >; + + /** + * The user preferences, always returned regardless if you edited it + */ + user_preferences: Record; +} + +export interface User { + id: string; + + data?: Record; +} + +export interface UserBannedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.banned" in this case + */ + type: string; + + /** + * The ID of the channel where the target user was banned + */ + channel_id?: string; + + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel where the target user was banned + */ + channel_type?: string; + + /** + * The CID of the channel where the target user was banned + */ + cid?: string; + + /** + * The expiration date of the ban + */ + expiration?: Date; + + /** + * The reason for the ban + */ + reason?: string; + + received_at?: Date; + + /** + * ID of the review queue item (flagged message) that triggered the ban, if the ban was applied from the moderation review queue + */ + review_queue_item_id?: string; + + /** + * Whether the user was shadow banned + */ + shadow?: boolean; + + /** + * The team of the channel where the target user was banned + */ + team?: string; + + total_bans?: number; + + channel_custom?: CustomChannelData; + + created_by?: UserResponseCommonFields; +} + +export interface UserCreatedWithinParameters { + max_age?: string; +} + +export interface UserCustomPropertyParameters { + operator?: string; + + property_key?: string; +} + +export interface UserDeactivatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.deactivated" in this case + */ + type: string; + + received_at?: Date; + + created_by?: UserResponseCommonFields; +} + +export interface UserDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The type of deletion that was used for the user's conversations. One of: hard, soft, pruning, (empty string) + */ + delete_conversation: string; + + /** + * Whether the user's conversation channels were deleted + */ + delete_conversation_channels: boolean; + + /** + * The type of deletion that was used for the user's messages. One of: hard, soft, pruning, (empty string) + */ + delete_messages: string; + + /** + * The type of deletion that was used for the user. One of: hard, soft, pruning, (empty string) + */ + delete_user: string; + + /** + * Whether the user was hard deleted + */ + hard_delete: boolean; + + /** + * Whether the user's messages were marked as deleted + */ + mark_messages_deleted: boolean; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.deleted" in this case + */ + type: string; + + received_at?: Date; +} + +export interface UserGroup { + app_pk: number; + + created_at: Date; + + id: string; + + name: string; + + updated_at: Date; + + created_by?: string; + + description?: string; + + team_id?: string; + + members?: Array; +} + +export interface UserGroupCreatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "user_group.created" in this case + */ + type: string; + + received_at?: Date; + + user?: UserResponseCommonFields; + + user_group?: UserGroup; +} + +export interface UserGroupDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "user_group.deleted" in this case + */ + type: string; + + received_at?: Date; + + user?: UserResponseCommonFields; + + user_group?: UserGroup; +} + +export interface UserGroupMember { + app_pk: number; + + created_at: Date; + + group_id: string; + + is_admin: boolean; + + user_id: string; +} + +export interface UserGroupMemberAddedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The user IDs that were added + */ + members: Array; + + custom: CustomEventData; + + /** + * The type of event: "user_group.member_added" in this case + */ + type: string; + + received_at?: Date; + + user?: UserResponseCommonFields; + + user_group?: UserGroup; +} + +export interface UserGroupMemberRemovedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The user IDs that were removed + */ + members: Array; + + custom: CustomEventData; + + /** + * The type of event: "user_group.member_removed" in this case + */ + type: string; + + received_at?: Date; + + user?: UserResponseCommonFields; + + user_group?: UserGroup; +} + +export interface UserGroupResponse { + created_at: Date; + + id: string; + + name: string; + + updated_at: Date; + + created_by?: string; + + description?: string; + + team_id?: string; + + members?: Array; +} + +export interface UserGroupUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + /** + * The type of event: "user_group.updated" in this case + */ + type: string; + + received_at?: Date; + + user?: UserResponseCommonFields; + + user_group?: UserGroup; +} + +export interface UserIdenticalContentCountParameters { + threshold?: number; + + time_window?: string; +} + +export interface UserMessagesDeletedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.messages.deleted" in this case + */ + type: string; + + /** + * The ID of the channel where the target user's messages were deleted + */ + channel_id?: string; + + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel where the target user's messages were deleted + */ + channel_type?: string; + + /** + * The CID of the channel where the target user's messages were deleted + */ + cid?: string; + + /** + * Whether Messages were hard deleted + */ + hard_delete?: boolean; + + received_at?: Date; + + /** + * The team of the channel where the target user's messages were deleted + */ + team?: string; + + channel_custom?: CustomChannelData; +} + +export interface UserMuteResponse { + created_at: Date; + + updated_at: Date; + + expires?: Date; + + target?: UserResponse; + + user?: UserResponse; +} + +export interface UserMutedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.muted" in this case + */ + type: string; + + received_at?: Date; + + /** + * The target users that were muted + */ + target_users?: Array; + + target_user?: UserResponseCommonFields; +} + +export interface UserPresenceChangedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.presence.changed" in this case + */ + type: string; + + received_at?: Date; +} + +export interface UserReactivatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.reactivated" in this case + */ + type: string; + + received_at?: Date; + + created_by?: UserResponseCommonFields; +} + +export interface UserRequest { + /** + * User ID + */ + id: string; + + /** + * User's profile image URL + */ + image?: string; + + invisible?: boolean; + + language?: string; + + /** + * Optional name of user + */ + name?: string; + + /** + * Custom user data + */ + custom?: CustomUserData; + + privacy_settings?: PrivacySettingsResponse; +} + +export interface UserResponse { + /** + * Whether a user is banned or not + */ + banned: boolean; + + /** + * Date/time of creation + */ + created_at: Date; + + /** + * Unique user identifier + */ + id: string; + + /** + * Preferred language of a user + */ + language: string; + + /** + * Whether a user online or not + */ + online: boolean; + + /** + * Determines the set of user permissions + */ + role: string; + + /** + * Date/time of the last update + */ + updated_at: Date; + + blocked_user_ids: Array; + + /** + * List of teams user is a part of + */ + teams: Array; + + /** + * Custom data for this object + */ + custom: CustomUserData; + + avg_response_time?: number; + + /** + * Date of deactivation + */ + deactivated_at?: Date; + + /** + * Date/time of deletion + */ + deleted_at?: Date; + + image?: string; + + /** + * Date of last activity + */ + last_active?: Date; + + /** + * Optional name of user + */ + name?: string; + + /** + * Revocation date for tokens + */ + revoke_tokens_issued_before?: Date; + + teams_role?: Record; +} + +export interface UserResponseCommonFields { + banned: boolean; + + created_at: Date; + + id: string; + + language: string; + + online: boolean; + + role: string; + + updated_at: Date; + + blocked_user_ids: Array; + + teams: Array; + + custom: CustomUserData; + + avg_response_time?: number; + + deactivated_at?: Date; + + deleted_at?: Date; + + image?: string; + + last_active?: Date; + + name?: string; + + revoke_tokens_issued_before?: Date; + + teams_role?: Record; +} + +export interface UserResponsePrivacyFields { + banned: boolean; + + created_at: Date; + + id: string; + + language: string; + + online: boolean; + + role: string; + + updated_at: Date; + + blocked_user_ids: Array; + + teams: Array; + + custom: CustomUserData; + + avg_response_time?: number; + + deactivated_at?: Date; + + deleted_at?: Date; + + image?: string; + + invisible?: boolean; + + last_active?: Date; + + name?: string; + + revoke_tokens_issued_before?: Date; + + privacy_settings?: PrivacySettingsResponse; + + teams_role?: Record; +} + +export interface UserRoleParameters { + operator?: string; + + role?: string; +} + +export interface UserRuleParameters { + max_age?: string; +} + +export interface UserUnbannedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.unbanned" in this case + */ + type: string; + + /** + * The ID of the channel where the target user was unbanned + */ + channel_id?: string; + + channel_member_count?: number; + + channel_message_count?: number; + + /** + * The type of the channel where the target user was unbanned + */ + channel_type?: string; + + /** + * The CID of the channel where the target user was unbanned + */ + cid?: string; + + received_at?: Date; + + /** + * Whether the target user was shadow unbanned + */ + shadow?: boolean; + + /** + * The team of the channel where the target user was unbanned + */ + team?: string; + + channel_custom?: CustomChannelData; + + created_by?: UserResponseCommonFields; +} + +export interface UserUpdatedEvent { + /** + * Date/time of creation + */ + created_at: Date; + + custom: CustomEventData; + + user: UserResponsePrivacyFields; + + /** + * The type of event: "user.updated" in this case + */ + type: string; + + received_at?: Date; +} + +export interface UserWatchingStartEvent { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The number of users watching the channel + */ + watcher_count: number; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.watching.start" in this case + */ + type: string; + + /** + * The ID of the channel which the user started watching + */ + channel_id?: string; + + /** + * The type of the channel which the user started watching + */ + channel_type?: string; + + /** + * The CID of the channel which the user started watching + */ + cid?: string; + + received_at?: Date; +} + +export interface UserWatchingStopEvent { + /** + * Date/time of creation + */ + created_at: Date; + + /** + * The number of users watching the channel + */ + watcher_count: number; + + custom: CustomEventData; + + user: UserResponseCommonFields; + + /** + * The type of event: "user.watching.stop" in this case + */ + type: string; + + /** + * The ID of the channel which the user stopped watching + */ + channel_id?: string; + + /** + * The type of the channel which the user stopped watching + */ + channel_type?: string; + + /** + * The CID of the channel which the user stopped watching + */ + cid?: string; + + received_at?: Date; +} + +export interface VelocityFilterConfig { + advanced_filters: boolean; + + cascading_actions: boolean; + + cids_per_user: number; + + enabled: boolean; + + first_message_only: boolean; + + rules: Array; + + async?: boolean; +} + +export interface VelocityFilterConfigRule { + action: 'flag' | 'shadow' | 'remove' | 'ban'; + + ban_duration: number; + + cascading_action: 'flag' | 'shadow' | 'remove' | 'ban'; + + cascading_threshold: number; + + check_message_context: boolean; + + fast_spam_threshold: number; + + fast_spam_ttl: number; + + ip_ban: boolean; + + probation_period: number; + + shadow_ban: boolean; + + slow_spam_threshold: number; + + slow_spam_ttl: number; + + url_only: boolean; + + slow_spam_ban_duration?: number; +} + +export interface VideoCallRuleConfig { + flag_all_labels: boolean; + + flagged_labels: Array; + + rules: Array; +} + +export interface VideoContentParameters { + label_operator?: string; + + harm_labels?: Array; +} + +export interface VideoEndCallRequestPayload {} + +export interface VideoKickUserRequestPayload {} + +export interface VideoRuleParameters { + threshold?: number; + + time_window?: string; + + harm_labels?: Array; +} + +export interface VoteData { + answer_text?: string; + + option_id?: string; +} + +export interface WSAuthMessage { + /** + * JWT token for authentication + */ + token: string; + + user_details: ConnectUserDetailsRequest; + + /** + * List of products to subscribe to. One of: chat, video, feeds + */ + products?: Array; +} + +export type WSClientEvent = + | ({ type: '*' } & CustomEvent) + | ({ type: 'ai_indicator.clear' } & AIIndicatorClearEvent) + | ({ type: 'ai_indicator.stop' } & AIIndicatorStopEvent) + | ({ type: 'ai_indicator.update' } & AIIndicatorUpdateEvent) + | ({ type: 'app.updated' } & AppUpdatedEvent) + | ({ type: 'channel.created' } & ChannelCreatedEvent) + | ({ type: 'channel.deleted' } & ChannelDeletedEvent) + | ({ type: 'channel.frozen' } & ChannelFrozenEvent) + | ({ type: 'channel.hidden' } & ChannelHiddenEvent) + | ({ type: 'channel.kicked' } & ChannelKickedEvent) + | ({ type: 'channel.max_streak_changed' } & MaxStreakChangedEvent) + | ({ type: 'channel.truncated' } & ChannelTruncatedEvent) + | ({ type: 'channel.unfrozen' } & ChannelUnFrozenEvent) + | ({ type: 'channel.updated' } & ChannelUpdatedEvent) + | ({ type: 'channel.visible' } & ChannelVisibleEvent) + | ({ type: 'draft.deleted' } & DraftDeletedEvent) + | ({ type: 'draft.updated' } & DraftUpdatedEvent) + | ({ type: 'health.check' } & HealthCheckEvent) + | ({ type: 'member.added' } & MemberAddedEvent) + | ({ type: 'member.removed' } & MemberRemovedEvent) + | ({ type: 'member.updated' } & MemberUpdatedEvent) + | ({ type: 'message.deleted' } & MessageDeletedEvent) + | ({ type: 'message.delivered' } & MessageDeliveredEvent) + | ({ type: 'message.new' } & MessageNewEvent) + | ({ type: 'message.pending' } & PendingMessageEvent) + | ({ type: 'message.read' } & MessageReadEvent) + | ({ type: 'message.undeleted' } & MessageUndeletedEvent) + | ({ type: 'message.updated' } & MessageUpdatedEvent) + | ({ type: 'moderation.custom_action' } & ModerationCustomActionEvent) + | ({ type: 'moderation.flagged' } & ModerationFlaggedEvent) + | ({ type: 'moderation.mark_reviewed' } & ModerationMarkReviewedEvent) + | ({ type: 'notification.added_to_channel' } & NotificationAddedToChannelEvent) + | ({ type: 'notification.channel_deleted' } & NotificationChannelDeletedEvent) + | ({ + type: 'notification.channel_mutes_updated'; + } & NotificationChannelMutesUpdatedEvent) + | ({ type: 'notification.channel_truncated' } & NotificationChannelTruncatedEvent) + | ({ type: 'notification.invite_accepted' } & NotificationInviteAcceptedEvent) + | ({ type: 'notification.invite_rejected' } & NotificationInviteRejectedEvent) + | ({ type: 'notification.invited' } & NotificationInvitedEvent) + | ({ type: 'notification.mark_read' } & NotificationMarkReadEvent) + | ({ type: 'notification.mark_unread' } & NotificationMarkUnreadEvent) + | ({ type: 'notification.message_new' } & NotificationNewMessageEvent) + | ({ type: 'notification.mutes_updated' } & NotificationMutesUpdatedEvent) + | ({ type: 'notification.reminder_due' } & ReminderNotificationEvent) + | ({ type: 'notification.removed_from_channel' } & NotificationRemovedFromChannelEvent) + | ({ type: 'notification.thread_message_new' } & NotificationThreadMessageNewEvent) + | ({ type: 'poll.closed' } & PollClosedEvent) + | ({ type: 'poll.deleted' } & PollDeletedEvent) + | ({ type: 'poll.updated' } & PollUpdatedEvent) + | ({ type: 'poll.vote_casted' } & PollVoteCastedEvent) + | ({ type: 'poll.vote_changed' } & PollVoteChangedEvent) + | ({ type: 'poll.vote_removed' } & PollVoteRemovedEvent) + | ({ type: 'reaction.deleted' } & ReactionDeletedEvent) + | ({ type: 'reaction.new' } & ReactionNewEvent) + | ({ type: 'reaction.updated' } & ReactionUpdatedEvent) + | ({ type: 'reminder.created' } & ReminderCreatedEvent) + | ({ type: 'reminder.deleted' } & ReminderDeletedEvent) + | ({ type: 'reminder.updated' } & ReminderUpdatedEvent) + | ({ type: 'thread.updated' } & ThreadUpdatedEvent) + | ({ type: 'typing.start' } & TypingStartEvent) + | ({ type: 'typing.stop' } & TypingStopEvent) + | ({ type: 'user.banned' } & UserBannedEvent) + | ({ type: 'user.deactivated' } & UserDeactivatedEvent) + | ({ type: 'user.deleted' } & UserDeletedEvent) + | ({ type: 'user.messages.deleted' } & UserMessagesDeletedEvent) + | ({ type: 'user.muted' } & UserMutedEvent) + | ({ type: 'user.presence.changed' } & UserPresenceChangedEvent) + | ({ type: 'user.reactivated' } & UserReactivatedEvent) + | ({ type: 'user.unbanned' } & UserUnbannedEvent) + | ({ type: 'user.updated' } & UserUpdatedEvent) + | ({ type: 'user.watching.start' } & UserWatchingStartEvent) + | ({ type: 'user.watching.stop' } & UserWatchingStopEvent) + | ({ type: 'user_group.created' } & UserGroupCreatedEvent) + | ({ type: 'user_group.deleted' } & UserGroupDeletedEvent) + | ({ type: 'user_group.member_added' } & UserGroupMemberAddedEvent) + | ({ type: 'user_group.member_removed' } & UserGroupMemberRemovedEvent) + | ({ type: 'user_group.updated' } & UserGroupUpdatedEvent); + +export type WSEvent = + | ({ type: '*' } & CustomEvent) + | ({ type: 'ai_indicator.clear' } & AIIndicatorClearEvent) + | ({ type: 'ai_indicator.stop' } & AIIndicatorStopEvent) + | ({ type: 'ai_indicator.update' } & AIIndicatorUpdateEvent) + | ({ type: 'app.updated' } & AppUpdatedEvent) + | ({ type: 'channel.created' } & ChannelCreatedEvent) + | ({ type: 'channel.deleted' } & ChannelDeletedEvent) + | ({ type: 'channel.frozen' } & ChannelFrozenEvent) + | ({ type: 'channel.hidden' } & ChannelHiddenEvent) + | ({ type: 'channel.kicked' } & ChannelKickedEvent) + | ({ type: 'channel.max_streak_changed' } & MaxStreakChangedEvent) + | ({ type: 'channel.truncated' } & ChannelTruncatedEvent) + | ({ type: 'channel.unfrozen' } & ChannelUnFrozenEvent) + | ({ type: 'channel.updated' } & ChannelUpdatedEvent) + | ({ type: 'channel.visible' } & ChannelVisibleEvent) + | ({ type: 'draft.deleted' } & DraftDeletedEvent) + | ({ type: 'draft.updated' } & DraftUpdatedEvent) + | ({ type: 'health.check' } & HealthCheckEvent) + | ({ type: 'member.added' } & MemberAddedEvent) + | ({ type: 'member.removed' } & MemberRemovedEvent) + | ({ type: 'member.updated' } & MemberUpdatedEvent) + | ({ type: 'message.deleted' } & MessageDeletedEvent) + | ({ type: 'message.delivered' } & MessageDeliveredEvent) + | ({ type: 'message.new' } & MessageNewEvent) + | ({ type: 'message.pending' } & PendingMessageEvent) + | ({ type: 'message.read' } & MessageReadEvent) + | ({ type: 'message.undeleted' } & MessageUndeletedEvent) + | ({ type: 'message.updated' } & MessageUpdatedEvent) + | ({ type: 'moderation.custom_action' } & ModerationCustomActionEvent) + | ({ type: 'moderation.flagged' } & ModerationFlaggedEvent) + | ({ type: 'moderation.mark_reviewed' } & ModerationMarkReviewedEvent) + | ({ type: 'notification.added_to_channel' } & NotificationAddedToChannelEvent) + | ({ type: 'notification.channel_deleted' } & NotificationChannelDeletedEvent) + | ({ + type: 'notification.channel_mutes_updated'; + } & NotificationChannelMutesUpdatedEvent) + | ({ type: 'notification.channel_truncated' } & NotificationChannelTruncatedEvent) + | ({ type: 'notification.invite_accepted' } & NotificationInviteAcceptedEvent) + | ({ type: 'notification.invite_rejected' } & NotificationInviteRejectedEvent) + | ({ type: 'notification.invited' } & NotificationInvitedEvent) + | ({ type: 'notification.mark_read' } & NotificationMarkReadEvent) + | ({ type: 'notification.mark_unread' } & NotificationMarkUnreadEvent) + | ({ type: 'notification.message_new' } & NotificationNewMessageEvent) + | ({ type: 'notification.mutes_updated' } & NotificationMutesUpdatedEvent) + | ({ type: 'notification.reminder_due' } & ReminderNotificationEvent) + | ({ type: 'notification.removed_from_channel' } & NotificationRemovedFromChannelEvent) + | ({ type: 'notification.thread_message_new' } & NotificationThreadMessageNewEvent) + | ({ type: 'poll.closed' } & PollClosedEvent) + | ({ type: 'poll.deleted' } & PollDeletedEvent) + | ({ type: 'poll.updated' } & PollUpdatedEvent) + | ({ type: 'poll.vote_casted' } & PollVoteCastedEvent) + | ({ type: 'poll.vote_changed' } & PollVoteChangedEvent) + | ({ type: 'poll.vote_removed' } & PollVoteRemovedEvent) + | ({ type: 'reaction.deleted' } & ReactionDeletedEvent) + | ({ type: 'reaction.new' } & ReactionNewEvent) + | ({ type: 'reaction.updated' } & ReactionUpdatedEvent) + | ({ type: 'reminder.created' } & ReminderCreatedEvent) + | ({ type: 'reminder.deleted' } & ReminderDeletedEvent) + | ({ type: 'reminder.updated' } & ReminderUpdatedEvent) + | ({ type: 'thread.updated' } & ThreadUpdatedEvent) + | ({ type: 'typing.start' } & TypingStartEvent) + | ({ type: 'typing.stop' } & TypingStopEvent) + | ({ type: 'user.banned' } & UserBannedEvent) + | ({ type: 'user.deactivated' } & UserDeactivatedEvent) + | ({ type: 'user.deleted' } & UserDeletedEvent) + | ({ type: 'user.messages.deleted' } & UserMessagesDeletedEvent) + | ({ type: 'user.muted' } & UserMutedEvent) + | ({ type: 'user.presence.changed' } & UserPresenceChangedEvent) + | ({ type: 'user.reactivated' } & UserReactivatedEvent) + | ({ type: 'user.unbanned' } & UserUnbannedEvent) + | ({ type: 'user.updated' } & UserUpdatedEvent) + | ({ type: 'user.watching.start' } & UserWatchingStartEvent) + | ({ type: 'user.watching.stop' } & UserWatchingStopEvent) + | ({ type: 'user_group.created' } & UserGroupCreatedEvent) + | ({ type: 'user_group.deleted' } & UserGroupDeletedEvent) + | ({ type: 'user_group.member_added' } & UserGroupMemberAddedEvent) + | ({ type: 'user_group.member_removed' } & UserGroupMemberRemovedEvent) + | ({ type: 'user_group.updated' } & UserGroupUpdatedEvent); + +export interface WrappedUnreadCountsResponse { + /** + * Duration of the request in milliseconds + */ + duration: string; + + total_unread_count: number; + + total_unread_threads_count: number; + + channel_type: Array; + + channels: Array; + + threads: Array; + + total_unread_count_by_team?: Record; +} diff --git a/src/gen/moderation/ModerationApi.ts b/src/gen/moderation/ModerationApi.ts new file mode 100644 index 0000000000..4793fce30b --- /dev/null +++ b/src/gen/moderation/ModerationApi.ts @@ -0,0 +1,607 @@ +import type { ApiClient, StreamResponse } from '../../gen-imports'; +import type { + AppealRequest, + AppealResponse, + BanRequest, + BulkActionAppealsRequest, + BulkActionAppealsResponse, + BulkDeleteActionConfigRequest, + BulkDeleteActionConfigResponse, + BulkUpsertActionConfigRequest, + BulkUpsertActionConfigResponse, + CreateQueueRequest, + DeleteActionConfigResponse, + DeleteModerationConfigResponse, + DeleteQueueRequest, + FlagItemResponse, + FlagRequest, + GetActionConfigResponse, + GetAppealResponse, + GetConfigResponse, + ListQueuesResponse, + ModerationBanResponse, + MuteRequest, + MuteResponse, + QueryAppealsRequest, + QueryAppealsResponse, + QueryModerationConfigsRequest, + QueryModerationConfigsResponse, + QueryReviewQueueRequest, + QueryReviewQueueResponse, + QueueResponse, + SubmitActionRequest, + SubmitActionResponse, + UpdateQueueRequest, + UpsertActionConfigRequest, + UpsertActionConfigResponse, + UpsertConfigRequest, + UpsertConfigResponse, +} from '../models'; +import { decoders } from '../model-decoders/decoders'; + +export class ModerationApi { + constructor(public readonly apiClient: ApiClient) {} + + async getActionConfig(request?: { + queue_type?: string; + entity_type?: string; + exclude_defaults?: boolean; + only_defaults?: boolean; + }): Promise> { + const queryParams = { + queue_type: request?.queue_type, + entity_type: request?.entity_type, + exclude_defaults: request?.exclude_defaults, + only_defaults: request?.only_defaults, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('GET', '/api/v2/moderation/action_config', undefined, queryParams); + + decoders['GetActionConfigResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async upsertActionConfig( + request: UpsertActionConfigRequest, + ): Promise> { + const body = { + action: request?.action, + entity_type: request?.entity_type, + order: request?.order, + description: request?.description, + icon: request?.icon, + id: request?.id, + queue_type: request?.queue_type, + custom: request?.custom, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/action_config', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['UpsertActionConfigResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async bulkUpsertActionConfig( + request: BulkUpsertActionConfigRequest, + ): Promise> { + const body = { + action_configs: request?.action_configs, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/action_config/bulk', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['BulkUpsertActionConfigResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async bulkDeleteActionConfig( + request: BulkDeleteActionConfigRequest, + ): Promise> { + const body = { + ids: request?.ids, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/action_config/bulk_delete', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['BulkDeleteActionConfigResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteActionConfig(request: { + id: string; + }): Promise> { + const pathParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('DELETE', '/api/v2/moderation/action_config/{id}', pathParams, undefined); + + decoders['DeleteActionConfigResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async appeal(request: AppealRequest): Promise> { + const body = { + appeal_reason: request?.appeal_reason, + entity_id: request?.entity_id, + entity_type: request?.entity_type, + review_queue_item_id: request?.review_queue_item_id, + attachments: request?.attachments, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/moderation/appeal', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['AppealResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getAppeal(request: { id: string }): Promise> { + const pathParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/moderation/appeal/{id}', + pathParams, + undefined, + ); + + decoders['GetAppealResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryAppeals( + request?: QueryAppealsRequest, + ): Promise> { + const body = { + limit: request?.limit, + next: request?.next, + prev: request?.prev, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/appeals', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['QueryAppealsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async bulkActionAppeals( + request: BulkActionAppealsRequest, + ): Promise> { + const body = { + action_type: request?.action_type, + appeal_ids: request?.appeal_ids, + mark_reviewed: request?.mark_reviewed, + reject_appeal: request?.reject_appeal, + restore: request?.restore, + unban: request?.unban, + unblock: request?.unblock, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/appeals/bulk_action', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['BulkActionAppealsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async ban(request: BanRequest): Promise> { + const body = { + target_user_id: request?.target_user_id, + banned_by_id: request?.banned_by_id, + channel_cid: request?.channel_cid, + delete_messages: request?.delete_messages, + ip_ban: request?.ip_ban, + reason: request?.reason, + shadow: request?.shadow, + timeout: request?.timeout, + banned_by: request?.banned_by, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('POST', '/api/v2/moderation/ban', undefined, undefined, body, 'application/json'); + + decoders['ModerationBanResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async upsertConfig( + request: UpsertConfigRequest, + ): Promise> { + const body = { + key: request?.key, + async: request?.async, + team: request?.team, + ai_image_config: request?.ai_image_config, + ai_text_config: request?.ai_text_config, + ai_video_config: request?.ai_video_config, + automod_platform_circumvention_config: + request?.automod_platform_circumvention_config, + automod_semantic_filters_config: request?.automod_semantic_filters_config, + automod_toxicity_config: request?.automod_toxicity_config, + aws_rekognition_config: request?.aws_rekognition_config, + block_list_config: request?.block_list_config, + bodyguard_config: request?.bodyguard_config, + flood_config: request?.flood_config, + google_vision_config: request?.google_vision_config, + llm_config: request?.llm_config, + rule_builder_config: request?.rule_builder_config, + velocity_filter_config: request?.velocity_filter_config, + video_call_rule_config: request?.video_call_rule_config, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/config', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['UpsertConfigResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteConfig(request: { + key: string; + team?: string; + }): Promise> { + const queryParams = { + team: request?.team, + }; + const pathParams = { + key: request?.key, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >('DELETE', '/api/v2/moderation/config/{key}', pathParams, queryParams); + + decoders['DeleteModerationConfigResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getConfig(request: { + key: string; + team?: string; + }): Promise> { + const queryParams = { + team: request?.team, + }; + const pathParams = { + key: request?.key, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/moderation/config/{key}', + pathParams, + queryParams, + ); + + decoders['GetConfigResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryModerationConfigs( + request?: QueryModerationConfigsRequest, + ): Promise> { + const body = { + limit: request?.limit, + next: request?.next, + prev: request?.prev, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/configs', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['QueryModerationConfigsResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async flag(request: FlagRequest): Promise> { + const body = { + entity_id: request?.entity_id, + entity_type: request?.entity_type, + entity_creator_id: request?.entity_creator_id, + reason: request?.reason, + custom: request?.custom, + moderation_payload: request?.moderation_payload, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/moderation/flag', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['FlagItemResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async mute(request: MuteRequest): Promise> { + const body = { + target_ids: request?.target_ids, + timeout: request?.timeout, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/moderation/mute', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['MuteResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async listQueues(): Promise> { + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/moderation/queues', + undefined, + undefined, + ); + + decoders['ListQueuesResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async createQueue(request: CreateQueueRequest): Promise> { + const body = { + name: request?.name, + type: request?.type, + description: request?.description, + sort: request?.sort, + filters: request?.filters, + }; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/moderation/queues', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['QueueResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async getQueue(request: { id: string }): Promise> { + const pathParams = { + id: request?.id, + }; + + const response = await this.apiClient.sendRequest>( + 'GET', + '/api/v2/moderation/queues/{id}', + pathParams, + undefined, + ); + + decoders['QueueResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async updateQueue( + request: UpdateQueueRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = { + description: request?.description, + name: request?.name, + sort: request?.sort, + filters: request?.filters, + }; + + const response = await this.apiClient.sendRequest>( + 'PATCH', + '/api/v2/moderation/queues/{id}', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['QueueResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async deleteQueue( + request: DeleteQueueRequest & { id: string }, + ): Promise> { + const pathParams = { + id: request?.id, + }; + const body = {}; + + const response = await this.apiClient.sendRequest>( + 'POST', + '/api/v2/moderation/queues/{id}/delete', + pathParams, + undefined, + body, + 'application/json', + ); + + decoders['QueueResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async queryReviewQueue( + request?: QueryReviewQueueRequest, + ): Promise> { + const body = { + exclude_default_action_config: request?.exclude_default_action_config, + limit: request?.limit, + lock_count: request?.lock_count, + lock_duration: request?.lock_duration, + lock_items: request?.lock_items, + next: request?.next, + prev: request?.prev, + stats_only: request?.stats_only, + sort: request?.sort, + filter: request?.filter, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/review_queue', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['QueryReviewQueueResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } + + async submitAction( + request: SubmitActionRequest, + ): Promise> { + const body = { + action_type: request?.action_type, + appeal_id: request?.appeal_id, + item_id: request?.item_id, + ban: request?.ban, + block: request?.block, + bypass: request?.bypass, + custom: request?.custom, + delete_activity: request?.delete_activity, + delete_comment: request?.delete_comment, + delete_message: request?.delete_message, + delete_reaction: request?.delete_reaction, + delete_user: request?.delete_user, + escalate: request?.escalate, + flag: request?.flag, + mark_reviewed: request?.mark_reviewed, + reject_appeal: request?.reject_appeal, + restore: request?.restore, + shadow_block: request?.shadow_block, + unban: request?.unban, + unblock: request?.unblock, + }; + + const response = await this.apiClient.sendRequest< + StreamResponse + >( + 'POST', + '/api/v2/moderation/submit_action', + undefined, + undefined, + body, + 'application/json', + ); + + decoders['SubmitActionResponse']?.(response.body); + + return { ...response.body, metadata: response.metadata }; + } +} diff --git a/src/index.ts b/src/index.ts index 402e55c30b..28953be440 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,4 @@ export * from './base64'; -export * from './campaign'; -export * from './channel_batch_updater'; export * from './client'; export * from './client_state'; export * from './channel'; @@ -8,8 +6,8 @@ export * from './channel_state'; export * from './configuration'; export * from './connection'; export { type CooldownTimerState } from './CooldownTimer'; -export * from './events'; export * from './insights'; +export * from './logger'; export * from './messageComposer'; export * from './messageDelivery'; export * from './middleware'; @@ -21,7 +19,6 @@ export * from './poll'; export * from './poll_manager'; export * from './reminders'; export * from './search'; -export * from './segment'; export * from './signing'; export * from './store'; export { Thread } from './thread'; diff --git a/src/insights.ts b/src/insights.ts index bc11790d30..9a05edf6af 100644 --- a/src/insights.ts +++ b/src/insights.ts @@ -18,11 +18,12 @@ export class InsightMetrics { } /** - * postInsights is not supposed to be used by end users directly within chat application, and thus is kept isolated - * from all the client/connection code/logic. + * Posts internal insights telemetry to the Stream insights endpoint. Not intended for end-user use; + * kept isolated from the client/connection code/logic. * - * @param insightType - * @param insights + * @internal + * @param insightType - The category of insight being reported (e.g. `'ws_fatal'`). + * @param insights - The insight payload to send. */ export const postInsights = async ( insightType: InsightTypes, @@ -63,7 +64,7 @@ function buildWsBaseInsight(connection: StableWSConnection) { end_ts: new Date().getTime(), auth_type: client.getAuthType(), token: client.tokenManager.token, - user_id: client.userID, + user_id: client.userId, user_details: client._user, device: client.options.device, client_id: connection.connectionID, diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000000..c30d312948 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,28 @@ +import * as scopedLogger from '@stream-io/logger'; + +export type ChatLoggerScope = + | 'api-client' + | 'channel' + | 'channel-manager' + | 'client' + | 'connection' + | 'connection-fallback' + | 'message-composer' + | 'offline-db' + | 'state-store' + | 'text-composer' + | 'thread' + | 'thread-manager' + | 'token-manager' + | 'upload-manager' + | 'utils'; + +/** + * @internal + */ +export type ScopedLogger = scopedLogger.Logger; + +export { LogLevelEnum } from '@stream-io/logger'; +export type { ConfigureLoggersOptions, LogLevel, Sink } from '@stream-io/logger'; + +export const chatLoggerSystem = scopedLogger.createLoggerSystem(); diff --git a/src/messageComposer/LocationComposer.ts b/src/messageComposer/LocationComposer.ts index 7c91d88be9..7738113c9a 100644 --- a/src/messageComposer/LocationComposer.ts +++ b/src/messageComposer/LocationComposer.ts @@ -4,7 +4,7 @@ import type { DraftMessage, LiveLocationPayload, LocalMessage, - StaticLocationPayload, + SharedLocation, } from '../types'; export type Coords = { latitude: number; longitude: number }; @@ -14,10 +14,13 @@ export type LocationComposerOptions = { message?: DraftMessage | LocalMessage; }; -export type StaticLocationPreview = StaticLocationPayload; +export type StaticLocationPreview = SharedLocation & { + message_id?: string; +}; export type LiveLocationPreview = Omit & { durationMs?: number; + message_id?: string; }; export type LocationComposerState = { @@ -59,7 +62,7 @@ export class LocationComposer { return this.state.getLatestValue().location; } - get validLocation(): StaticLocationPayload | LiveLocationPayload | null { + get validLocation(): SharedLocation | null { const { durationMs, ...location } = (this.location ?? {}) as LiveLocationPreview; if ( !!location?.created_by_device_id && @@ -71,8 +74,9 @@ export class LocationComposer { ) { return { ...location, - end_at: durationMs && new Date(Date.now() + durationMs).toISOString(), - } as StaticLocationPayload | LiveLocationPayload; + end_at: + typeof durationMs === 'number' ? new Date(Date.now() + durationMs) : undefined, + }; } return null; } diff --git a/src/messageComposer/attachmentIdentity.ts b/src/messageComposer/attachmentIdentity.ts index 81d72463dc..38f866ce75 100644 --- a/src/messageComposer/attachmentIdentity.ts +++ b/src/messageComposer/attachmentIdentity.ts @@ -1,4 +1,4 @@ -import type { Attachment, SharedLocationResponse } from '../types'; +import type { Attachment, SharedLocationResponseData } from '../types'; import type { AudioAttachment, FileAttachment, @@ -33,8 +33,10 @@ export const isFileAttachment = ( ): attachment is FileAttachment => attachment.type === 'file' || !!( - attachment.mime_type && - supportedVideoFormat.indexOf(attachment.mime_type) === -1 && + (attachment as FileAttachment).custom?.mime_type && + supportedVideoFormat.indexOf( + (attachment as FileAttachment).custom?.mime_type as string, + ) === -1 && attachment.type !== 'video' ); @@ -76,7 +78,12 @@ export const isVideoAttachment = ( supportedVideoFormat: string[] = [], ): attachment is VideoAttachment => attachment.type === 'video' || - !!(attachment.mime_type && supportedVideoFormat.indexOf(attachment.mime_type) !== -1); + !!( + (attachment as VideoAttachment).custom?.mime_type && + supportedVideoFormat.indexOf( + (attachment as VideoAttachment).custom?.mime_type as string, + ) !== -1 + ); export const isLocalVideoAttachment = ( attachment: Attachment | LocalAttachment, @@ -92,12 +99,12 @@ export const isUploadedAttachment = ( isVideoAttachment(attachment) || isVoiceRecordingAttachment(attachment); -export const isSharedLocationResponse = ( +export const isSharedLocationResponseData = ( location: unknown, -): location is SharedLocationResponse => - !!(location as SharedLocationResponse).latitude && - !!(location as SharedLocationResponse).longitude && - !!(location as SharedLocationResponse).channel_cid; +): location is SharedLocationResponseData => + !!(location as SharedLocationResponseData).latitude && + !!(location as SharedLocationResponseData).longitude && + !!(location as SharedLocationResponseData).channel_cid; export const isGiphyAttachment = ( attachment: Attachment, diff --git a/src/messageComposer/attachmentManager.ts b/src/messageComposer/attachmentManager.ts index c3b1f57d66..42c07dc835 100644 --- a/src/messageComposer/attachmentManager.ts +++ b/src/messageComposer/attachmentManager.ts @@ -129,14 +129,14 @@ export class AttachmentManager { this.composer.updateConfig({ attachments: { acceptedFiles } }); } - /* + /** @deprecated attachments can be filtered using injecting pre-upload middleware */ get fileUploadFilter() { return this.config.fileUploadFilter; } - /* + /** @deprecated attachments can be filtered using injecting pre-upload middleware */ set fileUploadFilter(fileUploadFilter: AttachmentManagerConfig['fileUploadFilter']) { @@ -168,8 +168,16 @@ export class AttachmentManager { )?.includes('upload-file'); } + get hasCustomDoUploadRequest() { + return typeof this.config.doUploadRequest === 'function'; + } + + get hasAvailableUploadSlots() { + return this.availableUploadSlots > 0; + } + get isUploadEnabled() { - return this.hasUploadPermission && this.availableUploadSlots > 0; + return this.hasUploadPermission && this.hasAvailableUploadSlots; } get successfulUploads() { @@ -418,8 +426,10 @@ export class AttachmentManager { }); const localAttachment: LocalUploadAttachment = { - file_size: file.size, - mime_type: file.type, + custom: { + file_size: file.size, + mime_type: file.type, + }, localMetadata: { file, id: generateUUIDv4(), @@ -448,8 +458,12 @@ export class AttachmentManager { localAttachment.thumb_url = fileLike.thumb_url; } - if (isFileReference(fileLike) && fileLike.duration) { - localAttachment.duration = fileLike.duration; + if ( + isFileReference(fileLike) && + fileLike.duration && + localAttachment.type === 'voiceRecording' + ) { + localAttachment.custom.duration = fileLike.duration; } return localAttachment; @@ -551,8 +565,7 @@ export class AttachmentManager { mimeType: fileLike.type, }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { duration, ...result } = await this.channel[ + const { duration: _duration, ...result } = await this.channel[ isImageFile(fileLike) ? 'sendImage' : 'sendFile' ](file, undefined, undefined, undefined, axiosUploadConfig); return result; @@ -726,7 +739,12 @@ export class AttachmentManager { }; uploadFiles = async (files: FileReference[] | FileList | FileLike[]) => { - if (!this.isUploadEnabled) return; + if ( + (this.hasCustomDoUploadRequest && !this.hasAvailableUploadSlots) || + (!this.hasCustomDoUploadRequest && !this.isUploadEnabled) + ) + return; + const iterableFiles: FileReference[] | FileLike[] = isFileList(files) ? Array.from(files) : files; diff --git a/src/messageComposer/configuration/types.ts b/src/messageComposer/configuration/types.ts index 7e3d3a0f15..193fbe4697 100644 --- a/src/messageComposer/configuration/types.ts +++ b/src/messageComposer/configuration/types.ts @@ -2,7 +2,7 @@ import type { LinkPreview } from '../linkPreviewsManager'; import type { FileUploadFilter } from '../attachmentManager'; import type { MessageComposer } from '../messageComposer'; import type { FileLike, FileReference } from '../types'; -import type { CommandResponse, UserResponse } from '../../types'; +import type { Command, UserResponse } from '../../types'; export type MinimumUploadRequestResult = { file: string; thumb_url?: string } & Partial< Record @@ -41,14 +41,14 @@ export type TextComposerConfig = { }; export type CommandSendability = { - command: CommandResponse; + command: Command; ready: boolean; reason?: string & {}; metadata?: Record; }; export type CommandSendValidationContext = { - command: CommandResponse; + command: Command; composer: MessageComposer; commandArgsText: string; mentionedUsersInText: UserResponse[]; @@ -80,17 +80,17 @@ export type AttachmentManagerConfig = { /** Function that allows to customize the upload request. */ doUploadRequest?: UploadRequestFn; /** - * When true, the attachment manager sets `localMetadata.uploadProgress` and passes `options.onProgress` - * to `doUploadRequest` (built-in and custom). Set to false to disable progress tracking. - * @default true + * When `true`, the attachment manager sets `localMetadata.uploadProgress` and passes + * `options.onProgress` to `doUploadRequest` (built-in and custom). Set to `false` to disable + * progress tracking (defaults to `true`). */ trackUploadProgress: boolean; }; export type LinkPreviewsManagerConfig = { - /** Number of milliseconds to debounce firing the URL enrichment queries when typing. The default value is 1500(ms). */ + /** 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`. By default, the feature is disabled. */ + /** Allows for toggling the URL enrichment and link previews in `MessageInput` (defaults to `false`). */ enabled: boolean; /** Custom function to identify URLs in a string and request OG data */ findURLFn: (text: string) => string[]; @@ -100,11 +100,11 @@ export type LinkPreviewsManagerConfig = { export type LocationComposerConfig = { /** - * Allows for toggling the location addition. - * By default, the feature is enabled but has to be enabled also on channel level config via shared_locations. + * Allows for toggling the location addition (defaults to `true`). The feature also has to be + * enabled at the channel-level config via `shared_locations`. */ enabled: boolean; - /** Function that provides a stable id for a device from which the location is shared */ + /** Function that provides a stable ID for the device from which the location is shared. */ getDeviceId: () => string; }; diff --git a/src/messageComposer/linkPreviewsManager.ts b/src/messageComposer/linkPreviewsManager.ts index 10a50c1782..61b0715e4a 100644 --- a/src/messageComposer/linkPreviewsManager.ts +++ b/src/messageComposer/linkPreviewsManager.ts @@ -18,13 +18,13 @@ export interface ILinkPreviewsManager { } export enum LinkPreviewStatus { - /** Link preview has been dismissed using **/ + /** Link preview has been dismissed using */ DISMISSED = 'dismissed', - /** Link preview could not be loaded, the enrichment request has failed. **/ + /** Link preview could not be loaded, the enrichment request has failed. */ FAILED = 'failed', - /** Link preview has been successfully loaded. **/ + /** Link preview has been successfully loaded. */ LOADED = 'loaded', - /** The enrichment query is in progress for a given link. **/ + /** The enrichment query is in progress for a given link. */ LOADING = 'loading', /** The preview reference enrichment has not begun. Default status if not set. */ PENDING = 'pending', @@ -238,10 +238,9 @@ export class LinkPreviewsManager implements ILinkPreviewsManager { await Promise.all( newLinkPreviews.map(async (linkPreview) => { try { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { duration, ...ogAttachment } = await this.client.enrichURL( - linkPreview.og_scrape_url, - ); + const { duration: _duration, ...ogAttachment } = await this.client.getOG({ + url: linkPreview.og_scrape_url, + }); if (this.shouldDiscardEnrichQueries) return; // due to typing and text changes, the URL may not be anymore in the store if (this.previews.has(linkPreview.og_scrape_url)) { @@ -302,6 +301,7 @@ export class LinkPreviewsManager implements ILinkPreviewsManager { ...finalPreview, og_scrape_url: url, status, + custom: {}, }), }); }; @@ -330,8 +330,7 @@ export class LinkPreviewsManager implements ILinkPreviewsManager { preview.status === LinkPreviewStatus.PENDING; static getPreviewData = (preview: LinkPreview) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { status, ...data } = preview; + const { status: _status, ...data } = preview; return data; }; } diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index e2a4bd098e..dc16bd16a1 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -13,21 +13,22 @@ import { } from './middleware'; import type { Unsubscribe } from '../store'; import { StateStore } from '../store'; -import { formatMessage, generateUUIDv4, isLocalMessage, unformatMessage } from '../utils'; +import { formatMessage, generateUUIDv4, isLocalMessage } from '../utils'; import { mergeWith } from '../utils/mergeWith'; import { Channel } from '../channel'; import { Thread } from '../thread'; import type { - ChannelAPIResponse, - CommandResponse, + Attachment, + ChannelStateResponseFields, + Command, DraftMessage, DraftResponse, - EventTypes, + EventType, LocalMessage, - LocalMessageBase, MessageResponse, - MessageResponseBase, + UserResponse, } from '../types'; +import { chatLoggerSystem } from '../logger'; import { WithSubscriptions } from '../utils/WithSubscriptions'; import type { StreamChat } from '../client'; import type { CommandSendability, MessageComposerConfig } from './configuration/types'; @@ -90,7 +91,7 @@ export type MessageComposerState = { id: string; draftId: string | null; pollId: string | null; - quotedMessage: LocalMessageBase | null; + quotedMessage: LocalMessage | null; showReplyInChannel: boolean; /** * Baseline snapshot of the message being edited (if any). @@ -162,14 +163,15 @@ const initState = ( draftId, id, pollId: message.poll_id ?? null, - quotedMessage: quotedMessage - ? formatMessage(quotedMessage as MessageResponseBase) - : null, + quotedMessage: quotedMessage ? formatMessage(quotedMessage) : null, showReplyInChannel: false, editedMessage, }; }; +const logger = chatLoggerSystem.getLogger('message-composer'); +const offlineDbLogger = chatLoggerSystem.getLogger('offline-db'); + export class MessageComposer extends WithSubscriptions { readonly channel: Channel; readonly state: StateStore; @@ -386,7 +388,7 @@ export class MessageComposer extends WithSubscriptions { } getCommandDisabledReason = ( - command: CommandResponse, + command: Command, ): CommandSuggestionDisabledReason | undefined => { if (this.editedMessage) return 'editing'; @@ -400,11 +402,10 @@ export class MessageComposer extends WithSubscriptions { return undefined; }; - isCommandDisabled = (command: CommandResponse) => - !!this.getCommandDisabledReason(command); + isCommandDisabled = (command: Command) => !!this.getCommandDisabledReason(command); validateCommandSendability = ( - command: CommandResponse, + command: Command, text = this.textComposer.text, ): CommandSendability => { const currentMentionedUsers = this.textComposer.mentionedUsers; @@ -506,8 +507,8 @@ export class MessageComposer extends WithSubscriptions { this.state.next(initState(composition)); }; - initStateFromChannelResponse = (channelApiResponse: ChannelAPIResponse) => { - if (this.channel.cid !== channelApiResponse.channel.cid) { + initStateFromChannelResponse = (channelApiResponse: ChannelStateResponseFields) => { + if (this.channel.cid !== channelApiResponse.channel?.cid) { return; } if (channelApiResponse.draft) { @@ -616,12 +617,12 @@ export class MessageComposer extends WithSubscriptions { private subscribeMessageUpdated = () => { // todo: test the impact of 'reaction.new', 'reaction.deleted', 'reaction.updated' - const eventTypes: EventTypes[] = [ + const eventTypes = [ 'message.updated', 'reaction.new', 'reaction.deleted', // todo: do we need to subscribe to this especially when the whole state is overriden? 'reaction.updated', // todo: do we need to subscribe to this especially when the whole state is overriden? - ]; + ] satisfies EventType[]; const unsubscribeFunctions = eventTypes.map( (eventType) => @@ -866,21 +867,21 @@ export class MessageComposer extends WithSubscriptions { type: 'regular', }, localMessage: { - attachments: [], + attachments: [] as Attachment[], cid: this.channel.cid, // it is needed to match local paginator filters to be ingested into its state created_at, // only assigned to localMessage as this is used for optimistic update - deleted_at: null, + deleted_at: undefined, error: undefined, id: this.id, - mentioned_users: [], + mentioned_users: [] as UserResponse[], parent_id: this.threadId ?? undefined, - pinned_at: this.editedMessage?.pinned_at || null, - reaction_groups: null, + pinned_at: this.editedMessage?.pinned_at || undefined, + reaction_groups: undefined, status: this.editedMessage ? this.editedMessage.status : 'sending', text, type: 'regular', updated_at: created_at, - }, + } as LocalMessage, sendOptions: {}, }, }); @@ -894,7 +895,12 @@ export class MessageComposer extends WithSubscriptions { const { state, status } = await this.draftCompositionMiddlewareExecutor.execute({ eventName: 'compose', initialValue: { - draft: { id: this.id, parent_id: this.threadId ?? undefined, text: '' }, + draft: { + id: this.id, + parent_id: this.threadId ?? undefined, + text: '', + custom: {}, + }, }, }); if (status === 'discard') return; @@ -914,23 +920,20 @@ export class MessageComposer extends WithSubscriptions { try { const optimisticDraftResponse = { channel_cid: this.channel.cid, - created_at: new Date().toISOString(), + created_at: new Date(), message: draft as DraftMessage, parent_id: draft.parent_id, - quoted_message: this.quotedMessage - ? unformatMessage(this.quotedMessage) - : undefined, + quoted_message: this.quotedMessage ?? undefined, }; await this.client.offlineDb.upsertDraft({ draft: optimisticDraftResponse }); } catch (error) { - this.client.logger('error', `offlineDb:upsertDraft`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('createDraft', this.channel.cid) + .error('Upserting the draft to the offline database failed.', { error }); } } this.logDraftUpdateTimestamp(); - await this.channel.createDraft(draft); + await this.channel.createDraft({ message: draft }); }; deleteDraft = async () => { @@ -944,10 +947,9 @@ export class MessageComposer extends WithSubscriptions { parent_id: parentId, }); } catch (error) { - this.client.logger('error', `offlineDb:deleteDraft`, { - tags: ['channel', 'offlineDb'], - error, - }); + offlineDbLogger + .withExtraTags('deleteDraft', this.channel.cid) + .error('Deleting the draft from the offline database failed.', { error }); } } this.logDraftUpdateTimestamp(); @@ -955,11 +957,11 @@ export class MessageComposer extends WithSubscriptions { }; getDraft = async () => { - if (this.editedMessage || !this.config.drafts.enabled || !this.client.userID) return; + if (this.editedMessage || !this.config.drafts.enabled || !this.client.userId) return; const draftFromOfflineDB = await this.client.offlineDb?.getDraft({ cid: this.channel.cid, - userId: this.client.userID, + userId: this.client.userId, parent_id: this.threadId ?? undefined, }); @@ -986,10 +988,9 @@ export class MessageComposer extends WithSubscriptions { this.initState({ composition: draft }); } catch (error) { - this.client.logger('error', `messageComposer:getDraft`, { - tags: ['channel', 'messageComposer'], - error, - }); + logger + .withExtraTags('getDraft', this.channel.cid) + .error('Retrieving the draft from the server failed.', { error }); } }; diff --git a/src/messageComposer/middleware/messageComposer/attachments.ts b/src/messageComposer/middleware/messageComposer/attachments.ts index 3bde7dbd08..3793b66625 100644 --- a/src/messageComposer/middleware/messageComposer/attachments.ts +++ b/src/messageComposer/middleware/messageComposer/attachments.ts @@ -10,8 +10,7 @@ import type { } from './types'; const localAttachmentToAttachment = (localAttachment: LocalAttachment) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { localMetadata, ...attachment } = localAttachment; + const { localMetadata: _localMetadata, ...attachment } = localAttachment; return attachment as Attachment; }; diff --git a/src/messageComposer/middleware/messageComposer/cleanData.ts b/src/messageComposer/middleware/messageComposer/cleanData.ts index 18e1686c57..450411f00d 100644 --- a/src/messageComposer/middleware/messageComposer/cleanData.ts +++ b/src/messageComposer/middleware/messageComposer/cleanData.ts @@ -39,7 +39,7 @@ export const createCompositionDataCleanupMiddleware = ( ...editedMessagePayloadToBeSent, ...state.message, ...common, - }, + } as typeof state.message, sendOptions: composer.editedMessage && state.sendOptions?.skip_enrich_url ? { skip_enrich_url: state.sendOptions?.skip_enrich_url } diff --git a/src/messageComposer/middleware/messageComposer/compositionValidation.ts b/src/messageComposer/middleware/messageComposer/compositionValidation.ts index 59f85cebf2..0094218d38 100644 --- a/src/messageComposer/middleware/messageComposer/compositionValidation.ts +++ b/src/messageComposer/middleware/messageComposer/compositionValidation.ts @@ -1,5 +1,5 @@ import { textIsEmpty } from '../../textComposer'; -import type { CommandResponse } from '../../../types'; +import type { Command } from '../../../types'; import { CommandSearchSource } from '../textComposer/commands'; import { getCommandByName, @@ -20,7 +20,7 @@ const getDisabledRawCommand = ( composer: MessageComposer, searchSource: CommandSearchSource, text?: string, -): CommandResponse | undefined => { +): Command | undefined => { const rawCommand = getCommandByName(searchSource, getRawCommandName(text)); if (rawCommand && composer.isCommandDisabled(rawCommand)) { return rawCommand; diff --git a/src/messageComposer/middleware/messageComposer/messageComposerState.ts b/src/messageComposer/middleware/messageComposer/messageComposerState.ts index cf13487812..d12fe3a8b2 100644 --- a/src/messageComposer/middleware/messageComposer/messageComposerState.ts +++ b/src/messageComposer/middleware/messageComposer/messageComposerState.ts @@ -5,7 +5,7 @@ import type { MessageDraftCompositionMiddleware, } from './types'; import type { MessageComposer } from '../../messageComposer'; -import type { LocalMessage, LocalMessageBase } from '../../../types'; +import type { LocalMessage } from '../../../types'; import type { MiddlewareHandlerParams } from '../../../middleware'; export const createMessageComposerStateCompositionMiddleware = ( @@ -37,7 +37,7 @@ export const createMessageComposerStateCompositionMiddleware = ( localMessage: { ...state.localMessage, ...payload, - quoted_message: (composer.quotedMessage as LocalMessageBase) ?? undefined, + quoted_message: composer.quotedMessage ?? undefined, }, message: { ...state.message, diff --git a/src/messageComposer/middleware/messageComposer/sharedLocation.ts b/src/messageComposer/middleware/messageComposer/sharedLocation.ts index 00e17b68d6..aaf15be764 100644 --- a/src/messageComposer/middleware/messageComposer/sharedLocation.ts +++ b/src/messageComposer/middleware/messageComposer/sharedLocation.ts @@ -1,4 +1,5 @@ import type { MiddlewareHandlerParams } from '../../../middleware'; +import type { SharedLocationResponseData as Gen_SharedLocationResponseData } from '../../../gen/models'; import type { MessageComposer } from '../../messageComposer'; import type { MessageComposerMiddlewareState, @@ -18,7 +19,7 @@ export const createSharedLocationCompositionMiddleware = ( const { locationComposer } = composer; const location = locationComposer.validLocation; if (!locationComposer || !location || !composer.client.user) return forward(); - const timestamp = new Date().toISOString(); + const timestamp = new Date(); return next({ ...state, @@ -30,12 +31,12 @@ export const createSharedLocationCompositionMiddleware = ( created_at: timestamp, updated_at: timestamp, user_id: composer.client.user.id, - }, + } as Gen_SharedLocationResponseData, }, message: { ...state.message, shared_location: location, - }, + } as typeof state.message, }); }, }, diff --git a/src/messageComposer/middleware/messageComposer/textComposer.ts b/src/messageComposer/middleware/messageComposer/textComposer.ts index 054dcfe1c8..cd94f5e64d 100644 --- a/src/messageComposer/middleware/messageComposer/textComposer.ts +++ b/src/messageComposer/middleware/messageComposer/textComposer.ts @@ -1,5 +1,5 @@ import type { MiddlewareHandlerParams } from '../../../middleware'; -import type { DraftMessage, LocalMessage, UserResponse } from '../../../types'; +import type { LocalMessage, MessageRequest, UserResponse } from '../../../types'; import type { MessageComposer } from '../../messageComposer'; import { mentionEntityToUserResponse } from '../textComposer/mentionUtils'; import type { MentionEntity } from '../textComposer/types'; @@ -30,7 +30,7 @@ type BuildMentionCompositionMetadataParams = { }; type DraftMentionPayload = Pick< - DraftMessage, + MessageRequest, | 'mentioned_channel' | 'mentioned_group_ids' | 'mentioned_here' diff --git a/src/messageComposer/middleware/messageComposer/types.ts b/src/messageComposer/middleware/messageComposer/types.ts index 51ae110fd5..b2c58f698c 100644 --- a/src/messageComposer/middleware/messageComposer/types.ts +++ b/src/messageComposer/middleware/messageComposer/types.ts @@ -1,15 +1,14 @@ import type { Middleware, MiddlewareExecutionResult } from '../../../middleware'; import type { - DraftMessagePayload, LocalMessage, - Message, + MessageRequest, SendMessageOptions, UpdatedMessage, } from '../../../types'; import type { MessageComposer } from '../../messageComposer'; export type MessageComposerMiddlewareState = { - message: Message | UpdatedMessage; + message: MessageRequest | UpdatedMessage; localMessage: LocalMessage; sendOptions: SendMessageOptions; }; @@ -22,7 +21,7 @@ export type MessageComposerMiddlewareExecutorOptions = { }; export type MessageDraftComposerMiddlewareValueState = { - draft: DraftMessagePayload; + draft: MessageRequest; }; export type MessageDraftComposerMiddlewareExecutorOptions = { diff --git a/src/messageComposer/middleware/messageComposer/userDataInjection.ts b/src/messageComposer/middleware/messageComposer/userDataInjection.ts index 9526045caa..c6f22a16cd 100644 --- a/src/messageComposer/middleware/messageComposer/userDataInjection.ts +++ b/src/messageComposer/middleware/messageComposer/userDataInjection.ts @@ -4,7 +4,7 @@ import type { MessageCompositionMiddleware, } from './types'; import type { MiddlewareHandlerParams } from '../../../middleware'; -import type { OwnUserResponse } from '../../../types'; +import type { OwnUserResponse, RequireLiteral } from '../../../types'; export const createUserDataInjectionMiddleware = ( composer: MessageComposer, @@ -27,14 +27,18 @@ export const createUserDataInjectionMiddleware = ( // precedence after we connectUser the first time and we get the connection health // check event. Due to how liberal the type of client.user is, we have to do it this // way to maintain type safety. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { channel_mutes, devices, mutes, ...messageUser } = composer.client - .user as OwnUserResponse; + + const { + channel_mutes: _channel_mutes, + devices: _devices, + mutes: _mutes, + ...messageUser + } = composer.client.user; return next({ ...state, localMessage: { ...state.localMessage, - user: messageUser, + user: messageUser as RequireLiteral, // TODO: drop RequireLiteral once the oapi spec is adjusted, user_id: messageUser.id, }, }); diff --git a/src/messageComposer/middleware/pollComposer/state.ts b/src/messageComposer/middleware/pollComposer/state.ts index ba380bac7b..2cce5ce8c2 100644 --- a/src/messageComposer/middleware/pollComposer/state.ts +++ b/src/messageComposer/middleware/pollComposer/state.ts @@ -21,7 +21,6 @@ export type PollStateValidationOutput = Partial< export type PollStateChangeValidator = (params: { data: PollComposerState['data']; - // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any; currentError?: PollComposerFieldErrors[keyof PollComposerFieldErrors]; }) => PollStateValidationOutput; @@ -95,7 +94,6 @@ export type PollCompositionStateProcessorOutput = Partial PollCompositionStateProcessorOutput; diff --git a/src/messageComposer/middleware/pollComposer/types.ts b/src/messageComposer/middleware/pollComposer/types.ts index d2aeb14010..9dcca6b030 100644 --- a/src/messageComposer/middleware/pollComposer/types.ts +++ b/src/messageComposer/middleware/pollComposer/types.ts @@ -1,5 +1,5 @@ import type { MiddlewareExecutionResult } from '../../../middleware'; -import type { CreatePollData, VotingVisibility } from '../../../types'; +import type { CreatePollRequest, VotingVisibility } from '../../../types'; export type PollComposerOption = { id: string; @@ -19,17 +19,15 @@ export type UpdateFieldsData = Partial, 'options'> & { + Omit, 'options'> & { options?: Record; } >; export type PollComposerState = { data: { - id: Id; + id: string; max_votes_allowed: string; name: string; options: PollComposerOption[]; @@ -38,14 +36,13 @@ export type PollComposerState = { description?: string; enforce_unique_vote?: boolean; is_closed?: boolean; - user_id?: string; voting_visibility?: VotingVisibility; }; errors: PollComposerFieldErrors; }; export type PollComposerCompositionMiddlewareValueState = { - data: CreatePollData; + data: CreatePollRequest; errors: PollComposerFieldErrors; }; diff --git a/src/messageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.ts b/src/messageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.ts index 167a24c751..61478bd7c5 100644 --- a/src/messageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.ts +++ b/src/messageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.ts @@ -2,6 +2,7 @@ import { createCommandsMiddleware } from './commands'; import { createCommandEffectsMiddleware } from './commandEffects'; import { createMentionsMiddleware } from './mentions'; import { createTextComposerPreValidationMiddleware } from './validation'; +import { chatLoggerSystem } from '../../../logger'; import { MiddlewareExecutor } from '../../../middleware'; import type { ExecuteParams, @@ -16,6 +17,8 @@ import type { TextComposerState, } from './types'; +const logger = chatLoggerSystem.getLogger('text-composer'); + export type TextComposerMiddlewareExecutorState = TextComposerState & { change?: { @@ -71,7 +74,13 @@ export class TextComposerMiddlewareExecutor< * That means the result of the previous search call as the debounced call result is unknown at the moment. * Custom search source implementation should handle errors meaningfully internally. */ - searchSource?.search(query)?.catch(console.error); + searchSource + ?.search(query) + ?.catch((error) => + logger + .withExtraTags('execute') + .error('Searching for suggestions failed.', { error }), + ); return result; } diff --git a/src/messageComposer/middleware/textComposer/commandEffects.ts b/src/messageComposer/middleware/textComposer/commandEffects.ts index a579f0174e..81d05e49bc 100644 --- a/src/messageComposer/middleware/textComposer/commandEffects.ts +++ b/src/messageComposer/middleware/textComposer/commandEffects.ts @@ -1,5 +1,5 @@ import type { Middleware } from '../../../middleware'; -import type { CommandResponse } from '../../../types'; +import type { Command } from '../../../types'; import type { CommandSuggestion, TextComposerCommandActivationEffect, @@ -18,14 +18,14 @@ const emptyCommandStateToRestore: TextComposerCommandActivationStateToRestore = }; const createCommandActivationEffect = ( - command: CommandResponse, + command: Command, ): TextComposerCommandActivationEffect => ({ command, stateToRestore: emptyCommandStateToRestore, type: 'command.activate', }); -const isCommandResponse = (suggestion: unknown): suggestion is CommandSuggestion => +const isCommand = (suggestion: unknown): suggestion is CommandSuggestion => typeof (suggestion as CommandSuggestion | undefined)?.name === 'string'; export const createCommandEffectsMiddleware = (): CommandEffectsMiddleware => ({ @@ -34,7 +34,7 @@ export const createCommandEffectsMiddleware = (): CommandEffectsMiddleware => ({ onSuggestionItemSelect: ({ state, next, forward }) => { const { selectedSuggestion } = state.change ?? {}; if ( - !isCommandResponse(selectedSuggestion) || + !isCommand(selectedSuggestion) || !state.command || state.command.name !== selectedSuggestion.name ) { diff --git a/src/messageComposer/middleware/textComposer/commandUtils.ts b/src/messageComposer/middleware/textComposer/commandUtils.ts index 2b5939d3dd..a46e7cf2b7 100644 --- a/src/messageComposer/middleware/textComposer/commandUtils.ts +++ b/src/messageComposer/middleware/textComposer/commandUtils.ts @@ -1,5 +1,5 @@ import type { MessageComposer } from '../../messageComposer'; -import type { CommandResponse, UserResponse } from '../../../types'; +import type { Command, UserResponse } from '../../../types'; import type { CommandSendability } from '../../configuration'; import type { CommandSearchSource } from './commands'; @@ -49,7 +49,7 @@ export const getMentionedUsersInText = (text: string, mentionedUsers: UserRespon export const getCommandByName = ( searchSource: CommandSearchSource, commandName?: string, -): CommandResponse | undefined => { +): Command | undefined => { if (!commandName) return; const normalizedCommandName = commandName.toLowerCase(); @@ -58,10 +58,7 @@ export const getCommandByName = ( .items.find((command) => command.name?.toLowerCase() === normalizedCommandName); }; -export const notifyCommandDisabled = ( - composer: MessageComposer, - command: CommandResponse, -) => { +export const notifyCommandDisabled = (composer: MessageComposer, command: Command) => { const disabledReason = composer.getCommandDisabledReason(command); if (!disabledReason) return; diff --git a/src/messageComposer/middleware/textComposer/commands.ts b/src/messageComposer/middleware/textComposer/commands.ts index 9066f27669..cc8eea7aeb 100644 --- a/src/messageComposer/middleware/textComposer/commands.ts +++ b/src/messageComposer/middleware/textComposer/commands.ts @@ -2,7 +2,7 @@ import type { Channel } from '../../../channel'; import type { Middleware } from '../../../middleware'; import type { SearchSourceOptions } from '../../../search'; import { BaseSearchSourceSync } from '../../../search'; -import type { CommandResponse } from '../../../types'; +import type { Command } from '../../../types'; import { mergeWith } from '../../../utils/mergeWith'; import type { MessageComposer } from '../../messageComposer'; import type { CommandSuggestion, TextComposerMiddlewareOptions } from './types'; @@ -40,8 +40,8 @@ export class CommandSearchSource extends BaseSearchSourceSync query(searchQuery: string) { const channelConfig = this.channel.getConfig(); const commands = channelConfig?.commands || []; - const selectedCommands: (CommandResponse & { name: string })[] = commands.filter( - (command): command is CommandResponse & { name: string } => + const selectedCommands: Command[] = commands.filter( + (command): command is Command => !!( command.name && command.name.toLowerCase().indexOf(searchQuery.toLowerCase()) !== -1 diff --git a/src/messageComposer/middleware/textComposer/mentionUtils.ts b/src/messageComposer/middleware/textComposer/mentionUtils.ts index 0b0af1fa22..e49ab1dcb7 100644 --- a/src/messageComposer/middleware/textComposer/mentionUtils.ts +++ b/src/messageComposer/middleware/textComposer/mentionUtils.ts @@ -13,8 +13,8 @@ export const userResponsesToMentionEntities = (users: UserResponse[]) => users.map(userResponseToMentionEntity); export const mentionEntityToUserResponse = (entity: UserMentionEntity): UserResponse => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { mentionType, ...user } = entity; + void mentionType; return user; }; diff --git a/src/messageComposer/middleware/textComposer/mentions.ts b/src/messageComposer/middleware/textComposer/mentions.ts index 096566e8c8..57d338cf8b 100644 --- a/src/messageComposer/middleware/textComposer/mentions.ts +++ b/src/messageComposer/middleware/textComposer/mentions.ts @@ -487,7 +487,7 @@ export class MentionsSearchSource extends BaseSearchSource { return this.getMembersAndWatchers() .filter((user) => { - if (user.id === this.client.userID) return false; + if (user.id === this.client.userId) return false; if (!searchQuery) return true; const updatedId = this.transliterate(removeDiacritics(user.id)).toLowerCase(); @@ -513,7 +513,7 @@ export class MentionsSearchSource extends BaseSearchSource { if (!this.memberSort) return (a.name || '').localeCompare(b.name || ''); // Apply each sort criteria in order - for (const [field, direction] of Object.entries(this.memberSort)) { + for (const { field, direction } of this.memberSort) { const aValue = a[field as keyof UserResponse]; const bValue = b[field as keyof UserResponse]; @@ -534,20 +534,22 @@ export class MentionsSearchSource extends BaseSearchSource { ], ...this.userFilters, } as UserFilters, - sort: this.userSort ?? ([{ name: 1 }, { id: 1 }] as UserSort), // todo: document the change - the sort is overridden, not merged + sort: + this.userSort ?? + ([ + { field: 'name', direction: 1 }, + { field: 'id', direction: 1 }, + ] satisfies UserSort), // todo: document the change - the sort is overridden, not merged options: { ...this.searchOptions, limit: this.pageSize, offset }, }); prepareQueryMembersParams = (searchQuery: string, offset = 0) => { // QueryMembers failed with error: \"sort must contain at maximum 1 item\" - const maxSortParamsCount = 1; - let sort: MemberSort = [{ user_id: 1 }]; - if (!this.memberSort) { - sort = [{ user_id: 1 }]; - } else if (Array.isArray(this.memberSort)) { - sort = this.memberSort[0]; - } else if (Object.keys(this.memberSort).length === maxSortParamsCount) { - sort = this.memberSort; + let sort: MemberSort = [{ field: 'user_id', direction: 1 }]; + if (!this.memberSort || !this.memberSort.length) { + sort = [{ field: 'user_id', direction: 1 }]; + } else { + sort = this.memberSort.slice(0, 1); } // todo: document the change - the sort is overridden, not merged return { // todo: document the change - the filter is overridden, not merged @@ -560,7 +562,13 @@ export class MentionsSearchSource extends BaseSearchSource { queryUsers = async (searchQuery: string, offset = 0) => { const { filters, sort, options } = this.prepareQueryUsersParams(searchQuery, offset); - const { users } = await this.client.queryUsers(filters, sort, options); + const { users } = await this.client.queryUsers({ + payload: { + filter_conditions: filters, + sort, + ...options, + }, + }); return users; }; @@ -569,7 +577,13 @@ export class MentionsSearchSource extends BaseSearchSource { searchQuery, offset, ); - const response = await this.channel.queryMembers(filters, sort, options); + const response = await this.channel.queryMembers({ + payload: { + filter_conditions: filters, + sort, + ...options, + }, + }); return response.members.map((member) => member.user) as UserResponse[]; }; @@ -721,13 +735,15 @@ export class MentionsSearchSource extends BaseSearchSource { return data.filter( (suggestion) => suggestion.mentionType === 'user' && - mutedUsers.some((mute) => mute.target.id === suggestion.id), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + mutedUsers.some((mute) => mute.target!.id === suggestion.id), ); } return data.filter( (suggestion) => suggestion.mentionType !== 'user' || - mutedUsers.every((mute) => mute.target.id !== suggestion.id), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + mutedUsers.every((mute) => mute.target!.id !== suggestion.id), ); } diff --git a/src/messageComposer/middleware/textComposer/types.ts b/src/messageComposer/middleware/textComposer/types.ts index e99eae0e77..9967368a30 100644 --- a/src/messageComposer/middleware/textComposer/types.ts +++ b/src/messageComposer/middleware/textComposer/types.ts @@ -1,6 +1,6 @@ import type { MessageComposer } from '../../messageComposer'; import type { MessageComposerEffect } from '../../messageComposer'; -import type { CommandResponse, Event, UserResponse } from '../../../types'; +import type { Command, Event, UserResponse } from '../../../types'; import type { TokenizationPayload } from './textMiddlewareUtils'; import type { SearchSource, SearchSourceSync } from '../../../search'; import type { CustomTextComposerSuggestion } from '../../types.custom'; @@ -15,7 +15,7 @@ export type BaseSuggestion = { export type CommandSuggestionDisabledReason = 'editing' | 'quoted_message'; -export type CommandSuggestion = BaseSuggestion & CommandResponse; +export type CommandSuggestion = BaseSuggestion & Command; export type UserSuggestion = BaseSuggestion & UserResponse & TokenizationPayload & { @@ -90,7 +90,7 @@ export type TextComposerCommandActivationStateToRestore = Partial; export type TextComposerCommandActivationEffect = { - command: CommandResponse; + command: Command; stateToRestore?: TextComposerCommandActivationStateToRestore; type: 'command.activate'; }; @@ -131,6 +131,6 @@ export type TextComposerState = { * Maps `user.id` -> latest typing event (`typing.start`/`typing.stop`) for that user. */ typing: Record; - command?: CommandResponse | null; + command?: Command | null; suggestions?: Suggestions; }; diff --git a/src/messageComposer/pollComposer.ts b/src/messageComposer/pollComposer.ts index 0a4b001d2e..762e09a6c6 100644 --- a/src/messageComposer/pollComposer.ts +++ b/src/messageComposer/pollComposer.ts @@ -45,7 +45,6 @@ export class PollComposer { max_votes_allowed: '', name: '', options: [{ id: generateUUIDv4(), text: '' }], - user_id: this.composer.client.user?.id, voting_visibility: VotingVisibility.public, }, errors: {}, @@ -76,9 +75,6 @@ export class PollComposer { get options() { return this.state.getLatestValue().data.options; } - get user_id() { - return this.state.getLatestValue().data.user_id; - } get voting_visibility() { return this.state.getLatestValue().data.voting_visibility; } @@ -86,7 +82,7 @@ export class PollComposer { get canCreatePoll() { const { data, errors } = this.state.getLatestValue(); const hasAtLeastOneNonEmptyOption = - data.options.filter((o) => !!o.text.trim()).length > 0; + Array.isArray(data.options) && data.options.some((o) => !!o.text?.trim()); const hasName = !!data.name; const maxVotesAllowedNumber = parseInt( data.max_votes_allowed?.match(VALID_MAX_VOTES_VALUE_REGEX)?.[0] || '', @@ -116,9 +112,11 @@ export class PollComposer { }; /** - * Updates specified fields and generates relevant errors - * @param data - * @param injectedFieldErrors - errors produced externally that will take precedence over the errors generated in the middleware chaing + * Updates specified fields and generates relevant errors. + * + * @param data - Partial poll data with the fields to update. + * @param injectedFieldErrors - Errors produced externally that will take precedence over the + * errors generated in the middleware chain. */ // FIXME: change method params to a single object with the next major release updateFields = async ( diff --git a/src/messageComposer/textComposer.ts b/src/messageComposer/textComposer.ts index a1b8b9c9c7..bb5c91a041 100644 --- a/src/messageComposer/textComposer.ts +++ b/src/messageComposer/textComposer.ts @@ -18,13 +18,7 @@ import { userResponseToMentionEntity, } from './middleware/textComposer/mentionUtils'; import type { MessageComposer } from './messageComposer'; -import type { - CommandResponse, - DraftMessage, - Event, - LocalMessage, - UserResponse, -} from '../types'; +import type { Command, DraftMessage, Event, LocalMessage, UserResponse } from '../types'; export type TextComposerOptions = { composer: MessageComposer; @@ -372,7 +366,7 @@ export class TextComposer { this.setMentions(mentions); }; - setCommand = (command: CommandResponse | null) => { + setCommand = (command: Command | null) => { if (!command) { this.clearCommand(); return; diff --git a/src/messageComposer/types.ts b/src/messageComposer/types.ts index 8c0545b940..5c41fab047 100644 --- a/src/messageComposer/types.ts +++ b/src/messageComposer/types.ts @@ -1,4 +1,5 @@ -import type { Attachment, FileUploadConfig, GiphyData } from '../types'; +import type { CustomAttachmentData } from '../custom_types'; +import type { Attachment, FileUploadConfig } from '../types'; export type LocalAttachment = AnyLocalAttachment | LocalUploadAttachment; @@ -57,54 +58,31 @@ export type UploadedAttachment = | VoiceRecordingAttachment; export type VoiceRecordingAttachment = Attachment & { - asset_url: string; type: 'voiceRecording'; - duration?: number; - file_size?: number; - mime_type?: string; - title?: string; - waveform_data?: Array; + custom: CustomAttachmentData & { + duration?: number; + waveform_data?: Array; + }; }; export type FileAttachment = Attachment & { type: 'file'; - asset_url?: string; - file_size?: number; - mime_type?: string; - title?: string; }; export type AudioAttachment = Attachment & { type: 'audio'; - asset_url?: string; - file_size?: number; - mime_type?: string; - title?: string; }; export type VideoAttachment = Attachment & { type: 'video'; - asset_url?: string; - file_size?: number; - mime_type?: string; - thumb_url?: string; - title?: string; }; export type ImageAttachment = Attachment & { type: 'image'; - fallback?: string; - image_url?: string; - original_height?: number; - original_width?: number; }; export type GiphyAttachment = Attachment & { type: 'giphy'; - giphy?: GiphyData; - title?: string; - title_link?: string; - thumbnail_url?: string; }; export type BaseLocalAttachmentMetadata = { diff --git a/src/messageDelivery/MessageDeliveryReporter.ts b/src/messageDelivery/MessageDeliveryReporter.ts index 2140551845..757d4ee71e 100644 --- a/src/messageDelivery/MessageDeliveryReporter.ts +++ b/src/messageDelivery/MessageDeliveryReporter.ts @@ -3,15 +3,16 @@ import { Channel } from '../channel'; import type { ThreadUserReadState } from '../thread'; import { Thread } from '../thread'; import type { - ErrorFromResponse, EventAPIResponse, LocalMessage, - MarkDeliveredOptions, - MarkReadOptions, + MarkDeliveredRequest, + MarkReadRequest, + StreamAPIError, + StreamResponse, } from '../types'; -import { type APIErrorResponse } from '../types'; 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; @@ -25,7 +26,7 @@ type MessageId = string; type ChannelThreadCompositeId = string; export type AnnounceDeliveryOptions = Omit< - MarkDeliveredOptions, + MarkDeliveredRequest, 'latest_delivered_messages' >; @@ -41,7 +42,7 @@ export class MessageDeliveryReporter { protected nextDeliveryReportCandidates: Map = new Map(); - protected markDeliveredRequestPromise: Promise | null = null; + protected markDeliveredRequestPromise: Promise | null = null; protected markDeliveredTimeout: ReturnType | null = null; protected requestTimeoutMs: number = MARK_AS_DELIVERED_BUFFER_TIMEOUT; @@ -85,7 +86,11 @@ export class MessageDeliveryReporter { } /** - * Build latest_delivered_messages payload from an arbitrary buffer (deliveryReportCandidates / nextDeliveryReportCandidates) + * Builds the `latest_delivered_messages` payload from an arbitrary buffer + * (`deliveryReportCandidates` or `nextDeliveryReportCandidates`). + * + * @param map - The buffer mapping channel/thread composite IDs to the latest delivered message ID. + * @returns The payload entries ready to be sent to the server. */ private confirmationsFrom(map: Map) { return Array.from(map.entries()).map(([key, messageId]) => { @@ -107,9 +112,10 @@ export class MessageDeliveryReporter { } /** - * Generate candidate key for storing in the candidates buffer - * @param collection - * @private + * Generates a candidate key for storing in the candidates buffer. + * + * @param collection - The channel or thread to derive a candidate key for. + * @returns The composite identifier, or `undefined` when the collection is neither a Channel nor a Thread. */ private candidateKeyFor( collection: Channel | Thread, @@ -119,8 +125,11 @@ export class MessageDeliveryReporter { } /** - * Retrieve the reference to the latest message in the state that is nor read neither reported as delivered - * @param collection + * Retrieves a reference to the latest message in the state that is neither read nor reported as + * delivered. + * + * @param collection - The channel or thread to inspect. + * @returns The next candidate to report as delivered, or `undefined` when none applies. */ private getNextDeliveryReportCandidate = ( collection: Channel | Thread, @@ -133,6 +142,7 @@ export class MessageDeliveryReporter { let lastReadAt: Date | undefined; let key: string | undefined = undefined; + // todo: unify the API for read state access btw channel and threads if (isChannel(collection)) { latestMessages = collection.messagePaginator.headItems; const ownReadState = collection.state.read[ownUserId] ?? {}; @@ -140,7 +150,10 @@ export class MessageDeliveryReporter { lastDeliveredAt = ownReadState?.last_delivered_at; key = collection.cid; } else if (isThread(collection)) { - latestMessages = collection.messagePaginator.state.getLatestValue().items ?? []; + // Use the head (newest-loaded) window, not the active/visible interval: the candidate logic + // below inspects the newest message, which the active interval only reflects when scrolled to + // the head. Mirrors the channel branch above. + latestMessages = collection.messagePaginator.headItems; const ownReadState = collection.state.getLatestValue().read[ownUserId] ?? ({} as ThreadUserReadState); lastReadAt = ownReadState?.lastReadAt; @@ -168,8 +181,9 @@ export class MessageDeliveryReporter { }; /** - * Updates the delivery candidates buffer with the latest delivery candidates - * @param collection + * Updates the delivery candidates buffer with the latest delivery candidates. + * + * @param collection - The channel or thread whose latest delivery candidate to track. */ private trackDeliveredCandidate(collection: Channel | Thread) { if (!MessageDeliveryReporter.hasPermissionToReportDeliveryFor(collection)) return; @@ -183,9 +197,9 @@ export class MessageDeliveryReporter { } /** - * Removes candidate from the delivery report buffer - * @param collection - * @private + * Removes a candidate from the delivery report buffer. + * + * @param collection - The channel or thread whose candidate should be removed. */ private removeCandidateFor(collection: Channel | Thread) { const candidateKey = this.candidateKeyFor(collection); @@ -195,10 +209,11 @@ export class MessageDeliveryReporter { } /** - * Records the latest message delivered for Channel or Thread instances and schedules the next report - * if not already scheduled and candidates exist. - * Should be used for WS handling (message.new) as well as for ingesting HTTP channel query results. - * @param collections + * Records the latest message delivered for Channel or Thread instances and schedules the next + * report if not already scheduled and candidates exist. Should be used for WS handling + * (`message.new`) as well as for ingesting HTTP channel query results. + * + * @param collections - The channels or threads whose candidates should be synced. */ public syncDeliveredCandidates(collections: (Channel | Thread)[]) { if (this.client.user?.privacy_settings?.delivery_receipts?.enabled === false) return; @@ -207,8 +222,9 @@ export class MessageDeliveryReporter { } /** - * Fires delivery announcement request followed by immediate delivery candidate buffer reset. - * @param options + * Fires a delivery announcement request followed by an immediate delivery candidate buffer reset. + * + * @param options - Flags forwarded to `client.markDelivered` (optional). */ public announceDelivery = (options?: AnnounceDeliveryOptions) => { if (!this.canExecuteRequest) return; @@ -240,7 +256,7 @@ export class MessageDeliveryReporter { postFlightReconcile(); }; - const handleError = (error: ErrorFromResponse | Error) => { + const handleError = (error: StreamAPIError | Error) => { // re-populate relevant candidates for the next report // but make sure to keep the items that failed to be reported the first next time const newDeliveryReportCandidates = new Map(sendBuffer); @@ -251,7 +267,9 @@ export class MessageDeliveryReporter { if ( (isAPIError(error) && isErrorRetryable(error)) || - (error as ErrorFromResponse).status >= 500 + (typeof (error as StreamAPIError).status === 'number' && + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (error as StreamAPIError).status! >= 500) ) { this.increaseBackOff(); postFlightReconcile(); @@ -261,7 +279,7 @@ export class MessageDeliveryReporter { }; this.markDeliveredRequestPromise = this.client - .markChannelsDelivered(payload) + .markDelivered(payload) .then(handleSuccess, handleError); }; @@ -274,11 +292,13 @@ export class MessageDeliveryReporter { }; /** - * Delegates the mark-read call to the Channel or Thread instance - * @param collection - * @param options + * Delegates the mark-read call to the Channel or Thread instance. + * + * @param collection - The channel or thread to mark as read. + * @param options - Flags forwarded to the underlying `markRead` call (optional). + * @returns The server response, or `null` when the collection is unsupported. */ - public markRead = async (collection: Channel | Thread, options?: MarkReadOptions) => { + public markRead = async (collection: Channel | Thread, options?: MarkReadRequest) => { if (!userHasReadReceipts(this.client)) return null; const isThreadCollection = isThread(collection); const channel = isThreadCollection ? collection.channel : collection; @@ -286,14 +306,14 @@ export class MessageDeliveryReporter { ? { ...options, thread_id: collection.id } : options; - let result: EventAPIResponse | null = null; + let result: EventAPIResponse | StreamResponse | null = null; if (isThreadCollection) { const markReadRequestHandler = collection.configState.getLatestValue() .requestHandlers?.markReadRequest as | ((params: { thread: Thread; - options?: MarkReadOptions; + options?: MarkReadRequest; }) => Promise | void) | undefined; result = markReadRequestHandler @@ -301,18 +321,18 @@ export class MessageDeliveryReporter { options: requestOptions, thread: collection, })) ?? null) - : await channel.markAsReadRequest(requestOptions); + : await channel.markRead(requestOptions); } else { const markReadRequestHandler = channel.configState.getLatestValue().requestHandlers ?.markReadRequest as | ((params: { channel: Channel; - options?: MarkReadOptions; + options?: MarkReadRequest; }) => Promise | void) | undefined; result = markReadRequestHandler ? ((await markReadRequestHandler({ channel, options: requestOptions })) ?? null) - : await channel.markAsReadRequest(requestOptions); + : await channel.markRead(requestOptions); } this.removeCandidateFor(collection); @@ -321,11 +341,23 @@ export class MessageDeliveryReporter { /** * Throttles the MessageDeliveryReporter.markRead call + * * @param collection * @param options */ - public throttledMarkRead = throttle(this.markRead, MARK_AS_READ_THROTTLE_TIMEOUT, { - leading: true, - trailing: true, - }); + // 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, + }, + ); } diff --git a/src/messageDelivery/MessageReceiptsTracker.ts b/src/messageDelivery/MessageReceiptsTracker.ts index ea9787528b..c265051c6b 100644 --- a/src/messageDelivery/MessageReceiptsTracker.ts +++ b/src/messageDelivery/MessageReceiptsTracker.ts @@ -1,4 +1,4 @@ -import type { ReadResponse, UserResponse } from '../types'; +import type { ReadStateResponse, UserResponse } from '../types'; import { StateStore } from '../store'; import type { Channel } from '../channel'; import { WithSubscriptions } from '../utils/WithSubscriptions'; @@ -62,10 +62,13 @@ const findIndex = (arr: T[], target: MsgRef, keyOf: (x: T) => MsgRef): number }; /** - * For insertion after the last equal item. E.g. array [a] exists and b is being inserted -> we want [a,b], not [b,a]. - * @param arr - * @param target - * @param keyOf + * Finds the insertion index after the last equal item. E.g. when array `[a]` exists and `b` is + * being inserted we want `[a, b]`, not `[b, a]`. + * + * @param arr - The sorted array to search. + * @param target - The reference value to compare against. + * @param keyOf - Accessor that maps an item to its comparable reference. + * @returns The insertion index in `arr`. */ const findUpperIndex = (arr: T[], target: MsgRef, keyOf: (x: T) => MsgRef): number => { let lo = 0, @@ -131,7 +134,7 @@ export type OwnMessageReceiptsTrackerOptions = { * * Event ingestion * --------------- - * - `ingestInitial(rows: ReadResponse[])`: Builds initial state from server snapshot. + * - `ingestInitial(rows: ReadStateResponse[])`: Builds initial state from server snapshot. * If a user’s `last_read` is ahead of `last_delivered_at`, the tracker enforces * the invariant `lastDeliveredRef >= lastReadRef`. * - `onMessageRead(user, readAtISO)`: @@ -261,7 +264,7 @@ export class MessageReceiptsTracker extends WithSubscriptions { } /** Build initial state from server snapshots (single pass + sort). */ - ingestInitial(responses: ReadResponse[]) { + ingestInitial(responses: ReadStateResponse[]) { this.byUser.clear(); this.readSorted = []; this.deliveredSorted = []; @@ -303,13 +306,13 @@ export class MessageReceiptsTracker extends WithSubscriptions { lastDeliveredMessageId, }: { user: UserResponse; - deliveredAt: string; + deliveredAt: Date; lastDeliveredMessageId?: string; }) { - const timestampMs = new Date(deliveredAt).getTime(); + const timestampMs = deliveredAt.getTime(); const msgRef = lastDeliveredMessageId ? { timestampMs, msgId: lastDeliveredMessageId } - : this.locateMessage(new Date(deliveredAt).getTime()); + : this.locateMessage(deliveredAt.getTime()); if (!msgRef) return; const userProgress = this.ensureUser(user); @@ -338,10 +341,10 @@ export class MessageReceiptsTracker extends WithSubscriptions { lastReadMessageId, }: { user: UserResponse; - readAt: string; + readAt: Date; lastReadMessageId?: string; }) { - const timestampMs = new Date(readAt).getTime(); + const timestampMs = readAt.getTime(); const msgRef = lastReadMessageId ? { timestampMs, msgId: lastReadMessageId } : this.locateMessage(timestampMs); @@ -387,13 +390,13 @@ export class MessageReceiptsTracker extends WithSubscriptions { lastReadMessageId, }: { user: UserResponse; - lastReadAt?: string; + lastReadAt?: Date; lastReadMessageId?: string; }) { const userProgress = this.ensureUser(user); const newReadRef: MsgRef = lastReadAt - ? { timestampMs: new Date(lastReadAt).getTime(), msgId: lastReadMessageId ?? '' } + ? { timestampMs: lastReadAt.getTime(), msgId: lastReadMessageId ?? '' } : { ...MIN_REF }; // If no change, exit early. @@ -649,26 +652,28 @@ export class MessageReceiptsTracker extends WithSubscriptions { private readStoreStateToResponses( readState: Record, - ): ReadResponse[] { - return Object.values(readState).reduce((responses, userReadState) => { - if (!isValidReadState(userReadState)) return responses; - const lastReadDate = new Date(userReadState.last_read); - if (Number.isNaN(lastReadDate.getTime())) return responses; - const lastReadIso = lastReadDate.toISOString(); - - responses.push({ - last_read: lastReadIso, - user: userReadState.user, - last_read_message_id: userReadState.last_read_message_id, - unread_messages: userReadState.unread_messages ?? 0, - last_delivered_at: userReadState.last_delivered_at - ? new Date(userReadState.last_delivered_at).toISOString() - : undefined, - last_delivered_message_id: userReadState.last_delivered_message_id, - }); + ): ReadStateResponse[] { + return Object.values(readState).reduce( + (responses, userReadState) => { + if (!isValidReadState(userReadState)) return responses; + const lastReadDate = new Date(userReadState.last_read); + if (Number.isNaN(lastReadDate.getTime())) return responses; + + responses.push({ + last_read: lastReadDate, + user: userReadState.user, + last_read_message_id: userReadState.last_read_message_id, + unread_messages: userReadState.unread_messages ?? 0, + last_delivered_at: userReadState.last_delivered_at + ? new Date(userReadState.last_delivered_at) + : undefined, + last_delivered_message_id: userReadState.last_delivered_message_id, + }); - return responses; - }, []); + return responses; + }, + [], + ); } private emitSnapshot() { diff --git a/src/messageOperations/MessageOperationStatePolicy.ts b/src/messageOperations/MessageOperationStatePolicy.ts index 329690d835..8db6083f6a 100644 --- a/src/messageOperations/MessageOperationStatePolicy.ts +++ b/src/messageOperations/MessageOperationStatePolicy.ts @@ -1,9 +1,4 @@ -import type { - APIErrorResponse, - ErrorFromResponse, - LocalMessage, - MessageResponse, -} from '../types'; +import type { LocalMessage, MessageResponse, StreamAPIError } from '../types'; import { formatMessage } from '../utils'; export type MessageOperationStatePolicyContext = { @@ -11,17 +6,12 @@ export type MessageOperationStatePolicyContext = { get: (id: string) => LocalMessage | undefined; }; -const parseError = (error: unknown): ErrorFromResponse => { +const parseError = (error: unknown): StreamAPIError => { const stringError = JSON.stringify(error); - return ( - stringError ? JSON.parse(stringError) : {} - ) as ErrorFromResponse; + return (stringError ? JSON.parse(stringError) : {}) as StreamAPIError; }; -const isAlreadyExistsError = ( - error: unknown, - parsed: ErrorFromResponse, -) => +const isAlreadyExistsError = (error: unknown, parsed: StreamAPIError) => parsed.code === 4 && error instanceof Error && error.message.includes('already exists'); export class MessageOperationStatePolicy { diff --git a/src/messageOperations/MessageOperations.ts b/src/messageOperations/MessageOperations.ts index 8fb2314016..5d5a8eb9bf 100644 --- a/src/messageOperations/MessageOperations.ts +++ b/src/messageOperations/MessageOperations.ts @@ -1,5 +1,5 @@ // todo: add tests -import type { Message, UpdateMessageOptions } from '../types'; +import type { MessageRequest, UpdateMessageOptions } from '../types'; import { formatMessage, localMessageToNewMessagePayload } from '../utils'; import { MessageOperationStatePolicy } from './MessageOperationStatePolicy'; import type { @@ -13,7 +13,7 @@ const FAILED_SEND_CACHE_MAX_SIZE = 100; const FAILED_SEND_CACHE_TTL_MS = 5 * 60 * 1000; type FailedSendCacheEntry = { - message: Message; + message: MessageRequest; options?: OperationParams<'send'>['options']; cachedAt: number; }; @@ -28,7 +28,7 @@ export class MessageOperations { this.policy = new MessageOperationStatePolicy({ ingest: ctx.ingest, get: ctx.get }); } - private normalizeMessage(message: Message): Message { + private normalizeMessage(message: MessageRequest): MessageRequest { return this.ctx.normalizeOutgoingMessage ? this.ctx.normalizeOutgoingMessage(message) : message; @@ -46,7 +46,7 @@ export class MessageOperations { private cacheFailedSend(params: { messageId: string; - message: Message; + message: MessageRequest; options?: OperationParams<'send'>['options']; }) { this.pruneExpiredFailedSendCache(); diff --git a/src/messageOperations/types.ts b/src/messageOperations/types.ts index 4d7ce185e6..1ac9911323 100644 --- a/src/messageOperations/types.ts +++ b/src/messageOperations/types.ts @@ -1,7 +1,7 @@ import type { DeleteMessageOptions, LocalMessage, - Message, + MessageRequest, MessageResponse, SendMessageAPIResponse, SendMessageOptions, @@ -33,7 +33,7 @@ export type MessageOperationSpec = { export type OperationParams = { localMessage: LocalMessage; options?: MessageOperationSpec[K]['options']; -} & (K extends 'send' | 'retry' ? { message?: Message } : {}); +} & (K extends 'send' | 'retry' ? { message?: MessageRequest } : {}); export type OperationResponse = { message: MessageResponse }; @@ -52,11 +52,11 @@ export type MessageOperationsContext = { ingest: (m: LocalMessage) => void; get: (id: string) => LocalMessage | undefined; - normalizeOutgoingMessage?: (m: Message) => Message; + normalizeOutgoingMessage?: (m: MessageRequest) => MessageRequest; defaults: { delete: (id: string, o?: DeleteMessageOptions) => Promise; - send: (m: Message, o?: SendMessageOptions) => Promise; + send: (m: MessageRequest, o?: SendMessageOptions) => Promise; update: (m: LocalMessage, o?: UpdateMessageOptions) => Promise; }; diff --git a/src/moderation.ts b/src/moderation.ts index b40c265091..a95e4ed36f 100644 --- a/src/moderation.ts +++ b/src/moderation.ts @@ -1,464 +1,71 @@ -import type { - APIResponse, - CheckResponse, - CustomCheckFlag, - CustomCheckResponse, - GetConfigResponse, - GetUserModerationReportOptions, - GetUserModerationReportResponse, - ModerationConfig, - ModerationFlagOptions, - ModerationMuteOptions, - ModerationRule, - ModerationRuleRequest, - MuteUserResponse, - Pager, - QueryConfigsResponse, - QueryModerationConfigsFilters, - QueryModerationConfigsSort, - QueryModerationRulesFilters, - QueryModerationRulesResponse, - QueryModerationRulesSort, - RequireAtLeastOne, - ReviewQueueFilters, - ReviewQueuePaginationOptions, - ReviewQueueResponse, - ReviewQueueSort, - SubmitActionOptions, - SubmitActionResponse, - UnmuteUserResponse, - UpsertConfigResponse, - UpsertModerationRuleResponse, -} from './types'; +import type { ModerationFlagOptions, UnmuteUserResponse } from './types'; import type { StreamChat } from './client'; -import { normalizeQuerySort } from './utils'; +import { ModerationApi } from './gen/moderation/ModerationApi'; export const MODERATION_ENTITY_TYPES = { user: 'stream:user', message: 'stream:chat:v1:message', - userprofile: 'stream:v1:user_profile', }; // Moderation class provides all the endpoints related to moderation v2. -export class Moderation { +export class Moderation extends ModerationApi { client: StreamChat; constructor(client: StreamChat) { + super(client.api); this.client = client; } /** - * Flag a user + * Flags a user. * - * @param {string} flaggedUserID User ID to be flagged - * @param {string} reason Reason for flagging the user - * @param {Object} options Additional options for flagging the user - * @param {string} options.user_id (For server side usage) User ID of the user who is flagging the target user - * @param {Object} options.custom Additional data to be stored with the flag - * @returns + * @param flaggedUserId - User ID to be flagged. + * @param reason - Reason for flagging the user. + * @param options - Additional options for flagging the user (optional, defaults to `{}`). + * @param options.custom - Additional data to be stored with the flag (optional). + * @returns The flag response. */ - flagUser(flaggedUserID: string, reason: string, options: ModerationFlagOptions = {}) { - return this.flag(MODERATION_ENTITY_TYPES.user, flaggedUserID, '', reason, options); + flagUser(flaggedUserId: string, reason: string, options: ModerationFlagOptions = {}) { + return this.flag({ + entity_type: MODERATION_ENTITY_TYPES.user, + entity_id: flaggedUserId, + entity_creator_id: '', + reason, + ...options, + }); } /** - * Flag a message + * Flags a message. * - * @param {string} messageID Message ID to be flagged - * @param {string} reason Reason for flagging the message - * @param {Object} options Additional options for flagging the message - * @param {string} options.user_id (For server side usage) User ID of the user who is flagging the target message - * @param {Object} options.custom Additional data to be stored with the flag - * @returns + * @param messageId - MessageRequest ID to be flagged. + * @param reason - Reason for flagging the message. + * @param options - Additional options for flagging the message (optional, defaults to `{}`). + * @param options.custom - Additional data to be stored with the flag (optional). + * @returns The flag response. */ - flagMessage(messageID: string, reason: string, options: ModerationFlagOptions = {}) { - return this.flag(MODERATION_ENTITY_TYPES.message, messageID, '', reason, options); + flagMessage(messageId: string, reason: string, options: ModerationFlagOptions = {}) { + return this.flag({ + entity_type: MODERATION_ENTITY_TYPES.message, + entity_id: messageId, + entity_creator_id: '', + reason, + ...options, + }); } /** - * Flag a user + * Unmutes a user. * - * @param {string} entityType Entity type to be flagged - * @param {string} entityId Entity ID to be flagged - * @param {string} entityCreatorID User ID of the entity creator - * @param {string} reason Reason for flagging the entity - * @param {Object} options Additional options for flagging the entity - * @param {string} options.user_id (For server side usage) User ID of the user who is flagging the target entity - * @param {Object} options.moderation_payload Content to be flagged e.g., { texts: ['text1', 'text2'], images: ['image1', 'image2']} - * @param {Object} options.custom Additional data to be stored with the flag - * @returns + * @param targetId - User ID to be unmuted. + * @returns The unmute response. */ - async flag( - entityType: string, - entityId: string, - entityCreatorID: string, - reason: string, - options: ModerationFlagOptions = {}, - ) { - return await this.client.post<{ item_id: string } & APIResponse>( - this.client.baseURL + '/api/v2/moderation/flag', - { - entity_type: entityType, - entity_id: entityId, - entity_creator_id: entityCreatorID, - reason, - ...options, - }, - ); - } - - /** - * Mute a user - * @param {string} targetID User ID to be muted - * @param {Object} options Additional options for muting the user - * @param {string} options.user_id (For server side usage) User ID of the user who is muting the target user - * @param {number} options.timeout Timeout for the mute in minutes - * @returns - */ - async muteUser(targetID: string, options: ModerationMuteOptions = {}) { - return await this.client.post( - this.client.baseURL + '/api/v2/moderation/mute', - { - target_ids: [targetID], - ...options, - }, - ); - } - - /** - * Unmute a user - * @param {string} targetID User ID to be unmuted - * @param {Object} options Additional options for unmuting the user - * @param {string} options.user_id (For server side usage) User ID of the user who is unmuting the target user - * @returns - */ - async unmuteUser( - targetID: string, - options: { - user_id?: string; - }, - ) { - return await this.client.post( + async unmuteUser(targetId: string) { + return await this.client.api.post( this.client.baseURL + '/api/v2/moderation/unmute', { - target_ids: [targetID], - ...options, - }, - ); - } - - /** - * Get moderation report for a user - * @param {string} userID User ID for which moderation report is to be fetched - * @param {Object} options Additional options for fetching the moderation report - * @param {boolean} options.create_user_if_not_exists Create user if not exists - * @param {boolean} options.include_user_blocks Include user blocks - * @param {boolean} options.include_user_mutes Include user mutes - */ - async getUserModerationReport( - userID: string, - options: GetUserModerationReportOptions = {}, - ) { - return await this.client.get( - this.client.baseURL + `/api/v2/moderation/user_report`, - { - user_id: userID, - ...options, - }, - ); - } - - /** - * Query review queue - * @param {Object} filterConditions Filter conditions for querying review queue - * @param {Object} sort Sort conditions for querying review queue - * @param {Object} options Pagination options for querying review queue - */ - async queryReviewQueue( - filterConditions: ReviewQueueFilters = {}, - sort: ReviewQueueSort = [], - options: ReviewQueuePaginationOptions = {}, - ) { - return await this.client.post( - this.client.baseURL + '/api/v2/moderation/review_queue', - { - filter: filterConditions, - sort: normalizeQuerySort(sort), - ...options, - }, - ); - } - - /** - * Upsert moderation config - * @param {Object} config Moderation config to be upserted - */ - async upsertConfig(config: ModerationConfig) { - return await this.client.post( - this.client.baseURL + '/api/v2/moderation/config', - config, - ); - } - - /** - * Get moderation config - * @param {string} key Key for which moderation config is to be fetched - */ - async getConfig(key: string, data?: { team?: string }) { - return await this.client.get( - this.client.baseURL + '/api/v2/moderation/config/' + key, - data, - ); - } - - async deleteConfig(key: string, data?: { team?: string }) { - return await this.client.delete( - this.client.baseURL + '/api/v2/moderation/config/' + key, - data, - ); - } - - /** - * Query moderation configs - * @param {Object} filterConditions Filter conditions for querying moderation configs - * @param {Object} sort Sort conditions for querying moderation configs - * @param {Object} options Additional options for querying moderation configs - */ - async queryConfigs( - filterConditions: QueryModerationConfigsFilters, - sort: QueryModerationConfigsSort, - options: Pager = {}, - ) { - return await this.client.post( - this.client.baseURL + '/api/v2/moderation/configs', - { - filter: filterConditions, - sort, - ...options, - }, - ); - } - - async submitAction( - actionType: string, - itemID: string, - options: SubmitActionOptions = {}, - ) { - return await this.client.post( - this.client.baseURL + '/api/v2/moderation/submit_action', - { - action_type: actionType, - item_id: itemID, - ...options, - }, - ); - } - - /** - * - * @param {string} entityType string Type of entity to be checked E.g., stream:user, stream:chat:v1:message, or any custom string - * @param {string} entityID string ID of the entity to be checked. This is mainly for tracking purposes - * @param {string} entityCreatorID string ID of the entity creator - * @param {object} moderationPayload object Content to be checked for moderation. E.g., { texts: ['text1', 'text2'], images: ['image1', 'image2']} - * @param {Array} moderationPayload.texts array Array of texts to be checked for moderation - * @param {Array} moderationPayload.images array Array of images to be checked for moderation - * @param {Array} moderationPayload.videos array Array of videos to be checked for moderation - * @param configKey - * @param options - * @returns - */ - async check( - entityType: string, - entityID: string, - entityCreatorID: string, - moderationPayload: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - custom?: Record; - images?: string[]; - texts?: string[]; - videos?: string[]; - }, - configKey: string, - options?: { - force_sync?: boolean; - }, - testMode?: boolean, - ) { - return await this.client.post( - this.client.baseURL + `/api/v2/moderation/check`, - { - entity_type: entityType, - entity_id: entityID, - entity_creator_id: entityCreatorID, - moderation_payload: moderationPayload, - config_key: configKey, - options, - test_mode: testMode, - }, - ); - } - - /** - * Experimental: Check user profile - * - * Warning: This is an experimental feature and the API is subject to change. - * - * This function is used to check a user profile for moderation. - * This will not create any review queue items for the user profile. - * You can just use this to check whether to allow a certain user profile to be created or not. - * - * Example: - * - * ```ts - * const res = await client.moderation.checkUserProfile(userId, { username: "fuck_boy_001", image: "https://example.com/profile.jpg" }); - * if (res.recommended_action === "remove") { - * // Block the user profile from being created - * } else { - * // Allow the user profile to be created - * } - * ``` - * - * @param userId - * @param profile.username - * @param profile.image - * @returns - */ - async checkUserProfile( - userId: string, - profile: RequireAtLeastOne<{ image?: string; username?: string }>, - ) { - if (!profile.username && !profile.image) { - throw new Error('Either username or image must be provided'); - } - - const moderationPayload: { images?: string[]; texts?: string[] } = {}; - if (profile.username) { - moderationPayload.texts = [profile.username]; - } - if (profile.image) { - moderationPayload.images = [profile.image]; - } - - return await this.check( - MODERATION_ENTITY_TYPES.userprofile, - userId, - userId, - moderationPayload, - 'user_profile:default', - { - force_sync: true, - }, - true, - ); - } - - /** - * - * @param {string} entityType string Type of entity to be checked E.g., stream:user, stream:chat:v1:message, or any custom string - * @param {string} entityID string ID of the entity to be checked. This is mainly for tracking purposes - * @param {string} entityCreatorID string ID of the entity creator - * @param {object} moderationPayload object Content to be checked for moderation. E.g., { texts: ['text1', 'text2'], images: ['image1', 'image2']} - * @param {Array} moderationPayload.texts array Array of texts to be checked for moderation - * @param {Array} moderationPayload.images array Array of images to be checked for moderation - * @param {Array} moderationPayload.videos array Array of videos to be checked for moderation - * @param {object} moderationPayload.custom object Additional custom data to attach to the moderation review queue item - * @param {Array} flags Array of CustomCheckFlag to be passed to flag the entity - * @returns - */ - async addCustomFlags( - entityType: string, - entityID: string, - entityCreatorID: string, - moderationPayload: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - custom?: Record; - images?: string[]; - texts?: string[]; - videos?: string[]; - }, - flags: CustomCheckFlag[], - ) { - return await this.client.post( - this.client.baseURL + `/api/v2/moderation/custom_check`, - { - entity_type: entityType, - entity_id: entityID, - entity_creator_id: entityCreatorID, - moderation_payload: moderationPayload, - flags, + target_ids: [targetId], }, ); } - - /** - * Add custom flags to a message - * @param {string} messageID Message ID to be flagged - * @param {Array} flags Array of CustomCheckFlag to be passed to flag the message - * @returns - */ - async addCustomMessageFlags(messageID: string, flags: CustomCheckFlag[]) { - return await this.addCustomFlags( - MODERATION_ENTITY_TYPES.message, - messageID, - '', - {}, - flags, - ); - } - - /** - * Create or update a moderation rule - * @param {ModerationRuleRequest} rule Rule configuration to be upserted - * @returns - */ - async upsertModerationRule(rule: ModerationRuleRequest) { - return await this.client.post( - this.client.baseURL + '/api/v2/moderation/moderation_rule', - rule, - ); - } - - /** - * Query moderation rules - * @param {QueryModerationRulesFilters} filterConditions Filter conditions for querying moderation rules - * @param {QueryModerationRulesSort} sort Sort conditions for querying moderation rules - * @param {Pager} options Pagination options for querying moderation rules - * @returns - */ - async queryModerationRules( - filterConditions: QueryModerationRulesFilters = {}, - sort: QueryModerationRulesSort = [], - options: Pager = {}, - ) { - return await this.client.post( - this.client.baseURL + '/api/v2/moderation/moderation_rules', - { - filter: filterConditions, - sort, - ...options, - }, - ); - } - - /** - * Get a specific moderation rule by ID - * @param {string} id ID of the moderation rule to fetch - * @returns - */ - async getModerationRule(id: string) { - return await this.client.get<{ rule: ModerationRule }>( - this.client.baseURL + '/api/v2/moderation/moderation_rule/' + id, - ); - } - - /** - * Delete a moderation rule by ID - * @param {string} id ID of the moderation rule to delete - * @returns - */ - async deleteModerationRule(id: string) { - return await this.client.delete( - this.client.baseURL + '/api/v2/moderation/moderation_rule/' + id, - ); - } } diff --git a/src/notifications/types.ts b/src/notifications/types.ts index c929062d98..f764f69ead 100644 --- a/src/notifications/types.ts +++ b/src/notifications/types.ts @@ -33,7 +33,7 @@ export type Notification = { origin: NotificationOrigin; /** Array of action buttons for the notification */ actions?: NotificationAction[]; - /** The severity level of the notification. Defaults to undefined unless explicitly provided. */ + /** The severity level of the notification (defaults to `undefined` unless explicitly provided). */ severity?: NotificationSeverity; /** * Optional code that can be used to group the notifications of the same type, e.g. attachment-upload-blocked. @@ -55,9 +55,9 @@ export type Notification = { * 'validation:attachment:size:exceeded' // File size too large * 'validation:attachment:count:exceeded' // Too many attachments * - * Message related errors - * 'api:message:send:failed' // Message send failed - * 'validation:message:content:empty' // Message content validation failed + * MessageRequest related errors + * 'api:message:send:failed' // MessageRequest send failed + * 'validation:message:content:empty' // MessageRequest content validation failed * * Channel related errors * 'api:channel:join:failed' // Channel join failed @@ -104,11 +104,12 @@ export type NotificationOptions = Partial< }; /** - * State shape for the notification store - * @deprcated use NotificationManagerState + * State shape for the notification store. + * + * @deprecated Use {@link NotificationManagerState} instead. */ export type NotificationState = { - /** Array of current notification objects */ + /** Array of current notification objects. */ notifications: Notification[]; }; diff --git a/src/offline-support/offline_support_api.ts b/src/offline-support/offline_support_api.ts index 8ee0511070..05178047ef 100644 --- a/src/offline-support/offline_support_api.ts +++ b/src/offline-support/offline_support_api.ts @@ -1,10 +1,14 @@ import type { - APIErrorResponse, + APIError, ChannelResponse, Event, + EventPayload, + EventType, LocalMessage, - Message, + MessageRequest, MessageResponse, + OwnUserResponse, + RequireLiteral, } from '../types'; import type { @@ -17,6 +21,7 @@ import { OfflineError } from './types'; import type { StreamChat } from '../client'; import type { AxiosError } from 'axios'; import { OfflineDBSyncManager } from './offline_sync_manager'; +import { chatLoggerSystem } from '../logger'; import { StateStore } from '../store'; import { channelHasReadEvents, @@ -27,6 +32,9 @@ import { } from '../utils'; import { isMessageUpdateReplayable } from './util'; +const logger = chatLoggerSystem.getLogger('offline-db'); +import type { WSEvent } from '../gen/models'; + /** * Abstract base class for an offline database implementation used with StreamChat. * @@ -46,12 +54,11 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { this.syncManager = new OfflineDBSyncManager({ client, offlineDb: this }); this.state = new StateStore({ initialized: false, - userId: this.client.userID, + userId: this.client.userId, }); } /** - * @abstract * Inserts a reaction into the DB. * Will write to: * - The reactions table with the new reaction @@ -59,24 +66,24 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * - The users table with any users associated * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBInsertReactionType} options * @returns {Promise} */ abstract insertReaction: OfflineDBApi['insertReaction']; /** - * @abstract * Upserts the list of CIDs for a filter + sort query hash. * Will write to only the table containing the cids. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertCidsForQueryType} options * @returns {Promise} */ abstract upsertCidsForQuery: OfflineDBApi['upsertCidsForQuery']; /** - * @abstract * Upserts the channels passed as an argument within the DB. Relies on * writing the properties we need from a ChannelResponse into the adequate * tables. @@ -90,72 +97,72 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * - The reads table for each user * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertChannelsType} options * @returns {Promise} */ abstract upsertChannels: OfflineDBApi['upsertChannels']; /** - * @abstract * Upserts the current active user's sync status. * Will only write to the sync status table. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertUserSyncStatusType} options * @returns {Promise} */ abstract upsertUserSyncStatus: OfflineDBApi['upsertUserSyncStatus']; /** - * @abstract * Upserts the app settings for the current Stream App into the DB. It * is only intended to be run once per lifecycle of the app. * Will only write to the respective app settings table. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertAppSettingsType} options * @returns {Promise} */ abstract upsertAppSettings: OfflineDBApi['upsertAppSettings']; /** - * @abstract * Upserts a poll fully in the DB. * Will write to the polls table. It should not update the message * associated due to how the poll state works. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertPollType} options * @returns {Promise} */ abstract upsertPoll: OfflineDBApi['upsertPoll']; /** - * @abstract * Upserts only the channel.data for the provided channels in the DB. * Will only write to the channels table. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertChannelDataType} options * @returns {Promise} */ abstract upsertChannelData: OfflineDBApi['upsertChannelData']; /** - * @abstract * Upserts the provided reads in the DB. * Will write to: * - The reads table * - The users table for each user associated with a read * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertReadsType} options * @returns {Promise} */ abstract upsertReads: OfflineDBApi['upsertReads']; /** - * @abstract * Upserts the messages in the DB. * Will write to: * - The messages table @@ -165,287 +172,288 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * - The users table * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertMessagesType} options * @returns {Promise} */ abstract upsertMessages: OfflineDBApi['upsertMessages']; /** - * @abstract * Upserts the members in the DB. * Will write to: * - The users table (for each user associated with a member) * - The members table * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertMembersType} options * @returns {Promise} */ abstract upsertMembers: OfflineDBApi['upsertMembers']; /** - * @abstract * Updates a reaction in the DB. Will update the DB the same way * a reaction.updated event would (it assumes enforce_unique is true * and removes all other reactions associated with the user. * Will write to the reactions table. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpdateReactionType} options * @returns {Promise} */ abstract updateReaction: OfflineDBApi['updateReaction']; /** - * @abstract * Updates a single message in the DB. This is used as a faster * alternative to upsertMessages with more optimized queries. * Will write to the messages table. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpdateMessageType} options * @returns {Promise} */ abstract updateMessage: OfflineDBApi['updateMessage']; /** - * @abstract * Fetches the provided draft from the DB. Should return as close to * the server side DraftResponse as possible. + * * @param {DBGetDraftType} options * @returns {Promise} */ abstract getDraft: OfflineDBApi['getDraft']; /** - * @abstract * Upserts a draft in the DB. * Will write to the draft table upserting the draft. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpsertDraftType} options * @returns {Promise} */ abstract upsertDraft: OfflineDBApi['upsertDraft']; /** - * @abstract * Deletes a draft from the DB. * Will write to the draft table removing the draft. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDeleteDraftType} options * @returns {Promise} */ abstract deleteDraft: OfflineDBApi['deleteDraft']; /** - * @abstract * Fetches the provided channels from the DB and aggregates all data associated - * with them in a single ChannelAPIResponse. The implementation itself is responsible + * with them in a single ChannelStateResponseFields. The implementation itself is responsible * for aggregating and serialization of all of the data. Should return as close to - * the server side ChannelAPIResponse as possible. + * the server side ChannelStateResponseFields as possible. + * * @param {DBGetChannelsType} options - * @returns {Promise[] | null>} + * @returns {Promise[] | null>} */ abstract getChannels: OfflineDBApi['getChannels']; /** - * @abstract * Fetches the channels from the DB that were the last known response to a filters & sort - * hash as a query and aggregates all data associated with them in a single ChannelAPIResponse. + * hash as a query and aggregates all data associated with them in a single ChannelStateResponseFields. * The implementation itself is responsible for aggregating and serialization of all of the data. - * Should return as close to the server side ChannelAPIResponse as possible. + * Should return as close to the server side ChannelStateResponseFields as possible. + * * @param {DBGetChannelsForQueryType} options - * @returns {Promise[] | null>} + * @returns {Promise[] | null>} */ abstract getChannelsForQuery: OfflineDBApi['getChannelsForQuery']; /** - * @abstract * Will return a list of all available CIDs in the DB. The same can be achieved * by fetching all channels, however this is meant to be much faster as a query. + * * @returns {Promise} */ abstract getAllChannelCids: OfflineDBApi['getAllChannelCids']; /** - * @abstract * Fetches the timestamp of the last sync of the DB. + * * @param {DBGetLastSyncedAtType} options * @returns {Promise} */ abstract getLastSyncedAt: OfflineDBApi['getLastSyncedAt']; /** - * @abstract * Fetches all pending tasks from the DB. It will return them in an * ordered fashion by the time they were created. + * * @param {DBGetPendingTasksType} [conditions] * @returns {Promise} */ abstract getPendingTasks: OfflineDBApi['getPendingTasks']; /** - * @abstract * Fetches the app settings stored in the DB. Is mainly meant to be used * only while offline and opening the application, as we only update the * app settings whenever they are fetched again so it has the potential to * be stale. + * * @param {DBGetAppSettingsType} options - * @returns {Promise} + * @returns {Promise} */ abstract getAppSettings: OfflineDBApi['getAppSettings']; /** - * @abstract * Fetches reactions from the DB for a given filter & sort hash and * for a given message ID. + * * @param {DBGetReactionsType} options * @returns {Promise} */ abstract getReactions: OfflineDBApi['getReactions']; /** - * @abstract * Executes multiple queries in a batched fashion. It will also be done * within a transaction. + * * @param {ExecuteBatchDBQueriesType} queries * @returns {Promise} */ abstract executeSqlBatch: OfflineDBApi['executeSqlBatch']; /** - * @abstract * Adds a pending task to the pending tasks table. Can only be one of the * supported types of pending tasks, otherwise its execution will throw. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {PendingTask} task * @returns {Promise<() => Promise>} */ abstract addPendingTask: OfflineDBApi['addPendingTask']; /** - * @abstract * Updates a pending task in the DB, given its ID. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBUpdatePendingTaskType} options * @returns {Promise} */ abstract updatePendingTask: OfflineDBApi['updatePendingTask']; /** - * @abstract * Deletes a pending task from the DB, given its ID. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDeletePendingTaskType} options * @returns {Promise} */ abstract deletePendingTask: OfflineDBApi['deletePendingTask']; /** - * @abstract * Deletes a reaction from the DB. * Will write to the reactions table removing the reaction. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDeleteReactionType} options * @returns {Promise} */ abstract deleteReaction: OfflineDBApi['deleteReaction']; /** - * @abstract * Deletes a member from the DB. * Will only write to the members table. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDeleteMemberType} options * @returns {Promise} */ abstract deleteMember: OfflineDBApi['deleteMember']; /** - * @abstract * Deletes a channel from the DB. * It will also delete all other entities associated with the channel in * a cascading fashion (messages, reactions, members etc.). * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDeleteChannelType} options * @returns {Promise} */ abstract deleteChannel: OfflineDBApi['deleteChannel']; /** - * @abstract * Deletes multiple messages for a given channel. Works as `channel.truncated` would. * Should remove entities primarily from the messages table and then from all associated * tables in a cascading fashion (reactions, polls etc.). * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDeleteMessagesForChannelType} options * @returns {Promise} */ abstract deleteMessagesForChannel: OfflineDBApi['deleteMessagesForChannel']; /** - * @abstract * Deletes all pending tasks from the DB. * Will only update the pending tasks table. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDropPendingTasksType} options * @returns {Promise} */ abstract dropPendingTasks: OfflineDBApi['dropPendingTasks']; /** - * @abstract * Deletes a message from the DB. * All other entities associated with the message will also be deleted * in a cascading fashion (reactions, polls etc.). * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDeleteMessageType} options * @returns {Promise} */ abstract hardDeleteMessage: OfflineDBApi['hardDeleteMessage']; /** - * @abstract * Updates a message with a deleted_at value in the DB. * Will only update the messages table, as the message is simply marked * as deleted and not removed from the DB. * Will return the prepared queries for delayed execution (even if they are * already executed). + * * @param {DBDeleteMessageType} options * @returns {Promise} */ abstract softDeleteMessage: OfflineDBApi['softDeleteMessage']; /** - * @abstract * Drops all tables and reinitializes the connection to the DB. + * * @returns {Promise} */ abstract resetDB: OfflineDBApi['resetDB']; /** - * @abstract * A utility query that checks whether a specific channel exists in the DB. * Technically the same as actually fetching that channel through other queries, * but much faster. + * * @param {DBChannelExistsType} options * @returns {Promise} */ abstract channelExists: OfflineDBApi['channelExists']; /** - * @abstract * Initializes the DB (typically creating a simple file handle as a connection pointer for * SQLite and likely similar for other DBs). + * * @returns {Promise} */ abstract initializeDB: OfflineDBApi['initializeDB']; @@ -453,6 +461,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { /** * Initializes the DB as well as its syncManager for a given userId. * It will update the DBs reactive state with initialization values. + * * @param userId - the user ID for which we want to initialize */ public init = async (userId: string) => { @@ -470,13 +479,16 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { } } catch (error) { this.state.partialNext({ initialized: false, userId: undefined }); - console.log('Error Initializing DB:', error); + logger + .withExtraTags('init') + .error('Failed to initialize the offline database.', { error }); } }; /** * Checks whether the DB should be initialized or if it has been initialized already. - * @param {string} userId - the user ID for which we want to check initialization + * + * @param userId - the user ID for which we want to check initialization */ public shouldInitialize(userId: string): boolean { const { userId: userIdFromState, initialized } = this.state.getLatestValue(); @@ -488,6 +500,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * passed uses a reference to the DB itself and will handle errors gracefully * and silently. Only really meant to be used for write queries that need to * be run in synchronous functions. + * * @param queryCallback - a callback wrapping all query logic that is to be executed * @param method - a utility parameter used for proper logging (will make sure the method * is logged on failure) @@ -516,21 +529,29 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * If both fail, it will not execute the query as it would result in a foreign key constraint * error. * - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. - * @param forceUpdate - whether to upsert the channel data anyway - * @param createQueries - a callback function to creation of the queries that we want to execute + * @param event - The WS event we are trying to process. + * @param event.execute - Whether to immediately execute the operation (optional, defaults to `true`). + * @param event.forceUpdate - Whether to upsert the channel data anyway (optional, defaults to `false`). + * @param createQueries - A callback that creates the queries to execute. + * @returns The list of prepared queries (executed when `execute` is `true`). */ public queriesWithChannelGuard = async ( { event, execute = true, forceUpdate = false, - }: { event: Event; execute?: boolean; forceUpdate?: boolean }, + }: { + event: Extract< + Event, + { channel?: any; cid?: any; channel_type?: any; channel_id?: any } + >; + execute?: boolean; + forceUpdate?: boolean; + }, createQueries: (executeOverride?: boolean) => Promise, ) => { - const channelFromEvent = event.channel; - const cid = event.cid || channelFromEvent?.cid; + const channelFromEvent = (event as Extract).channel; + const cid = (event as Extract).cid || channelFromEvent?.cid; const type = event.type; if (!cid) { @@ -543,11 +564,13 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { // This can happen for example when a message.new event is received for a channel that is not in the db due to a channel being hidden. const shouldUpsertChannelData = forceUpdate || !(await this.channelExists({ cid })); if (shouldUpsertChannelData) { + const event_ = event as Extract; + let channelData = channelFromEvent; - if (!channelData && event.channel_type && event.channel_id) { + if (!channelData && event_.channel_type && event_.channel_id) { const channelFromState = this.client.channel( - event.channel_type, - event.channel_id, + event_.channel_type, + event_.channel_id, ); if (channelFromState.initialized && !channelFromState.disconnected) { channelData = channelFromState.data as unknown as ChannelResponse; @@ -566,17 +589,21 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { } return newQueries; } else { - console.warn( - `Couldn't create channel queries on ${type} event for an initialized channel that is not in DB, skipping event`, - { event }, - ); + logger + .withExtraTags('queriesWithChannelGuard') + .warn( + `Could not create channel queries on a "${type}" event for an initialized channel that is not in the database. Skipping the event.`, + { event }, + ); return []; } } else { - console.warn( - `Received ${type} event for a non initialized channel that is not in DB, skipping event`, - { event }, - ); + logger + .withExtraTags('queriesWithChannelGuard') + .warn( + `Received a "${type}" event for a non-initialized channel that is not in the database. Skipping the event.`, + { event }, + ); return []; } } @@ -588,14 +615,15 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * and it is going to make sure that both messages and reads are upserted. It will not * try to fetch the reads from the DB first and it will rely on channel.state to handle * the number of unreads. - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleNewMessage = async ({ event, execute = true, }: { - event: Event; + event: EventPayload<'message.new'>; execute?: boolean; }) => { const client = this.client; @@ -629,10 +657,13 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { execute: false, reads: [ { - last_read: (ownReads?.last_read ?? new Date(0)).toISOString() as string, + last_read: ownReads?.last_read ?? new Date(0), last_read_message_id: ownReads?.last_read_message_id, unread_messages: unreadCount, - user: client.user, + user: client.user as RequireLiteral< + OwnUserResponse, + 'blocked_user_ids' + >, // TODO: drop RequireLiteral once the oapi spec is adjusted }, ], }); @@ -653,14 +684,15 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { /** * A handler for message deletion. It provides a channel guard and determines whether * it should hard delete or soft delete the message. - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleDeleteMessage = async ({ event, execute = true, }: { - event: Event; + event: EventPayload<'message.deleted'>; execute?: boolean; }) => { const { message, deleted_for_me, hard_delete = false } = event; @@ -685,8 +717,9 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * A utility method used for removing a message that has already failed from the * state as well as the DB. We want to drop all pending tasks and finally hard * delete the message from the DB. - * @param messageId - the message id of the message we want to remove - * @param execute - whether to immediately execute the operation. + * + * @param payload.messageId - The ID of the message we want to remove. + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleRemoveMessage = async ({ messageId, @@ -718,22 +751,29 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * The unreadMessages argument is useful for cases where we know the exact number of unreads * (for example reading an entire channel), but `unread_messages` might not necessarily exist * in the event (or it exists with a stale value if we know what we want to ultimately update to). - * @param event - the WS event we are trying to process - * @param unreadMessages - an override of unread_messages that will be preferred when upserting reads - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - The WS event we are trying to process. + * @param payload.unreadMessages - An override of `unread_messages` that will be preferred when upserting reads. + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleRead = async ({ event, unreadMessages, execute = true, }: { - event: Event; + event: EventPayload< + | 'message.read' + | 'message.read_locally' + | 'notification.mark_read' + | 'notification.mark_unread' + >; unreadMessages?: number; execute?: boolean; }) => { const { - received_at: last_read, + received_at: last_read = new Date(), last_read_message_id, + // @ts-expect-error property missing unread_messages = 0, user, cid, @@ -748,7 +788,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { execute: executeOverride, reads: [ { - last_read: last_read as string, + last_read, last_read_message_id, unread_messages: overriddenUnreadMessages, user, @@ -765,14 +805,15 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * A utility method used to handle member events. It guards the processing * of each event with a channel guard and also forces an update of member_count * for the respective channel if applicable. - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleMemberEvent = async ({ event, execute = true, }: { - event: Event; + event: EventPayload<`member.${string}`>; execute?: boolean; }) => { const { member, cid, type } = event; @@ -803,14 +844,15 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { /** * A utility method used to handle message.updated events. It guards each * event handler within a channel guard. - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleMessageUpdatedEvent = async ({ event, execute = true, }: { - event: Event; + event: EventPayload<'message.updated' | 'message.undeleted'>; execute?: boolean; }) => { const { message } = event; @@ -832,14 +874,15 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * simple upsertion is not enough. * It will update the hidden property of a channel to true if handling the `channel.hidden` * event and to false if handling `channel.visible`. - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload. - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleChannelVisibilityEvent = async ({ event, execute = true, }: { - event: Event; + event: EventPayload<'channel.visible' | 'channel.hidden'>; execute?: boolean; }) => { const { type, channel } = event; @@ -859,14 +902,15 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * A utility handler used to handle channel.truncated events. It handles both * removing all messages and relying on truncated_at as well. It will also upsert * reads adequately (and calculate the correct unread messages when truncating). - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleChannelTruncatedEvent = async ({ event, execute = true, }: { - event: Event; + event: EventPayload<'channel.truncated'>; execute?: boolean; }) => { const { channel } = event; @@ -901,10 +945,10 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { execute: false, reads: [ { - last_read: (ownReads?.last_read ?? new Date(0)).toString() as string, + last_read: ownReads?.last_read ?? new Date(0), last_read_message_id: ownReads?.last_read_message_id, unread_messages: unreadCount, - user: ownUser, + user: ownUser as RequireLiteral, // TODO: drop RequireLiteral once the oapi spec is adjusted }, ], }); @@ -927,14 +971,15 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * - reaction.new -> insertReaction * - reaction.updated -> updateReaction * - reaction.deleted -> deleteReaction - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleReactionEvent = async ({ event, execute = true, }: { - event: Event; + event: EventPayload<`reaction.${string}`>; execute?: boolean; }) => { const { type, message, reaction } = event; @@ -943,7 +988,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { return []; } - const getReactionMethod = (type: Event['type']) => { + const getReactionMethod = (type: EventType) => { switch (type) { case 'reaction.new': return this.insertReaction; @@ -969,14 +1014,15 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * A utility handler for all draft events: * - draft.updated -> updateDraft * - draft.deleted -> deleteDraft - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ handleDraftEvent = async ({ event, execute = true, }: { - event: Event; + event: EventPayload<`draft.${string}`>; execute?: boolean; }) => { const { cid, draft, type } = event; @@ -1007,8 +1053,9 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * A generic event handler that decides which DB API to invoke based on * event.type for all events we are currently handling. It is used to both * react on WS events as well as process the sync API events. - * @param event - the WS event we are trying to process - * @param execute - whether to immediately execute the operation. + * + * @param payload.event - the WS event we are trying to process + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). */ public handleEvent = async ({ event, @@ -1017,10 +1064,13 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { event: Event; execute?: boolean; }) => { - const { type, channel } = event; + const { type } = event; if (type.startsWith('reaction')) { - return await this.handleReactionEvent({ event, execute }); + return await this.handleReactionEvent({ + event: event as EventPayload<`reaction.${string}`>, + execute, + }); } if (type === 'message.new') { @@ -1056,7 +1106,10 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { } if (type.startsWith('member.')) { - return await this.handleMemberEvent({ event, execute }); + return await this.handleMemberEvent({ + event: event as EventPayload<`member.${string}`>, + execute, + }); } if (type === 'channel.hidden' || type === 'channel.visible') { @@ -1078,18 +1131,18 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { (type === 'channel.updated' || type === 'notification.message_new' || type === 'notification.added_to_channel') && - channel + event.channel ) { - return await this.upsertChannelData({ channel, execute }); + return await this.upsertChannelData({ channel: event.channel, execute }); } if ( (type === 'channel.deleted' || type === 'notification.channel_deleted' || type === 'notification.removed_from_channel') && - channel + event.channel ) { - return await this.deleteChannel({ cid: channel.cid, execute }); + return await this.deleteChannel({ cid: event.channel.cid, execute }); } if (type === 'channel.truncated') { @@ -1108,6 +1161,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * 3. If it is, it will insert the task in the pending tasks table * * It will return the response from the execution if it succeeded. + * * @param task - the pending task we want to execute */ public queueTask = async ({ task }: { task: PendingTask }): Promise => { @@ -1123,7 +1177,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { try { return await attemptTaskExecution(); } catch (e) { - if (!this.shouldSkipQueueingTask(e as AxiosError)) { + if (!this.shouldSkipQueueingTask(e as AxiosError)) { await this.handleAddPendingTask({ task }); } throw e; @@ -1131,13 +1185,14 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { }; /** - * A utility method that determines if a failed task should be added to the - * queue based on its error. - * Error code 4 - bad request data - * Error code 17 - missing own_capabilities to execute the task - * @param error + * A utility method that determines if a failed task should be added to the queue based on its + * error. Error code 4 — bad request data. Error code 17 — missing `own_capabilities` to execute + * the task. + * + * @param error - The failed task's Axios error. + * @returns `true` when the task should not be re-queued. */ - private shouldSkipQueueingTask = (error: AxiosError) => + private shouldSkipQueueingTask = (error: AxiosError) => error?.response?.data?.code === 4 || error?.response?.data?.code === 17; private mergeFailedMessageUpdateIntoPendingSendMessage = ({ @@ -1145,13 +1200,13 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { pendingMessage, }: { editedMessage: LocalMessage | Partial; - pendingMessage: Message; + pendingMessage: MessageRequest; }) => { const normalizedEditedMessageSource = { ...editedMessage, } as LocalMessage & { message_text_updated_at?: string }; - if (editedMessage.status === 'failed') { + if ((editedMessage as LocalMessage).status === 'failed') { delete normalizedEditedMessageSource.message_text_updated_at; } @@ -1166,7 +1221,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { ...(typeof pendingMessageStatus !== 'undefined' ? { status: pendingMessageStatus } : {}), - } as Message; + } as MessageRequest; }; private isPendingSendMessageTask = ( @@ -1177,12 +1232,12 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { private handleOfflineFailedUpdateMessagePendingTask = async ( task: Extract, ) => { - const [message] = task.payload; - if (!message.id) { + const [{ id, message }] = task.payload; + if (!id) { return; } - const pendingTasks = await this.getPendingTasks({ messageId: message.id }); + const pendingTasks = await this.getPendingTasks({ messageId: id }); const pendingSendMessageTask = pendingTasks.find(this.isPendingSendMessageTask); if (!pendingSendMessageTask) { @@ -1191,14 +1246,20 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { const updatedPendingSendMessage = this.mergeFailedMessageUpdateIntoPendingSendMessage( { - editedMessage: message, - pendingMessage: pendingSendMessageTask.payload[0], + // TODO: this is not good, we have too many message types, should probably only have two (request, response) + editedMessage: message as unknown as LocalMessage, + pendingMessage: pendingSendMessageTask.payload[0].message as MessageRequest, }, ); const updatedPendingTask: Extract = { ...pendingSendMessageTask, - payload: [updatedPendingSendMessage, pendingSendMessageTask.payload[1]], + payload: [ + { + ...pendingSendMessageTask.payload[0], + message: updatedPendingSendMessage, + }, + ], }; if (pendingSendMessageTask.id) { @@ -1220,14 +1281,17 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * or rewrites an existing pending `send-message` task for offline edits of failed messages. */ public handleAddPendingTask = async ({ task }: { task: PendingTask }) => { - if (task.type === 'update-message' && !isMessageUpdateReplayable(task.payload[0])) { + if ( + task.type === 'update-message' && + !isMessageUpdateReplayable(task.payload[0].message ?? {}) + ) { return; } if ( task.type === 'update-message' && !this.client.wsConnection?.isHealthy && - task.payload[0].status === 'failed' + (task.payload[0].message as { status?: string } | undefined)?.status === 'failed' ) { await this.handleOfflineFailedUpdateMessagePendingTask(task); return; @@ -1247,6 +1311,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { * - Creating a draft * - Deleting a draft * It will throw if we try to execute a pending task that is not supported. + * * @param task - The task we want to execute * @param isPendingTask - a control value telling us if it's an actual pending task being executed * or delayed execution @@ -1326,7 +1391,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { true, ); } catch (e) { - const error = e as AxiosError; + const error = e as AxiosError; if (!this.shouldSkipQueueingTask(error)) { // executing the pending task has failed, so keep it in the queue continue; diff --git a/src/offline-support/offline_sync_manager.ts b/src/offline-support/offline_sync_manager.ts index 28e706ef91..6b1de9a2c6 100644 --- a/src/offline-support/offline_sync_manager.ts +++ b/src/offline-support/offline_sync_manager.ts @@ -3,7 +3,10 @@ import type { StreamChat } from '../client'; import type { AbstractOfflineDB } from './offline_support_api'; import type { AxiosError } from 'axios'; import { isAxiosError } from 'axios'; -import type { APIErrorResponse } from '../types'; +import { chatLoggerSystem } from '../logger'; +import type { APIError } from '../types'; + +const logger = chatLoggerSystem.getLogger('offline-db'); /** * Manages synchronization between the local offline database and the Stream backend. @@ -40,8 +43,8 @@ export class OfflineDBSyncManager { */ public init = async () => { try { - // If the websocket connection is already active, then call - // the sync api straight away and also execute pending api calls. + // If the WebSocket connection is already active, then call + // the sync API straight away and also execute pending API calls. // Otherwise wait for the `connection.changed` event. if (this.client.user?.id && this.client.wsConnection?.isHealthy) { await this.syncAndExecutePendingTasks(); @@ -69,7 +72,9 @@ export class OfflineDBSyncManager { }, ); } catch (error) { - console.log('Error in DBSyncManager.init: ', error); + logger + .withExtraTags('init') + .error('Failed to initialize the offline DB sync manager.', { error }); } }; @@ -159,7 +164,10 @@ export class OfflineDBSyncManager { // In that case reset the entire DB and start fresh. await this.offlineDb.resetDB(); } else { - const result = await this.client.sync(cids, lastSyncedAtDate.toISOString()); + const result = await this.client.sync({ + channel_cids: cids, + last_sync_at: lastSyncedAtDate, + }); const queryPromises = result.events.map((event) => this.offlineDb.handleEvent({ event, execute: false }), ); @@ -176,14 +184,16 @@ export class OfflineDBSyncManager { lastSyncedAt: new Date().toString(), }); } catch (e) { - console.log('An error has occurred while syncing the DB.', e); + logger + .withExtraTags('syncAndExecutePendingTasks') + .error('An error occurred while syncing the database.', { error: e }); if (isAxiosError(e) && e.code === 'ECONNABORTED') { // If the sync was aborted due to timeout, we can simply return return; } - const error = e as AxiosError; + const error = e as AxiosError; if (error.response?.data?.code === 23) { return; diff --git a/src/offline-support/types.ts b/src/offline-support/types.ts index 840297b9d5..5504c88903 100644 --- a/src/offline-support/types.ts +++ b/src/offline-support/types.ts @@ -1,19 +1,20 @@ import type { - AppSettingsAPIResponse, - ChannelAPIResponse, ChannelFilters, ChannelMemberResponse, ChannelOptions, ChannelResponse, ChannelSort, + ChannelStateResponseFields, DraftResponse, + GetApplicationResponse, LocalMessage, MessageResponse, - PollResponse, + PollResponse_old, + QueryChannelsRequest, ReactionFilters, ReactionResponse, ReactionSort, - ReadResponse, + ReadStateResponse, } from '../types'; import type { Channel } from '../channel'; import type { StreamChat } from '../client'; @@ -26,7 +27,7 @@ export type PrepareBatchDBQueries = * Options to insert a reaction into a message. */ export type DBInsertReactionType = { - /** Message to which the reaction is applied. */ + /** MessageRequest to which the reaction is applied. */ message: MessageResponse | LocalMessage; /** The reaction to insert. */ reaction: ReactionResponse; @@ -55,7 +56,7 @@ export type DBUpsertCidsForQueryType = { */ export type DBUpsertChannelsType = { /** Array of channel API responses. */ - channels: ChannelAPIResponse[]; + channels: ChannelStateResponseFields[]; /** Whether to immediately execute the operation. */ execute?: boolean; /** If true, marks that the latest messages are already set. */ @@ -67,7 +68,7 @@ export type DBUpsertChannelsType = { */ export type DBUpsertAppSettingsType = { /** App settings data. */ - appSettings: AppSettingsAPIResponse; + appSettings: GetApplicationResponse; /** ID of the user the settings belong to. */ userId: string; /** Whether to immediately execute the operation. */ @@ -91,7 +92,7 @@ export type DBUpsertUserSyncStatusType = { */ export type DBUpsertPollType = { /** Poll data to be stored. */ - poll: PollResponse; + poll: PollResponse_old; /** Whether to immediately execute the operation. */ execute?: boolean; }; @@ -113,7 +114,7 @@ export type DBUpsertReadsType = { /** Channel ID. */ cid: string; /** Array of read statuses. */ - reads: ReadResponse[]; + reads: ReadStateResponse[]; /** Whether to immediately execute the operation. */ execute?: boolean; }; @@ -144,7 +145,7 @@ export type DBUpsertMembersType = { * Options to update a reaction. */ export type DBUpdateReactionType = { - /** Message associated with the reaction. */ + /** MessageRequest associated with the reaction. */ message: MessageResponse | LocalMessage; /** The updated reaction. */ reaction: ReactionResponse; @@ -156,7 +157,7 @@ export type DBUpdateReactionType = { * Options to update a message. */ export type DBUpdateMessageType = { - /** Message to update. */ + /** MessageRequest to update. */ message: MessageResponse | LocalMessage; /** Whether to immediately execute the operation. */ execute?: boolean; @@ -179,15 +180,11 @@ export type DBGetChannelsForQueryType = { /** ID of the user. */ userId: string; /** Optional filters for channels. */ - filters?: ChannelFilters; - /** Optional full query options for channels. */ - options?: ChannelOptions; - /** Optional sorting for the channels. */ - sort?: ChannelSort; + options?: QueryChannelsRequest; }; /** - * Get the last sync timestamp for a user. + * Payload for retrieving the last sync timestamp for a user. */ export type DBGetLastSyncedAtType = { /** ID of the user. */ @@ -203,7 +200,7 @@ export type DBGetPendingTasksType = { }; /** - * Get application settings for a user. + * Payload for retrieving application settings for a user. */ export type DBGetAppSettingsType = { /** ID of the user. */ @@ -217,7 +214,7 @@ export type DBGetReactionsType = { /** ID of the message. */ messageId: string; /** Optional filter to apply to reactions. */ - filters?: Pick; + filters?: ReactionFilters; /** Optional sorting for reactions. */ sort?: ReactionSort; /** Optional maximum number of reactions to return. */ @@ -225,7 +222,7 @@ export type DBGetReactionsType = { }; /** - * Delete a pending task by ID. + * Payload for deleting a pending task by ID. */ export type DBDeletePendingTaskType = { /** ID of the pending task. */ @@ -233,7 +230,7 @@ export type DBDeletePendingTaskType = { }; /** - * Update a pending task by ID. + * Payload for updating a pending task by ID. */ export type DBUpdatePendingTaskType = { /** ID of the pending task. */ @@ -305,13 +302,13 @@ export type DBDeleteMessagesForChannelType = { /** Channel ID. */ cid: string; /** Timestamp before which messages are deleted. */ - truncated_at?: string; + truncated_at?: Date; /** Whether to immediately execute the operation. */ execute?: boolean; }; /** - * Check if a channel exists by ID. + * Payload for checking whether a channel exists by ID. */ export type DBChannelExistsType = { /** Channel ID. */ @@ -373,15 +370,15 @@ export interface OfflineDBApi { getDraft: (options: DBGetDraftType) => Promise; getChannels: ( options: DBGetChannelsType, - ) => Promise[] | null>; + ) => Promise[] | null>; getChannelsForQuery: ( options: DBGetChannelsForQueryType, - ) => Promise[] | null>; + ) => Promise[] | null>; getAllChannelCids: () => Promise; getLastSyncedAt: (options: DBGetLastSyncedAtType) => Promise; getAppSettings: ( options: DBGetAppSettingsType, - ) => Promise; + ) => Promise; getReactions: (options: DBGetReactionsType) => Promise; executeSqlBatch: (queries: ExecuteBatchDBQueriesType) => Promise; addPendingTask: (task: PendingTask) => Promise<() => Promise>; diff --git a/src/offline-support/util.ts b/src/offline-support/util.ts index d5b069d033..b173fe9d49 100644 --- a/src/offline-support/util.ts +++ b/src/offline-support/util.ts @@ -1,4 +1,4 @@ -import type { Attachment, LocalMessage, MessageResponse } from '../types'; +import type { Attachment } from '../types'; export const isLocalUrl = (value: string | undefined) => !!value && !value.startsWith('http'); @@ -11,9 +11,10 @@ export const isAttachmentReplayable = (attachment: Attachment) => { return !isLocalUrl(attachment.asset_url) && !isLocalUrl(attachment.image_url); }; -export const isMessageUpdateReplayable = ( - message: LocalMessage | Partial, -) => !message.attachments?.some((attachment) => !isAttachmentReplayable(attachment)); +export const isMessageUpdateReplayable = (minimalMessage: { + attachments?: Attachment[]; +}) => + !minimalMessage.attachments?.some((attachment) => !isAttachmentReplayable(attachment)); export const getPendingTaskChannelData = (cid?: string) => { if (!cid) { diff --git a/src/pagination/filterCompiler.ts b/src/pagination/filterCompiler.ts index fab5ad5f4f..fd1c1f3bba 100644 --- a/src/pagination/filterCompiler.ts +++ b/src/pagination/filterCompiler.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { arraysEqualAsSets, asArray, @@ -120,9 +119,10 @@ export function itemMatchesFilter( * $gt/$gte/$lt/$lte remain scalar-only (return false if either side is iterable), as you wanted. * * $in/$nin left may be scalar or iterable; the right is a list. - * @param a - * @param b - * @param ok + * + * @param a - Left-hand operand. + * @param b - Right-hand operand. + * @param ok - Predicate applied to the result of comparing `a` and `b`. */ function orderedCompareOp(a: any, b: any, ok: (c: number) => boolean): boolean { if (isIterableButNotString(a) || isIterableButNotString(b)) return false; diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 1ed3c51669..69e9dff0a6 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -320,7 +320,7 @@ export type PaginatorOptions = { * Function containing custom logic that decides, whether the next pagination query to be executed should be considered the first page query. * It makes sense to consider the next query as the first page query if filters, sort, options etc. (query params) excluding the page size have changed. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any + hasPaginationQueryShapeChanged?: PaginationQueryShapeChangeIdentifier; /** * Optional hook to fully control cursor + hasMore logic in 'derived' mode. @@ -371,7 +371,6 @@ const baseHasPaginationQueryShapeChanged: PaginationQueryShapeChangeIdentifier< unknown > = (prevQueryShape, nextQueryShape) => !isEqual(prevQueryShape, nextQueryShape); -// eslint-disable-next-line @typescript-eslint/no-explicit-any export const DEFAULT_PAGINATION_OPTIONS: BasePaginatorConfig = { debounceMs: 300, lockItemOrder: false, @@ -768,10 +767,9 @@ export abstract class BasePaginator { /** * Subclasses must return the query shape. */ - protected getNextQueryShape({ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - direction, - }: Pick, 'direction'> = {}): Q { + protected getNextQueryShape( + _params: Pick, 'direction'> = {}, + ): Q { throw new Error('Paginator.getNextQueryShape() is not implemented'); } @@ -820,8 +818,9 @@ export abstract class BasePaginator { /** * Applied by the effectiveComparator to take into consideration item boosts when sorting items. - * @param a - * @param b + * + * @param a - The first item to compare. + * @param b - The second item to compare. */ protected boostComparator = (a: T, b: T): number => { const now = Date.now(); @@ -848,8 +847,9 @@ export abstract class BasePaginator { /** * Increases the item's importance when sorting. * Boost affects position inside an item interval (if used), but should not redefine interval boundaries. - * @param itemId - * @param opts + * + * @param itemId - Id of the item to boost. + * @param opts - Boost options: `ttlMs` / `until` control expiry and `seq` orders concurrent boosts. */ boost(itemId: string, opts?: { ttlMs?: number; until?: number; seq?: number }) { const now = Date.now(); @@ -884,8 +884,7 @@ export abstract class BasePaginator { // Interval manipulation // --------------------------------------------------------------------------- - // eslint-disable-next-line @typescript-eslint/no-unused-vars - generateIntervalId(page: (T | string)[]): string { + generateIntervalId(_page: (T | string)[]): string { return `interval-${generateUUIDv4()}`; } @@ -1315,7 +1314,8 @@ export abstract class BasePaginator { /** * Locates the current position of the item and the index at which the item should be inserted * according to effectiveComparator. - * @param item + * + * @param item - The item to locate within the current state. */ protected locateItemInState(item: T): ItemLocation | null { const items = [...(this.items ?? [])]; @@ -2024,15 +2024,14 @@ export abstract class BasePaginator { return state; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - isJumpQueryShape(queryShape: Q): boolean { + isJumpQueryShape(_queryShape: Q): boolean { return false; } protected getStateAfterQuery( stateUpdate: Partial>, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - isFirstPage: boolean, + + _isFirstPage: boolean, ): PaginatorState { const current = this.state.getLatestValue(); return { @@ -2045,12 +2044,10 @@ export abstract class BasePaginator { } preloadFirstPageFromOfflineDb = ( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - params: PaginationQueryParams, + _params: PaginationQueryParams, ): Promise | T[] | undefined => undefined; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - populateOfflineDbAfterQuery = (params: { + populateOfflineDbAfterQuery = (_params: { items: T[] | undefined; queryShape: Q | undefined; }): Promise | T[] | undefined => undefined; @@ -2086,13 +2083,15 @@ export abstract class BasePaginator { /** * Falsy return value means query was not successful. - * @param direction - * @param keepPreviousItems - * @param forcedQueryShape - * @param reset - * @param retryCount - * @param silent - * @param updateState + * + * @param params - Query parameters. + * @param params.direction - Direction to paginate in (headward or tailward). + * @param params.keepPreviousItems - Keep already-loaded items instead of clearing them on a first-page query. + * @param params.queryShape - Explicit query shape overriding the one derived from current state. + * @param params.reset - Whether to reset the loaded state before querying. + * @param params.retryCount - Number of remaining retry attempts on failure. + * @param params.silent - Suppress loading/state updates for this query. + * @param params.updateState - Whether to write the query results back to state. */ async executeQuery({ direction, diff --git a/src/pagination/paginators/ChannelPaginator.ts b/src/pagination/paginators/ChannelPaginator.ts index 97d1796b0b..6edbcf2279 100644 --- a/src/pagination/paginators/ChannelPaginator.ts +++ b/src/pagination/paginators/ChannelPaginator.ts @@ -7,6 +7,7 @@ import type { SetPaginatorItemsParams, } from './BasePaginator'; import { BasePaginator } from './BasePaginator'; +import { chatLoggerSystem } from '../../logger'; import type { FilterBuilderOptions } from '../FilterBuilder'; import { FilterBuilder } from '../FilterBuilder'; import { makeComparator } from '../sortCompiler'; @@ -24,7 +25,10 @@ import type { FieldToDataResolver, PathResolver } from '../types.normalization'; import { resolveDotPathValue } from '../utility.normalization'; import { isEqual } from '../../utils/mergeWith/mergeWithCore'; -const DEFAULT_BACKEND_SORT: ChannelSort = { last_message_at: -1, updated_at: -1 }; // {last_updated: -1} +const DEFAULT_BACKEND_SORT: ChannelSort = [ + { direction: -1, field: 'last_message_at' }, + { direction: -1, field: 'updated_at' }, +]; export type ChannelQueryShape = { filters: ChannelFilters; @@ -47,17 +51,16 @@ export type ChannelPaginatorOptions = { id?: string; paginatorOptions?: PaginatorOptions; requestOptions?: ChannelPaginatorRequestOptions; - sort?: ChannelSort | ChannelSort[]; + sort?: ChannelSort; }; const getQueryShapeRelevantChannelOptions = (options: ChannelOptions) => { const { - /* eslint-disable @typescript-eslint/no-unused-vars */ limit: _, member_limit: __, message_limit: ___, offset: ____, - /* eslint-enable @typescript-eslint/no-unused-vars */ + ...relevantShape } = options; return relevantShape; @@ -155,7 +158,7 @@ const pinnedFilterResolver: FieldToDataResolver = { const mutedFilterResolver: FieldToDataResolver = { matchesField: (field) => field === 'muted', - // Mute state lives on the client (client.mutedChannels), not on channel.data — resolve it via + // UserMuteResponse state lives on the client (client.mutedChannels), not on channel.data — resolve it via // the client so `{ muted: true/false }` matches client-side, rather than letting the generic // data resolver read a non-existent `channel.data.muted` (which would resolve to undefined and // never equal a boolean filter value). @@ -195,7 +198,7 @@ export class ChannelPaginator extends BasePaginator private readonly _id: string; private client: StreamChat; protected _staticFilters: ChannelFilters | undefined; - protected _sort: ChannelSort | ChannelSort[] | undefined; + protected _sort: ChannelSort | undefined; protected _options: ChannelPaginatorRequestOptions | undefined; protected _channelStateOptions: ChannelStateOptions | undefined; protected _nextQueryShape: ChannelQueryShape | undefined; @@ -275,7 +278,7 @@ export class ChannelPaginator extends BasePaginator this._staticFilters = filters; } - set sort(sort: ChannelSort | ChannelSort[] | undefined) { + set sort(sort: ChannelSort | undefined) { this._sort = sort; this.sortComparator = makeComparator({ sort: this.sort ?? DEFAULT_BACKEND_SORT, @@ -335,8 +338,7 @@ export class ChannelPaginator extends BasePaginator try { const channelsFromDB = await this.client.offlineDb.getChannelsForQuery({ userId: this.client.user.id, - filters: queryShape.filters, - sort: queryShape.sort, + options: { filter_conditions: queryShape.filters, sort: queryShape.sort }, }); if (channelsFromDB) { @@ -358,7 +360,7 @@ export class ChannelPaginator extends BasePaginator return; } } catch (error) { - this.client.logger('error', (error as Error).message); + chatLoggerSystem.getLogger('channel').error((error as Error).message); if (this.config.throwErrors) throw error; } return; @@ -394,7 +396,10 @@ export class ChannelPaginator extends BasePaginator if (this.config.doRequest) { items = (await this.config.doRequest(this._nextQueryShape)).items; } else { - items = await this.client.queryChannels(filters, sort, options, stateOptions); + items = await this.client.queryChannelsAndHydrate( + { filter_conditions: filters, sort, ...options }, + stateOptions, + ); } return { items }; }; diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 56e2199466..80da4b5dbb 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -20,6 +20,7 @@ import type { AscDesc, LocalMessage, MessagePaginationOptions, + MessagePaginationParams, MessageResponse, PinnedMessagePaginationOptions, ReactionResponse, @@ -27,7 +28,12 @@ import type { } from '../../types'; import type { Channel } from '../../channel'; import { StateStore } from '../../store'; -import { formatMessage, generateUUIDv4, toDeletedMessage } from '../../utils'; +import { + formatMessage, + generateUUIDv4, + normalizeQuerySort, + toDeletedMessage, +} from '../../utils'; import { makeComparator } from '../sortCompiler'; import type { FieldToDataResolver } from '../types.normalization'; import { resolveDotPathValue } from '../utility.normalization'; @@ -314,13 +320,13 @@ export class MessageIntervalPaginator extends BasePaginator< : undefined; } else { const { messages } = this.parentMessageId - ? await this.channel.getReplies( - this.parentMessageId, - options, - Array.isArray(this.requestSort) ? this.requestSort : [this.requestSort], - ) + ? await this.channel.getReplies({ + parent_id: this.parentMessageId, + ...options, + sort: normalizeQuerySort(this.requestSort), + }) : await this.channel.query({ - messages: options, + messages: options as MessagePaginationParams, // todo: why do we query for watchers? // watchers: { limit: this.pageSize }, }); @@ -817,7 +823,7 @@ export class MessageIntervalPaginator extends BasePaginator< this.ingestItem({ ...message, quoted_message: toDeletedMessage({ - message: message.quoted_message, + message: formatMessage(message.quoted_message), hardDelete, deletedAt, }) as LocalMessage, @@ -883,15 +889,15 @@ export class MessageIntervalPaginator extends BasePaginator< * falling back to the event's own_reactions when the message is not loaded — matching the legacy * behavior where `_updateMessage` only mutated a message that existed locally. * - * @param params - * @param {MessageResponse | LocalMessage} params.message The reaction event's message, carrying the + * @param params - The reaction event payload. + * @param params.message - The reaction event's message, carrying the * server-computed `reaction_groups` / `latest_reactions`. Ingested as-is except for `own_reactions`. - * @param {ReactionResponse} params.reaction The reaction from the event. Only added to/removed from + * @param params.reaction - The reaction from the event. Only added to/removed from * `own_reactions` when its `user_id` is the current user; otherwise the current user's * `own_reactions` are left untouched. - * @param {boolean} [params.removed=false] `true` for `reaction.deleted` (remove the reaction from + * @param [params.removed=false] - `true` for `reaction.deleted` (remove the reaction from * `own_reactions`); `false` for `reaction.new` / `reaction.updated` (add it). - * @param {boolean} [params.enforceUnique=false] When adding, first clear the current user's existing + * @param [params.enforceUnique=false] - When adding, first clear the current user's existing * `own_reactions` so only the incoming one remains (used by `reaction.updated`, where a user's * reaction replaces their previous one). */ diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index d86bb05174..5591a71c23 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -43,7 +43,7 @@ export type MessagePaginatorAggregateState = { * * Lives here, NOT derived from pagination `state`, so it stays reactive when a WS message lands in * the head interval while an older window is active — the pagination store only emits when the - * *active* interval is impacted (see `BasePaginator.ingestItem`), so a `state`-derived latest would + * active* interval is impacted (see `BasePaginator.ingestItem`), so a `state`-derived latest would * go stale in that case. */ lastMessage: LocalMessage | null; diff --git a/src/pagination/paginators/PinnedMessagePaginator.ts b/src/pagination/paginators/PinnedMessagePaginator.ts index 484521b735..6335e1bca8 100644 --- a/src/pagination/paginators/PinnedMessagePaginator.ts +++ b/src/pagination/paginators/PinnedMessagePaginator.ts @@ -86,7 +86,7 @@ export class PinnedMessagePaginator extends MessageIntervalPaginator { ): Promise<{ cursor?: PaginatorCursor; items: LocalMessage[] }> => { const { messages } = await this.channel.getPinnedMessages( options as PinnedMessagePaginationOptions, - [{ pinned_at: 1 }], + [{ direction: 1, field: 'pinned_at' }], ); const items = messages.map(formatMessage); return { cursor: this.getCursorFromQueryResults({ items }), items }; diff --git a/src/pagination/paginators/ReminderPaginator.ts b/src/pagination/paginators/ReminderPaginator.ts index 7a5480ee8c..1f108e00b2 100644 --- a/src/pagination/paginators/ReminderPaginator.ts +++ b/src/pagination/paginators/ReminderPaginator.ts @@ -7,7 +7,7 @@ import type { import type { QueryRemindersOptions, ReminderFilters, - ReminderResponse, + ReminderResponseData, ReminderSort, } from '../../types'; import type { StreamChat } from '../../client'; @@ -16,15 +16,15 @@ import { makeComparator } from '../sortCompiler'; import { resolveDotPathValue } from '../utility.normalization'; // Reminders are keyed by the message they belong to; used for interval dedup and index addressing. -const getReminderId = (reminder: ReminderResponse) => reminder.message_id; +const getReminderId = (reminder: ReminderResponseData) => reminder.message_id; // Fallback order for interval placement when no explicit sort is set. Order is not a pinned contract // (ReminderManager stores reminders in a message_id-keyed Map), but interval storage needs a total // order, so default to a deterministic one. -const DEFAULT_SORT: ReminderSort = { created_at: 1 }; +const DEFAULT_SORT: ReminderSort = [{ direction: 1, field: 'created_at' }]; export class ReminderPaginator extends BasePaginator< - ReminderResponse, + ReminderResponseData, QueryRemindersOptions > { private client: StreamChat; @@ -52,25 +52,25 @@ export class ReminderPaginator extends BasePaginator< constructor( client: StreamChat, - options?: PaginatorOptions, + options?: PaginatorOptions, ) { super({ initialCursor: ZERO_PAGE_CURSOR, - itemIndex: new ItemIndex({ getId: getReminderId }), + itemIndex: new ItemIndex({ getId: getReminderId }), ...options, }); this.client = client; this.sortComparator = this.buildSortComparator(); } - getItemId(item: ReminderResponse): string { + getItemId(item: ReminderResponseData): string { return getReminderId(item); } // Interval storage needs a total order. Derive it from the requested sort (rebuilt when `sort` // changes, which also resets the accumulated pages), with a message_id tiebreaker. private buildSortComparator() { - return makeComparator({ + return makeComparator({ sort: this._sort ?? DEFAULT_SORT, resolvePathValue: resolveDotPathValue, tiebreaker: (l, r) => @@ -95,11 +95,11 @@ export class ReminderPaginator extends BasePaginator< query = async ({ queryShape, }: PaginationQueryParams): Promise< - PaginationQueryReturnValue + PaginationQueryReturnValue > => { const { reminders: items, next, prev } = await this.client.queryReminders(queryShape); return { items, headward: prev, tailward: next }; }; - filterQueryResults = (items: ReminderResponse[]) => items; + filterQueryResults = (items: ReminderResponseData[]) => items; } diff --git a/src/pagination/paginators/UserGroupPaginator.ts b/src/pagination/paginators/UserGroupPaginator.ts index bc19ee1f76..89776e85f1 100644 --- a/src/pagination/paginators/UserGroupPaginator.ts +++ b/src/pagination/paginators/UserGroupPaginator.ts @@ -5,7 +5,7 @@ import type { PaginatorOptions, PaginatorState, } from './BasePaginator'; -import type { QueryUserGroupsOptions, UserGroupResponse } from '../../types'; +import type { ListUserGroupsOptions, UserGroupResponse } from '../../types'; import type { StreamChat } from '../../client'; import { ItemIndex } from '../ItemIndex'; @@ -38,14 +38,14 @@ const decodeCursor = (cursor: string | null | undefined) */ export class UserGroupPaginator extends BasePaginator< UserGroupResponse, - QueryUserGroupsOptions + ListUserGroupsOptions > { private client: StreamChat; protected _teamId: string | undefined; constructor( client: StreamChat, - options?: PaginatorOptions, + options?: PaginatorOptions, ) { super({ initialCursor: { ...ZERO_PAGE_CURSOR, headward: null }, @@ -85,7 +85,7 @@ export class UserGroupPaginator extends BasePaginator< if (!lastItem) return undefined; return JSON.stringify({ - created_at_gt: lastItem.created_at, + created_at_gt: lastItem.created_at.toISOString(), id_gt: lastItem.id, } satisfies UserGroupListCursor); }; @@ -93,7 +93,7 @@ export class UserGroupPaginator extends BasePaginator< // The query shape must stay stable across pages: the paginator resets its // accumulated list when the query shape changes ('auto' reset policy), so the // forward cursor is NOT part of the shape — it is applied per request in `query`. - protected getNextQueryShape(): QueryUserGroupsOptions { + protected getNextQueryShape(): ListUserGroupsOptions { return { limit: this.pageSize, ...(this.teamId ? { team_id: this.teamId } : {}), @@ -103,7 +103,7 @@ export class UserGroupPaginator extends BasePaginator< query = async ({ direction, queryShape, - }: PaginationQueryParams): Promise< + }: PaginationQueryParams): Promise< PaginationQueryReturnValue > => { if (direction === 'headward') { @@ -111,13 +111,13 @@ export class UserGroupPaginator extends BasePaginator< } const cursor = decodeCursor(this.cursor?.tailward); - const options: QueryUserGroupsOptions = { + const options: ListUserGroupsOptions = { ...(queryShape ?? this.getNextQueryShape()), ...(cursor?.id_gt ? { id_gt: cursor.id_gt } : {}), ...(cursor?.created_at_gt ? { created_at_gt: cursor.created_at_gt } : {}), }; - const { user_groups: items } = await this.client.queryUserGroups(options); + const { user_groups: items } = await this.client.listUserGroups(options); return { items, tailward: this.buildNextCursor(items) }; }; diff --git a/src/pagination/sortCompiler.ts b/src/pagination/sortCompiler.ts index e775dbcb19..b9abd5d87f 100644 --- a/src/pagination/sortCompiler.ts +++ b/src/pagination/sortCompiler.ts @@ -1,12 +1,10 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - import { compare, resolveDotPathValue as defaultResolvePathValue, normalizeComparedValues, } from './utility.normalization'; import { normalizeQuerySort } from '../utils'; -import type { AscDesc } from '../types'; +import type { AscDesc, SortParamRequest } from '../types'; import type { Comparator, PathResolver } from './types.normalization'; export type ItemLocation = { @@ -124,13 +122,15 @@ export function binarySearch({ * (but they can still move relative to others — sort in JS is not guaranteed stable in older engines, though modern V8/Node/Chrome/Firefox make it stable) * * Positive number (> 0) → a comes after b - * @param sort - * @param resolvePathValue - * @param tiebreaker + * + * @param params - Comparator configuration. + * @param params.sort - The sort specification defining fields and directions. + * @param params.resolvePathValue - Resolver used to read a field value from an item. + * @param params.tiebreaker - Comparator applied when all sort terms are equal. */ export function makeComparator< T, - S extends Record | Record[], + S extends Record | Record[] | SortParamRequest[], >({ sort, resolvePathValue = defaultResolvePathValue, diff --git a/src/pagination/utility.normalization.ts b/src/pagination/utility.normalization.ts index c7df10aef9..fda6dace8c 100644 --- a/src/pagination/utility.normalization.ts +++ b/src/pagination/utility.normalization.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - export function asArray(v: any): any[] { return Array.isArray(v) ? v : [v]; } diff --git a/src/pagination/utility.queryChannel.ts b/src/pagination/utility.queryChannel.ts index 2a2fedd9b0..ea33d5a1c2 100644 --- a/src/pagination/utility.queryChannel.ts +++ b/src/pagination/utility.queryChannel.ts @@ -1,4 +1,4 @@ -import type { ChannelQueryOptions, QueryChannelAPIResponse } from '../types'; +import type { ChannelGetOrCreateRequest, ChannelStateResponse } from '../types'; import type { StreamChat } from '../client'; import type { Channel } from '../channel'; import { generateChannelTempCid } from '../utils'; @@ -9,7 +9,7 @@ import { generateChannelTempCid } from '../utils'; */ const WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL: Record< string, - Promise | undefined + Promise | undefined > = {}; type GetChannelParams = { @@ -17,19 +17,21 @@ type GetChannelParams = { channel?: Channel; id?: string; members?: string[]; - options?: ChannelQueryOptions; + options?: ChannelGetOrCreateRequest; type?: string; }; /** * Watches a channel, coalescing concurrent invocations for the same CID. * If a watch is already in flight, this call waits for it to settle instead of * issuing another network request. - * @param client - * @param members - * @param options - * @param type - * @param id - * @param channel + * + * @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 getChannel = async ({ channel, @@ -44,8 +46,13 @@ export const getChannel = async ({ } // unfortunately typescript is not able to infer that if (!channel && !type) === false, then channel or type has to be truthy - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const theChannel = channel || client.channel(type!, id, { members }); + + const theChannel = + 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 = theChannel?.id diff --git a/src/permissions.ts b/src/permissions.ts index 042ec46990..776bfb7ed8 100644 --- a/src/permissions.ts +++ b/src/permissions.ts @@ -56,8 +56,7 @@ export const DenyAll = new Permission( Deny, ); -// Fixme: rename to RoleName with next major release -export type Role = +export type RoleName = | 'admin' | 'user' | 'guest' @@ -79,25 +78,25 @@ export const BuiltinPermissions = { AddLinks: 'Add Links', BanUser: 'Ban User', CreateChannel: 'Create Channel', - CreateMessage: 'Create Message', + CreateMessage: 'Create MessageRequest', CreateReaction: 'Create Reaction', DeleteAnyAttachment: 'Delete Any Attachment', DeleteAnyChannel: 'Delete Any Channel', - DeleteAnyMessage: 'Delete Any Message', + DeleteAnyMessage: 'Delete Any MessageRequest', DeleteAnyReaction: 'Delete Any Reaction', DeleteOwnAttachment: 'Delete Own Attachment', DeleteOwnChannel: 'Delete Own Channel', - DeleteOwnMessage: 'Delete Own Message', + DeleteOwnMessage: 'Delete Own MessageRequest', DeleteOwnReaction: 'Delete Own Reaction', ReadAnyChannel: 'Read Any Channel', ReadOwnChannel: 'Read Own Channel', - RunMessageAction: 'Run Message Action', + RunMessageAction: 'Run MessageRequest Action', UpdateAnyChannel: 'Update Any Channel', - UpdateAnyMessage: 'Update Any Message', + UpdateAnyMessage: 'Update Any MessageRequest', UpdateMembersAnyChannel: 'Update Members Any Channel', UpdateMembersOwnChannel: 'Update Members Own Channel', UpdateOwnChannel: 'Update Own Channel', - UpdateOwnMessage: 'Update Own Message', + UpdateOwnMessage: 'Update Own MessageRequest', UploadAttachment: 'Upload Attachment', UseFrozenChannel: 'Send messages and reactions to frozen channels', }; diff --git a/src/poll.ts b/src/poll.ts index b14d6ab9c6..34cf113b03 100644 --- a/src/poll.ts +++ b/src/poll.ts @@ -1,64 +1,36 @@ import { StateStore } from './store'; import type { StreamChat } from './client'; import type { - Event, + EventPayload, PartialPollUpdate, - PollAnswer, - PollData, PollEnrichData, PollOptionData, - PollResponse, - PollVote, + PollResponse_old, + PollVoteResponseData, QueryVotesFilters, QueryVotesOptions, + RequireLiteral, + UpdatePollRequest, VoteSort, + VotingVisibility, } from './types'; +import type { PollResponseData as Gen_PollResponseData, WSEvent } from './gen/models'; -type PollEvent = { - cid: string; - created_at: string; - poll: PollResponse; -}; - -type PollUpdatedEvent = PollEvent & { - type: 'poll.updated'; -}; - -type PollClosedEvent = PollEvent & { - type: 'poll.closed'; -}; - -type PollVoteEvent = { - cid: string; - created_at: string; - poll: PollResponse; - poll_vote: PollVote | PollAnswer; -}; - -type PollVoteCastedEvent = PollVoteEvent & { - type: 'poll.vote_casted'; -}; - -type PollVoteCastedChanged = PollVoteEvent & { - type: 'poll.vote_removed'; -}; - -type PollVoteCastedRemoved = PollVoteEvent & { - type: 'poll.vote_removed'; -}; - -const isPollUpdatedEvent = (e: Event): e is PollUpdatedEvent => e.type === 'poll.updated'; -const isPollClosedEventEvent = (e: Event): e is PollClosedEvent => +const isPollUpdatedEvent = (e: WSEvent): e is EventPayload<'poll.updated'> => + e.type === 'poll.updated'; +const isPollClosedEventEvent = (e: WSEvent): e is EventPayload<'poll.closed'> => e.type === 'poll.closed'; -const isPollVoteCastedEvent = (e: Event): e is PollVoteCastedEvent => +const isPollVoteCastedEvent = (e: WSEvent): e is EventPayload<'poll.vote_casted'> => e.type === 'poll.vote_casted'; -const isPollVoteChangedEvent = (e: Event): e is PollVoteCastedChanged => +const isPollVoteChangedEvent = (e: WSEvent): e is EventPayload<'poll.vote_changed'> => e.type === 'poll.vote_changed'; -const isPollVoteRemovedEvent = (e: Event): e is PollVoteCastedRemoved => +const isPollVoteRemovedEvent = (e: WSEvent): e is EventPayload<'poll.vote_removed'> => e.type === 'poll.vote_removed'; -export const isVoteAnswer = (vote: PollVote | PollAnswer): vote is PollAnswer => - !!(vote as PollAnswer)?.answer_text; +export const isVoteAnswer = ( + vote: any | undefined, +): vote is RequireLiteral => + !!vote?.answer_text; export type PollAnswersQueryParams = { filter?: QueryVotesFilters; @@ -74,16 +46,16 @@ export type PollOptionVotesQueryParams = { type OptionId = string; -export type PollState = Omit & { +export type PollState = Omit & { lastActivityAt: Date; // todo: would be ideal to get this from the BE maxVotedOptionIds: OptionId[]; - ownVotesByOptionId: Record; - ownAnswer?: PollAnswer; // each user can have only one answer + ownVotesByOptionId: Record; + ownAnswer?: PollVoteResponseData; // each user can have only one answer }; type PollInitOptions = { client: StreamChat; - poll: PollResponse; + poll: Gen_PollResponseData; }; export class Poll { @@ -99,11 +71,10 @@ export class Poll { } private getInitialStateFromPollResponse = (poll: PollInitOptions['poll']) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { own_votes, id, ...pollResponseForState } = poll; + const { own_votes, id: _id, ...pollResponseForState } = poll; const { ownAnswer, ownVotes } = own_votes?.reduce<{ - ownVotes: PollVote[]; - ownAnswer?: PollAnswer; + ownVotes: PollVoteResponseData[]; + ownAnswer?: PollVoteResponseData; }>( (acc, voteOrAnswer) => { if (isVoteAnswer(voteOrAnswer)) { @@ -119,9 +90,7 @@ export class Poll { return { ...pollResponseForState, lastActivityAt: new Date(), - maxVotedOptionIds: getMaxVotedOptionIds( - pollResponseForState.vote_counts_by_option as PollResponse['vote_counts_by_option'], - ), + maxVotedOptionIds: getMaxVotedOptionIds(pollResponseForState.vote_counts_by_option), ownAnswer, ownVotesByOptionId: getOwnVotesByOptionId(ownVotes), }; @@ -142,17 +111,17 @@ export class Poll { return this.state.getLatestValue(); } - public handlePollUpdated = (event: Event) => { + public handlePollUpdated = (event: EventPayload<'poll.updated'>) => { if (event.poll?.id && event.poll.id !== this.id) return; if (!isPollUpdatedEvent(event)) return; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { id, ...pollData } = extractPollData(event.poll); + + const { id: _id, ...pollData } = extractPollData(event.poll); // @ts-expect-error type mismatch this.state.partialNext({ ...pollData, lastActivityAt: new Date(event.created_at) }); this.upsertOfflineDb(); }; - public handlePollClosed = (event: Event) => { + public handlePollClosed = (event: EventPayload<'poll.closed'>) => { if (event.poll?.id && event.poll.id !== this.id) return; if (!isPollClosedEventEvent(event)) return; this.state.partialNext({ @@ -162,12 +131,12 @@ export class Poll { this.upsertOfflineDb(); }; - public handleVoteCasted = (event: Event) => { + public handleVoteCasted = (event: EventPayload<'poll.vote_casted'>) => { if (event.poll?.id && event.poll.id !== this.id) return; if (!isPollVoteCastedEvent(event)) return; const currentState = this.data; - const isOwnVote = event.poll_vote.user_id === this.client.userID; - let latestAnswers = [...(currentState.latest_answers as PollAnswer[])]; + const isOwnVote = event.poll_vote.user_id === this.client.userId; + let latestAnswers = [...(currentState.latest_answers as PollVoteResponseData[])]; let ownAnswer = currentState.ownAnswer; const ownVotesByOptionId = currentState.ownVotesByOptionId; let maxVotedOptionIds = currentState.maxVotedOptionIds; @@ -198,13 +167,13 @@ export class Poll { this.upsertOfflineDb(); }; - public handleVoteChanged = (event: Event) => { + public handleVoteChanged = (event: EventPayload<'poll.vote_changed'>) => { // this event is triggered only when event.poll.enforce_unique_vote === true if (event.poll?.id && event.poll.id !== this.id) return; if (!isPollVoteChangedEvent(event)) return; const currentState = this.data; - const isOwnVote = event.poll_vote.user_id === this.client.userID; - let latestAnswers = [...(currentState.latest_answers as PollAnswer[])]; + const isOwnVote = event.poll_vote.user_id === this.client.userId; + let latestAnswers = [...(currentState.latest_answers as PollVoteResponseData[])]; let ownAnswer = currentState.ownAnswer; let ownVotesByOptionId = currentState.ownVotesByOptionId; let maxVotedOptionIds = currentState.maxVotedOptionIds; @@ -221,7 +190,7 @@ export class Poll { ownVotesByOptionId = { [event.poll_vote.option_id]: event.poll_vote }; } else { ownVotesByOptionId = Object.entries(ownVotesByOptionId).reduce< - Record + Record >((acc, [optionId, vote]) => { if ( optionId !== event.poll_vote.option_id && @@ -258,12 +227,12 @@ export class Poll { this.upsertOfflineDb(); }; - public handleVoteRemoved = (event: Event) => { + public handleVoteRemoved = (event: EventPayload<'poll.vote_removed'>) => { if (event.poll?.id && event.poll.id !== this.id) return; if (!isPollVoteRemovedEvent(event)) return; const currentState = this.data; - const isOwnVote = event.poll_vote.user_id === this.client.userID; - let latestAnswers = [...(currentState.latest_answers as PollAnswer[])]; + const isOwnVote = event.poll_vote.user_id === this.client.userId; + let latestAnswers = [...(currentState.latest_answers as PollVoteResponseData[])]; let ownAnswer = currentState.ownAnswer; const ownVotesByOptionId = { ...currentState.ownVotesByOptionId }; let maxVotedOptionIds = currentState.maxVotedOptionIds; @@ -293,29 +262,36 @@ export class Poll { }; query = async (id: string) => { - const { poll } = await this.client.getPoll(id); + const { poll } = await this.client.getPoll({ poll_id: id }); this.state.partialNext({ ...poll, lastActivityAt: new Date() }); return poll; }; - update = async (data: Exclude) => - await this.client.updatePoll({ ...data, id: this.id }); + update = async (data: Exclude) => + await this.client.updatePoll({ ...data, id: this.id as string }); partialUpdate = async (partialPollObject: PartialPollUpdate) => - await this.client.partialUpdatePoll(this.id as string, partialPollObject); + await this.client.updatePollPartial({ + poll_id: this.id as string, + ...partialPollObject, + }); - close = async () => await this.client.closePoll(this.id as string); + close = async () => + await this.client.updatePollPartial({ + poll_id: this.id as string, + set: { is_closed: true }, + }); - delete = async () => await this.client.deletePoll(this.id as string); + delete = async () => await this.client.deletePoll({ poll_id: this.id as string }); createOption = async (option: PollOptionData) => - await this.client.createPollOption(this.id as string, option); + await this.client.createPollOption({ poll_id: this.id as string, ...option }); updateOption = async (option: PollOptionData) => - await this.client.updatePollOption(this.id as string, option); + await this.client.updatePollOption({ poll_id: this.id as string, ...option }); - deleteOption = async (optionId: string) => - await this.client.deletePollOption(this.id as string, optionId); + deleteOption = async (option_id: string) => + await this.client.deletePollOption({ poll_id: this.id as string, option_id }); castVote = async (optionId: string, messageId: string) => { const { max_votes_allowed, ownVotesByOptionId } = this.data; @@ -336,38 +312,54 @@ export class Poll { }); return; } - return await this.client.castPollVote(messageId, this.id as string, { - option_id: optionId, + return await this.client.castPollVote({ + message_id: messageId, + poll_id: this.id as string, + vote: { option_id: optionId }, }); }; removeVote = async (voteId: string, messageId: string) => - await this.client.removePollVote(messageId, this.id as string, voteId); + await this.client.deletePollVote({ + message_id: messageId, + poll_id: this.id as string, + vote_id: voteId, + }); addAnswer = async (answerText: string, messageId: string) => - await this.client.addPollAnswer(messageId, this.id as string, answerText); + await this.client.castPollVote({ + message_id: messageId, + poll_id: this.id as string, + vote: { answer_text: answerText }, + }); removeAnswer = async (answerId: string, messageId: string) => - await this.client.removePollVote(messageId, this.id as string, answerId); + await this.client.deletePollVote({ + message_id: messageId, + poll_id: this.id as string, + vote_id: answerId, + }); queryAnswers = async (params: PollAnswersQueryParams) => - await this.client.queryPollAnswers( - this.id as string, - params.filter, - params.sort, - params.options, - ); + await this.client.queryPollVotes({ + poll_id: this.id as string, + sort: params.sort, + filter: { ...(params.filter ?? {}), is_answer: true }, + ...(params.options ?? {}), + }); queryOptionVotes = async (params: PollOptionVotesQueryParams) => - await this.client.queryPollVotes( - this.id as string, - params.filter, - params.sort, - params.options, - ); + await this.client.queryPollVotes({ + poll_id: this.id as string, + sort: params.sort, + filter: params.filter, + ...(params.options ?? {}), + }); } -function getMaxVotedOptionIds(voteCountsByOption: PollResponse['vote_counts_by_option']) { +function getMaxVotedOptionIds( + voteCountsByOption: PollResponse_old['vote_counts_by_option'], +) { let maxVotes = 0; let winningOptions: string[] = []; for (const [id, count] of Object.entries(voteCountsByOption ?? {})) { @@ -381,17 +373,17 @@ function getMaxVotedOptionIds(voteCountsByOption: PollResponse['vote_counts_by_o return winningOptions; } -function getOwnVotesByOptionId(ownVotes: PollVote[]) { +function getOwnVotesByOptionId(ownVotes: PollVoteResponseData[]) { return !ownVotes - ? ({} as Record) - : ownVotes.reduce>((acc, vote) => { + ? ({} as Record) + : ownVotes.reduce>((acc, vote) => { if (isVoteAnswer(vote) || !vote.option_id) return acc; acc[vote.option_id] = vote; return acc; }, {}); } -export function extractPollData(pollResponse: PollResponse): PollData { +export function extractPollData(pollResponse: Gen_PollResponseData): UpdatePollRequest { return { allow_answers: pollResponse.allow_answers, allow_user_suggested_options: pollResponse.allow_user_suggested_options, @@ -402,16 +394,15 @@ export function extractPollData(pollResponse: PollResponse): PollData { max_votes_allowed: pollResponse.max_votes_allowed, name: pollResponse.name, options: pollResponse.options, - voting_visibility: pollResponse.voting_visibility, + voting_visibility: pollResponse.voting_visibility as VotingVisibility, }; } -export function mapPollStateToResponse(poll: Poll): PollResponse { +export function mapPollStateToResponse(poll: Poll): PollResponse_old { const { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - lastActivityAt, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - maxVotedOptionIds, + lastActivityAt: _lastActivityAt, + + maxVotedOptionIds: _maxVotedOptionIds, ownVotesByOptionId, ownAnswer, ...restState @@ -419,7 +410,7 @@ export function mapPollStateToResponse(poll: Poll): PollResponse { const ownVotes = [ ...Object.values(ownVotesByOptionId), ...(ownAnswer ? [ownAnswer] : []), - ].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at)); + ].sort((a, b) => a.created_at.getTime() - b.created_at.getTime()); return { ...restState, @@ -429,7 +420,7 @@ export function mapPollStateToResponse(poll: Poll): PollResponse { } export function extractPollEnrichedData( - pollResponse: PollResponse, + pollResponse: Gen_PollResponseData, ): Omit { return { answers_count: pollResponse.answers_count, diff --git a/src/poll_manager.ts b/src/poll_manager.ts index 7b91c6ad51..97c0f313b0 100644 --- a/src/poll_manager.ts +++ b/src/poll_manager.ts @@ -1,9 +1,9 @@ import type { StreamChat } from './client'; import type { - CreatePollData, + CreatePollRequest, LocalMessage, MessageResponse, - PollResponse, + PollResponse_old, PollSort, QueryPollsFilters, QueryPollsOptions, @@ -46,7 +46,7 @@ export class PollManager extends WithSubscriptions { this.addUnsubscribeFunction(this.subscribeVoteRemoved()); }; - public createPoll = async (poll: CreatePollData) => { + public createPoll = async (poll: CreatePollRequest) => { const { poll: createdPoll } = await this.client.createPoll(poll); if (!createdPoll.vote_counts_by_option) { @@ -63,11 +63,13 @@ export class PollManager extends WithSubscriptions { // optimistically return the cached poll if it exists and update in the background if (cachedPoll) { - this.client.getPoll(id).then(({ poll }) => this.setOrOverwriteInCache(poll, true)); + this.client + .getPoll({ poll_id: id }) + .then(({ poll }) => this.setOrOverwriteInCache(poll, true)); return cachedPoll; } // fetch it, write to the cache and return otherwise - const { poll } = await this.client.getPoll(id); + const { poll } = await this.client.getPoll({ poll_id: id }); this.setOrOverwriteInCache(poll); @@ -79,7 +81,11 @@ export class PollManager extends WithSubscriptions { sort: PollSort = [], options: QueryPollsOptions = {}, ) => { - const { polls, next } = await this.client.queryPolls(filter, sort, options); + const { polls, next } = await this.client.queryPolls({ + filter, + sort, + ...options, + }); const pollInstances = polls.map((poll) => { this.setOrOverwriteInCache(poll, true); @@ -101,13 +107,13 @@ export class PollManager extends WithSubscriptions { if (!message.poll) { continue; } - const pollResponse = message.poll as PollResponse; + const pollResponse = message.poll as PollResponse_old; this.setOrOverwriteInCache(pollResponse, overwriteState); } }; private setOrOverwriteInCache = ( - pollResponse: PollResponse, + pollResponse: PollResponse_old, overwriteState?: boolean, ) => { if (!this.client._cacheEnabled()) { diff --git a/src/reminders/Reminder.ts b/src/reminders/Reminder.ts index b41f2b6844..669e02bb3c 100644 --- a/src/reminders/Reminder.ts +++ b/src/reminders/Reminder.ts @@ -1,14 +1,11 @@ import { ReminderTimer } from './ReminderTimer'; import { StateStore } from '../store'; import type { ReminderTimerConfig } from './ReminderTimer'; -import type { MessageResponse, ReminderResponseBase, UserResponse } from '../types'; +import type { MessageResponse, ReminderResponseData, UserResponse } from '../types'; export const timeLeftMs = (remindAt: number) => remindAt - new Date().getTime(); -export type ReminderResponseBaseOrResponse = ReminderResponseBase & { - user?: UserResponse; - message?: MessageResponse; -}; +export type ReminderResponseBaseOrResponse = ReminderResponseData; export type ReminderState = { channel_cid: string; diff --git a/src/reminders/ReminderManager.ts b/src/reminders/ReminderManager.ts index 95b12fefa5..90f6bdb34c 100644 --- a/src/reminders/ReminderManager.ts +++ b/src/reminders/ReminderManager.ts @@ -8,10 +8,9 @@ import type { StreamChat } from '../client'; import type { CreateReminderOptions, Event, - EventTypes, + EventPayload, LocalMessage, MessageResponse, - ReminderResponse, } from '../types'; const oneMinute = 60 * 1000; @@ -38,14 +37,9 @@ const isReminderDoesNotExistError = (error: Error) => type MessageId = string; -export type ReminderEvent = { - cid: string; - created_at: string; - message_id: MessageId; - reminder: ReminderResponse; - type: EventTypes; - user_id: string; -}; +export type ReminderEvent = EventPayload< + `reminder.${string}` | 'notification.reminder_due' +>; export type ReminderManagerState = { reminders: Map; @@ -167,6 +161,7 @@ export class ReminderManager extends WithSubscriptions { // WS event handling START // static isReminderWsEventPayload = (event: Event): event is ReminderEvent => + 'reminder' in event && !!event.reminder && (event.type.startsWith('reminder.') || event.type === 'notification.reminder_due'); @@ -186,14 +181,17 @@ export class ReminderManager extends WithSubscriptions { this.client.on('reminder.created', (event) => { if (!ReminderManager.isReminderWsEventPayload(event)) return; const { reminder } = event; - this.upsertToState({ data: reminder }); + // TODO: OAPI discrepancy? + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.upsertToState({ data: reminder! }); }).unsubscribe; private subscribeReminderUpdated = () => this.client.on('reminder.updated', (event) => { if (!ReminderManager.isReminderWsEventPayload(event)) return; const { reminder } = event; - this.upsertToState({ data: reminder }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.upsertToState({ data: reminder! }); }).unsubscribe; private subscribeReminderDeleted = () => @@ -249,8 +247,8 @@ export class ReminderManager extends WithSubscriptions { // API calls START // upsertReminder = async (options: CreateReminderOptions) => { - const { messageId } = options; - if (this.getFromState(messageId)) { + const { message_id } = options; + if (this.getFromState(message_id)) { try { return await this.updateReminder(options); } catch (error) { @@ -272,17 +270,17 @@ export class ReminderManager extends WithSubscriptions { }; createReminder = async (options: CreateReminderOptions) => { - const { reminder } = await this.client.createReminder(options); - return this.upsertToState({ data: reminder, overwrite: false }); + const response = await this.client.createReminder(options); + return this.upsertToState({ data: response, overwrite: false }); }; updateReminder = async (options: CreateReminderOptions) => { - const { reminder } = await this.client.updateReminder(options); - return this.upsertToState({ data: reminder }); + const response = await this.client.updateReminder(options); + return this.upsertToState({ data: response.reminder }); }; deleteReminder = async (messageId: MessageId) => { - await this.client.deleteReminder(messageId); + await this.client.deleteReminder({ message_id: messageId }); this.removeFromState(messageId); }; diff --git a/src/search/BaseSearchSource.ts b/src/search/BaseSearchSource.ts index 6b5f9a28d1..044da90cd4 100644 --- a/src/search/BaseSearchSource.ts +++ b/src/search/BaseSearchSource.ts @@ -14,7 +14,6 @@ export type DebounceOptions = { }; type DebouncedExecQueryFunction = DebouncedFunc<(searchString?: string) => Promise>; -// eslint-disable-next-line @typescript-eslint/no-explicit-any interface ISearchSource { activate(): void; @@ -40,14 +39,12 @@ interface ISearchSource { readonly type: SearchSourceType; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any export interface SearchSource extends ISearchSource { cancelScheduledQuery(): void; setDebounceOptions(options: DebounceOptions): void; search(text?: string): Promise | undefined; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any export interface SearchSourceSync extends ISearchSource { cancelScheduledQuery(): void; setDebounceOptions(options: DebounceOptions): void; diff --git a/src/search/ChannelMemberSearchSource.ts b/src/search/ChannelMemberSearchSource.ts index f851d8c9c4..0d21138e65 100644 --- a/src/search/ChannelMemberSearchSource.ts +++ b/src/search/ChannelMemberSearchSource.ts @@ -75,7 +75,13 @@ export class ChannelMemberSearchSource< }); const sort = this.sort ?? []; const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset }; - const { members } = await this.channel.queryMembers(filters ?? {}, sort, options); + const { members } = await this.channel.queryMembers({ + payload: { + filter_conditions: filters ?? {}, + sort, + ...options, + }, + }); return { items: members }; } diff --git a/src/search/ChannelSearchSource.ts b/src/search/ChannelSearchSource.ts index 8bf1729338..e2373b8fb5 100644 --- a/src/search/ChannelSearchSource.ts +++ b/src/search/ChannelSearchSource.ts @@ -54,16 +54,23 @@ export class ChannelSearchSource< protected async query(searchQuery: string) { const filters = this.filterBuilder.buildFilters({ baseFilters: { - ...(this.client.userID ? { members: { $in: [this.client.userID] } } : {}), + ...(this.client.userId ? { members: { $in: [this.client.userId] } } : {}), ...this.filters, }, context: { searchQuery } as Partial< ChannelSearchSourceFilterBuilderContext >, }); - const sort = this.sort ?? {}; + const sort = this.sort; const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset }; - const items = await this.client.queryChannels(filters, sort, options); + const items = await this.client.queryChannelsAndHydrate( + { + filter_conditions: filters, + sort, + ...options, + }, + { withResponse: false }, + ); return { items }; } diff --git a/src/search/MessageSearchSource.ts b/src/search/MessageSearchSource.ts index 63c9c33774..a2dbd4c175 100644 --- a/src/search/MessageSearchSource.ts +++ b/src/search/MessageSearchSource.ts @@ -6,7 +6,7 @@ import type { MessageFilters, MessageResponse, SearchMessageSort, - SearchOptions, + SearchPayload, } from '../types'; import type { StreamChat } from '../client'; import type { SearchSourceOptions } from './types'; @@ -60,7 +60,7 @@ export class MessageSearchSource< readonly type = 'messages'; private client: StreamChat; - messageSearchChannelFilters: ChannelFilters | undefined; + messageSearchChannelFilters: SearchPayload['filter_conditions'] | undefined; messageSearchFilters: MessageFilters | undefined; messageSearchSort: SearchMessageSort | undefined; @@ -69,7 +69,7 @@ export class MessageSearchSource< channelQueryOptions: Omit | undefined; messageSearchChannelFilterBuilder: FilterBuilder< - ChannelFilters, + SearchPayload['filter_conditions'], MergeContext< BuiltInContexts['messageSearchChannel'], TContexts['messageSearchChannelContext'] @@ -130,11 +130,11 @@ export class MessageSearchSource< } protected async query(searchQuery: string) { - if (!this.client.userID || this.next === null) return { items: [] }; + if (!this.client.userId || this.next === null) return { items: [] }; const channelFilters = this.messageSearchChannelFilterBuilder.buildFilters({ baseFilters: { - ...(this.client.userID ? { members: { $in: [this.client.userID] } } : {}), + ...(this.client.userId ? { members: { $in: [this.client.userId] } } : {}), ...this.messageSearchChannelFilters, }, context: { searchQuery } as Partial< @@ -155,23 +155,25 @@ export class MessageSearchSource< >, }); - const sort: SearchMessageSort = { - created_at: -1, - ...this.messageSearchSort, - }; - - const options: SearchOptions = { - limit: this.pageSize, - next: this.next, - sort, - }; - - const { next, results } = await this.client.search( - channelFilters, - messageFilters, - options, - ); - const items = results.map(({ message }) => message); + const { next, results } = await this.client.search({ + payload: { + filter_conditions: channelFilters, + message_filter_conditions: messageFilters, + limit: this.pageSize, + next: this.next, + sort: [ + { + field: 'created_at', + direction: -1, + }, + ...(this.messageSearchSort ?? []), + ], + }, + }); + + const items = results + .map(({ message }) => message) + .filter((m): m is NonNullable => Boolean(m)); const cids = Array.from( items.reduce((acc, message) => { @@ -187,14 +189,11 @@ export class MessageSearchSource< MergeContext >, }); - await this.client.queryChannels( - channelQueryFilters, - { - last_message_at: -1, - ...this.channelQuerySort, - }, - this.channelQueryOptions, - ); + await this.client.queryChannelsAndHydrate({ + filter_conditions: channelQueryFilters, + sort: [{ direction: -1, field: 'last_message_at' }], + ...this.channelQueryOptions, + }); } return { items, next }; diff --git a/src/search/UserSearchSource.ts b/src/search/UserSearchSource.ts index 335073c8ad..d47b5d7310 100644 --- a/src/search/UserSearchSource.ts +++ b/src/search/UserSearchSource.ts @@ -60,15 +60,19 @@ export class UserSearchSource< baseFilters: this.filters, context: { searchQuery } as UserSearchSourceFilterBuilderContext, }); - let sort: UserSort; - if (Array.isArray(this.sort)) { - const hasIdSort = this.sort.some((entry) => 'id' in entry); - sort = hasIdSort ? this.sort : [...this.sort, { id: 1 }]; - } else { - sort = { id: 1, ...this.sort }; - } + const baseSort = this.sort ?? []; + const hasIdSort = baseSort.some((entry) => entry.field === 'id'); + const sort: UserSort = hasIdSort + ? baseSort + : [...baseSort, { field: 'id', direction: 1 }]; const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset }; - const { users } = await this.client.queryUsers(filters, sort, options); + const { users } = await this.client.queryUsers({ + payload: { + filter_conditions: filters, + sort, + ...options, + }, + }); return { items: users }; } diff --git a/src/search/types.ts b/src/search/types.ts index f1708df177..41adaf360e 100644 --- a/src/search/types.ts +++ b/src/search/types.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line @typescript-eslint/no-explicit-any export type SearchSourceState = { hasNext: boolean; isActive: boolean; @@ -11,12 +10,12 @@ export type SearchSourceState = { }; export type SearchSourceOptions = { - /** The number of milliseconds to debounce the search query. The default interval is 300ms. */ + /** The number of milliseconds to debounce the search query (defaults to `300`). */ debounceMs?: number; pageSize?: number; - /** When true, the source can execute queries with an empty search string. Defaults to false. */ + /** When `true`, the source can execute queries with an empty search string (defaults to `false`). */ allowEmptySearchString?: boolean; - /** When true, previously loaded items are cleared at the start of a new search query. Defaults to true. */ + /** When `true`, previously loaded items are cleared at the start of a new search query (defaults to `true`). */ resetOnNewSearchQuery?: boolean; }; diff --git a/src/segment.ts b/src/segment.ts index a3091fd361..0e99cb9593 100644 --- a/src/segment.ts +++ b/src/segment.ts @@ -1,95 +1 @@ -import type { StreamChat } from './client'; -import type { - QuerySegmentTargetsFilter, - SegmentData, - SegmentResponse, - SortParam, -} from './types'; - -type SegmentType = 'user' | 'channel'; - -type SegmentUpdatableFields = { - description?: string; - filter?: {}; - name?: string; -}; - -export class Segment { - type: SegmentType; - id: string | null; - client: StreamChat; - data?: SegmentData | SegmentResponse; - - constructor( - client: StreamChat, - type: SegmentType, - id: string | null, - data?: SegmentData, - ) { - this.client = client; - this.type = type; - this.id = id; - this.data = data; - } - - create() { - const body = { - name: this.data?.name, - filter: this.data?.filter, - description: this.data?.description, - all_sender_channels: this.data?.all_sender_channels, - all_users: this.data?.all_users, - }; - - return this.client.createSegment(this.type, this.id, body); - } - - verifySegmentId() { - if (!this.id) { - throw new Error( - 'Segment id is missing. Either create the segment using segment.create() or set the id during instantiation - const segment = client.segment(id)', - ); - } - } - - get() { - this.verifySegmentId(); - return this.client.getSegment(this.id as string); - } - - update(data: Partial) { - this.verifySegmentId(); - - return this.client.updateSegment(this.id as string, data); - } - - addTargets(targets: string[]) { - this.verifySegmentId(); - return this.client.addSegmentTargets(this.id as string, targets); - } - - removeTargets(targets: string[]) { - this.verifySegmentId(); - return this.client.removeSegmentTargets(this.id as string, targets); - } - - delete() { - this.verifySegmentId(); - return this.client.deleteSegment(this.id as string); - } - - targetExists(targetId: string) { - this.verifySegmentId(); - return this.client.segmentTargetExists(this.id as string, targetId); - } - - queryTargets( - filter: QuerySegmentTargetsFilter | null = {}, - sort: SortParam[] | null | [] = [], - options = {}, - ) { - this.verifySegmentId(); - - return this.client.querySegmentTargets(this.id as string, filter, sort, options); - } -} +// Segment functionality has been moved to the server-side SDK. diff --git a/src/signing.ts b/src/signing.ts index 648e585cae..35fc296d52 100644 --- a/src/signing.ts +++ b/src/signing.ts @@ -2,18 +2,17 @@ import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import zlib from 'zlib'; import { decodeBase64, encodeBase64 } from './base64'; -import type { Event, UR } from './types'; +import type { UR } from './types'; +import type { WSEvent } from './gen/models'; /** - * Creates the JWT token that can be used for a UserSession - * @method JWTUserToken - * @memberof signing - * @private - * @param {Secret} apiSecret - API Secret key - * @param {string} userId - The user_id key in the JWT payload - * @param {UR} [extraData] - Extra that should be part of the JWT token - * @param {SignOptions} [jwtOptions] - Options that can be past to jwt.sign - * @return {string} JWT Token + * Creates the JWT token that can be used for a user session. + * + * @param apiSecret - API secret key. + * @param userId - The `user_id` key in the JWT payload. + * @param extraData - Extra data that should be part of the JWT token (optional, defaults to `{}`). + * @param jwtOptions - Options that can be passed to `jwt.sign` (optional, defaults to `{}`). + * @returns The signed JWT token. */ export function JWTUserToken( apiSecret: jwt.Secret, @@ -30,7 +29,7 @@ export function JWTUserToken( ...extraData, }; - // make sure we return a clear error when jwt is shimmed (ie. browser build) + // make sure we return a clear error when the JWT module is shimmed (i.e. browser build) if (jwt == null || jwt.sign == null) { throw Error( `Unable to find jwt crypto, if you are getting this error is probably because you are trying to generate tokens on browser or React Native (or other environment where crypto functions are not available). Please Note: token should only be generated server-side.`, @@ -48,6 +47,13 @@ export function JWTUserToken( return jwt.sign(payload, apiSecret, opts); } +/** + * Creates the JWT token that can be used for a server-side session. + * + * @param apiSecret - API secret key. + * @param jwtOptions - Options that can be passed to `jwt.sign` (optional, defaults to `{}`). + * @returns The signed JWT token. + */ export function JWTServerToken(apiSecret: jwt.Secret, jwtOptions: jwt.SignOptions = {}) { const payload = { server: true, @@ -60,6 +66,12 @@ export function JWTServerToken(apiSecret: jwt.Secret, jwtOptions: jwt.SignOption return jwt.sign(payload, apiSecret, opts); } +/** + * Decodes a JWT token and returns the embedded `user_id`. + * + * @param token - The JWT token to decode. + * @returns The `user_id` extracted from the token's payload, or an empty string when the token is malformed. + */ export function UserFromToken(token: string) { const fragments = token.split('.'); if (fragments.length !== 3) { @@ -72,9 +84,12 @@ export function UserFromToken(token: string) { } /** + * Generates a development token for the given user. + * + * Development tokens are unsigned and must only be used in environments where token validation is disabled. * - * @param {string} userId the id of the user - * @return {string} + * @param userId - The ID of the user. + * @returns The development token. */ export function DevToken(userId: string) { return [ @@ -85,16 +100,18 @@ export function DevToken(userId: string) { } /** - * Constant-time HMAC-SHA256 verification of `signature` against the - * digest of `body` using `secret` as the key. The signature is always - * computed over the **uncompressed** JSON bytes, so callers that - * decoded a gzipped or base64-wrapped payload must pass the inflated - * bytes here. + * Constant-time HMAC-SHA256 verification of `signature` against the digest of `body` using `secret` + * as the key. The signature is always computed over the **uncompressed** JSON bytes, so callers that + * decoded a gzipped or base64-wrapped payload must pass the inflated bytes here. * - * The legacy `client.verifyWebhook` helper wraps this function, so - * callers that have already migrated to `verifyAndParseWebhook`, - * `parseSqs`, or `parseSns` rarely need to invoke this - * directly. + * The legacy `client.verifyWebhook` helper wraps this function, so callers that have already + * migrated to {@link verifyAndParseWebhook}, {@link parseSqs}, or {@link parseSns} rarely need to + * invoke this directly. + * + * @param body - The uncompressed payload bytes that Stream signed. + * @param signature - The HMAC-SHA256 signature delivered alongside the payload. + * @param secret - Your app's API secret used as the HMAC key. + * @returns `true` when the signature matches the digest of `body`, otherwise `false`. */ export function verifySignature( body: string | Buffer, @@ -111,9 +128,14 @@ export function verifySignature( } /** - * @deprecated Use {@link verifySignature} - same logic, parameters - * reordered to match the cross-SDK contract - * (`verifySignature(body, signature, secret)`). + * Verifies an HMAC-SHA256 signature with the legacy parameter order. + * + * @param body - The uncompressed payload bytes that Stream signed. + * @param secret - Your app's API secret used as the HMAC key. + * @param signature - The HMAC-SHA256 signature delivered alongside the payload. + * @returns `true` when the signature matches the digest of `body`, otherwise `false`. + * @deprecated Use {@link verifySignature} instead — same logic, parameters reordered to match the + * cross-SDK contract (`verifySignature(body, signature, secret)`). */ export function CheckSignature(body: string | Buffer, secret: string, signature: string) { return verifySignature(body, signature, secret); @@ -150,14 +172,16 @@ export class InvalidWebhookError extends Error { } /** - * Returns `body` as a `Buffer`, gzip-decompressed when its first two - * bytes match the gzip magic (`1f 8b`, per RFC 1952). When the body is - * plain JSON (no compression, or middleware already decompressed), the - * bytes are returned unchanged. + * Returns `body` as a `Buffer`, gzip-decompressed when its first two bytes match the gzip magic + * (`1f 8b`, per RFC 1952). When the body is plain JSON (no compression, or middleware already + * decompressed), the bytes are returned unchanged. * - * Magic-byte detection (rather than relying on a header) keeps the - * same handler correct when middleware - Express, Next.js, AWS Lambda - * - auto-decompresses the request before your code sees it. + * Magic-byte detection (rather than relying on a header) keeps the same handler correct when + * middleware — Express, Next.js, AWS Lambda — auto-decompresses the request before your code sees it. + * + * @param rawBody - The raw HTTP request body, either as a string or a `Buffer`. + * @returns The uncompressed payload bytes. + * @throws {@link InvalidWebhookError} when the gzip envelope is malformed. */ export function gunzipPayload(rawBody: string | Buffer): Buffer { const GZIP_MAGIC = Buffer.from([0x1f, 0x8b]); @@ -174,13 +198,15 @@ export function gunzipPayload(rawBody: string | Buffer): Buffer { } /** - * Reverses the SQS firehose envelope: the message `Body` is - * base64-decoded, then the result is gzip-decompressed when it begins - * with the gzip magic. Returns the raw JSON `Buffer` Stream signed. + * Reverses the SQS firehose envelope: the message `Body` is base64-decoded, then the result is + * gzip-decompressed when it begins with the gzip magic. Returns the raw JSON `Buffer` Stream signed. + * + * SQS bodies are always base64-encoded so they remain valid UTF-8 over the queue. The same call + * works whether or not Stream is currently compressing payloads for this app. * - * SQS bodies are always base64-encoded so they remain valid UTF-8 over - * the queue. The same call works whether or not Stream is currently - * compressing payloads for this app. + * @param body - The base64-encoded SQS message body. + * @returns The decoded (and decompressed, when gzipped) payload bytes. + * @throws {@link InvalidWebhookError} when the body is not canonical base64 or the gzip envelope is malformed. */ export function decodeSqsPayload(body: string): Buffer { // Reject anything that isn't canonical base64 up front. Node's base64 @@ -199,12 +225,15 @@ export function decodeSqsPayload(body: string): Buffer { } /** - * Reverses an SNS HTTP notification envelope. When `notificationBody` - * is a JSON envelope (`{"Type":"Notification","Message":"..."}`), the - * inner `Message` field is extracted and run through the SQS pipeline - * (base64-decode, then gzip-if-magic). When the input is not a JSON - * envelope it is treated as the already-extracted `Message` string, - * so call sites that pre-unwrap continue to work. + * Reverses an SNS HTTP notification envelope. When `notificationBody` is a JSON envelope + * (`{"Type":"Notification","MessageRequest":"..."}`), the inner `MessageRequest` field is extracted and run + * through the SQS pipeline (base64-decode, then gzip-if-magic). When the input is not a JSON + * envelope it is treated as the already-extracted `MessageRequest` string, so call sites that pre-unwrap + * continue to work. + * + * @param notificationBody - The raw SNS notification body, or a pre-extracted `MessageRequest` string. + * @returns The decoded (and decompressed, when gzipped) payload bytes. + * @throws {@link InvalidWebhookError} when the body is not canonical base64 or the gzip envelope is malformed. */ export function decodeSnsPayload(notificationBody: string): Buffer { const inner = extractSnsMessage(notificationBody); @@ -226,28 +255,32 @@ function extractSnsMessage(notificationBody: string): string | null { parsed === null || typeof parsed !== 'object' || Array.isArray(parsed) || - typeof (parsed as { Message?: unknown }).Message !== 'string' + typeof (parsed as { MessageRequest?: unknown }).MessageRequest !== 'string' ) { return null; } - return (parsed as { Message: string }).Message; + return (parsed as { MessageRequest: string }).MessageRequest; } /** - * Parse a JSON-encoded webhook event into a typed {@link Event}. New - * event types Stream introduces still parse successfully - the runtime - * shape is the JSON Stream sent and the `type` field stays preserved. + * Parses a JSON-encoded webhook event into a typed {@link WSEvent}. New event types Stream + * introduces still parse successfully — the runtime shape is the JSON Stream sent and the `type` + * field stays preserved. + * + * @param payload - The raw event payload bytes or string. + * @returns The parsed WebSocket event. + * @throws {@link InvalidWebhookError} when the payload is not valid JSON. */ -export function parseEvent(payload: Buffer | string): Event { +export function parseEvent(payload: Buffer | string): WSEvent { const text = Buffer.isBuffer(payload) ? payload.toString('utf8') : payload; try { - return JSON.parse(text) as Event; + return JSON.parse(text) as WSEvent; } catch { throw new InvalidWebhookError(InvalidWebhookErrorMessages.invalidJson); } } -function verifyAndParse(payload: Buffer, signature: string, secret: string): Event { +function verifyAndParse(payload: Buffer, signature: string, secret: string): WSEvent { if (!verifySignature(payload, signature, secret)) { throw new InvalidWebhookError(InvalidWebhookErrorMessages.signatureMismatch); } @@ -255,36 +288,43 @@ function verifyAndParse(payload: Buffer, signature: string, secret: string): Eve } /** - * Decompress (when gzipped), verify the HMAC `signature`, and return - * the parsed {@link Event}. + * Decompress (when gzipped), verify the HMAC `signature`, and return the parsed {@link WSEvent}. * - * @param rawBody Raw HTTP request body bytes Stream signed - * @param signature Value of the `X-Signature` header - * @param secret Your app's API secret - * @throws {InvalidWebhookError} When the signature does not match or - * the gzip envelope is malformed. + * @param rawBody - Raw HTTP request body bytes Stream signed. + * @param signature - Value of the `X-Signature` header. + * @param secret - Your app's API secret. + * @returns The parsed WebSocket event. + * @throws {@link InvalidWebhookError} when the signature does not match or the gzip envelope is malformed. */ export function verifyAndParseWebhook( rawBody: string | Buffer, signature: string, secret: string, -): Event { +): WSEvent { return verifyAndParse(gunzipPayload(rawBody), signature, secret); } /** - * Decode the SQS message `Body` (base64, then gzip-if-magic) and return - * the parsed {@link Event}. Stream does not attach an application-level HMAC - * to SQS deliveries — use {@link verifyAndParseWebhook} for HTTP webhooks. + * Decodes the SQS message `Body` (base64, then gzip-if-magic) and returns the parsed {@link WSEvent}. + * Stream does not attach an application-level HMAC to SQS deliveries — use + * {@link verifyAndParseWebhook} for HTTP webhooks. + * + * @param messageBody - The base64-encoded SQS message body. + * @returns The parsed WebSocket event. + * @throws {@link InvalidWebhookError} when the body is malformed. */ -export function parseSqs(messageBody: string): Event { +export function parseSqs(messageBody: string): WSEvent { return parseEvent(decodeSqsPayload(messageBody)); } /** - * Decode an SNS notification (unwrap the JSON envelope when needed; same - * inner format as SQS). No application-level HMAC verification. + * Decodes an SNS notification (unwraps the JSON envelope when needed; same inner format as SQS). + * No application-level HMAC verification. + * + * @param notificationBody - The raw SNS notification body, or a pre-extracted `MessageRequest` string. + * @returns The parsed WebSocket event. + * @throws {@link InvalidWebhookError} when the body is malformed. */ -export function parseSns(notificationBody: string): Event { +export function parseSns(notificationBody: string): WSEvent { return parseEvent(decodeSnsPayload(notificationBody)); } diff --git a/src/store.ts b/src/store.ts index 70a018dd04..a786ea6f80 100644 --- a/src/store.ts +++ b/src/store.ts @@ -20,10 +20,11 @@ export class StateStore> { /** * Allows merging two stores only if their keys differ otherwise there's no way to ensure the data type stability. + * * @experimental * This method is experimental and may change in future versions. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any + public merge>( stateStore: Q extends StateStore ? Extract extends never diff --git a/src/thread.ts b/src/thread.ts index a1461da346..61201f06bc 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -1,15 +1,15 @@ import { StateStore } from './store'; -import { formatMessage } from './utils'; +import { formatMessage, localMessageToNewMessagePayload } from './utils'; import type { AscDesc, DraftResponse, EventAPIResponse, - EventTypes, + EventType, LocalMessage, - MarkReadOptions, + MarkReadRequest, MessageResponse, - ReadResponse, - ThreadResponse, + ReadStateResponse, + ThreadStateResponse, UserResponse, } from './types'; import type { @@ -24,6 +24,7 @@ import { MessageComposer } from './messageComposer'; import { MessageOperations } from './messageOperations'; import { WithSubscriptions } from './utils/WithSubscriptions'; import { MessagePaginator } from './pagination'; +import type { PipelineEvent } from './EventHandlerPipeline'; export type ThreadState = { /** @@ -42,7 +43,7 @@ export type ThreadState = { * We use parent message id as a thread id. */ parentMessage: LocalMessage; - participants: ThreadResponse['thread_participants']; + participants: ThreadStateResponse['thread_participants']; read: ThreadReadState; replyCount: number; title: string; @@ -62,48 +63,10 @@ export type ThreadReadState = Record; const DEFAULT_PAGE_LIMIT = 50; const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }]; const DEFAULT_ITEM_ORDER: { created_at: AscDesc } = { created_at: 1 }; -// TODO: remove this once we move to API v2 -export const THREAD_RESPONSE_RESERVED_KEYS: Record = { - active_participant_count: true, - channel: true, - channel_cid: true, - created_at: true, - created_by: true, - created_by_user_id: true, - deleted_at: true, - draft: true, - last_message_at: true, - latest_replies: true, - parent_message: true, - parent_message_id: true, - participant_count: true, - read: true, - reply_count: true, - thread_participants: true, - title: true, - updated_at: true, -}; - -// TODO: remove this once we move to API v2 -const constructCustomDataObject = (threadData: T) => { - const custom: CustomThreadData = {}; - - for (const key in threadData) { - if (THREAD_RESPONSE_RESERVED_KEYS[key as keyof ThreadResponse]) { - continue; - } - - const customKey = key as keyof CustomThreadData; - - custom[customKey] = threadData[customKey]; - } - - return custom; -}; export type CustomThreadMarkReadRequestFn = (params: { thread: Thread; - options?: MarkReadOptions; + options?: MarkReadRequest; }) => Promise | void; export type ThreadInstanceConfig = { @@ -131,20 +94,23 @@ export class Thread extends WithSubscriptions { draft, }: { client: StreamChat; - threadData?: ThreadResponse; + threadData?: ThreadStateResponse; channel?: Channel; parentMessage?: MessageResponse | LocalMessage; draft?: DraftResponse; }) { super(); if (threadData) { + if (!threadData.channel) { + throw new Error('Thread channel is required when threadData is provided'); + } + if (!threadData.parent_message) { + throw new Error('Thread parent_message is required when threadData is provided'); + } const threadChannel = client.channel( threadData.channel.type, threadData.channel.id, - { - // @ts-expect-error name is a "custom" property - name: threadData.channel.name, - }, + { custom: threadData.channel.custom }, ); threadChannel._hydrateMembers({ members: threadData.channel.members ?? [], @@ -176,7 +142,7 @@ export class Thread extends WithSubscriptions { replyCount: threadData.parent_message.reply_count ?? 0, updatedAt: threadData.updated_at ? new Date(threadData.updated_at) : null, title: threadData.title, - custom: constructCustomDataObject(threadData), + custom: threadData.custom ?? {}, }); this.id = threadData.parent_message_id; @@ -201,7 +167,7 @@ export class Thread extends WithSubscriptions { channel, createdAt, custom: {}, - deletedAt: formattedParentMessage.deleted_at, + deletedAt: formattedParentMessage.deleted_at ?? null, isLoading: false, isStateStale: false, parentMessage: formattedParentMessage, @@ -300,15 +266,19 @@ export class Thread extends WithSubscriptions { }, defaults: { delete: async (id, o) => { - const result = await this.channel.getClient().deleteMessage(id, o); + const result = await this.channel.getClient().deleteMessage({ id, ...o }); return { message: result.message }; }, send: async (m, o) => { - const result = await this.channel.sendMessage(m, o); + const result = await this.channel.sendMessage({ message: m, ...o }); return { message: result.message }; }, update: async (m, o) => { - const result = await this.channel.getClient().updateMessage(m, undefined, o); + const result = await this.channel.getClient().updateMessage({ + id: m.id, + message: localMessageToNewMessagePayload(m), + ...o, + }); return { message: result.message }; }, }, @@ -343,9 +313,8 @@ export class Thread extends WithSubscriptions { this.state.partialNext({ isLoading: true }); try { - const loadedReplyCount = - this.messagePaginator.state.getLatestValue().items?.length ?? 0; - const thread = await this.client.getThread(this.id, { + const loadedReplyCount = this.messagePaginator.items?.length ?? 0; + const thread = await this.client.getThreadAndHydrate(this.id, { watch: true, reply_limit: loadedReplyCount || this.messagePaginator.pageSize, }); @@ -397,9 +366,7 @@ export class Thread extends WithSubscriptions { isStateStale: false, }); - this.messagePaginator.mergeNewestPage( - thread.messagePaginator.state.getLatestValue().items ?? [], - ); + this.messagePaginator.mergeNewestPage(thread.messagePaginator.items ?? []); pendingReplies.forEach((reply) => this.messagePaginator.ingestItem(reply)); // Carry the re-queried thread's last-activity floor so lastMessageAt stays fresh even when the // merged page does not include the newest reply. Monotonic, so an older value is a no-op. @@ -436,8 +403,7 @@ export class Thread extends WithSubscriptions { title: threadData.title, updatedAt: new Date(threadData.updated_at), deletedAt: threadData.deleted_at ? new Date(threadData.deleted_at) : null, - // TODO: use threadData.custom once we move to API v2 - custom: constructCustomDataObject(threadData), + custom: threadData.custom ?? {}, }); }).unsubscribe; @@ -464,7 +430,7 @@ export class Thread extends WithSubscriptions { ); private subscribeMarkThreadStale = () => - this.client.on('user.watching.stop', (event) => { + this.client.on('user.watching.stop', (event: PipelineEvent) => { const { channel } = this.state.getLatestValue(); if ( @@ -516,7 +482,7 @@ export class Thread extends WithSubscriptions { this.upsertReplyLocally({ message: event.message, - // Message from current user could have been added optimistically, + // MessageRequest from current user could have been added optimistically, // so the actual timestamp might differ in the event timestampChanged: isOwnMessage, }); @@ -621,8 +587,8 @@ export class Thread extends WithSubscriptions { }).unsubscribe; private subscribeMessageUpdated = () => { - const messageUpdateTypes: EventTypes[] = ['message.updated', 'message.undeleted']; - const reactionTypes: EventTypes[] = [ + const messageUpdateTypes: EventType[] = ['message.updated', 'message.undeleted']; + const reactionTypes: EventType[] = [ 'reaction.new', 'reaction.deleted', 'reaction.updated', @@ -630,7 +596,7 @@ export class Thread extends WithSubscriptions { const unsubscribeMessageUpdated = messageUpdateTypes.map( (eventType) => - this.client.on(eventType, (event) => { + this.client.on(eventType, (event: PipelineEvent) => { if (!event.message) return; // A `message.updated` WS event carries `own_reactions: []`; upserting it verbatim would // wipe the current user's reactions on a reply edit. The reply paginator is this thread's @@ -652,7 +618,7 @@ export class Thread extends WithSubscriptions { const unsubscribeReactions = reactionTypes.map( (eventType) => - this.client.on(eventType, (event) => { + this.client.on(eventType, (event: PipelineEvent) => { if (!event.message || !event.reaction) return; const { message, reaction } = event; if (message.parent_id === this.id) { @@ -680,11 +646,11 @@ export class Thread extends WithSubscriptions { // Apply a user ban / deletion to this thread's own reply list. Previously // channel.state.deleteUserMessages marked banned-user replies deleted in the (now removed) // channel.state.threads shadow; the reply paginator is the thread's source of truth now. - const eventTypes: EventTypes[] = ['user.messages.deleted', 'user.deleted']; + const eventTypes: EventType[] = ['user.messages.deleted', 'user.deleted']; const unsubscribeFunctions = eventTypes.map( (eventType) => - this.client.on(eventType, (event) => { + this.client.on(eventType, (event: PipelineEvent) => { if (!event.user) return; // user.deleted carries the deletion time on the user; user.messages.deleted on the event. const deletedAtSource = @@ -729,7 +695,7 @@ export class Thread extends WithSubscriptions { const formattedMessage = formatMessage(message); // todo: do we really need to keep the failedRepliesMap? - if (message.status === 'failed') { + if (formattedMessage.status === 'failed') { // store failed reply so that it's not lost when reloading or hydrating this.failedRepliesMap.set(formattedMessage.id, formattedMessage); } else if (this.failedRepliesMap.has(message.id)) { @@ -743,7 +709,7 @@ export class Thread extends WithSubscriptions { // todo: can be removed with the next breaking change and use MessagePaginator only public updateParentMessageLocally = ({ message }: { message: MessageResponse }) => { if (message.id !== this.id) { - throw new Error('Message does not belong to this thread'); + throw new Error('MessageRequest does not belong to this thread'); } this.state.next((current) => { @@ -751,7 +717,7 @@ export class Thread extends WithSubscriptions { return { ...current, - deletedAt: formattedMessage.deleted_at, + deletedAt: formattedMessage.deleted_at ?? null, parentMessage: formattedMessage, participants: normalizeThreadParticipants(message.thread_participants, current.channel.cid) ?? @@ -856,28 +822,29 @@ export class Thread extends WithSubscriptions { type MessageThreadParticipant = NonNullable< MessageResponse['thread_participants'] >[number]; -type ThreadParticipant = NonNullable[number]; +type ThreadParticipant = NonNullable[number]; const normalizeThreadParticipants = ( participants: MessageResponse['thread_participants'] | undefined, channelCid: string, -): ThreadResponse['thread_participants'] | undefined => { +): ThreadStateResponse['thread_participants'] | undefined => { if (!participants) return undefined; - const nowIso = new Date().toISOString(); + const now = new Date(); return participants.map( - (participant: MessageThreadParticipant): ThreadParticipant => ({ - channel_cid: channelCid, - created_at: nowIso, - last_read_at: nowIso, - user: participant, - user_id: participant.id, - }), + (participant: MessageThreadParticipant) => + ({ + channel_cid: channelCid, + created_at: now, + last_read_at: now, + user: participant as UserResponse, + user_id: participant.id, + }) as ThreadParticipant, ); }; -const formatReadState = (read: ReadResponse[]): ThreadReadState => +const formatReadState = (read: ReadStateResponse[]): ThreadReadState => read.reduce((state, userRead) => { state[userRead.user.id] = { user: userRead.user, @@ -888,13 +855,13 @@ const formatReadState = (read: ReadResponse[]): ThreadReadState => return state; }, {}); -const getPlaceholderReadResponse = (currentUserId?: string): ReadResponse[] => +const getPlaceholderReadResponse = (currentUserId?: string): ReadStateResponse[] => currentUserId ? [ { - user: { id: currentUserId }, + user: { id: currentUserId } as UserResponse, unread_messages: 0, - last_read: new Date().toISOString(), + last_read: new Date(), }, ] : []; diff --git a/src/thread_manager.ts b/src/thread_manager.ts index 702d6720d5..0b7d5bb194 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -1,15 +1,26 @@ +import { chatLoggerSystem } from './logger'; import { StateStore } from './store'; import { throttle } from './utils'; import type { StreamChat } from './client'; import type { Thread } from './thread'; -import type { Event, OwnUserResponse, QueryThreadsOptions } from './types'; +import type { + Event, + EventPayload, + EventType, + OwnUserResponse, + QueryThreadsRequest, +} from './types'; 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; const MAX_QUERY_THREADS_LIMIT = 25; export const THREAD_MANAGER_INITIAL_STATE = { active: false, + wasActivatedAtLeastOnce: false, isThreadOrderStale: false, threads: [], unreadThreadCount: 0, @@ -25,6 +36,12 @@ export const THREAD_MANAGER_INITIAL_STATE = { export type ThreadManagerState = { active: boolean; + /** + * Whether the thread manager has been activated at least once in the current + * session (i.e. `activate()` was called). Used to avoid requerying threads + * on connection recovery for consumers that never actually activate the manager. + */ + wasActivatedAtLeastOnce: boolean; isThreadOrderStale: boolean; lastConnectionDropAt: Date | null; pagination: ThreadManagerPagination; @@ -44,6 +61,8 @@ export type ThreadManagerPagination = { nextCursor: string | null; }; +const logger = chatLoggerSystem.getLogger('thread-manager'); + export class ThreadManager extends WithSubscriptions { public readonly state: StateStore; private client: StreamChat; @@ -90,7 +109,7 @@ export class ThreadManager extends WithSubscriptions { }; public activate = () => { - this.state.partialNext({ active: true }); + this.state.partialNext({ active: true, wasActivatedAtLeastOnce: true }); }; public deactivate = () => { @@ -114,16 +133,21 @@ export class ThreadManager extends WithSubscriptions { (this.client.user as OwnUserResponse) ?? {}; this.state.partialNext({ unreadThreadCount }); - const unsubscribeFunctions = [ - 'health.check', - 'notification.mark_read', - 'notification.mark_unread', - 'notification.thread_message_new', - 'notification.channel_deleted', - ].map( + const unsubscribeFunctions = ( + [ + 'health.check', + 'notification.mark_read', + 'notification.mark_unread', + 'notification.thread_message_new', + 'notification.channel_deleted', + ] as const satisfies EventType[] + ).map( (eventType) => this.client.on(eventType, (event) => { - const { unread_threads: unreadThreadCount } = event.me ?? event; + const { unread_threads: unreadThreadCount } = + (eventIsHealthCheck(event) && event.me) || + (event as Extract); + if (typeof unreadThreadCount === 'number') { this.state.partialNext({ unreadThreadCount }); } @@ -167,7 +191,7 @@ export class ThreadManager extends WithSubscriptions { ); private subscribeNewReplies = () => - this.client.on('notification.thread_message_new', (event: Event) => { + this.client.on('notification.thread_message_new', (event) => { const parentId = event.message?.parent_id; if (!parentId) return; @@ -197,8 +221,9 @@ export class ThreadManager extends WithSubscriptions { const throttledHandleConnectionRecovered = throttle( () => { - const { lastConnectionDropAt } = this.state.getLatestValue(); - if (!lastConnectionDropAt) return; + const { lastConnectionDropAt, wasActivatedAtLeastOnce } = + this.state.getLatestValue(); + if (!lastConnectionDropAt || !wasActivatedAtLeastOnce) return; this.reload({ force: true }); }, DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION, @@ -272,7 +297,9 @@ export class ThreadManager extends WithSubscriptions { ready: true, })); } catch (error) { - this.client.logger('error', (error as Error).message); + logger + .withExtraTags('reload') + .error('Failed to reload the thread list.', { error }); this.state.next((current) => ({ ...current, pagination: { @@ -283,8 +310,8 @@ export class ThreadManager extends WithSubscriptions { } }; - public queryThreads = (options: QueryThreadsOptions = {}) => - this.client.queryThreads({ + public queryThreads = (options: QueryThreadsRequest = {}) => + this.client.queryThreadsAndHydrate({ limit: 25, participant_limit: 10, reply_limit: 10, @@ -292,7 +319,7 @@ export class ThreadManager extends WithSubscriptions { ...options, }); - public loadNextPage = async (options: Omit = {}) => { + public loadNextPage = async (options: Omit = {}) => { const { pagination } = this.state.getLatestValue(); if (pagination.isLoadingNext || !pagination.nextCursor) return; @@ -317,7 +344,9 @@ export class ThreadManager extends WithSubscriptions { }, })); } catch (error) { - this.client.logger('error', (error as Error).message); + logger + .withExtraTags('loadNextPage') + .error('Failed to load the next page of threads.', { error }); this.state.next((current) => ({ ...current, pagination: { diff --git a/src/token_manager.ts b/src/token_manager.ts index 617e7ce2ba..9af2fb74e7 100644 --- a/src/token_manager.ts +++ b/src/token_manager.ts @@ -1,9 +1,13 @@ import type jwt from 'jsonwebtoken'; +import { chatLoggerSystem } from './logger'; import { JWTServerToken, JWTUserToken, UserFromToken } from './signing'; import { isFunction } from './utils'; -import type { TokenOrProvider, UserResponse } from './types'; +import type { TokenOrProvider } from './types'; +const logger = chatLoggerSystem.getLogger('token-manager'); + +export type TokenManagerMinimalUser = { id: string; anon?: boolean }; /** * TokenManager * @@ -15,11 +19,12 @@ export class TokenManager { secret?: jwt.Secret; token?: string; tokenProvider?: TokenOrProvider; - user?: UserResponse; + user?: TokenManagerMinimalUser; /** - * Constructor + * Initializes the token manager, optionally with a server-side API secret used to mint tokens + * locally. * - * @param {Secret} secret + * @param secret - Optional API secret. When provided, the manager will sign server tokens locally. */ constructor(secret?: jwt.Secret) { this.loadTokenPromise = null; @@ -35,13 +40,16 @@ export class TokenManager { } /** - * Set the static string token or token provider. - * Token provider should return a token string or a promise which resolves to string token. + * Sets the static string token or token provider. A token provider should return a token string + * or a promise that resolves to a token string. * - * @param {TokenOrProvider} tokenOrProvider - * @param {UserResponse} user + * @param tokenOrProvider - A token string or an async provider that returns one. + * @param user - The user the token belongs to. */ - setTokenOrProvider = async (tokenOrProvider: TokenOrProvider, user: UserResponse) => { + setTokenOrProvider = async ( + tokenOrProvider: TokenOrProvider, + user: TokenManagerMinimalUser, + ) => { this.validateToken(tokenOrProvider, user); this.user = user; @@ -76,7 +84,7 @@ export class TokenManager { }; // Validates the user token. - validateToken = (tokenOrProvider: TokenOrProvider, user: UserResponse) => { + validateToken = (tokenOrProvider: TokenOrProvider, user: TokenManagerMinimalUser) => { // allow empty token for anon user if (user && user.anon && !tokenOrProvider) return; @@ -126,6 +134,9 @@ export class TokenManager { try { this.token = await this.tokenProvider(); } catch (e) { + logger + .withExtraTags('loadToken') + .error('The token provider threw an error.', { error: e }); return reject( new Error(`Call to tokenProvider failed with message: ${e}`, { cause: e }), ); diff --git a/src/types.ts b/src/types.ts index 5b77f5448f..7dff83763f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,65 +1,74 @@ -import type { EVENT_MAP } from './events'; import type { Channel } from './channel'; import type { AxiosRequestConfig, AxiosResponse } from 'axios'; import type { StableWSConnection } from './connection'; -import type { Role } from './permissions'; import type { - CustomAttachmentData, CustomChannelData, CustomCommandData, - CustomEventData, CustomEventTypes, - CustomMemberData, - CustomMessageData, - CustomPollData, - CustomPollOptionData, - CustomReactionData, - CustomThreadData, - CustomUserData, } from './custom_types'; import type { NotificationManager } from './notifications'; import type { RESERVED_UPDATED_MESSAGE_FIELDS } from './constants'; +import type { + APIError, + Attachment, + AutomodDetailsResponse, + ChannelConfigWithInfo, + ChannelInput, + ChannelMemberResponse, + ChannelMute, + ChannelOwnCapability, + ChannelResponse, + ChannelStateResponseFields, + CreateDeviceRequest, + DraftPayloadResponse, + Images, + MessageResponse, + ModerationPayload, + OwnUserResponse, + PollResponseData, + PollVoteResponseData, + PrivacySettingsResponse, + PushPreferencesResponse, + QueryChannelsRequest, + QueryFilters, + QueryMembersPayload, + QueryThreadsRequest, + QueryUsersPayload, + ReactionResponse, + ReminderResponseData, + RequireAtLeastOne, + SearchPayload, + SearchWarning, + SendMessageRequest, + SendMessageResponse, + SharedLocation, + SharedLocationResponseData, + SortParamRequest, + TranslateMessageRequest, + UpdateChannelRequest, + UpdateMessageRequest, + UpdateMessageResponse, + UpdatePollOptionRequest, + UpdatePollRequest, + UserMuteResponse, + UserResponse, + WSEvent, +} from './gen/models'; + +import type { ChatApi } from './gen-imports'; /** * Utility Types */ - -export type Readable = { - [key in keyof T]: T[key]; -} & {}; - -export type ArrayOneOrMore = { - 0: T; -} & Array; - -export type ArrayTwoOrMore = { - 0: T; - 1: T; -} & Array; - -export type KnownKeys = { - [K in keyof T]: string extends K ? never : number extends K ? never : K; -} extends { [_ in keyof T]: infer U } - ? U - : never; - -export type RequireAtLeastOne = { - [K in keyof T]-?: Required> & Partial>; -}[keyof T]; - export type RequireOnlyOne = Omit & { [K in Keys]-?: Required> & Partial, undefined>>; }[Keys]; -export type PartializeKeys = Partial> & Omit; - -/* Unknown Record */ export type UR = Record; -export type UnknownType = UR; //alias to avoid breaking change export type Unpacked = T extends (infer U)[] - ? U // eslint-disable-next-line @typescript-eslint/no-explicit-any + ? U : T extends (...args: any[]) => infer U ? U : T extends Promise @@ -72,166 +81,10 @@ export type Unpacked = T extends (infer U)[] export type APIResponse = { duration: string; - blocklist?: BlockListResponse; -}; - -export type TranslateResponse = { - language: string; - translated_text: string; -}; - -export type AppSettingsAPIResponse = APIResponse & { - app?: { - id?: string | number; - allow_multi_user_devices?: boolean; - feed_audit_logs_enabled?: boolean; - moderation_onboarding_complete?: boolean | null; - // TODO - // eslint-disable-next-line @typescript-eslint/no-explicit-any - call_types: any; - channel_configs: Record< - string, - { - reminders: boolean; - automod?: ChannelConfigAutomod; - automod_behavior?: ChannelConfigAutomodBehavior; - automod_thresholds?: ChannelConfigAutomodThresholds; - blocklist_behavior?: ChannelConfigAutomodBehavior; - commands?: CommandVariants[]; - connect_events?: boolean; - created_at?: string; - custom_events?: boolean; - delivery_events?: boolean; - mark_messages_pending?: boolean; - max_message_length?: number; - message_retention?: string; - mutes?: boolean; - name?: string; - polls?: boolean; - push_notifications?: boolean; - quotes?: boolean; - reactions?: boolean; - read_events?: boolean; - replies?: boolean; - search?: boolean; - shared_locations?: boolean; - skip_last_msg_update_for_system_msgs?: boolean; - count_messages?: boolean; - typing_events?: boolean; - updated_at?: string; - uploads?: boolean; - url_enrichment?: boolean; - user_message_reminders?: boolean; - push_level?: - | 'all' - | 'all_mentions' - | 'direct_mentions' - | 'mentions' - | 'none' - | ''; - } - >; - reminders_interval: number; - async_moderation_config?: AsyncModerationOptions; - async_url_enrich_enabled?: boolean; - auto_translation_enabled?: boolean; - before_message_send_hook_url?: string; - before_message_send_hook_attempt_timeout_ms?: number; - campaign_enabled?: boolean; - cdn_expiration_seconds?: number; - custom_action_handler_url?: string; - datadog_info?: { - api_key: string; - site: string; - enabled?: boolean; - }; - disable_auth_checks?: boolean; - disable_permissions_checks?: boolean; - enforce_unique_usernames?: 'no' | 'app' | 'team'; - event_hooks?: Array; - file_upload_config?: FileUploadConfig; - geofences?: Array<{ - country_codes: Array; - description: string; - name: string; - type: string; - }>; - grants?: Record; - guest_user_creation_disabled?: boolean; - image_moderation_enabled?: boolean; - image_moderation_labels?: string[]; - image_upload_config?: FileUploadConfig; - allowed_flag_reasons?: string[]; - max_aggregated_activities_length?: number; - moderation_bulk_submit_action_enabled?: boolean; - moderation_dashboard_preferences?: Record | null; - moderation_audio_call_moderation_enabled?: boolean; - moderation_enabled?: boolean; - moderation_llm_configurability_enabled?: boolean; - moderation_multitenant_blocklist_enabled?: boolean; - moderation_video_call_moderation_enabled?: boolean; - moderation_webhook_url?: string; - multi_tenant_enabled?: boolean; - name?: string; - organization?: string; - permission_version?: string; - /** - * The placement of the app in the form of `${region}.${shard}`. - * Examples: "us-east.c1", "dublin.c3", "singapore.c2" - * Note: The backend may add/remove regions or shards occasionally. - */ - placement?: string; - policies?: Record; - poll_enabled?: boolean; - push_notifications?: { - offline_only: boolean; - version: string; - apn?: APNConfig; - firebase?: FirebaseConfig; - huawei?: HuaweiConfig; - providers?: PushProviderConfig[]; - xiaomi?: XiaomiConfig; - }; - revoke_tokens_issued_before?: string | null; - search_backend?: 'disabled' | 'elasticsearch' | 'postgres'; - sns_key?: string; - sns_secret?: string; - sns_topic_arn?: string; - sqs_key?: string; - sqs_secret?: string; - sqs_url?: string; - suspended?: boolean; - suspended_explanation?: string; - use_hook_v2?: boolean; - user_response_time_enabled?: boolean; - user_search_disallowed_roles?: string[] | null; - video_provider?: string; - webhook_events?: Array; - webhook_url?: string; - }; -}; - -export type ModerationResult = { - action: string; - created_at: string; - message_id: string; - updated_at: string; - user_bad_karma: boolean; - user_karma: number; - blocked_word?: string; - blocklist_name?: string; - moderated_by?: string; -}; - -export type AutomodDetails = { - action?: string; - image_labels?: Array; - original_message_type?: string; - result?: ModerationResult; }; export type FlagDetails = { - automod?: AutomodDetails; + automod?: AutomodDetailsResponse; }; export type Flag = { @@ -244,264 +97,10 @@ export type Flag = { user?: UserResponse; }; -export type FlagsResponse = APIResponse & { - flags?: Array; -}; - -export type MessageFlagsResponse = APIResponse & { - flags?: Array<{ - message: MessageResponse; - user: UserResponse; - approved_at?: string; - created_at?: string; - created_by_automod?: boolean; - moderation_result?: ModerationResult; - rejected_at?: string; - reviewed_at?: string; - reviewed_by?: UserResponse; - updated_at?: string; - }>; -}; - -export type FlagReport = { - flags_count: number; - id: string; - message: MessageResponse; - user: UserResponse; - created_at?: string; - details?: FlagDetails; - first_reporter?: UserResponse; - review_result?: string; - reviewed_at?: string; - reviewed_by?: UserResponse; - updated_at?: string; -}; - -export type FlagReportsResponse = APIResponse & { - flag_reports: Array; -}; - -export type ReviewFlagReportResponse = APIResponse & { - flag_report: FlagReport; -}; - -export type BannedUsersResponse = APIResponse & { - bans?: Array<{ - user: UserResponse; - banned_by?: UserResponse; - channel?: ChannelResponse; - expires?: string; - ip_ban?: boolean; - reason?: string; - timeout?: number; - }>; -}; - -export type FutureChannelBan = { - user: UserResponse; - expires?: string; - reason?: string; - shadow?: boolean; - created_at: string; -}; - -export type FutureChannelBansResponse = APIResponse & { - bans: FutureChannelBan[]; -}; - -export type QueryFutureChannelBansOptions = { - user_id?: string; - target_user_id?: string; - exclude_expired_bans?: boolean; - limit?: number; - offset?: number; -}; - -export type BlockListResponse = BlockList & { - created_at?: string; - type?: string; - updated_at?: string; -}; - -export type ChannelResponse = CustomChannelData & { - cid: string; - disabled: boolean; - frozen: boolean; - id: string; - type: string; - blocked?: boolean; - auto_translation_enabled?: boolean; - auto_translation_language?: TranslationLanguages; - hide_messages_before?: string; - config?: ChannelConfigWithInfo; - cooldown?: number; - created_at?: string; - created_by?: UserResponse | null; - created_by_id?: string; - deleted_at?: string; - filter_tags?: string[]; - hidden?: boolean; - invites?: string[]; - joined?: boolean; - last_message_at?: string; - member_count?: number; - members?: ChannelMemberResponse[]; - message_count?: number; - muted?: boolean; - mute_expires_at?: string; - own_capabilities?: string[]; - team?: string; - truncated_at?: string; - truncated_by?: UserResponse; - truncated_by_id?: string; - updated_at?: string; -}; - -export type QueryReactionsOptions = Pager; - -export type QueryReactionsAPIResponse = APIResponse & { - reactions: ReactionResponse[]; - next?: string; -}; - -export type QueryChannelsAPIResponse = APIResponse & { - channels: Omit[]; - predefined_filter?: ParsedPredefinedFilterResponse; -}; - -export type QueryChannelAPIResponse = APIResponse & ChannelAPIResponse; - -export type ChannelAPIResponse = { - channel: ChannelResponse; - members: ChannelMemberResponse[]; - messages: MessageResponse[]; - pinned_messages: MessageResponse[]; - draft?: DraftResponse; - hidden?: boolean; - membership?: ChannelMemberResponse | null; - pending_messages?: PendingMessageResponse[]; - push_preferences?: ChannelPushPreference; - read?: ReadResponse[]; - threads?: ThreadResponse[]; - watcher_count?: number; - watchers?: UserResponse[]; - active_live_locations?: SharedLocationResponse[]; -}; - -export type ChannelUpdateOptions = { - hide_history?: boolean; - hide_history_before?: string | Date; - skip_push?: boolean; -}; - -export type ChannelMemberAPIResponse = APIResponse & { - members: ChannelMemberResponse[]; -}; - -export type ChannelMemberUpdates = CustomMemberData & { - archived?: boolean; - channel_role?: Role; - pinned?: boolean; -}; - -export type ChannelMemberResponse = CustomMemberData & { - archived_at?: string | null; - ban_expires?: string; - banned?: boolean; - channel_role?: Role; - created_at?: string; - invite_accepted_at?: string; - invite_rejected_at?: string; - invited?: boolean; - is_moderator?: boolean; - notifications_muted?: boolean; - pinned_at?: string | null; - role?: string; - shadow_banned?: boolean; - status?: InviteStatus; - updated_at?: string; - user?: UserResponse; - user_id?: string; -}; - -export type PartialUpdateMemberAPIResponse = APIResponse & { - channel_member: ChannelMemberResponse; -}; - -export type CheckPushResponse = APIResponse & { - device_errors?: { - [deviceID: string]: { - error_message?: string; - provider?: PushProvider; - provider_name?: string; - }; - }; - general_errors?: string[]; - rendered_apn_template?: string; - rendered_firebase_template?: string; - rendered_message?: {}; - skip_devices?: boolean; -}; - -export type CheckSQSResponse = APIResponse & { - status: string; - data?: {}; - error?: string; -}; - -export type CheckSNSResponse = APIResponse & { - status: string; - data?: {}; - error?: string; -}; - -export type CommandResponse = Partial & { - args?: string; - description?: string; - name?: CommandVariants; - set?: CommandVariants; -}; +export type ChannelUpdateOptions = Omit; export type ConnectAPIResponse = Promise; -export type CreateChannelResponse = APIResponse & - Omit & { - created_at: string; - updated_at: string; - grants?: Record; - }; - -export type CreateCommandResponse = APIResponse & { - command: CreateCommandOptions & CreatedAtUpdatedAt; -}; - -export type DeleteChannelAPIResponse = APIResponse & { - channel: ChannelResponse; -}; - -export type DeleteCommandResponse = APIResponse & { - name?: CommandVariants; -}; - -export type EventAPIResponse = APIResponse & { - event: Event; -}; - -export type ExportChannelResponse = { - task_id: string; -}; - -export type ExportUsersResponse = { - task_id: string; -}; - -export type ExportChannelStatusResponse = { - created_at?: string; - error?: {}; - result?: {}; - updated_at?: string; -}; - export type FlagMessageResponse = APIResponse & { flag: { created_at: string; @@ -536,107 +135,19 @@ export type FlagUserResponse = APIResponse & { review_queue_item_id?: string; }; -export type LocalMessageBase = Omit< - MessageResponseBase, - 'created_at' | 'deleted_at' | 'pinned_at' | 'status' | 'updated_at' -> & { - created_at: Date; - deleted_at: Date | null; - pinned_at: Date | null; +export type LocalMessage = MessageResponse & { status: string; - updated_at: Date; -}; - -export type LocalMessage = LocalMessageBase & { - error?: ErrorFromResponse | null; - quoted_message?: LocalMessageBase | null; + error?: StreamAPIError; + user_id?: string; }; -/** - * @deprecated in favor of LocalMessage - */ -export type FormatMessageResponse = LocalMessage; - -export type GetCommandResponse = APIResponse & CreateCommandOptions & CreatedAtUpdatedAt; - -export type GetMessageAPIResponse = SendMessageAPIResponse; - -export interface ThreadResponse extends CustomThreadData { - // FIXME: according to OpenAPI, `channel` could be undefined but since cid is provided I'll asume that it's wrong - channel: ChannelResponse; - channel_cid: string; - created_at: string; - created_by_user_id: string; - latest_replies: Array; - parent_message: MessageResponse; - parent_message_id: string; - title: string; - updated_at: string; - active_participant_count?: number; - created_by?: UserResponse; - deleted_at?: string; - draft?: DraftResponse; - last_message_at?: string; - participant_count?: number; - read?: Array; - reply_count?: number; - thread_participants?: Array<{ - channel_cid: string; - created_at: string; - last_read_at: string; - last_thread_message_at?: string; - left_thread_at?: string; - thread_id?: string; - user?: UserResponse; - user_id?: string; - }>; - // TODO: when moving to API v2 we should do this instead - // custom: CustomThreadType; -} - // TODO: Figure out a way to strongly type set and unset. export type PartialThreadUpdate = { set?: Partial>; unset?: Array; }; -export type QueryThreadsOptions = { - filter?: ThreadFilters; - limit?: number; - member_limit?: number; - next?: string; - participant_limit?: number; - reply_limit?: number; - sort?: ThreadSort; - watch?: boolean; -}; - -export type QueryThreadsAPIResponse = APIResponse & { - threads: ThreadResponse[]; - next?: string; -}; - -export type GetThreadOptions = { - member_limit?: number; - participant_limit?: number; - reply_limit?: number; - watch?: boolean; -}; - -export type GetThreadAPIResponse = APIResponse & { - thread: ThreadResponse; -}; - -export type GetMultipleMessagesAPIResponse = APIResponse & { - messages: MessageResponse[]; -}; - -export type GetRateLimitsResponse = APIResponse & { - android?: RateLimitsMap; - ios?: RateLimitsMap; - server_side?: RateLimitsMap; - web?: RateLimitsMap; -}; +export type GetThreadOptions = Omit[0], 'message_id'>; export enum Product { Chat = 'chat', @@ -645,229 +156,13 @@ export enum Product { Feeds = 'feeds', } -export type HookEvent = { - name: string; - description: string; - products: Product[]; -}; - -export type GetHookEventsResponse = APIResponse & { - events: HookEvent[]; -}; - -export type GetReactionsAPIResponse = APIResponse & { - reactions: ReactionResponse[]; -}; - export type GetRepliesAPIResponse = APIResponse & { messages: MessageResponse[]; }; -export type GetUnreadCountAPIResponse = APIResponse & { - channel_type: { - channel_count: number; - channel_type: string; - unread_count: number; - }[]; - channels: { - channel_id: string; - last_read: string; - unread_count: number; - }[]; - threads: { - last_read: string; - last_read_message_id: string; - parent_message_id: string; - unread_count: number; - }[]; - total_unread_count: number; - total_unread_threads_count: number; - total_unread_count_by_team?: Record; -}; - -export type ChatLevelPushPreference = - | 'all' - | 'mentions' // deprecated by the API in favor of 'direct_mentions' - | 'direct_mentions' - | 'all_mentions' - | 'none' - | 'default' - | (string & {}); - -export type CallLevelPushPreference = 'all' | 'none' | 'default' | (string & {}); - -/** Granular all/none toggle used by the chat sub-preferences. */ -export type PushPreferenceLevel = 'all' | 'none' | (string & {}); - -/** Per-mention-type chat push preferences (matches OpenAPI `ChatPreferencesInput`). */ -export type ChatPreferences = { - channel_mentions?: PushPreferenceLevel; - default_preference?: PushPreferenceLevel; - direct_mentions?: PushPreferenceLevel; - group_mentions?: PushPreferenceLevel; - here_mentions?: PushPreferenceLevel; - role_mentions?: PushPreferenceLevel; - thread_replies?: PushPreferenceLevel; -}; - -/** - * Input accepted by {@link StreamChat.setPushPreferences} (matches OpenAPI `PushPreferenceInput`). - * - * Set `channel_cid` to scope the preference to a single channel; leave it empty to - * set the user-level default. `user_id` is required for server-side auth and - * defaults to the connected user for client-side auth. - */ -export type PushPreference = { - call_level?: CallLevelPushPreference; - channel_cid?: string; - chat_level?: ChatLevelPushPreference; - chat_preferences?: ChatPreferences; - disabled_until?: string; // snooze until this time - remove_disable?: boolean; // stop snoozing (clears disabled_until) - user_id?: string; -}; - -/** Per-user push preferences returned by the API (matches OpenAPI `PushPreferencesResponse`). */ -export type PushPreferencesResponse = { - call_level?: CallLevelPushPreference; - chat_level?: ChatLevelPushPreference; - chat_preferences?: ChatPreferences; - disabled_until?: string; -}; - -/** Per-channel push preferences returned by the API (matches OpenAPI `ChannelPushPreferencesResponse`). */ -export type ChannelPushPreference = { - chat_level?: ChatLevelPushPreference; // "all", "mentions", "direct_mentions", "all_mentions", "none", "default" or other custom strings - disabled_until?: string; -}; - -export type UpsertPushPreferencesResponse = APIResponse & { - // Mapping of user id -> channel cid -> channel push preferences - user_channel_preferences: Record>; - // Mapping of user id -> user push preferences - user_preferences: Record; -}; - -export type GetUnreadCountBatchAPIResponse = APIResponse & { - counts_by_user: { [userId: string]: GetUnreadCountAPIResponse }; -}; - -export type ListChannelResponse = APIResponse & { - channel_types: Record< - string, - Omit & { - commands: CommandResponse[]; - created_at: string; - updated_at: string; - grants?: Record; - } - >; -}; - -export type ListChannelTypesAPIResponse = ListChannelResponse; - -export type ListCommandsResponse = APIResponse & { - commands: Array>; -}; - -export type MuteChannelAPIResponse = APIResponse & { - channel_mute: ChannelMute; - own_user: OwnUserResponse; - channel_mutes?: ChannelMute[]; - mute?: MuteResponse; -}; - -export type MessageResponse = MessageResponseBase & { - quoted_message?: MessageResponseBase; -}; - -export type MessageResponseBase = MessageBase & { - type: MessageLabel; - args?: string; - before_message_send_failed?: boolean; - channel?: ChannelResponse; - cid?: string; - command?: string; - command_info?: { name?: string }; - created_at?: string; - deleted_at?: string; - deleted_reply_count?: number; - i18n?: RequireAtLeastOne> & { - language: TranslationLanguages; - }; - latest_reactions?: ReactionResponse[]; - member?: ChannelMemberResponse; - mentioned_users?: UserResponse[]; - mentioned_channel?: boolean; - mentioned_here?: boolean; - mentioned_group_ids?: string[]; - mentioned_groups?: UserGroupResponse[]; - mentioned_roles?: string[]; - message_text_updated_at?: string; - moderation?: ModerationResponse; // present only with Moderation v2 - moderation_details?: ModerationDetailsResponse; // present only with Moderation v1 - own_reactions?: ReactionResponse[] | null; - pin_expires?: string | null; - pinned_at?: string | null; - pinned_by?: UserResponse | null; - poll?: PollResponse; - reaction_counts?: { [key: string]: number } | null; - reaction_groups?: { [key: string]: ReactionGroupResponse } | null; - reaction_scores?: { [key: string]: number } | null; - reminder?: ReminderResponseBase; - reply_count?: number; - shadowed?: boolean; - shared_location?: SharedLocationResponse; - status?: string; - thread_participants?: UserResponse[]; - updated_at?: string; - deleted_for_me?: boolean; -}; - -export type ReactionGroupResponse = { - count: number; - sum_scores: number; - first_reaction_at?: string; - last_reaction_at?: string; - latest_reactions_by?: ReactionGroupUserResponse[]; -}; - -export type ReactionGroupUserResponse = { - created_at: string; - user_id: string; - user?: UserResponse; -}; - -export type ModerationDetailsResponse = { - action: 'MESSAGE_RESPONSE_ACTION_BOUNCE' | (string & {}); - error_msg: string; - harms: ModerationHarmResponse[]; - original_text: string; -}; - -export type ModerationHarmResponse = { - name: string; - phrase_list_ids: number[]; -}; - -export type ModerationAction = 'bounce' | 'flag' | 'remove' | 'shadow'; - -export type ModerationResponse = { - action: ModerationAction; - original_text: string; -}; - -export type MuteResponse = { - user: UserResponse; - created_at?: string; - expires?: string; - target?: UserResponse; - updated_at?: string; -}; - export type MuteUserResponse = APIResponse & { - mute?: MuteResponse; - mutes?: Array; + mute?: UserMuteResponse; + mutes?: Array; own_user?: OwnUserResponse; non_existing_users?: string[]; }; @@ -876,74 +171,26 @@ export type UnmuteUserResponse = APIResponse & { non_existing_users?: string[]; }; -export type BlockUserAPIResponse = APIResponse & { - blocked_at: string; - blocked_by_user_id: string; - blocked_user_id: string; -}; - -export type GetBlockedUsersAPIResponse = APIResponse & { - blocks: BlockedUserDetails[]; -}; - -export type BlockedUserDetails = APIResponse & { - blocked_user: UserResponse; - blocked_user_id: string; - created_at: string; - user: UserResponse; - user_id: string; -}; - export type OwnUserBase = { channel_mutes: ChannelMute[]; devices: Device[]; - mutes: Mute[]; + mutes: UserMuteResponse[]; total_unread_count: number; unread_channels: number; unread_count: number; unread_threads: number; invisible?: boolean; - privacy_settings?: PrivacySettings; + privacy_settings?: PrivacySettingsResponse; push_preferences?: PushPreferencesResponse; roles?: string[]; total_unread_count_by_team?: Record | null; }; -export type OwnUserResponse = UserResponse & OwnUserBase; - -export type PartialUpdateChannelAPIResponse = APIResponse & { - channel: ChannelResponse; - members: ChannelMemberResponse[]; -}; - -export type PermissionAPIResponse = APIResponse & { - permission?: PermissionAPIObject; -}; - -export type PermissionsAPIResponse = APIResponse & { - permissions?: PermissionAPIObject[]; -}; - export type ReactionAPIResponse = APIResponse & { message: MessageResponse; reaction: ReactionResponse; }; -export type ReactionResponse = Reaction & { - created_at: string; - message_id: string; - updated_at: string; -}; - -export type ReadResponse = { - last_read: string; - user: UserResponse; - last_read_message_id?: string; - unread_messages?: number; - last_delivered_at?: string; - last_delivered_message_id?: string; -}; - export type SearchAPIResponse = APIResponse & { results: { message: MessageResponse; @@ -953,169 +200,20 @@ export type SearchAPIResponse = APIResponse & { results_warning?: SearchWarning | null; }; -export type RoleResponse = { - name: Role; - custom: boolean; - scopes: string[]; - created_at: string; - updated_at: string; -}; - -export type CreateRoleAPIResponse = APIResponse & { - role: RoleResponse; -}; - -export type ListRolesAPIResponse = APIResponse & { - roles: RoleResponse[]; -}; - -export type SearchRolesAPIResponse = APIResponse & { - roles: RoleResponse[]; -}; - -export type SearchRolesOptions = { - query: string; - include_global_roles?: boolean; - limit?: number; - name_gt?: string; - // If not provided, the default is search performed both in user-assignable + channel-assignable roles - role_type?: 'user' | 'channel'; -}; - -export type SearchWarning = { - channel_search_cids: string[]; - channel_search_count: number; - warning_code: number; - warning_description: string; -}; - // Thumb URL(thumb_url) is added considering video attachments as the backend will return the thumbnail in the response. export type SendFileAPIResponse = APIResponse & { file: string; thumb_url?: string }; -export type SendMessageAPIResponse = APIResponse & { - message: MessageResponse; - pending_message_metadata?: Record | null; -}; - -export type SyncResponse = APIResponse & { - events: Event[]; - inaccessible_cids?: string[]; -}; - -export type TruncateChannelAPIResponse = APIResponse & { - channel: ChannelResponse; - message?: MessageResponse; -}; - export type UpdateChannelAPIResponse = APIResponse & { channel: ChannelResponse; members: ChannelMemberResponse[]; message?: MessageResponse; }; -export type UpdateChannelResponse = APIResponse & - Omit & { - created_at: string; - updated_at: string; - }; - -export type UpdateCommandResponse = APIResponse & { - command: UpdateCommandOptions & - CreatedAtUpdatedAt & { - name: CommandVariants; - }; -}; - -export type UpdateMessageAPIResponse = APIResponse & { - message: MessageResponse; -}; - export type UsersAPIResponse = APIResponse & { users: Array; membership_deletion_task_id?: string; }; -export type UpdateUsersAPIResponse = APIResponse & { - users: { [key: string]: UserResponse }; - membership_deletion_task_id?: string; -}; - -export type UserResponse = CustomUserData & { - id: string; - anon?: boolean; - banned?: boolean; - blocked_user_ids?: string[]; - created_at?: string; - deactivated_at?: string; - deleted_at?: string; - image?: string; - language?: TranslationLanguages | ''; - last_active?: string; - name?: string; - notifications_muted?: boolean; - online?: boolean; - privacy_settings?: PrivacySettings; - push_notifications?: PushNotificationSettings; - revoke_tokens_issued_before?: string; - role?: string; - shadow_banned?: boolean; - teams?: string[]; - teams_role?: TeamsRole | null; - updated_at?: string; - username?: string; - avg_response_time?: number; -}; - -export type TeamsRole = { [team: string]: string }; - -export type PrivacySettings = { - read_receipts?: { - enabled?: boolean; - }; - typing_indicators?: { - enabled?: boolean; - }; - delivery_receipts?: { - enabled?: boolean; - }; -}; - -export type PushNotificationSettings = { - disabled?: boolean; - disabled_until?: string | null; -}; - -/** - * Option Types - */ - -export type MessageFlagsPaginationOptions = { - limit?: number; - offset?: number; -}; - -export type FlagsPaginationOptions = { - limit?: number; - offset?: number; -}; - -export type FlagReportsPaginationOptions = { - limit?: number; - offset?: number; -}; - -export type ReviewFlagReportOptions = { - review_details?: object; - user_id?: string; -}; - -export type BannedUsersPaginationOptions = Omit< - PaginationOptions, - 'id_gt' | 'id_gte' | 'id_lt' | 'id_lte' -> & { - exclude_expired_bans?: boolean; -}; - export type BanUserOptions = UnBanUserOptions & { ban_from_future_channels?: boolean; banned_by?: UserResponse; @@ -1169,21 +267,6 @@ export type ChannelOptions = { sort_values?: Record; }; -export type ChannelQueryOptions = { - client_id?: string; - connection_id?: string; - created_by?: UserResponse | null; - created_by_id?: UserResponse['id']; - data?: ChannelResponse; - hide_for_creator?: boolean; - members?: PaginationOptions; - messages?: MessagePaginationOptions; - presence?: boolean; - state?: boolean; - watch?: boolean; - watchers?: PaginationOptions; -}; - export type ChannelStateOptions = { offlineMode?: boolean; skipInitialization?: string[]; @@ -1199,77 +282,6 @@ export type ChannelStateOptions = { withResponse?: boolean; }; -export type CreateChannelOptions = { - automod?: ChannelConfigAutomod; - automod_behavior?: ChannelConfigAutomodBehavior; - automod_thresholds?: ChannelConfigAutomodThresholds; - blocklist?: string; - blocklist_behavior?: ChannelConfigAutomodBehavior; - client_id?: string; - commands?: CommandVariants[]; - connect_events?: boolean; - connection_id?: string; - custom_events?: boolean; - delivery_events?: boolean; - grants?: Record; - mark_messages_pending?: boolean; - max_message_length?: number; - message_retention?: string; - mutes?: boolean; - name?: string; - permissions?: PermissionObject[]; - polls?: boolean; - push_notifications?: boolean; - quotes?: boolean; - reactions?: boolean; - read_events?: boolean; - reminders?: boolean; - replies?: boolean; - search?: boolean; - shared_locations?: boolean; - skip_last_msg_update_for_system_msgs?: boolean; - typing_events?: boolean; - uploads?: boolean; - url_enrichment?: boolean; - user_message_reminders?: boolean; - count_messages?: boolean; - push_level?: 'all' | 'all_mentions' | 'direct_mentions' | 'mentions' | 'none'; -}; - -export type CreateCommandOptions = { - description: string; - name: CommandVariants; - args?: string; - set?: CommandVariants; -}; - -export type CustomPermissionOptions = { - action: string; - condition: object; - id: string; - name: string; - description?: string; - owner?: boolean; - same_team?: boolean; -}; - -export type DeactivateUsersOptions = { - created_by_id?: string; - mark_messages_deleted?: boolean; -}; - -export type NewMemberPayload = CustomMemberData & - Pick; - -export type Thresholds = Partial< - Record<'explicit' | 'spam' | 'toxic', Partial<{ block: number; flag: number }>> ->; - -export type BlockListOptions = { - behavior: BlocklistBehavior; - blocklist: string; -}; - export type PolicyRequest = { action: 'Deny' | 'Allow' | (string & {}); /** @@ -1293,194 +305,6 @@ export type PolicyRequest = { export type Automod = 'disabled' | 'simple' | 'AI' | (string & {}); export type AutomodBehavior = 'flag' | 'block' | 'shadow_block' | (string & {}); -export type BlocklistBehavior = AutomodBehavior; -export type Command = { - args: string; - description: string; - name: string; - set: string; - created_at?: string; - updated_at?: string; -}; - -export type UpdateChannelTypeRequest = - // these three properties are required in OpenAPI spec but omitted in some QA tests - Partial<{ - automod: Automod; - automod_behavior: AutomodBehavior; - max_message_length: number; - }> & { - allowed_flag_reasons?: string[]; - automod_thresholds?: Thresholds; - blocklist?: string; - blocklist_behavior?: BlocklistBehavior; - blocklists?: BlockListOptions[]; - commands?: CommandVariants[]; - connect_events?: boolean; - custom_events?: boolean; - delivery_events?: boolean; - grants?: Record; - mark_messages_pending?: boolean; - mutes?: boolean; - partition_size?: number; - /** - * @example 24h - */ - partition_ttl?: string | null; - permissions?: PolicyRequest[]; - polls?: boolean; - push_notifications?: boolean; - quotes?: boolean; - reactions?: boolean; - read_events?: boolean; - reminders?: boolean; - replies?: boolean; - search?: boolean; - skip_last_msg_update_for_system_msgs?: boolean; - typing_events?: boolean; - uploads?: boolean; - url_enrichment?: boolean; - count_messages?: boolean; - push_level?: 'all' | 'all_mentions' | 'direct_mentions' | 'mentions' | 'none'; - }; - -export type UpdateChannelTypeResponse = { - automod: Automod; - automod_behavior: AutomodBehavior; - commands: CommandVariants[]; - connect_events: boolean; - created_at: string; - custom_events: boolean; - delivery_events: boolean; - duration: string; - grants: Record; - mark_messages_pending: boolean; - max_message_length: number; - mutes: boolean; - name: string; - permissions: PolicyRequest[]; - polls: boolean; - push_notifications: boolean; - quotes: boolean; - reactions: boolean; - read_events: boolean; - reminders: boolean; - replies: boolean; - search: boolean; - shared_locations: boolean; - skip_last_msg_update_for_system_msgs: boolean; - typing_events: boolean; - updated_at: string; - uploads: boolean; - url_enrichment: boolean; - allowed_flag_reasons?: string[]; - automod_thresholds?: Thresholds; - blocklist?: string; - blocklist_behavior?: BlocklistBehavior; - blocklists?: BlockListOptions[]; - message_retention?: string; - partition_size?: number; - partition_ttl?: string; - count_messages?: boolean; - user_message_reminders?: boolean; - push_level?: string; -}; - -export type GetChannelTypeResponse = { - automod: Automod; - automod_behavior: AutomodBehavior; - commands: Command[]; - connect_events: boolean; - created_at: string; - custom_events: boolean; - delivery_events: boolean; - duration: string; - grants: Record; - mark_messages_pending: boolean; - max_message_length: number; - mutes: boolean; - name: string; - permissions: PolicyRequest[]; - polls: boolean; - push_notifications: boolean; - quotes: boolean; - reactions: boolean; - read_events: boolean; - reminders: boolean; - replies: boolean; - search: boolean; - shared_locations: boolean; - skip_last_msg_update_for_system_msgs: boolean; - typing_events: boolean; - updated_at: string; - uploads: boolean; - url_enrichment: boolean; - allowed_flag_reasons?: string[]; - automod_thresholds?: Thresholds; - blocklist?: string; - blocklist_behavior?: BlocklistBehavior; - blocklists?: BlockListOptions[]; - message_retention?: string; - partition_size?: number; - partition_ttl?: string; - count_messages?: boolean; - user_message_reminders?: boolean; - push_level?: string; -}; - -export type UpdateChannelOptions = Partial<{ - accept_invite: boolean; - add_members: string[]; - add_moderators: string[]; - client_id: string; - connection_id: string; - data: Omit; - demote_moderators: string[]; - invites: string[]; - message: MessageResponse; - reject_invite: boolean; - remove_members: string[]; - user: UserResponse; - user_id: string; -}>; - -export type MarkChannelsReadOptions = { - client_id?: string; - connection_id?: string; - read_by_channel?: Record; - user?: UserResponse; - user_id?: string; -}; - -export type MarkReadOptions = { - client_id?: string; - connection_id?: string; - thread_id?: string; - user?: UserResponse; - user_id?: string; -}; - -export type MarkUnreadOptions = { - client_id?: string; - connection_id?: string; - message_id?: string; - thread_id?: string; - message_timestamp?: string | Date; - user?: UserResponse; - user_id?: string; -}; - -export type DeliveredMessageConfirmation = { - cid: string; - id: string; - parent_id?: string; // todo: should we include parent_id if thread delivery receipts are not yet supported? -}; - -export type MarkDeliveredOptions = { - latest_delivered_messages: DeliveredMessageConfirmation[]; - user?: UserResponse; - user_id?: string; -}; export type MuteUserOptions = { client_id?: string; @@ -1527,48 +351,10 @@ export type PinnedMessagePaginationOptions = { pinned_at_before_or_equal?: string | Date; }; -export type QueryMembersOptions = { - // Pagination option: select members created after the date (RFC399) - created_at_after?: string; - // Pagination option: select members created after or equal the date (RFC399) - created_at_after_or_equal?: string; - // Pagination option: select members created before the date (RFC399) - created_at_before?: string; - // Pagination option: select members created before or equal the date (RFC399) - created_at_before_or_equal?: string; - // Number of members to return, default 100 - limit?: number; - // Offset (max is 1000) - offset?: number; - // Pagination option: excludes members with ID less or equal the value - user_id_gt?: string; - // Pagination option: excludes members with ID less than the value - user_id_gte?: string; - // Pagination option: excludes members with ID greater or equal the value - user_id_lt?: string; - // Pagination option: excludes members with ID greater than the value - user_id_lte?: string; -}; - -export type ReactivateUserOptions = { - created_by_id?: string; - name?: string; - restore_messages?: boolean; -}; - -export type ReactivateUsersOptions = { - created_by_id?: string; - restore_messages?: boolean; -}; - -export type SearchOptions = { - limit?: number; - next?: string; - offset?: number; - sort?: SearchMessageSort; -}; +export type GetRepliesRequest = Parameters[0]; +export type QueryMembersOptions = Partial>; -export type StreamChatOptions = AxiosRequestConfig & { +export type StreamChatOptions = { /** * Used to disable warnings that are triggered by using connectUser or connectAnonymousUser server-side. */ @@ -1599,7 +385,6 @@ export type StreamChatOptions = AxiosRequestConfig & { isLocalUnreadCountEnabled?: boolean; /** experimental feature, please contact support if you want this feature enabled for you */ enableWSFallback?: boolean; - logger?: Logger; /** * Custom notification manager service to use for the client. * If not provided, a default notification manager will be created. @@ -1625,8 +410,8 @@ export type StreamChatOptions = AxiosRequestConfig & { recoverStateOnReconnect?: boolean; warmUp?: boolean; /** - * Set the instance of StableWSConnection on chat client. Its purely for testing purpose and should - * not be used in production apps. + * Sets the instance of `StableWSConnection` on the chat client. Intended purely for testing and + * should not be used in production apps. */ wsConnection?: StableWSConnection; /** @@ -1636,19 +421,6 @@ export type StreamChatOptions = AxiosRequestConfig & { wsUrlParams?: URLSearchParams; }; -export type SyncOptions = { - /** - * This will behave as queryChannels option. - */ - watch?: boolean; - /** - * Return channels from request that user does not have access to in a separate - * field in the response called 'inaccessible_cids' instead of - * adding them as 'notification.removed_from_channel' events. - */ - with_inaccessible_cids?: boolean; -}; - export type UnBanUserOptions = { client_id?: string; connection_id?: string; @@ -1659,12 +431,6 @@ export type UnBanUserOptions = { type?: string; }; -export type UpdateCommandOptions = { - description: string; - args?: string; - set?: CommandVariants; -}; - export type UserOptions = { include_deactivated_users?: boolean; limit?: number; @@ -1672,440 +438,139 @@ export type UserOptions = { presence?: boolean; }; -/** - * Event Types - */ - -export type ConnectionChangeEvent = { - type: EventTypes; - online?: boolean; -}; - -export type Event = CustomEventData & { - type: EventTypes; - ai_message?: string; - ai_state?: AIState; - channel?: ChannelResponse; - channel_custom?: CustomChannelData; - channel_id?: string; - channel_member_count?: number; - channel_type?: string; - cid?: string; - clear_history?: boolean; - connection_id?: string; - // event creation timestamp, format Date ISO string - created_at?: string; - deleted_for_me?: boolean; - draft?: DraftResponse; - // id of the message that was marked as unread - all the following messages are considered unread. (notification.mark_unread) - first_unread_message_id?: string; - hard_delete?: boolean; - last_delivered_at?: string; - last_delivered_message_id?: string; - // creation date of a message with last_read_message_id, formatted as Date ISO string - last_read_at?: string; - last_read_message_id?: string; - live_location?: SharedLocationResponse; - mark_messages_deleted?: boolean; - me?: OwnUserResponse; - member?: ChannelMemberResponse; - message?: MessageResponse; - message_id?: string; - mode?: string; - online?: boolean; - own_capabilities?: string[]; - parent_id?: string; - poll?: PollResponse; - poll_vote?: PollVote | PollAnswer; - queriedChannels?: { - channels: ChannelAPIResponse[]; - isLatestMessageSet?: boolean; - }; - offlineReactions?: ReactionResponse[]; - reaction?: ReactionResponse; - received_at?: string | Date; - reminder?: ReminderResponse; - shadow?: boolean; - team?: string; - thread?: ThreadResponse; - // @deprecated number of all unread messages across all current user's unread channels, equals unread_count - total_unread_count?: number; - // number of all current user's channels with at least one unread message including the channel in this event - unread_channels?: number; - // number of all unread messages across all current user's unread channels - unread_count?: number; - // number of unread messages in the channel from this event (notification.mark_unread) - unread_messages?: number; - unread_thread_messages?: number; - unread_threads?: number; - user?: UserResponse; - user_id?: string; - watcher_count?: number; - channel_last_message_at?: string; - app?: Record; // TODO: further specify type - thread_id?: string; -}; - -export type UserCustomEvent = CustomEventData & { - type: string; -}; +type LocalEvent = ( + | ({ type: 'live_location_sharing.started' } & { message: MessageResponse }) + | ({ type: 'live_location_sharing.stopped' } & { + live_location?: SharedLocationResponseData; + }) + | ({ type: 'channels.queried' } & { + queriedChannels: { + channels: ChannelStateResponseFields[]; + isLatestMessageSet: boolean; + }; + }) + | ({ type: 'transport.changed' } & { mode: string }) + | ({ type: 'connection.changed' } & { online: boolean }) + | { type: 'connection.recovered' } + | ({ type: 'offline_reactions.queried' } & { + offlineReactions: ReactionResponse[]; + }) + | ({ type: 'capabilities.changed' } & { + cid: string; + own_capabilities: ChannelOwnCapability[]; + }) + | ({ type: 'message.read_locally' } & { + channel_type: string; + cid: string; + created_at: Date; + channel_id?: string; + last_read_message_id?: string; + team?: string; + user?: UserResponse; + }) +) & { received_at?: Date }; -export type EventHandler = (event: Event) => void; +export type Event = WSEvent | LocalEvent | keyof CustomEventTypes; +export type EventType = Event['type'] | 'all'; -export type EventTypes = 'all' | keyof typeof EVENT_MAP | keyof CustomEventTypes; +export type EventHandler = (event: Extract) => void; /** * Filter Types */ -export type AscDesc = 1 | -1; +export type ReactionFilters = NonNullable; -export type MessageFlagsFiltersOptions = { - channel_cid?: string; - is_reviewed?: boolean; - team?: string; +export type QueryReactionsRequestWithId = Parameters[0]; + +export type ChannelFilters = NonNullable; + +export type QueryPollsOptions = Pager; + +export type VotesFiltersOptions = { + is_answer?: boolean; + option_id?: string; user_id?: string; }; -export type MessageFlagsFilters = QueryFilters< +export type QueryVotesOptions = Pager; + +export type QueryPollsFilters = QueryFilters< { - channel_cid?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - team?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; + id?: + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; } & { user_id?: + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; + } & { + is_closed?: + | RequireOnlyOne, '$eq'>> + | PrimitiveFilter; + } & { + max_votes_allowed?: | RequireOnlyOne< - Pick, '$eq' | '$in'> + Pick< + QueryFilter, + '$eq' | '$gt' | '$lt' | '$gte' | '$lte' + > > - | PrimitiveFilter; + | PrimitiveFilter; } & { - [Key in keyof Omit< - MessageFlagsFiltersOptions, - 'channel_cid' | 'user_id' | 'is_reviewed' - >]: - | RequireOnlyOne> - | PrimitiveFilter; - } ->; - -export type FlagsFiltersOptions = { - channel_cid?: string; - message_id?: string; - message_user_id?: string; - reporter_id?: string; - team?: string; - user_id?: string; -}; - -export type FlagsFilters = QueryFilters< - { - user_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; + allow_answers?: + | RequireOnlyOne, '$eq'>> + | PrimitiveFilter; } & { - message_id?: + allow_user_suggested_options?: | RequireOnlyOne< - Pick, '$eq' | '$in'> + Pick, '$eq'> > - | PrimitiveFilter; + | PrimitiveFilter; + } & { + voting_visibility?: + | RequireOnlyOne, '$eq'>> + | PrimitiveFilter; } & { - message_user_id?: + created_at?: | RequireOnlyOne< - Pick, '$eq' | '$in'> + Pick< + QueryFilter, + '$eq' | '$gt' | '$lt' | '$gte' | '$lte' + > > - | PrimitiveFilter; + | PrimitiveFilter; } & { - channel_cid?: + created_by_id?: | RequireOnlyOne< - Pick, '$eq' | '$in'> + Pick, '$eq' | '$in'> > - | PrimitiveFilter; + | PrimitiveFilter; } & { - reporter_id?: + updated_at?: | RequireOnlyOne< - Pick, '$eq' | '$in'> + Pick< + QueryFilter, + '$eq' | '$gt' | '$lt' | '$gte' | '$lte' + > > - | PrimitiveFilter; + | PrimitiveFilter; } & { - team?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; + name?: + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; } >; -export type FlagReportsFiltersOptions = { - channel_cid?: string; - is_reviewed?: boolean; - message_id?: string; - message_user_id?: string; - report_id?: string; - review_result?: string; - reviewed_by?: string; - team?: string; - user_id?: string; -}; - -export type FlagReportsFilters = QueryFilters< +export type QueryVotesFilters = QueryFilters< { - report_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; + id?: + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; } & { - review_result?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - reviewed_by?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - user_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - message_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - message_user_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - channel_cid?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - team?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - [Key in keyof Omit< - FlagReportsFiltersOptions, - 'report_id' | 'user_id' | 'message_id' | 'review_result' | 'reviewed_by' - >]: - | RequireOnlyOne> - | PrimitiveFilter; - } ->; - -export type BannedUsersFilterOptions = { - banned_by_id?: string; - channel_cid?: string; - created_at?: string; - reason?: string; - user_id?: string; -}; - -export type BannedUsersFilters = QueryFilters< - { - channel_cid?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - reason?: - | RequireOnlyOne< - { - $autocomplete?: BannedUsersFilterOptions['reason']; - } & QueryFilter - > - | PrimitiveFilter; - } & { - [Key in keyof Omit]: - | RequireOnlyOne> - | PrimitiveFilter; - } ->; - -export type ReactionFilters = QueryFilters< - { - user_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - type?: - | RequireOnlyOne, '$eq'>> - | PrimitiveFilter; - } & { - created_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } ->; - -export type ChannelFilters = QueryFilters< - ContainsOperator> & { - app_banned?: 'only' | 'excluded'; - has_unread?: boolean; - archived?: boolean; - 'member.user.name'?: - | RequireOnlyOne<{ - $autocomplete?: string; - $eq?: string; - }> - | string; - - members?: - | RequireOnlyOne, '$in'>> - | RequireOnlyOne, '$eq'>> - | PrimitiveFilter; - name?: - | RequireOnlyOne< - { - $autocomplete?: string; - } & QueryFilter - > - | PrimitiveFilter; - pinned?: boolean; - last_updated?: - | RequireOnlyOne, '$eq' | '$gt' | '$gte' | '$lt' | '$lte'>> - | PrimitiveFilter; - } & { - [Key in keyof Omit]: - | RequireOnlyOne> - | PrimitiveFilter; - } ->; - -export type DraftFilters = { - channel_cid?: - | RequireOnlyOne, '$in' | '$eq'>> - | PrimitiveFilter; - created_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - parent_id?: - | RequireOnlyOne< - Pick, '$in' | '$eq' | '$exists'> - > - | PrimitiveFilter; -}; - -export type QueryPollsParams = { - filter?: QueryPollsFilters; - options?: QueryPollsOptions; - sort?: PollSort; -}; - -export type QueryPollsOptions = Pager; - -export type VotesFiltersOptions = { - is_answer?: boolean; - option_id?: string; - user_id?: string; -}; - -export type QueryVotesOptions = Pager; - -export type QueryPollsFilters = QueryFilters< - { - id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - user_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - is_closed?: - | RequireOnlyOne, '$eq'>> - | PrimitiveFilter; - } & { - max_votes_allowed?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - allow_answers?: - | RequireOnlyOne, '$eq'>> - | PrimitiveFilter; - } & { - allow_user_suggested_options?: - | RequireOnlyOne< - Pick, '$eq'> - > - | PrimitiveFilter; - } & { - voting_visibility?: - | RequireOnlyOne, '$eq'>> - | PrimitiveFilter; - } & { - created_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - created_by_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - updated_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - name?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } ->; - -export type QueryVotesFilters = QueryFilters< - { - id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - option_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; + option_id?: + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; } & { is_answer?: | RequireOnlyOne, '$eq'>> @@ -2118,82 +583,35 @@ export type QueryVotesFilters = QueryFilters< created_at?: | RequireOnlyOne< Pick< - QueryFilter, + QueryFilter, '$eq' | '$gt' | '$lt' | '$gte' | '$lte' > > - | PrimitiveFilter; + | PrimitiveFilter; } & { created_by_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; + | RequireOnlyOne< + Pick, '$eq' | '$in'> + > + | PrimitiveFilter; } & { updated_at?: | RequireOnlyOne< Pick< - QueryFilter, + QueryFilter, '$eq' | '$gt' | '$lt' | '$gte' | '$lte' > > - | PrimitiveFilter; - } ->; - -export type ContainsOperator = { - [Key in keyof CustomType]?: CustomType[Key] extends (infer ContainType)[] - ? - | RequireOnlyOne< - { - $contains?: ContainType extends object - ? PrimitiveFilter> - : PrimitiveFilter; - } & QueryFilter[]> - > - | PrimitiveFilter[]> - : RequireOnlyOne> | PrimitiveFilter; -}; - -export type MessageFilters = QueryFilters< - ContainsOperator & { - 'attachments.type'?: - | RequireOnlyOne<{ - $eq: PrimitiveFilter; - $in: PrimitiveFilter[]; - }> - | PrimitiveFilter; - 'mentioned_users.id'?: RequireOnlyOne<{ - $contains: PrimitiveFilter; - }>; - text?: - | RequireOnlyOne< - { - $autocomplete?: MessageResponse['text']; - $q?: MessageResponse['text']; - } & QueryFilter - > - | PrimitiveFilter; - 'user.id'?: - | RequireOnlyOne< - { - $autocomplete?: UserResponse['id']; - } & QueryFilter - > - | PrimitiveFilter; - } & { - [Key in keyof Omit]?: - | RequireOnlyOne> - | PrimitiveFilter; + | PrimitiveFilter; } >; -export type MessageOptions = { - include_thread_participants?: boolean; -}; +export type MessageFilters = NonNullable; export type PrimitiveFilter = ObjectType | null; export type QueryFilter = - NonNullable extends string | number | boolean + NonNullable extends string | number | boolean | Date ? { $eq?: PrimitiveFilter; $exists?: boolean; @@ -2209,246 +627,38 @@ export type QueryFilter = $in?: PrimitiveFilter>[]; }; -export type QueryFilters = { - [Key in keyof Operators]?: Operators[Key]; -} & QueryLogicalOperators; - -export type QueryLogicalOperators = { - $and?: ArrayOneOrMore>; - $nor?: ArrayOneOrMore>; - $or?: ArrayTwoOrMore>; -}; - -export type UserFilters = QueryFilters< - ContainsOperator & { - id?: - | RequireOnlyOne< - { $autocomplete?: UserResponse['id'] } & QueryFilter - > - | PrimitiveFilter; - name?: - | RequireOnlyOne< - { $autocomplete?: UserResponse['name'] } & QueryFilter - > - | PrimitiveFilter; - notifications_muted?: - | RequireOnlyOne<{ - $eq?: PrimitiveFilter; - }> - | boolean; - teams?: - | RequireOnlyOne<{ - $contains?: PrimitiveFilter; - $eq?: PrimitiveFilter; - $in?: PrimitiveFilter; - }> - | PrimitiveFilter; - username?: - | RequireOnlyOne< - { $autocomplete?: UserResponse['username'] } & QueryFilter< - UserResponse['username'] - > - > - | PrimitiveFilter; - } & { - [Key in keyof Omit< - UserResponse, - 'id' | 'name' | 'teams' | 'username' | keyof CustomUserData - >]?: - | RequireOnlyOne> - | PrimitiveFilter; - } ->; - -export type InviteStatus = 'pending' | 'accepted' | 'rejected' | 'member'; +export type UserFilters = QueryUsersPayload['filter_conditions']; -// https://getstream.io/chat/docs/react/channel_member/#update-channel-members -export type MemberFilters = QueryFilters< - { - banned?: { $eq?: ChannelMemberResponse['banned'] } | ChannelMemberResponse['banned']; - channel_role?: - | { $eq?: ChannelMemberResponse['channel_role'] } - | ChannelMemberResponse['channel_role']; - cid?: { $eq?: ChannelResponse['cid'] } | ChannelResponse['cid']; - created_at?: - | { - $eq?: ChannelMemberResponse['created_at']; - $gt?: ChannelMemberResponse['created_at']; - $gte?: ChannelMemberResponse['created_at']; - $lt?: ChannelMemberResponse['created_at']; - $lte?: ChannelMemberResponse['created_at']; - } - | ChannelMemberResponse['created_at']; - id?: - | RequireOnlyOne<{ - $eq?: UserResponse['id']; - $in?: UserResponse['id'][]; - }> - | UserResponse['id']; - invite?: { $eq?: ChannelMemberResponse['status'] } | ChannelMemberResponse['status']; - is_moderator?: - | RequireOnlyOne<{ $eq?: ChannelMemberResponse['is_moderator'] }> - | ChannelMemberResponse['is_moderator']; - joined?: { $eq?: boolean } | boolean; - last_active?: - | { - $eq?: UserResponse['last_active']; - $gt?: UserResponse['last_active']; - $gte?: UserResponse['last_active']; - $lt?: UserResponse['last_active']; - $lte?: UserResponse['last_active']; - } - | UserResponse['last_active']; - name?: - | RequireOnlyOne<{ - $autocomplete?: NonNullable['name']; - $eq?: NonNullable['name']; - $in?: NonNullable['name'][]; - $q?: NonNullable['name']; - }> - | PrimitiveFilter['name']>; - notifications_muted?: - | RequireOnlyOne<{ $eq?: ChannelMemberResponse['notifications_muted'] }> - | ChannelMemberResponse['notifications_muted']; - updated_at?: - | { - $eq?: ChannelMemberResponse['updated_at']; - $gt?: ChannelMemberResponse['updated_at']; - $gte?: ChannelMemberResponse['updated_at']; - $lt?: ChannelMemberResponse['updated_at']; - $lte?: ChannelMemberResponse['updated_at']; - } - | ChannelMemberResponse['updated_at']; - 'user.email'?: - | RequireOnlyOne<{ - $autocomplete?: string; - $eq?: string; - $in?: string; - }> - | string; - user_id?: - | RequireOnlyOne<{ - $eq?: ChannelMemberResponse['user_id']; - $in?: ChannelMemberResponse['user_id'][]; - }> - | PrimitiveFilter; - } & { - [Key in keyof ContainsOperator]?: - | RequireOnlyOne[Key]>> - | PrimitiveFilter[Key]>; - } ->; +export type MemberFilters = QueryMembersPayload['filter_conditions']; /** * Sort Types */ -export type BannedUsersSort = BannedUsersSortBase | Array; - -export type BannedUsersSortBase = { created_at?: AscDesc }; - -export type ReactionSort = ReactionSortBase | Array; - -export type ReactionSortBase = Sort & { - created_at?: AscDesc; -}; - -export type ChannelSort = ChannelSortBase | Array; - -export type ChannelSortBase = Sort & { - created_at?: AscDesc; - has_unread?: AscDesc; - last_message_at?: AscDesc; - last_updated?: AscDesc; - member_count?: AscDesc; - pinned_at?: AscDesc; - unread_count?: AscDesc; - updated_at?: AscDesc; -}; - -export type PinnedMessagesSort = PinnedMessagesSortBase | Array; -export type PinnedMessagesSortBase = { pinned_at?: AscDesc }; - -export type Sort = { - [P in keyof T]?: AscDesc; -}; - -export type UserSort = Sort | Array>; +export type BannedUsersSort = SortParamRequest[]; -export type MemberSort = - | Sort< - Pick & { - user_id?: string; - } - > - | Array< - Sort< - Pick & { - user_id?: string; - } - > - >; - -export type SearchMessageSortBase = Sort & { - attachments?: AscDesc; - 'attachments.type'?: AscDesc; - created_at?: AscDesc; - id?: AscDesc; - 'mentioned_users.id'?: AscDesc; - parent_id?: AscDesc; - pinned?: AscDesc; - relevance?: AscDesc; - reply_count?: AscDesc; - text?: AscDesc; - type?: AscDesc; - updated_at?: AscDesc; - 'user.id'?: AscDesc; -}; +export type ReactionSort = SortParamRequest[]; -export type SearchMessageSort = SearchMessageSortBase | Array; +export type ChannelSort = SortParamRequest[]; -export type QuerySort = BannedUsersSort | ChannelSort | SearchMessageSort | UserSort; +export type PinnedMessagesSort = SortParamRequest[]; -export type DraftSortBase = { - created_at?: AscDesc; -}; +export type UserSort = SortParamRequest[]; -export type DraftSort = DraftSortBase | Array; +export type MemberSort = SortParamRequest[]; -export type PollSort = PollSortBase | Array; +export type SearchMessageSort = SortParamRequest[]; -export type PollSortBase = { - created_at?: AscDesc; - id?: AscDesc; - is_closed?: AscDesc; - name?: AscDesc; - updated_at?: AscDesc; -}; +export type DraftSort = SortParamRequest[]; -export type VoteSort = VoteSortBase | Array; +export type PollSort = SortParamRequest[]; -export type VoteSortBase = { - created_at?: AscDesc; - id?: AscDesc; - is_closed?: AscDesc; - name?: AscDesc; - updated_at?: AscDesc; -}; +export type VoteSort = SortParamRequest[]; /** * Base Types */ -export type Action = { - name?: string; - style?: string; - text?: string; - type?: string; - value?: string; -}; - -export type AnonUserType = {}; - export type APNConfig = { auth_key?: string; auth_type?: string; @@ -2470,114 +680,12 @@ export type AsyncModerationOptions = { timeout_ms?: number; }; -export type AppSettings = { - allowed_flag_reasons?: string[]; - apn_config?: { - auth_key?: string; - auth_type?: string; - bundle_id?: string; - development?: boolean; - host?: string; - key_id?: string; - notification_template?: string; - p12_cert?: string; - team_id?: string; - }; - async_moderation_config?: AsyncModerationOptions; - async_url_enrich_enabled?: boolean; - auto_translation_enabled?: boolean; - before_message_send_hook_url?: string; - before_message_send_hook_attempt_timeout_ms?: number; - cdn_expiration_seconds?: number; - custom_action_handler_url?: string; - disable_auth_checks?: boolean; - disable_permissions_checks?: boolean; - enforce_unique_usernames?: 'no' | 'app' | 'team'; - event_hooks?: Array | null; - explicit_event_hooks_deletion?: boolean; - // all possible file mime types are https://www.iana.org/assignments/media-types/media-types.xhtml - file_upload_config?: FileUploadConfig; - firebase_config?: { - apn_template?: string; - credentials_json?: string; - data_template?: string; - notification_template?: string; - server_key?: string; - }; - grants?: Record; - huawei_config?: { - id: string; - secret: string; - }; - image_moderation_enabled?: boolean; - image_upload_config?: FileUploadConfig; - migrate_permissions_to_v2?: boolean; - multi_tenant_enabled?: boolean; - permission_version?: 'v1' | 'v2'; - push_config?: { - offline_only?: boolean; - version?: string; - }; - reminders_interval?: number; - revoke_tokens_issued_before?: string | null; - sns_key?: string; - sns_secret?: string; - sns_topic_arn?: string; - sqs_key?: string; - sqs_secret?: string; - sqs_url?: string; - user_response_time_enabled?: boolean; - video_provider?: string; - webhook_events?: Array | null; - webhook_url?: string; - xiaomi_config?: { - package_name: string; - secret: string; - }; -}; - -export type Attachment = CustomAttachmentData & { - actions?: Action[]; - asset_url?: string; - author_icon?: string; - author_link?: string; - author_name?: string; - color?: string; - duration?: number; - fallback?: string; - fields?: Field[]; - file_size?: number | string; - footer?: string; - footer_icon?: string; - giphy?: GiphyData; - image_url?: string; - latitude?: number; - longitude?: number; - mime_type?: string; - og_scrape_url?: string; - original_height?: number; - original_width?: number; - pretext?: string; - text?: string; - thumb_url?: string; - title?: string; - title_link?: string; - type?: string; - waveform_data?: Array; -}; +// export type Attachment = ReplacePropertyTypes< +// Attachment, +// { custom: CustomAttachmentData & { file_size?: number; mime_type?: string } } +// >; -export type OGAttachment = { - og_scrape_url: string; - asset_url?: string; // og:video | og:audio - author_link?: string; // og:site - author_name?: string; // og:site_name - image_url?: string; // og:image - text?: string; // og:description - thumb_url?: string; // og:image - title?: string; // og:title - title_link?: string; // og:url - type?: string | 'video' | 'audio' | 'image'; -}; +export type OGAttachment = RequireLiteral; export type BlockList = { name: string; @@ -2590,93 +698,12 @@ export type BlockList = { is_plural_check_enabled?: boolean; }; -export type ChannelConfig = ChannelConfigFields & - CreatedAtUpdatedAt & { - commands?: CommandVariants[]; - }; - -export type ChannelConfigAutomod = Automod; - -export type ChannelConfigAutomodBehavior = AutomodBehavior; - -export type ChannelConfigAutomodThresholds = null | Thresholds; - -export type ChannelConfigFields = { - reminders: boolean; - automod?: ChannelConfigAutomod; - automod_behavior?: ChannelConfigAutomodBehavior; - automod_thresholds?: ChannelConfigAutomodThresholds; - blocklist_behavior?: ChannelConfigAutomodBehavior; - connect_events?: boolean; - custom_events?: boolean; - delivery_events?: boolean; - mark_messages_pending?: boolean; - max_message_length?: number; - message_retention?: string; - mutes?: boolean; - name?: string; - polls?: boolean; - push_notifications?: boolean; - quotes?: boolean; - reactions?: boolean; - read_events?: boolean; - replies?: boolean; - search?: boolean; - shared_locations?: boolean; - skip_last_msg_update_for_system_msgs?: boolean; - count_messages?: boolean; - typing_events?: boolean; - uploads?: boolean; - url_enrichment?: boolean; - user_message_reminders?: boolean; // Feature flag for user message reminders - push_level?: 'all' | 'all_mentions' | 'direct_mentions' | 'mentions' | 'none' | ''; -}; - -export type ChannelConfigWithInfo = ChannelConfigFields & - CreatedAtUpdatedAt & { - commands?: CommandResponse[]; - }; - -export type ChannelData = CustomChannelData & - Partial<{ - blocked: boolean; - created_by: UserResponse | null; - created_by_id: UserResponse['id']; - members: string[] | Array; - blocklist_behavior: AutomodBehavior; - automod: Automod; - filter_tags: string[]; - team?: string; - }>; - -export type ChannelMute = { - user: UserResponse; - channel?: ChannelResponse; - created_at?: string; - expires?: string; - updated_at?: string; -}; - -export type ChannelRole = { - custom?: boolean; - name?: string; - owner?: boolean; - resource?: string; - same_team?: boolean; -}; - -export type CheckPushInput = { - apn_template?: string; - client_id?: string; - connection_id?: string; - firebase_data_template?: string; - firebase_template?: string; - message_id?: string; - user?: UserResponse; - user_id?: string; -}; +export type ChannelData = ReplacePropertyTypes< + ChannelInput, + { custom: CustomChannelData } +>; -export type PushProvider = 'apn' | 'firebase' | 'huawei' | 'xiaomi'; +export type PushProvider = CreateDeviceRequest['push_provider']; export type PushProviderConfig = PushProviderCommon & PushProviderID & @@ -2741,19 +768,7 @@ export type CommandVariants = export type Configs = Record; -export type ConnectionOpen = { - connection_id: string; - cid?: string; - created_at?: string; - received_at?: string; - me?: OwnUserResponse; - type?: string; -}; - -export type CreatedAtUpdatedAt = { - created_at: string; - updated_at: string; -}; +export type ConnectionOpen = EventPayload<'health.check'>; export type Device = DeviceFields & { provider?: string; @@ -2773,164 +788,6 @@ export type DeviceFields = BaseDeviceFields & { disabled_reason?: string; }; -export type EndpointName = - | 'Connect' - | 'LongPoll' - | 'DeleteFile' - | 'DeleteImage' - | 'DeleteMessage' - | 'DeleteUser' - | 'DeleteUsers' - | 'DeactivateUser' - | 'ExportUser' - | 'DeleteReaction' - | 'UpdateChannel' - | 'UpdateChannelPartial' - | 'UpdateMessage' - | 'UpdateMessagePartial' - | 'GetMessage' - | 'GetManyMessages' - | 'UpdateUsers' - | 'UpdateUsersPartial' - | 'CreateGuest' - | 'GetOrCreateChannel' - | 'StopWatchingChannel' - | 'QueryChannels' - | 'Search' - | 'QueryUsers' - | 'QueryMembers' - | 'QueryBannedUsers' - | 'QueryFlags' - | 'QueryMessageFlags' - | 'GetReactions' - | 'GetReplies' - | 'GetPinnedMessages' - | 'Ban' - | 'Unban' - | 'MuteUser' - | 'MuteChannel' - | 'UnmuteChannel' - | 'UnmuteUser' - | 'RunMessageAction' - | 'SendEvent' - | 'SendUserCustomEvent' - | 'MarkRead' - | 'MarkChannelsRead' - | 'SendMessage' - | 'ImportChannelMessages' - | 'UploadFile' - | 'UploadImage' - | 'UpdateApp' - | 'GetApp' - | 'CreateDevice' - | 'DeleteDevice' - | 'SendReaction' - | 'Flag' - | 'Unflag' - | 'Unblock' - | 'QueryFlagReports' - | 'FlagReportReview' - | 'CreateChannelType' - | 'DeleteChannel' - | 'DeleteChannels' - | 'DBDeleteChannelType' - | 'GetChannelType' - | 'ListChannelTypes' - | 'ListDevices' - | 'TruncateChannel' - | 'UpdateChannelType' - | 'CheckPush' - | 'PrivateSubmitModeration' - | 'ReactivateUser' - | 'HideChannel' - | 'ShowChannel' - | 'CreatePermission' - | 'UpdatePermission' - | 'GetPermission' - | 'DeletePermission' - | 'ListPermissions' - | 'CreateRole' - | 'DeleteRole' - | 'ListRoles' - | 'ListCustomRoles' - | 'Sync' - | 'TranslateMessage' - | 'CreateCommand' - | 'GetCommand' - | 'UpdateCommand' - | 'DeleteCommand' - | 'ListCommands' - | 'CreateBlockList' - | 'UpdateBlockList' - | 'GetBlockList' - | 'ListBlockLists' - | 'DeleteBlockList' - | 'ExportChannels' - | 'GetExportChannelsStatus' - | 'CheckSQS' - | 'GetRateLimits' - | 'CreateSegment' - | 'GetSegment' - | 'QuerySegments' - | 'UpdateSegment' - | 'DeleteSegment' - | 'CreateCampaign' - | 'GetCampaign' - | 'ListCampaigns' - | 'UpdateCampaign' - | 'DeleteCampaign' - | 'ScheduleCampaign' - | 'StopCampaign' - | 'ResumeCampaign' - | 'TestCampaign' - | 'GetOG' - | 'GetTask' - | 'ExportUsers' - | 'CreateImport' - | 'CreateImportURL' - | 'GetImport' - | 'ListImports' - | 'UpsertPushProvider' - | 'DeletePushProvider' - | 'ListPushProviders' - | 'CreatePoll'; - -export type ExportChannelRequest = ( - | { - id: string; - type: string; - } - | { - cid: string; - } -) & { messages_since?: Date; messages_until?: Date }; - -export type ExportChannelOptions = { - clear_deleted_message_text?: boolean; - export_users?: boolean; - include_soft_deleted_channels?: boolean; - include_truncated_messages?: boolean; - version?: string; -}; - -export type ExportUsersRequest = { - user_ids: string[]; -}; - -export type Field = { - short?: boolean; - title?: string; - value?: string; -}; - -export type FileUploadConfig = { - allowed_file_extensions?: string[] | null; - allowed_mime_types?: string[] | null; - blocked_file_extensions?: string[] | null; - blocked_mime_types?: string[] | null; - size_limit?: number | null; -}; - export type FirebaseConfig = { apn_template?: string; credentials_json?: string; @@ -2940,27 +797,6 @@ export type FirebaseConfig = { server_key?: string; }; -type GiphyVersionInfo = { - height: string; - url: string; - width: string; - frames?: string; - size?: string; -}; - -export type GiphyVersions = - | 'original' - | 'fixed_height' - | 'fixed_height_still' - | 'fixed_height_downsampled' - | 'fixed_width' - | 'fixed_width_still' - | 'fixed_width_downsampled'; - -export type GiphyData = { - [key in GiphyVersions]: GiphyVersionInfo; -}; - export type HuaweiConfig = { enabled?: boolean; id?: string; @@ -2973,134 +809,15 @@ export type XiaomiConfig = { secret?: string; }; -export type LiteralStringForUnion = string & {}; +export type MessageLabel = + | 'deleted' + | 'ephemeral' + | 'error' + | 'regular' + | 'reply' + | 'system'; -export type LogLevel = 'info' | 'error' | 'warn'; - -export type Logger = ( - logLevel: LogLevel, - message: string, - extraData?: Record, -) => void; - -export type Message = Partial< - MessageBase & { - mentioned_users: string[]; - shared_location?: StaticLocationPayload | LiveLocationPayload; - mentioned_channel?: boolean; - mentioned_here?: boolean; - mentioned_group_ids?: string[]; - mentioned_roles?: string[]; - } ->; - -export type MessageBase = CustomMessageData & { - id: string; - attachments?: Attachment[]; - html?: string; - mml?: string; - parent_id?: string; - pin_expires?: string | null; - pinned?: boolean; - pinned_at?: string | null; - poll_id?: string; - quoted_message_id?: string; - restricted_visibility?: string[]; - show_in_channel?: boolean; - silent?: boolean; - text?: string; - type?: MessageLabel; - user?: UserResponse | null; - user_id?: string; -}; - -export type MessageLabel = - | 'deleted' - | 'ephemeral' - | 'error' - | 'regular' - | 'reply' - | 'system'; - -export type SendMessageOptions = { - force_moderation?: boolean; - // @deprecated use `pending` instead - is_pending_message?: boolean; - keep_channel_hidden?: boolean; - pending?: boolean; - pending_message_metadata?: Record; - skip_enrich_url?: boolean; - skip_push?: boolean; -}; - -export type UpdateMessageOptions = { - skip_enrich_url?: boolean; - skip_push?: boolean; -}; - -export type SendReactionOptions = { - enforce_unique?: boolean; - skip_push?: boolean; -}; - -export type GetMessageOptions = { - show_deleted_message?: boolean; -}; - -export type Mute = { - created_at: string; - target: UserResponse; - updated_at: string; - user: UserResponse; -}; - -export type PartialUpdateChannelFields = Partial & { - config_overrides?: Partial; -}; - -export type PartialUpdateChannel = { - set?: PartialUpdateChannelFields; - unset?: Array; -}; - -export type PartialUpdateMember = { - set?: ChannelMemberUpdates; - unset?: Array; -}; - -export type PartialUserUpdate = { - id: string; - set?: Partial; - unset?: Array; -}; - -export type MessageUpdatableFields = Omit< - MessageResponse, - 'cid' | 'created_at' | 'updated_at' | 'deleted_at' | 'user' | 'user_id' ->; - -export type PartialMessageUpdate = { - set?: Partial; - unset?: Array; -}; - -export type PendingMessageResponse = { - message: MessageResponse; - pending_message_metadata?: Record; -}; - -export type PermissionAPIObject = { - action?: string; - condition?: object; - custom?: boolean; - description?: string; - id?: string; - level?: string; - name?: string; - owner?: boolean; - same_team?: boolean; - tags?: string[]; -}; +export type SendMessageOptions = Omit; export type PermissionObject = { action?: 'Deny' | 'Allow'; @@ -3122,143 +839,10 @@ export type Policy = { updated_at?: string; }; -export type RateLimitsInfo = { - limit: number; - remaining: number; - reset: number; -}; - -export type RateLimitsMap = Record; - -export type Reaction = CustomReactionData & { - type: string; - message_id?: string; - score?: number; - user?: UserResponse | null; - user_id?: string; - emoji_code?: string; -}; - -export type Resource = - | 'AddLinks' - | 'BanUser' - | 'CreateChannel' - | 'CreateMessage' - | 'CreateReaction' - | 'DeleteAttachment' - | 'DeleteChannel' - | 'DeleteMessage' - | 'DeleteReaction' - | 'EditUser' - | 'MuteUser' - | 'ReadChannel' - | 'RunMessageAction' - | 'UpdateChannel' - | 'UpdateChannelMembers' - | 'UpdateMessage' - | 'UpdateUser' - | 'UploadAttachment'; - -export type SearchPayload = Omit & { - client_id?: string; - connection_id?: string; - filter_conditions?: ChannelFilters; - message_filter_conditions?: MessageFilters; - message_options?: MessageOptions; - query?: string; - sort?: Array<{ - direction: AscDesc; - field: keyof SearchMessageSortBase; - }>; -}; - -export type TestPushDataInput = { - apnTemplate?: string; - firebaseDataTemplate?: string; - firebaseTemplate?: string; - messageID?: string; - pushProviderName?: string; - pushProviderType?: PushProvider; - skipDevices?: boolean; -}; - -export type TestSQSDataInput = { - sqs_key?: string; - sqs_secret?: string; - sqs_url?: string; -}; - -export type TestSNSDataInput = { - sns_key?: string; - sns_secret?: string; - sns_topic_arn?: string; -}; - export type TokenOrProvider = null | string | TokenProvider | undefined; export type TokenProvider = () => Promise; -export type TranslationLanguages = - | 'af' - | 'am' - | 'ar' - | 'az' - | 'bg' - | 'bn' - | 'bs' - | 'cs' - | 'da' - | 'de' - | 'el' - | 'en' - | 'es' - | 'es-MX' - | 'et' - | 'fa' - | 'fa-AF' - | 'fi' - | 'fr' - | 'fr-CA' - | 'ha' - | 'he' - | 'hi' - | 'hr' - | 'hu' - | 'id' - | 'it' - | 'ja' - | 'ka' - | 'ko' - | 'lt' - | 'lv' - | 'ms' - | 'nl' - | 'no' - | 'pl' - | 'ps' - | 'pt' - | 'ro' - | 'ru' - | 'sk' - | 'sl' - | 'so' - | 'sq' - | 'sr' - | 'sv' - | 'sw' - | 'ta' - | 'th' - | 'tl' - | 'tr' - | 'uk' - | 'ur' - | 'vi' - | 'zh' - | 'zh-TW' - | (string & {}); - -export type TypingStartEvent = Event; - export type ReservedUpdatedMessageFields = keyof typeof RESERVED_UPDATED_MESSAGE_FIELDS; export type UpdatedMessage = Omit< @@ -3273,270 +857,22 @@ export type UpdatedMessage = Omit< type?: MessageLabel; }; -/** - * @description type alias for UserResponse - */ -export type User = UserResponse; - export type TaskResponse = { task_id: string; }; -export type DeleteChannelsResponse = { - result: Record; -} & Partial; - -export type DeleteType = 'soft' | 'hard' | 'pruning'; - -/* - DeleteUserOptions specifies a collection of one or more `user_ids` to be deleted. - - `user`: - - soft: marks user as deleted and retains all user data - - pruning: marks user as deleted and nullifies user information - - hard: deletes user completely - this requires hard option for messages and conversation as well - `conversations`: - - soft: marks all conversation channels as deleted (same effect as Delete Channels with 'hard' option disabled) - - hard: deletes channel and all its data completely including messages (same effect as Delete Channels with 'hard' option enabled) - `messages`: - - soft: marks all user messages as deleted without removing any related message data - - pruning: marks all user messages as deleted, nullifies message information and removes some message data such as reactions and flags - - hard: deletes messages completely with all related information - `new_channel_owner_id`: any channels owned by the hard-deleted user will be transferred to this user ID - */ -export type DeleteUserOptions = { - conversations?: Exclude; - messages?: DeleteType; - new_channel_owner_id?: string; - user?: DeleteType; -}; - -export type SegmentType = 'channel' | 'user'; - -export type SegmentData = { - all_sender_channels?: boolean; - all_users?: boolean; - description?: string; - filter?: {}; - name?: string; -}; - -export type SegmentResponse = { - created_at: string; - deleted_at: string; - id: string; - locked: boolean; - size: number; - task_id: string; - type: SegmentType; - updated_at: string; -} & SegmentData; - -export type UpdateSegmentData = { - name: string; -} & SegmentData; - -export type SegmentTargetsResponse = { - created_at: string; - segment_id: string; - target_id: string; -}; - -export type SortParam = { - field: string; - direction?: AscDesc; -}; - export type Pager = { limit?: number; next?: string; prev?: string; }; -export type QuerySegmentsOptions = Pager; - -export type QuerySegmentTargetsFilter = { - target_id?: { - $eq?: string; - $gte?: string; - $in?: string[]; - $lte?: string; - }; -}; -export type QuerySegmentTargetsOptions = Pick; - -export type GetCampaignOptions = { - users?: { limit?: number; next?: string; prev?: string }; -}; - -export type CampaignSort = { - field: string; - direction?: number; -}[]; - -export type CampaignQueryOptions = { - limit?: number; - next?: string; - prev?: string; - sort?: CampaignSort; - user_limit?: number; -}; - -export type SegmentQueryOptions = CampaignQueryOptions; - -// TODO: add better typing -export type CampaignFilters = {}; - -export type CampaignData = { - channel_template?: { - type: string; - custom?: {}; - id?: string; - members?: string[]; - members_template?: Array<{ - user_id: string; - channel_role?: string; - custom?: Record; - }>; - team?: string; - }; - create_channels?: boolean; - deleted_at?: string; - description?: string; - id?: string | null; - message_template?: { - text: string; - attachments?: Attachment[]; - custom?: {}; - poll_id?: string; - }; - name?: string; - segment_ids?: string[]; - sender_id?: string; - sender_mode?: 'exclude' | 'include' | null; - sender_visibility?: 'hidden' | 'archived' | null; - show_channels?: boolean; - skip_push?: boolean; - skip_webhook?: boolean; - user_ids?: string[]; -}; - -export type CampaignStats = { - progress?: number; - stats_channels_created?: number; - stats_completed_at?: string; - stats_messages_sent?: number; - stats_started_at?: string; - stats_users_read?: number; - stats_users_sent?: number; -}; -export type CampaignResponse = { - created_at: string; - id: string; - segments: SegmentResponse[]; - sender: UserResponse; - stats: CampaignStats; - status: 'draft' | 'scheduled' | 'in_progress' | 'completed' | 'stopped'; - updated_at: string; - users: UserResponse[]; - scheduled_for?: string; -} & CampaignData; - -export type DeleteCampaignOptions = {}; - -export type TaskStatus = { - created_at: string; - status: string; - task_id: string; - updated_at: string; - error?: { - description: string; - type: string; - }; - result?: UR; -}; - -export type TruncateOptions = { - hard_delete?: boolean; - message?: Message; - skip_push?: boolean; - truncated_at?: Date; - user?: UserResponse; - user_id?: string; -}; - -export type CreateImportURLResponse = { - path: string; - upload_url: string; -}; - -export type CreateImportResponse = { - import_task: ImportTask; -}; - -export type GetImportResponse = { - import_task: ImportTask; -}; - -export type CreateImportOptions = { - mode: 'insert' | 'upsert'; -}; - -export type ListImportsPaginationOptions = { - limit?: number; - offset?: number; -}; - -export type ListImportsResponse = { - import_tasks: ImportTask[]; -}; - -export type ImportTaskHistory = { - created_at: string; - next_state: string; - prev_state: string; -}; - -export type ImportTask = { - created_at: string; - history: ImportTaskHistory[]; - id: string; - path: string; - state: string; - updated_at: string; - result?: UR; - size?: number; -}; - export type MessageSetType = 'latest' | 'current' | 'new'; -export type PushProviderUpsertResponse = { - push_provider: PushProvider; -}; - -export type PushProviderListResponse = { - push_providers: PushProvider[]; -}; - -type ErrorResponseDetails = { - code: number; - messages: string[]; -}; - -export type APIErrorResponse = { - duration: string; - message: string; - more_info: string; - StatusCode: number; - code?: number; - details?: ErrorResponseDetails; -}; - -export class ErrorFromResponse extends Error { - public code: number | null; - public status: number; - public response: AxiosResponse; - public name = 'ErrorFromResponse'; +export class StreamAPIError extends Error { + public code: number | undefined; + public status: number | undefined; + public response: AxiosResponse | undefined; constructor( message: string, @@ -3545,9 +881,15 @@ export class ErrorFromResponse extends Error { status, response, }: { - code: ErrorFromResponse['code']; - response: ErrorFromResponse['response']; - status: ErrorFromResponse['status']; + /** + * Stream error code (`APIError.code`) + */ + code: StreamAPIError['code']; + /** + * HTTP status code + */ + status: StreamAPIError['status']; + response: StreamAPIError['response']; }, ) { super(message); @@ -3556,24 +898,35 @@ export class ErrorFromResponse extends Error { this.status = status; } - // Vitest helper (serialized errors are too large to read) - // https://github.com/vitest-dev/vitest/blob/v3.1.3/packages/utils/src/error.ts#L60-L62 - toJSON() { - const extra = [ - ['status', this.status], - ['code', this.code], - ] as const; + get name() { + let tags = StreamAPIError.withMetadata({ status: this.status, code: this.code }); + + if (tags.length) { + tags = `(${tags})`; + } + + return `StreamAPIError${tags}`; + } + + static withMetadata(metadata: Record) { + const extra = Object.entries(metadata); const joinable = []; for (const [key, value] of extra) { - if (typeof value !== 'undefined' && value !== null) { + if (typeof value !== 'undefined' && value !== null && `${value}`.length) { joinable.push(`${key}: ${value}`); } } + return `${joinable.join(', ')}`; + } + + // Vitest helper (serialized errors are too large to read) + // https://github.com/vitest-dev/vitest/blob/v3.1.3/packages/utils/src/error.ts#L60-L62 + toJSON() { return { - message: `(${joinable.join(', ')}) - ${this.message}`, + message: this.message, stack: this.stack, name: this.name, code: this.code, @@ -3582,50 +935,7 @@ export class ErrorFromResponse extends Error { } } -export type QueryPollsResponse = { - polls: PollResponse[]; - next?: string; -}; - -export type CreatePollAPIResponse = { - poll: PollResponse; -}; - -export type GetPollAPIResponse = { - poll: PollResponse; -}; - -export type UpdatePollAPIResponse = { - poll: PollResponse; -}; - -export type PollResponse = CustomPollData & - PollEnrichData & { - created_at: string; - created_by: UserResponse | null; - created_by_id: string; - enforce_unique_vote: boolean; - id: string; - max_votes_allowed: number; - name: string; - options: PollOption[]; - updated_at: string; - allow_answers?: boolean; - allow_user_suggested_options?: boolean; - description?: string; - is_closed?: boolean; - voting_visibility?: VotingVisibility; - }; - -export type PollOption = { - created_at: string; - id: string; - poll_id: string; - text: string; - updated_at: string; - vote_count: number; - votes?: PollVote[]; -}; +export type PollResponse_old = PollResponseData & PollEnrichData; export enum VotingVisibility { anonymous = 'anonymous', @@ -3634,1663 +944,214 @@ export enum VotingVisibility { export type PollEnrichData = { answers_count: number; - latest_answers: PollAnswer[]; // not updated with WS events, ordered DESC by created_at, seems like updated_at cannot be different from created_at - latest_votes_by_option: Record; // not updated with WS events; always null in anonymous polls + latest_answers: PollVoteResponseData[]; // not updated with WS events, ordered DESC by created_at, seems like updated_at cannot be different from created_at + latest_votes_by_option: Record; // not updated with WS events; always null in anonymous polls vote_count: number; vote_counts_by_option: Record; - own_votes?: (PollVote | PollAnswer)[]; // not updated with WS events -}; - -export type PollData = CustomPollData & { - id: string; - name: string; - allow_answers?: boolean; - allow_user_suggested_options?: boolean; - description?: string; - enforce_unique_vote?: boolean; - is_closed?: boolean; - max_votes_allowed?: number; - options?: PollOptionData[]; - user_id?: string; - voting_visibility?: VotingVisibility; + own_votes?: PollVoteResponseData[]; // not updated with WS events }; -export type CreatePollData = Partial & Pick; - export type PartialPollUpdate = { - set?: Partial; - unset?: Array; + set?: Partial; + unset?: Array; }; -export type PollOptionData = CustomPollOptionData & { - text: string; - id?: string; +export type PollOptionData = UpdatePollOptionRequest & { position?: number; }; -export type PartialPollOptionUpdate = { - set?: Partial; - unset?: Array; -}; - -export type PollVoteData = { - answer_text?: string; - is_answer?: boolean; - option_id?: string; -}; - -export type PollPaginationOptions = { - limit?: number; - next?: string; -}; +export type MessageDeletionStrategy = 'soft' | 'hard' | 'pruning'; +// @deprecated use type MessageDeletionStrategy instead -export type CreatePollOptionAPIResponse = { - poll_option: PollOptionResponse; +export type ModerationFlagOptions = { + custom?: Record; + moderation_payload?: ModerationPayload; + user_id?: string; }; -export type GetPollOptionAPIResponse = CreatePollOptionAPIResponse; -export type UpdatePollOptionAPIResponse = CreatePollOptionAPIResponse; +export type AIState = + | 'AI_STATE_ERROR' + | 'AI_STATE_CHECKING_SOURCES' + | 'AI_STATE_THINKING' + | 'AI_STATE_GENERATING' + | (string & {}); -export type PollOptionResponse = CustomPollData & { - created_at: string; - id: string; - poll_id: string; - position: number; - text: string; - updated_at: string; - vote_count: number; - votes?: PollVote[]; +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; }; -export type PollVote = { - created_at: string; - id: string; - poll_id: string; - updated_at: string; - option_id?: string; - user?: UserResponse; - user_id?: string; +/** + * An identifier containing information about the downstream SDK using stream-chat. It + * is used to resolve the user agent. + */ +export type SdkIdentifier = { + name: 'react' | 'react-native' | 'expo' | 'angular'; + version: string; }; -export type PollAnswer = Exclude & { - answer_text: string; - is_answer: boolean; // this is absolutely redundant prop as answer_text indicates that a vote is an answer -}; - -export type PollVotesAPIResponse = { - votes: (PollVote | PollAnswer)[]; - next?: string; -}; - -export type PollAnswersAPIResponse = { - votes: PollAnswer[]; // todo: should be changes to answers? - next?: string; -}; - -export type CastVoteAPIResponse = { - vote: PollVote | PollAnswer; -}; - -export type QueryMessageHistoryFilters = QueryFilters< - { - message_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - user_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - created_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } ->; - -export type QueryMessageHistorySort = - | QueryMessageHistorySortBase - | Array; - -export type QueryMessageHistorySortBase = { - message_updated_at?: AscDesc; - message_updated_by_id?: AscDesc; -}; - -export type QueryMessageHistoryOptions = Pager; - -export type MessageHistoryEntry = { - message_id: string; - message_updated_at: string; - attachments?: Attachment[]; - message_updated_by_id?: string; - text?: string; -}; - -export type QueryMessageHistoryResponse = { - message_history: MessageHistoryEntry[]; - next?: string; - prev?: string; -}; - -// Moderation v2 -export type ModerationPayload = { - created_at: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - custom?: Record; - images?: string[]; - texts?: string[]; - videos?: string[]; -}; - -export type ModV2ReviewStatus = 'complete' | 'flagged' | 'partial'; - -export type ModerationFlag = { - created_at: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - custom: Record; - entity_creator_id: string; - entity_id: string; - entity_type: string; - id: string; - reason: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - result: Record[]; - review_queue_item_id: string; - updated_at: string; - user: UserResponse; - moderation_payload?: ModerationPayload; - moderation_payload_hash?: string; -}; - -export type ReviewQueueItem = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - actions_taken: any[]; - appealed_by: string; - assigned_to: string; - completed_at: string; - config_key: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - context: any[]; - created_at: string; - created_by: string; - entity_id: string; - entity_type: string; - entity_creator_id?: string; - flags: ModerationFlag[]; - has_image: boolean; - has_text: boolean; - has_video: boolean; - id: string; - moderation_payload: ModerationPayload; - moderation_payload_hash: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - options: any; - recommended_action: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - results: any; - reviewed_at: string; - status: string; - updated_at: string; - latest_moderator_action?: string; -}; - -export type CustomCheckFlag = { - type: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - custom?: Record[]; - labels?: string[]; - reason?: string; -}; - -export type MessageDeletionStrategy = 'soft' | 'hard' | 'pruning'; -// @deprecated use type MessageDeletionStrategy instead -export type DeleteMessagesOptions = MessageDeletionStrategy; - -export type DeleteMessageOptions = { - deleteForMe?: boolean; - hardDelete?: boolean; -}; - -export type SubmitActionOptions = { - appeal_id?: string; - ban?: { - target_user_id?: string; - shadow?: boolean; - reason?: string; - channel_ban_only?: boolean; - channel_cid?: string; - ip_ban?: boolean; - delete_messages?: MessageDeletionStrategy; - delete_reactions?: boolean; - timeout?: number; - }; - block?: { - reason?: string; - }; - custom?: { - id: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - options?: Record; - }; - delete_activity?: { - hard_delete?: boolean; - reason?: string; - entity_id?: string; - entity_type?: string; - }; - delete_comment?: { - hard_delete?: boolean; - reason?: string; - entity_id?: string; - entity_type?: string; - }; - delete_message?: { - hard_delete?: boolean; - reason?: string; - entity_id?: string; - entity_type?: string; - }; - delete_reaction?: { - hard_delete?: boolean; - reason?: string; - entity_id?: string; - entity_type?: string; - }; - delete_user?: { - hard_delete?: boolean; - reason?: string; - mark_messages_deleted?: boolean; - delete_conversation_channels?: boolean; - delete_feeds_content?: boolean; - entity_id?: string; - entity_type?: string; - }; - end_call?: Record; - escalate?: { - reason: string; - category: string; - priority: string; - }; - flag?: { - entity_type: string; - entity_id: string; - entity_creator_id?: string; - reason?: string; - moderation_payload?: ModerationPayload; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - custom?: Record; - }; - kick_user?: Record; - mark_reviewed?: { - disable_marking_content_as_reviewed?: boolean; - content_to_mark_as_reviewed_limit?: number; - decision_reason?: string; - }; - reject_appeal?: { - decision_reason: string; - }; - restore?: { - decision_reason?: string; - }; - shadow_block?: { - reason?: string; - }; - unban?: { - channel_cid?: string; - decision_reason?: string; - }; - unblock?: { - decision_reason?: string; - }; - user_id?: string; -}; - -export type SubmitActionResponse = APIResponse & { - item?: ReviewQueueItem; -}; - -export type GetUserModerationReportResponse = { - user: UserResponse; - user_blocks?: Array<{ - blocked_at: string; - blocked_by_user_id: string; - blocked_user_id: string; - }>; - user_mutes?: Mute[]; -}; - -export type CheckResponse = APIResponse & { - status: string; - task_id?: string; - recommended_action: string; - item?: ReviewQueueItem; -}; - -export type CustomCheckResponse = APIResponse & { - id: string; - item: ReviewQueueItem; - status: string; -}; - -export type QueryModerationConfigsFilters = QueryFilters< - { - key?: string; - } & { - created_at?: PrimitiveFilter; - } & { - updated_at?: PrimitiveFilter; - } & { - team?: string; - } ->; - -export type ReviewQueueFilters = QueryFilters< - { - assigned_to?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - completed_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - config_key?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - entity_type?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - created_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - entity_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - entity_creator_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - reviewed?: boolean; - } & { - reviewed_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - status?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - updated_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - has_image?: boolean; - } & { - has_text?: boolean; - } & { - has_video?: boolean; - } & { - has_media?: boolean; - } & { - language?: RequireOnlyOne<{ - $contains?: string; - $eq?: string; - $in?: string[]; - }>; - } & { - teams?: - | RequireOnlyOne<{ - $contains?: PrimitiveFilter; - $eq?: PrimitiveFilter; - $in?: PrimitiveFilter; - }> - | PrimitiveFilter; - } & { - user_report_reason?: RequireOnlyOne<{ - $eq?: string; - }>; - } & { - recommended_action?: RequireOnlyOne<{ - $eq?: string; - $in?: string[]; - }>; - } & { - flagged_user_id?: RequireOnlyOne<{ - $eq?: string; - }>; - } & { - category?: RequireOnlyOne<{ - $eq?: string; - }>; - } & { - label?: RequireOnlyOne<{ - $eq?: string; - $in?: string[]; - }>; - } & { - reporter_type?: RequireOnlyOne<{ - $eq?: 'automod' | 'user' | 'moderator' | 'admin' | 'velocity_filter'; - }>; - } & { - reporter_id?: RequireOnlyOne<{ - $eq?: string; - $in?: string[]; - }>; - } & { - date_range?: RequireOnlyOne<{ - $eq?: string; // Format: "date1_date2" - }>; - } & { - latest_moderator_action?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - flags_count?: RequireOnlyOne<{ - $eq?: number; - }>; - } & { - ai_text_severity?: RequireOnlyOne<{ - $eq?: string; - }>; - } & { - channel_cid?: RequireOnlyOne<{ - $eq?: string; - }>; - } ->; - -export type ReviewQueueSort = - | Sort> - | Array>>; - -export type QueryModerationConfigsSort = Array>; - -export type ReviewQueuePaginationOptions = Pager; - -export type FilterConfigResponse = { - llm_labels: string[]; - ai_text_labels?: string[]; -}; - -export type ModerationActionConfig = { - entity_type: string; - order: number; - action: string; - icon: string; - description: string; - custom?: Record; -}; - -export type ReviewQueueResponse = { - items: ReviewQueueItem[]; - action_config?: Record; - filter_config?: FilterConfigResponse; - stats?: Record; - next?: string; - prev?: string; -}; - -export type ModerationConfig = { - key: string; - ai_image_config?: AIImageConfig; - ai_text_config?: AITextConfig; - ai_video_config?: AIVideoConfig; - automod_platform_circumvention_config?: AutomodPlatformCircumventionConfig; - automod_semantic_filters_config?: AutomodSemanticFiltersConfig; - automod_toxicity_config?: AutomodToxicityConfig; - block_list_config?: BlockListConfig; - llm_config?: LLMConfig; - team?: string; -}; - -export type ModerationConfigResponse = ModerationConfig & { - created_at: string; - updated_at: string; -}; - -export type GetConfigResponse = { - config: ModerationConfigResponse; -}; - -export type QueryConfigsResponse = { - configs: ModerationConfigResponse[]; - next?: string; - prev?: string; -}; - -export type UpsertConfigResponse = { - config: ModerationConfigResponse; -}; - -// Moderation Rule Builder Types -export type ModerationRule = { - id: string; - name: string; - description: string; - config_keys: string[]; - team: string; - rule: RuleBuilderRule; - enabled: boolean; - created_at: string; - updated_at: string; -}; - -export type ModerationRuleRequest = { - name: string; - description: string; - config_keys: string[]; - team: string; - rule: RuleBuilderRule; - enabled: boolean; -}; - -export type RuleBuilderRule = { - id: string; - rule_type: 'user' | 'content'; - conditions?: RuleBuilderCondition[]; - logic?: 'AND' | 'OR'; - groups?: RuleBuilderConditionGroup[]; - action: RuleBuilderAction; - cooldown_period?: string; -}; - -export type RuleBuilderCondition = { - type: string; - confidence?: number; - text_rule_params?: TextRuleParameters; - image_rule_params?: ImageRuleParameters; - video_rule_params?: VideoRuleParameters; - user_rule_params?: UserRuleParameters; - content_count_rule_params?: ContentCountRuleParameters; - text_content_params?: TextContentParameters; - image_content_params?: ImageContentParameters; - video_content_params?: VideoContentParameters; - user_created_within_params?: UserCreatedWithinParameters; - user_custom_property_params?: UserCustomPropertyParameters; -}; - -export type RuleBuilderConditionGroup = { - logic: 'AND' | 'OR'; - conditions: RuleBuilderCondition[]; -}; - -export type RuleBuilderAction = { - type: string; - ban_options?: BanOptions; - flag_user_options?: FlagUserOptions; -}; - -export type TextRuleParameters = { - threshold: number; - time_window: string; - harm_labels?: string[]; - llm_harm_labels?: Record; - contains_url?: boolean; - severity?: string; - blocklist_match?: string[]; -}; - -export type ImageRuleParameters = { - threshold: number; - time_window: string; - harm_labels: string[]; -}; - -export type VideoRuleParameters = { - threshold: number; - time_window: string; - harm_labels: string[]; -}; - -export type UserRuleParameters = { - max_age: string; -}; - -export type ContentCountRuleParameters = { - threshold: number; - time_window: string; -}; - -export type TextContentParameters = { - harm_labels?: string[]; - llm_harm_labels?: Record; - contains_url?: boolean; - severity?: string; - blocklist_match?: string[]; -}; - -export type ImageContentParameters = { - harm_labels: string[]; -}; - -export type VideoContentParameters = { - harm_labels: string[]; -}; - -export type UserCreatedWithinParameters = { - max_age: string; -}; - -export type UserCustomPropertyParameters = { - property_key: string; - operator: string; - expected_value: string; -}; - -export type BanOptions = { - duration: number; - reason: string; - shadow_ban: boolean; - ip_ban: boolean; -}; - -export type FlagUserOptions = { - reason: string; -}; - -export type QueryModerationRulesFilters = QueryFilters<{ - name?: string; - team?: string; - enabled?: boolean; - rule_type?: string; - created_at?: PrimitiveFilter; - updated_at?: PrimitiveFilter; -}>; - -export type QueryModerationRulesSort = Array< - Sort<'name' | 'enabled' | 'team' | 'created_at' | 'updated_at'> ->; - -export type QueryModerationRulesResponse = { - rules: ModerationRule[]; - default_llm_labels: Record; - next?: string; - prev?: string; -}; - -export type UpsertModerationRuleResponse = { - rule: ModerationRule; -}; - -export type ModerationFlagOptions = { - custom?: Record; - moderation_payload?: ModerationPayload; - user_id?: string; -}; - -export type ModerationMuteOptions = { - timeout?: number; - user_id?: string; -}; -export type GetUserModerationReportOptions = { - create_user_if_not_exists?: boolean; - include_user_blocks?: boolean; - include_user_mutes?: boolean; -}; - -export type AIState = - | 'AI_STATE_ERROR' - | 'AI_STATE_CHECKING_SOURCES' - | 'AI_STATE_THINKING' - | 'AI_STATE_GENERATING' - | (string & {}); - -export type ModerationActionType = - | 'flag' - | 'shadow' - | 'remove' - | 'bounce' - | 'bounce_flag' - | 'bounce_remove'; - -export type ModerationSeverity = 'low' | 'medium' | 'high' | 'critical'; - -export type AutomodRule = { - action: ModerationActionType; - label: string; - threshold: number; -}; - -export type BlockListRule = { - action: ModerationActionType; - name?: string; -}; - -export type BlockListConfig = { - enabled: boolean; - rules: BlockListRule[]; - async?: boolean; -}; - -export type LLMConfig = { - rules: LLMRule[]; - severity_descriptions?: Record; - app_context?: string; -}; - -export type LLMRule = { - label: string; - description: string; - action: ModerationActionType; - severity_rules?: LLMSeverityRule[]; -}; - -export type LLMSeverityRule = { - severity: ModerationSeverity; - action: ModerationActionType; -}; - -export type AutomodToxicityConfig = { - enabled: boolean; - rules: AutomodRule[]; - async?: boolean; -}; - -export type AutomodPlatformCircumventionConfig = { - enabled: boolean; - rules: AutomodRule[]; - async?: boolean; -}; - -export type AutomodSemanticFiltersRule = { - action: ModerationActionType; - name: string; - threshold: number; -}; - -export type AutomodSemanticFiltersConfig = { - enabled: boolean; - rules: AutomodSemanticFiltersRule[]; - async?: boolean; -}; - -export type AITextSeverityRule = { - action: ModerationActionType; - severity: ModerationSeverity; -}; - -export type AITextRule = { - label: string; - action?: ModerationActionType; - severity_rules?: AITextSeverityRule[]; -}; - -export type AITextConfig = { - enabled: boolean; - rules: AITextRule[]; - async?: boolean; - profile?: string; - severity_rules?: AITextSeverityRule[]; // Deprecated: use rules instead -}; - -export type AIImageRule = { - action: ModerationActionType; - label: string; - min_confidence?: number; -}; - -export type AIImageConfig = { - enabled: boolean; - rules: AIImageRule[]; - async?: boolean; -}; - -export type AIVideoRule = { - action: ModerationActionType; - label: string; - min_confidence?: number; -}; - -export type AIVideoConfig = { - enabled: boolean; - rules: AIVideoRule[]; - async?: boolean; -}; - -export type VelocityFilterConfigRule = { - action: 'flag' | 'shadow' | 'remove' | 'ban'; - ban_duration?: number; - cascading_action?: 'flag' | 'shadow' | 'remove' | 'ban'; - cascading_threshold?: number; - check_message_context?: boolean; - fast_spam_threshold?: number; - fast_spam_ttl?: number; - ip_ban?: boolean; - shadow_ban?: boolean; - slow_spam_ban_duration?: number; - slow_spam_threshold?: number; - slow_spam_ttl?: number; -}; - -export type VelocityFilterConfig = { - cascading_actions: boolean; - enabled: boolean; - first_message_only: boolean; - rules: VelocityFilterConfigRule[]; - async?: boolean; -}; - -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. - */ -export type SdkIdentifier = { - name: 'react' | 'react-native' | 'expo' | 'angular'; - version: string; -}; - -/** - * An identifier containing information about the downstream device using stream-chat, if - * available. Is used by the react-native SDKs to enrich the user agent further. - */ -export type DeviceIdentifier = { os: string; model?: string }; - -/** - * An identifier containing information about the downstream application integrating - * stream-chat, if available. `name` is reported as `app` and `version` as `app_version` - * in the user agent. Distinct from the SDK ({@link SdkIdentifier}) and device - * ({@link DeviceIdentifier}) identifiers. - */ -export type AppIdentifier = { name: string; version?: string }; - -export type DraftResponse = { - channel_cid: string; - created_at: string; - message: DraftMessage; - channel?: ChannelResponse; - parent_id?: string; - parent_message?: MessageResponseBase; - quoted_message?: MessageResponseBase; -}; - -export type CreateDraftResponse = APIResponse & { - draft: DraftResponse; -}; - -export type GetDraftResponse = APIResponse & { - draft: DraftResponse; -}; - -export type QueryDraftsResponse = APIResponse & { - drafts: DraftResponse[]; -} & Omit; - -export type DraftMessagePayload = PartializeKeys< - Omit, - 'id' -> & { - user_id?: string; -}; - -export type DraftMessage = { - id: string; - text: string; - attachments?: Attachment[]; - custom?: {}; - html?: string; - mentioned_users?: string[]; - mentioned_channel?: boolean; - mentioned_here?: boolean; - mentioned_group_ids?: string[]; - mentioned_groups?: UserGroupResponse[]; - mentioned_roles?: string[]; - mml?: string; - parent_id?: string; - poll_id?: string; - quoted_message_id?: string; - shared_location?: StaticLocationPayload | LiveLocationPayload; // todo: live-location verify if possible - show_in_channel?: boolean; - silent?: boolean; - type?: MessageLabel; -}; - -export type ActiveLiveLocationsAPIResponse = APIResponse & { - active_live_locations: SharedLiveLocationResponse[]; -}; - -export type SharedLocationResponse = { - channel_cid: string; - created_at: string; - created_by_device_id: string; - end_at?: string; - latitude: number; - longitude: number; - message_id: string; - updated_at: string; - user_id: string; -}; - -export type SharedStaticLocationResponse = { - channel_cid: string; - created_at: string; - created_by_device_id: string; - latitude: number; - longitude: number; - message_id: string; - updated_at: string; - user_id: string; -}; - -export type SharedLiveLocationResponse = { - channel_cid: string; - created_at: string; - created_by_device_id: string; - end_at: string; - latitude: number; - longitude: number; - message_id: string; - updated_at: string; - user_id: string; -}; - -export type UpdateLocationPayload = { - message_id: string; - created_by_device_id?: string; - end_at?: string; - latitude?: number; - longitude?: number; - user?: { id: string }; - user_id?: string; -}; - -export type StaticLocationPayload = { - created_by_device_id: string; - latitude: number; - longitude: number; - message_id: string; -}; - -export type LiveLocationPayload = { - created_by_device_id: string; - end_at: string; - latitude: number; - longitude: number; - message_id: string; -}; - -export type ThreadSort = ThreadSortBase | Array; - -export type ThreadSortBase = { - active_participant_count?: AscDesc; - created_at?: AscDesc; - last_message_at?: AscDesc; - parent_message_id?: AscDesc; - participant_count?: AscDesc; - reply_count?: AscDesc; - updated_at?: AscDesc; -}; - -export type ThreadFilters = QueryFilters< - { - channel_cid?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; - } & { - parent_message_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - created_by_user_id?: - | RequireOnlyOne< - Pick, '$eq' | '$in'> - > - | PrimitiveFilter; - } & { - created_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - updated_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } & { - last_message_at?: - | RequireOnlyOne< - Pick< - QueryFilter, - '$eq' | '$gt' | '$lt' | '$gte' | '$lte' - > - > - | PrimitiveFilter; - } ->; +/** + * An identifier containing information about the downstream device using stream-chat, if + * available. Is used by the react-native SDKs to enrich the user agent further. + */ +export type DeviceIdentifier = { os: string; model?: string }; -export type ReminderResponseBase = { - channel_cid: string; - created_at: string; - message_id: string; - updated_at: string; - user_id: string; - remind_at?: string; -}; +/** + * An identifier containing information about the downstream application integrating + * stream-chat, if available. `name` is reported as `app` and `version` as `app_version` + * in the user agent. Distinct from the SDK ({@link SdkIdentifier}) and device + * ({@link DeviceIdentifier}) identifiers. + */ +export type AppIdentifier = { name: string; version?: string }; -export type ReminderResponse = ReminderResponseBase & { - user: UserResponse; - message: MessageResponse; - channel?: ChannelResponse; -}; +export type DraftMessage = DraftPayloadResponse & + Partial< + Pick< + MessageResponse, + | 'shared_location' + | 'mentioned_channel' + | 'mentioned_group_ids' + | 'mentioned_groups' + | 'mentioned_here' + | 'mentioned_roles' + > + >; -export type ReminderAPIResponse = APIResponse & { - reminder: ReminderResponse; -}; +export type SharedLiveLocationResponse = RequireLiteral< + SharedLocationResponseData, + 'end_at' +>; -export type CreateReminderOptions = { - messageId: string; - remind_at?: string | null; - user_id?: string; -}; +export type LiveLocationPayload = RequireLiteral; + +export type ThreadSort = SortParamRequest[]; -export type UpdateReminderOptions = CreateReminderOptions; +export type ThreadFilters = NonNullable; + +export type CreateReminderOptions = Parameters[0]; export type ReminderFilters = QueryFilters<{ channel_cid?: | RequireOnlyOne< - Pick, '$eq' | '$in'> + Pick, '$eq' | '$in'> > - | PrimitiveFilter; + | PrimitiveFilter; created_at?: | RequireOnlyOne< Pick< - QueryFilter, + QueryFilter, '$eq' | '$gt' | '$lt' | '$gte' | '$lte' > > - | PrimitiveFilter; + | PrimitiveFilter; message_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; remind_at?: | RequireOnlyOne< Pick< - QueryFilter, + QueryFilter, '$exists' | '$eq' | '$gt' | '$lt' | '$gte' | '$lte' > > - | PrimitiveFilter; + | PrimitiveFilter; user_id?: - | RequireOnlyOne, '$eq' | '$in'>> - | PrimitiveFilter; -}>; - -export type ReminderSort = - | Sort< - Pick< - ReminderResponseBase, - 'channel_cid' | 'created_at' | 'remind_at' | 'updated_at' - > - > - | Array< - Sort< - Pick< - ReminderResponseBase, - 'channel_cid' | 'created_at' | 'remind_at' | 'updated_at' - > - > - >; - -export type QueryRemindersOptions = Pager & { - filter?: ReminderFilters; - sort?: ReminderSort; -}; - -export type QueryRemindersResponse = { - reminders: ReminderResponse[]; - prev?: string; - next?: string; -}; - -export type UserGroupMemberResponse = { - group_id: string; - user_id: string; - is_admin: boolean; - created_at: string; -}; - -export type UserGroupResponse = { - id: string; - name: string; - created_at: string; - updated_at: string; - description?: string; - team_id?: string; - members?: UserGroupMemberResponse[]; - created_by?: string; -}; - -export type CreateUserGroupOptions = { - /** Human-readable user group name */ - name: string; - /** Optional user group description shown to members */ - description?: string; - /** Optional custom user group ID. If omitted, the backend generates one */ - id?: string; - /** Optional list of user IDs to add as members when the group is created */ - member_ids?: string[]; - /** Optional team ID that scopes the user group to a specific team */ - team_id?: string; -}; - -export type CreateUserGroupResponse = APIResponse & { - user_group: UserGroupResponse; -}; - -export type GetUserGroupOptions = { - team_id?: string; -}; - -export type GetUserGroupResponse = APIResponse & { - user_group: UserGroupResponse; -}; - -export type QueryUserGroupsOptions = { - limit?: number; - id_gt?: string; - created_at_gt?: string; - team_id?: string; -}; - -export type QueryUserGroupsResponse = APIResponse & { - user_groups: UserGroupResponse[]; -}; - -export type SearchUserGroupsOptions = { - query: string; - limit?: number; - id_gt?: string; - name_gt?: string; - team_id?: string; -}; - -export type SearchUserGroupsResponse = APIResponse & { - user_groups: UserGroupResponse[]; -}; - -export type UpdateUserGroupOptions = { - description?: string; - name?: string; - team_id?: string; -}; - -export type UpdateUserGroupResponse = APIResponse & { - user_group: UserGroupResponse; -}; - -export type DeleteUserGroupOptions = { - team_id?: string; -}; - -export type AddUserGroupMembersOptions = { - member_ids: string[]; - as_admin?: boolean; - team_id?: string; -}; - -export type AddUserGroupMembersResponse = APIResponse & { - user_group: UserGroupResponse; -}; - -export type RemoveUserGroupMembersOptions = { - member_ids: string[]; - team_id?: string; -}; - -export type RemoveUserGroupMembersResponse = APIResponse & { - user_group: UserGroupResponse; -}; - -export type HookType = 'webhook' | 'sqs' | 'sns' | 'pending_message'; - -export type EventHook = { - id?: string; - hook_type?: HookType; - enabled?: boolean; - product?: Product | 'all'; // optional, default is 'all' - event_types?: Array; - webhook_url?: string; - sqs_queue_url?: string; - sqs_region?: string; - sqs_auth_type?: string; - sqs_key?: string; - sqs_secret?: string; - sqs_role_arn?: string; - sns_topic_arn?: string; - sns_region?: string; - sns_auth_type?: string; - sns_key?: string; - sns_secret?: string; - sns_role_arn?: string; - should_send_custom_events?: boolean; - - // pending message config - timeout_ms?: number; - callback?: { - mode: 'CALLBACK_MODE_NONE' | 'CALLBACK_MODE_REST' | 'CALLBACK_MODE_TWIRP'; - }; - - delete?: boolean; - created_at?: string; - updated_at?: string; -}; - -export type BatchUpdateOperation = - | 'addMembers' - | 'removeMembers' - | 'inviteMembers' - | 'assignRoles' - | 'addModerators' - | 'demoteModerators' - | 'hide' - | 'show' - | 'archive' - | 'unarchive' - | 'updateData'; - -export type BatchChannelDataUpdate = { - frozen?: boolean; - disabled?: boolean; - custom?: Record; - team?: string; - config_overrides?: Record; - auto_translation_enabled?: boolean; - auto_translation_language?: string; -}; - -export type UpdateChannelsBatchOptions = { - operation: BatchUpdateOperation; - filter: UpdateChannelsBatchFilters; - members?: string[] | Array; - data?: BatchChannelDataUpdate; -}; - -export type UpdateChannelsBatchFilters = QueryFilters<{ - cids?: - | RequireOnlyOne, '$in' | '$eq'>> - | PrimitiveFilter; - types?: - | RequireOnlyOne, '$in' | '$eq'>> - | PrimitiveFilter; + | RequireOnlyOne, '$eq' | '$in'>> + | PrimitiveFilter; }>; -export type UpdateChannelsBatchResponse = { - result: Record; -} & Partial; - -/** - * Predefined Filter Types - */ - -export type PredefinedFilterOperation = 'QueryChannels'; - -export type PredefinedFilterSortParam = { - /** - * Field name to sort by. - * - * This may be a literal field name such as `created_at`, or a placeholder - * template such as `{{sort_field}}` that will be interpolated server-side. - */ - field: string; - /** - * Sort direction. `1` means ascending and `-1` means descending. - * - * The backend defaults this to `1` when omitted. - */ - direction?: AscDesc; - /** - * Optional server-side hint describing how the sort field value should be - * interpreted. - * - * This is mainly relevant for predefined-filter sort templates and is not - * part of the regular `queryChannels()` sort shape. Omitting it uses the - * backend default string behavior. Known backend values include: - * - * - `number`: cast custom-field values to numeric before sorting - * - `boolean`: cast custom-field values to boolean before sorting - * - * Other values are backend-defined. In most cases this should be omitted - * unless you are sorting by a custom field whose stored JSON value is not - * string-like. - */ - type?: string; -}; - -/** - * Stored predefined filter definition as returned by the server. - * - * `F` represents the raw filter template shape. It defaults to a generic record - * because predefined filters are server-managed templates and may include - * placeholders or app-specific structures. - */ -export type PredefinedFilter< - F extends Record = Record, -> = { - /** - * Unique predefined filter name within the app. - */ - name: string; - /** - * Operation this predefined filter is valid for. - */ - operation: PredefinedFilterOperation; - /** - * Filter template stored on the server. - * - * This is not necessarily the fully interpolated runtime filter; placeholder - * values such as `{{user_id}}` may still be present. - */ - filter: F; - /** - * Server creation timestamp in ISO-8601 format. - */ - created_at: string; - /** - * Server update timestamp in ISO-8601 format. - */ - updated_at: string; - /** - * Optional human-readable description. - */ - description?: string; - /** - * Optional sort template stored with the predefined filter. - */ - sort?: PredefinedFilterSortParam[]; - /** - * Query identifier generated by the backend for the filter/sort pattern. - * - * The exact value is backend-generated and primarily useful for correlating - * predefined filters with query analysis / query performance data. - */ - query_id?: number; -}; - -export type CreatePredefinedFilterOptions< - F extends Record = Record, -> = { - /** - * Unique predefined filter name. - */ - name: string; - /** - * Operation this predefined filter will be used with. - */ - operation: PredefinedFilterOperation; - /** - * Filter template to store on the server. - */ - filter: F; - /** - * Optional human-readable description. - */ - description?: string; - /** - * Optional sort template stored with the predefined filter. - */ - sort?: PredefinedFilterSortParam[]; -}; +export type ReminderSort = SortParamRequest[]; -export type UpdatePredefinedFilterOptions< - F extends Record = Record, -> = Omit, 'name'>; +export type ListUserGroupsOptions = NonNullable[0]>; -export type PredefinedFilterResponse< - F extends Record = Record, -> = APIResponse & { - predefined_filter: PredefinedFilter; -}; +export type SearchUserGroupsOptions = Parameters[0]; -/** - * Paginated response returned when listing predefined filters. - */ -export type ListPredefinedFiltersResponse< - F extends Record = Record, -> = APIResponse & { - predefined_filters: PredefinedFilter[]; - next?: string; - prev?: string; +export type RateLimit = { + rate_limit?: number; + rate_limit_remaining?: number; + rate_limit_reset?: Date; }; -/** - * Contains the interpolated filter and sort from a predefined filter. - * This is returned in the QueryChannels response when using a predefined filter. - */ -export type ParsedPredefinedFilterResponse< - F extends Record = Record, -> = { - /** - * Name of the predefined filter that was resolved. - */ - name: string; - /** - * Fully interpolated filter that the backend executed. - */ - filter: F; - /** - * Fully interpolated sort parameters resolved from the predefined filter. - */ - sort?: PredefinedFilterSortParam[]; +export type RequestMetadata = { + response_headers: Record; + rate_limit: RateLimit; + response_code: number; + client_request_id: string; }; -export type PredefinedFilterSort = SortParam[]; - -export type ListPredefinedFiltersOptions = Pager & { - sort?: PredefinedFilterSort; +export type StreamResponse = T & { + metadata: RequestMetadata; }; -/** - * Team Usage Stats Types - */ - -/** - * Represents a metric value for a specific date - */ -export type DailyValue = { - /** Date in YYYY-MM-DD format */ - date: string; - /** Metric value for this date */ - value: number; -}; +export type EventPayload = Extract< + Event, + { type: T } +>; -/** - * Statistics for a single metric with optional daily breakdown - */ -export type MetricStats = { - /** Per-day values (only present in daily mode) */ - daily?: DailyValue[]; - /** Aggregated total value */ - total: number; -}; +export type RequireLiteral = Omit & Required>; -/** - * Usage statistics for a single team containing all 16 metrics - */ -export type TeamUsageStats = { - /** Team identifier (empty string for users not assigned to any team) */ - team: string; - - // Daily activity metrics (total = SUM of daily values) - /** Daily active users */ - users_daily: MetricStats; - /** Daily messages sent */ - messages_daily: MetricStats; - /** Daily translations */ - translations_daily: MetricStats; - /** Daily image moderations */ - image_moderations_daily: MetricStats; - - // Peak metrics (total = MAX of daily values) - /** Peak concurrent users */ - concurrent_users: MetricStats; - /** Peak concurrent connections */ - concurrent_connections: MetricStats; - - // Rolling/cumulative metrics (total = LATEST daily value) - /** Total users */ - users_total: MetricStats; - /** Users active in last 24 hours */ - users_last_24_hours: MetricStats; - /** MAU - users active in last 30 days */ - users_last_30_days: MetricStats; - /** Users active this month */ - users_month_to_date: MetricStats; - /** Engaged MAU */ - users_engaged_last_30_days: MetricStats; - /** Engaged users this month */ - users_engaged_month_to_date: MetricStats; - /** Total messages */ - messages_total: MetricStats; - /** Messages in last 24 hours */ - messages_last_24_hours: MetricStats; - /** Messages in last 30 days */ - messages_last_30_days: MetricStats; - /** Messages this month */ - messages_month_to_date: MetricStats; -}; +export type ReplacePropertyTypes< + Base, + Replacement extends RequireAtLeastOne>, +> = keyof Replacement extends keyof Base + ? Omit & { + [K in keyof Replacement as undefined extends Base[K] ? never : K]: Replacement[K]; + } & { + [K in keyof Replacement as undefined extends Base[K] ? K : never]?: Replacement[K]; + } + : never; -/** - * Options for querying team-level usage statistics - */ -export type QueryTeamUsageStatsOptions = { - /** - * Month in YYYY-MM format (e.g., '2026-01'). - * Mutually exclusive with start_date/end_date. - * Returns aggregated monthly values. - */ - month?: string; - /** - * Start date in YYYY-MM-DD format. - * Used with end_date for custom date range. - * Returns daily breakdown. - */ - start_date?: string; - /** - * End date in YYYY-MM-DD format. - * Used with start_date for custom date range. - * Returns daily breakdown. - */ - end_date?: string; - /** Maximum number of teams to return per page (default: 30, max: 30) */ - limit?: number; - /** Cursor for pagination to fetch next page of teams */ - next?: string; -}; +export type PartializeAllBut = { + [P in K]-?: T[P]; +} & { [P in Exclude]?: T[P] }; -/** - * Response containing team-level usage statistics - */ -export type QueryTeamUsageStatsResponse = APIResponse & { - /** Array of team usage statistics */ - teams: TeamUsageStats[]; - /** Cursor for pagination to fetch next page */ - next?: string; -}; +export type DeleteMessageOptions = Omit[0], 'id'>; +export type SendMessageAPIResponse = StreamResponse; +export type UpdateMessageOptions = Omit; +export type UpdateMessageAPIResponse = StreamResponse; +export type GiphyVersions = keyof Images; +export type TranslationLanguage = TranslateMessageRequest['language']; -export type RetentionPolicyConfig = { - max_age_hours: number; -}; +export * from './gen/models'; -export type RetentionPolicy = { - app_pk: number; - policy: string; - config: RetentionPolicyConfig; - enabled_at: string; -}; +// Re-added during the OpenAPI merge: hand-written types dropped by the types.ts auto-merge but still +// referenced by the paginator/message-delivery code. See TODO(openapi-merge). +export type AscDesc = 1 | -1; -export type SetRetentionPolicyResponse = APIResponse & { - policy: RetentionPolicy; +export type EventAPIResponse = APIResponse & { + event: Event; }; -export type DeleteRetentionPolicyResponse = APIResponse; - -export type GetRetentionPolicyResponse = APIResponse & { - policies: RetentionPolicy[]; -}; +export type PartializeKeys = Partial> & Omit; -export type RetentionRunStats = { - channels_deleted?: number; - messages_deleted?: number; +type ErrorResponseDetails = { + code: number; + messages: string[]; }; -export type RetentionRunResponse = { - app_pk: number; - policy: string; - date: string; - stats: RetentionRunStats; +export type APIErrorResponse = { + duration: string; + message: string; + more_info: string; + StatusCode: number; + code?: number; + details?: ErrorResponseDetails; }; -export type GetRetentionPolicyRunsOptions = { - filter_conditions?: Record; - sort?: Array<{ field: string; direction: 1 | -1 }>; - next?: string; - prev?: string; - limit?: number; +export type DraftMessagePayload = PartializeKeys< + Omit, + 'id' +> & { + user_id?: string; }; -export type GetRetentionPolicyRunsResponse = APIResponse & { - runs: RetentionRunResponse[]; - next?: string; - prev?: string; +export type QueryRemindersOptions = Pager & { + filter?: ReminderFilters; + sort?: ReminderSort; }; diff --git a/src/uploadManager.ts b/src/uploadManager.ts index 738ee9254e..a9c0db4edd 100644 --- a/src/uploadManager.ts +++ b/src/uploadManager.ts @@ -1,8 +1,11 @@ import type { StreamChat } from './client'; +import { chatLoggerSystem } from './logger'; import type { UploadRequestOptions } from './messageComposer/configuration/types'; import { StateStore } from './store'; import type { AttachmentManager } from '.'; +const logger = chatLoggerSystem.getLogger('upload-manager'); + export type UploadRecord = { id: string; uploadProgress?: number; @@ -150,6 +153,11 @@ export class UploadManager { ); resolvePromise(response); } catch (error) { + if (!abortController.signal.aborted) { + logger + .withExtraTags('upload', channelCid) + .error(`Upload "${id}" failed.`, { error }); + } rejectPromise(error); } finally { this.inFlightUploads.delete(id); diff --git a/src/utils.ts b/src/utils.ts index d50551d9f9..dbbbc60d9a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,19 +2,17 @@ import FormData from 'form-data'; import type { AscDesc, ChannelFilters, - ChannelQueryOptions, + ChannelGetOrCreateRequest, ChannelSort, - ChannelSortBase, + ChannelStateResponse, LocalMessage, - LocalMessageBase, - Message, + MessageRequest, MessageResponse, - MessageResponseBase, OwnUserBase, OwnUserResponse, PromoteChannelParams, - QueryChannelAPIResponse, ReactionGroupResponse, + SortParamRequest, UpdatedMessage, UserResponse, } from './types'; @@ -22,14 +20,16 @@ import type { StreamChat } from './client'; import type { Channel } from './channel'; import type { AxiosRequestConfig } from 'axios'; import { LOCAL_MESSAGE_FIELDS, RESERVED_UPDATED_MESSAGE_FIELDS } from './constants'; +import { chatLoggerSystem } from './logger'; + +const logger = chatLoggerSystem.getLogger('utils'); /** * logChatPromiseExecution - utility function for logging the execution of a promise.. * use this when you want to run the promise and handle errors by logging a warning * - * @param {Promise} promise The promise you want to run and log - * @param {string} name A descriptive name of what the promise does for log output - * + * @param promise - The promise you want to run and log + * @param name - A descriptive name of what the promise does for log output */ export function logChatPromiseExecution(promise: Promise, name: string) { promise.then().catch((error) => { @@ -151,12 +151,21 @@ export function addFileToFormData( return data; } export function normalizeQuerySort>( - sort: T | T[], + sort: T | T[] | SortParamRequest[], ) { - const sortFields: Array<{ direction: AscDesc; field: keyof T }> = []; + const sortFields: Array<{ direction: AscDesc; field: string }> = []; const sortArr = Array.isArray(sort) ? sort : [sort]; for (const item of sortArr) { - const entries = Object.entries(item) as [keyof T, AscDesc][]; + // OpenAPI `SortParamRequest` (`{ field, direction }`) is already a normalized term — use it as-is. + if (item && typeof (item as SortParamRequest).field === 'string') { + const term = item as SortParamRequest; + sortFields.push({ + direction: (term.direction ?? 1) as AscDesc, + field: term.field as string, + }); + continue; + } + const entries = Object.entries(item) as [string, AscDesc][]; if (entries.length > 1) { console.warn( "client._buildSort() - multiple fields in a single sort object detected. Object's field order is not guaranteed", @@ -172,7 +181,7 @@ export function normalizeQuerySort /** * retryInterval - A retry interval which increases acc to number of failures * - * @return {number} Duration to wait in milliseconds + * @returns Duration to wait in milliseconds */ export function retryInterval(numberOfFailures: number) { // try to reconnect in 0.25-25 seconds (random to spread out the load from failures) @@ -319,68 +328,40 @@ export const axiosParamsSerializer: AxiosRequestConfig['paramsSerializer'] = (pa * Takes the message object, parses the dates, sets `__html` * and sets the status to `received` if missing; returns a new LocalMessage object. * - * @param {LocalMessage} message `LocalMessage` object + * @param message - `LocalMessage` object */ -export function formatMessage( - message: MessageResponse | MessageResponseBase | LocalMessage, -): LocalMessage { +export function formatMessage(message: MessageResponse | LocalMessage): LocalMessage { const toLocalMessageBase = ( - msg: MessageResponse | MessageResponseBase | LocalMessage | null | undefined, - ): LocalMessageBase | null => { + msg: MessageResponse | LocalMessage | null | undefined, + ): LocalMessage | null => { if (!msg) return null; return { ...msg, created_at: msg.created_at ? new Date(msg.created_at) : new Date(), - deleted_at: msg.deleted_at ? new Date(msg.deleted_at) : null, - pinned_at: msg.pinned_at ? new Date(msg.pinned_at) : null, + deleted_at: msg.deleted_at ? new Date(msg.deleted_at) : undefined, + pinned_at: msg.pinned_at ? new Date(msg.pinned_at) : undefined, reaction_groups: maybeGetReactionGroupsFallback( msg.reaction_groups, msg.reaction_counts, msg.reaction_scores, ), - status: msg.status || 'received', + status: (msg as LocalMessage).status || 'received', updated_at: msg.updated_at ? new Date(msg.updated_at) : new Date(), }; }; return { ...toLocalMessageBase(message), - error: (message as LocalMessage).error ?? null, - quoted_message: toLocalMessageBase((message as MessageResponse).quoted_message), + error: (message as LocalMessage).error ?? undefined, + quoted_message: + toLocalMessageBase((message as MessageResponse).quoted_message) ?? undefined, } as LocalMessage; } -/** - * @private - * - * Takes a LocalMessage, parses the dates back to strings, - * and converts the message back to a MessageResponse. - * - * @param {MessageResponse} message `MessageResponse` object - */ -export function unformatMessage(message: LocalMessage): MessageResponse { - const toMessageResponseBase = ( - msg: LocalMessage | null | undefined, - ): MessageResponseBase | null => { - if (!msg) return null; - const newDateString = new Date().toISOString(); - return { - ...msg, - created_at: message.created_at ? message.created_at.toISOString() : newDateString, - deleted_at: message.deleted_at ? message.deleted_at.toISOString() : undefined, - pinned_at: message.pinned_at ? message.pinned_at.toISOString() : undefined, - updated_at: message.updated_at ? message.updated_at.toISOString() : newDateString, - }; - }; - - return { - ...toMessageResponseBase(message), - quoted_message: toMessageResponseBase((message as LocalMessage).quoted_message), - } as MessageResponse; -} - -export const localMessageToNewMessagePayload = (localMessage: LocalMessage): Message => { - /* eslint-disable @typescript-eslint/no-unused-vars */ +export const localMessageToNewMessagePayload = ( + localMessage: LocalMessage, +): MessageRequest => { + /* eslint-disable unused-imports/no-unused-vars -- destructure-to-omit: fields intentionally stripped from the payload */ const { // Remove all timestamp fields and client-specific fields. // Field pinned_at can therefore be earlier than created_at as new message payload can hold it. @@ -396,22 +377,24 @@ export const localMessageToNewMessagePayload = (localMessage: LocalMessage): Mes reaction_counts, reaction_scores, reply_count, - // Message text related fields that shouldn't be in update + // MessageRequest text related fields that shouldn't be in update command, html, i18n, mentioned_groups, quoted_message, mentioned_users, - // Message content related fields + // MessageRequest content related fields ...messageFields } = localMessage; + /* eslint-enable unused-imports/no-unused-vars */ + // `messageFields` still carries LocalMessage-only fields (cid, deleted_reply_count, mentioned_*, + // pinned, shadowed, …) that the stricter OpenAPI `MessageRequest` omits; the server ignores them. return { ...messageFields, - pinned_at: messageFields.pinned_at?.toISOString(), mentioned_users: mentioned_users?.map((user) => user.id), - }; + } as MessageRequest; }; export const toUpdatedMessagePayload = ( @@ -442,7 +425,7 @@ export const toDeletedMessage = ({ deletedAt, hardDelete = false, }: { - message: LocalMessage | LocalMessageBase; + message: LocalMessage | LocalMessage; deletedAt: LocalMessage['deleted_at']; hardDelete: boolean; }) => { @@ -450,7 +433,7 @@ export const toDeletedMessage = ({ /** * In case of hard delete, we need to strip down all text, html, attachments and all the custom properties on message * The hard-deleted message is kept in the UI until the messages are re-queried - * FIXME: we are returning an object that does not match LocalMessage | LocalMessageBase + * FIXME: we are returning an object that does not match LocalMessage | LocalMessage */ return { attachments: [], @@ -572,7 +555,7 @@ function maybeGetReactionGroupsFallback( groups: { [key: string]: ReactionGroupResponse } | null | undefined, counts: { [key: string]: number } | null | undefined, scores: { [key: string]: number } | null | undefined, -): { [key: string]: ReactionGroupResponse } | null { +): { [key: string]: ReactionGroupResponse } | undefined { if (groups) { return groups; } @@ -581,19 +564,20 @@ function maybeGetReactionGroupsFallback( const fallback: { [key: string]: ReactionGroupResponse } = {}; for (const type of Object.keys(counts)) { + // Best-effort fallback derived from counts/scores; the richer OpenAPI `ReactionGroupResponse` + // fields (first/last_reaction_at, latest_reactions_by) are not available here. fallback[type] = { count: counts[type], sum_scores: scores[type], - }; + } as ReactionGroupResponse; } return fallback; } - return null; + return undefined; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any export interface DebouncedFunc any> { /** * Call the original function, but applying the debounce rules. @@ -622,7 +606,7 @@ export interface DebouncedFunc any> { } // works exactly the same as lodash.debounce -// eslint-disable-next-line @typescript-eslint/no-explicit-any + export const debounce = any>( fn: T, timeout = 0, @@ -670,7 +654,7 @@ export const debounce = any>( }; // works exactly the same as lodash.throttle -// eslint-disable-next-line @typescript-eslint/no-explicit-any + export const throttle = any>( fn: T, timeout = 200, @@ -738,7 +722,7 @@ export const uniqBy = ( */ const WATCH_QUERY_IN_PROGRESS_FOR_CHANNEL: Record< string, - Promise | undefined + Promise | undefined > = {}; type GetChannelParams = { @@ -746,18 +730,20 @@ type GetChannelParams = { channel?: Channel; id?: string; members?: string[]; - options?: ChannelQueryOptions; + 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 client - * @param members - * @param options - * @param type - * @param id - * @param channel + * + * @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, @@ -772,8 +758,13 @@ export const getAndWatchChannel = async ({ } // unfortunately typescript is not able to infer that if (!channel && !type) === false, then channel or type has to be truthy - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const channelToWatch = channel || client.channel(type!, id, { members }); + + 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 @@ -808,8 +799,9 @@ export const getAndWatchChannel = async ({ * 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 * which are sorted and can be recreated the same every time given the same arguments. - * @param channelType - * @param members + * + * @param channelType - The channel type. + * @param members - The member ids used to build the temporary cid. */ export const generateChannelTempCid = (channelType: string, members: string[]) => { if (!members) return; @@ -820,7 +812,8 @@ export const generateChannelTempCid = (channelType: string, members: string[]) = /** * Checks if a channel is pinned or not. Will return true only if channel.state.membership.pinned_at exists. - * @param channel + * + * @param channel - The channel to check. */ export const isChannelPinned = (channel: Channel) => { if (!channel) return false; @@ -832,7 +825,8 @@ export const isChannelPinned = (channel: Channel) => { /** * Checks if a channel is archived or not. Will return true only if channel.state.membership.archived_at exists. - * @param channel + * + * @param channel - The channel to check. */ export const isChannelArchived = (channel: Channel) => { if (!channel) return false; @@ -845,9 +839,10 @@ export const isChannelArchived = (channel: Channel) => { /** * 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 + * + * @param filters - The channel filters to inspect. */ -export const shouldConsiderArchivedChannels = (filters: ChannelFilters) => { +export const shouldConsiderArchivedChannels = (filters: ChannelFilters | undefined) => { if (!filters) return false; return typeof filters.archived === 'boolean'; @@ -857,9 +852,11 @@ export const shouldConsiderArchivedChannels = (filters: ChannelFilters) => { * 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 atIndex - the index at which we'll examine the sort value, if it's an array one - * @param sort - the sort value - both array and object notations are accepted - * @param targetKey - the target key which needs to exist for the sort at a certain index + * + * @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, @@ -867,33 +864,15 @@ export const extractSortValue = ({ targetKey, }: { atIndex: number; - targetKey: keyof ChannelSortBase; + targetKey: string; sort?: ChannelSort; }) => { if (!sort) return null; - let option: null | ChannelSortBase = null; - - if (Array.isArray(sort)) { - option = sort[atIndex] ?? null; - } else { - let index = 0; - for (const key in sort) { - if (index !== atIndex) { - index++; - continue; - } - - if (key !== targetKey) { - return null; - } - - option = sort; - - break; - } - } - - return option?.[targetKey] ?? 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; }; /** @@ -910,7 +889,9 @@ export const shouldConsiderPinnedChannels = (sort: ChannelSort) => { /** * 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 sort + * + * @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({ @@ -923,7 +904,9 @@ export const findPinnedAtSortOrder = ({ sort }: { sort: ChannelSort }) => * 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 channels + * + * @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; @@ -945,10 +928,12 @@ export const findLastPinnedChannelIndex = ({ channels }: { channels: Channel[] } * 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 channels - the list of channels we want to modify - * @param channelToMove - the channel we want to promote - * @param channelToMoveIndexWithinChannels - optionally, the index of the channel we want to move if we know it (will skip a manual check) - * @param sort - the sort value used to check for pinned channels + * + * @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, @@ -1013,7 +998,9 @@ export const runDetached = ( ) => { const { context, onSuccessCallback, onErrorCallback } = options ?? {}; const defaultOnError = (error: Error) => { - console.log(`An error has occurred in context ${context}: ${error}`); + logger + .withExtraTags('runDetached') + .error(`An error occurred in context "${context}".`, { error }); }; const onError = onErrorCallback ?? defaultOnError; @@ -1027,11 +1014,18 @@ export const runDetached = ( }; export const isBlockedMessage = (message: LocalMessage) => - message.type === 'error' && - (message.moderation_details?.action === 'MESSAGE_RESPONSE_ACTION_REMOVE' || - message.moderation?.action === 'remove'); + message.type === 'error' && message.moderation?.action === 'remove'; export const isBouncedMessage = (message: LocalMessage) => - message.type === 'error' && - (message?.moderation_details?.action === 'MESSAGE_RESPONSE_ACTION_BOUNCE' || - message?.moderation?.action === 'bounce'); + message.type === 'error' && message?.moderation?.action === 'bounce'; + +export const getEnv = (envKey: keyof NodeJS.ProcessEnv) => { + if ( + typeof process !== 'undefined' && + (Object.hasOwn(process, 'env') || 'env' in process) + ) { + return process.env[envKey]; + } + + return undefined; +}; diff --git a/src/utils/FixedSizeQueueCache.ts b/src/utils/FixedSizeQueueCache.ts index 9b5c6de57a..a1307d1cb6 100644 --- a/src/utils/FixedSizeQueueCache.ts +++ b/src/utils/FixedSizeQueueCache.ts @@ -2,6 +2,7 @@ type Dispose = (key: K, value: T) => void; /** * A cache that stores a fixed number of values in a queue. * The most recently added or retrieved value is kept at the front of the queue. + * * @template K - The type of the keys. * @template T - The type of the values. */ @@ -20,9 +21,10 @@ export class FixedSizeQueueCache { } /** - * Adds a new or moves the existing reference to the front of the queue - * @param key - * @param value + * Adds a new entry or moves the existing reference to the front of the queue. + * + * @param key - The cache key. + * @param value - The value to associate with `key`. */ add(key: K, value: T) { const index = this.keys.indexOf(key); @@ -48,8 +50,10 @@ export class FixedSizeQueueCache { } /** - * Retrieves the value by key. - * @param key + * Retrieves the value by key without changing its position in the queue. + * + * @param key - The cache key. + * @returns The value, or `undefined` when the key is not cached. */ peek(key: K) { const value = this.map.get(key); @@ -59,7 +63,9 @@ export class FixedSizeQueueCache { /** * Retrieves the value and moves it to the front of the queue. - * @param key + * + * @param key - The cache key. + * @returns The value, or `undefined` when the key is not cached. */ get(key: K) { const foundItem = this.peek(key); diff --git a/src/utils/WithSubscriptions.ts b/src/utils/WithSubscriptions.ts index 7c0dddf2a0..46e1a5688d 100644 --- a/src/utils/WithSubscriptions.ts +++ b/src/utils/WithSubscriptions.ts @@ -1,8 +1,9 @@ import type { Unsubscribe } from '../store'; /** - * @private * Class to use as a template for subscribable entities. + * + * @internal */ export abstract class WithSubscriptions { private unsubscribeFunctions: Set = new Set(); diff --git a/src/utils/concurrency.ts b/src/utils/concurrency.ts index abb985cdfc..99f912b3ab 100644 --- a/src/utils/concurrency.ts +++ b/src/utils/concurrency.ts @@ -16,9 +16,9 @@ type AsyncWrapper

= ( * should never run simultaneously: if marked with the same tag, functions * will run one after another. * - * @param tag Async functions with the same tag will run serially. Async functions + * @param tag - Async functions with the same tag will run serially. Async functions * with different tags can run in parallel. - * @param cb Async function to run. + * @param cb - Async function to run. * @returns Promise that resolves when async functions returns. */ export const withoutConcurrency = createRunner(wrapWithContinuationTracking); @@ -32,9 +32,9 @@ export const withoutConcurrency = createRunner(wrapWithContinuationTracking); * If an async function is already running and was canceled, it will be notified * via an abort signal passed as an argument. * - * @param tag Async functions with the same tag will run serially and are canceled + * @param tag - Async functions with the same tag will run serially and are canceled * when a new action with the same tag is scheduled. - * @param cb Async function to run. Receives AbortSignal as the only argument. + * @param cb - Async function to run. Receives AbortSignal as the only argument. * @returns Promise that resolves when async functions returns. If the function didn't * start and was canceled, will resolve with 'canceled'. If the function started to run, * it's up to the function to decide how to react to cancelation. diff --git a/src/utils/mergeWith/mergeWith.ts b/src/utils/mergeWith/mergeWith.ts index 8010f4116b..1f4cfd9082 100644 --- a/src/utils/mergeWith/mergeWith.ts +++ b/src/utils/mergeWith/mergeWith.ts @@ -6,9 +6,9 @@ * (objValue, srcValue, key, object, source, stack). * * @category Object - * @param object The destination object. - * @param source A single source object or an array of objects to be merged into the . - * @param customizer The function to customize assigned values. + * @param object - The destination object. + * @param source - A single source object or an array of objects to be merged into the . + * @param customizer - The function to customize assigned values. * @returns Returns `object`. * @example * diff --git a/src/utils/mergeWith/mergeWithDiff.ts b/src/utils/mergeWith/mergeWithDiff.ts index 34f02e0537..c5cb44ed82 100644 --- a/src/utils/mergeWith/mergeWithDiff.ts +++ b/src/utils/mergeWith/mergeWithDiff.ts @@ -3,9 +3,9 @@ * which keys have been added or updated during the merge operation. * * @category Object - * @param object The destination object. - * @param source A single source object or an array of objects to be merged into the object. - * @param customizer The function to customize assigned values. + * @param object - The destination object. + * @param source - A single source object or an array of objects to be merged into the object. + * @param customizer - The function to customize assigned values. * @returns Returns an object containing the merged result and a hierarchical diff object. * @example * diff --git a/src/utils/retryable.ts b/src/utils/retryable.ts new file mode 100644 index 0000000000..d33b1bfe0c --- /dev/null +++ b/src/utils/retryable.ts @@ -0,0 +1,117 @@ +type FunctionToRetry = (...functionArguments: any[]) => PromiseLike; + +enum RetryErrorType { + ABORT, + ATTEMPT_LIMIT_REACHED, +} + +export class RetryError extends Error { + public name = 'RetryError'; + + private static errorMap = { + [RetryErrorType.ABORT]: 'Value changed, retry handler aborted', + [RetryErrorType.ATTEMPT_LIMIT_REACHED]: 'Reached maximum amount of retry attempts', + } satisfies Record; + + constructor({ type }: { type: RetryErrorType }) { + super(RetryError.errorMap[type]); + } +} + +export const sleep = (duration: number) => + new Promise((resolve) => setTimeout(resolve, duration)); + +// export const handleFalsePositiveResponse = < +// T extends (...v: any[]) => PromiseLike, +// >( +// f: T, +// ) => +// async function falsePositiveHandler(...functionArguments: Parameters) { +// // await or throw if "f" fails +// const data = (await f(...functionArguments)) as Awaited>; +// // check for error data, throw if exist +// if (data.errors !== null) { +// const stringifiedErrors = data.errors +// .map((error, index) => `E${index + 1}(${error.errorCode}): ${error.message}`) +// .join('\n'); + +// throw new Error(stringifiedErrors); +// } + +// return data; +// }; + +export type RunWithRetryOptions = { + retryAttempts?: number; + delayBetweenRetries?: number | ((attempt: number) => number); + isRetryable?: (error: unknown) => boolean; + didValueChange?: (...functionArguments: Parameters) => Promise | boolean; +}; + +/** + * Function which wraps asynchronous functions with retry mechanism which'll keep executing said + * function pre-defined number of times until it resolves, rejects or the retry mechanism runs out of available attempts. + * + * #### Available options: + * - `didValueChange` - check function with initial argument value to check against new values, return `true` to abort next attempt + * + * - `isRetryable` - error evaluation function which determines whether to retry the execution + * + * - `delayBetweenRetries` - accepts either fixed numeric value or function which provides retry attempt as the argument, expects number as return value + * + * - `retryAttempts` - number of attempts to try out before rejecting the promise + */ +export const runWithRetry = ( + f: T, + { + retryAttempts = 3, + delayBetweenRetries, + isRetryable, + didValueChange, + }: RunWithRetryOptions = {}, +) => + async function retryable(...functionArguments: Parameters) { + // starting with -1 as first attempt is not considered a retry + let retryAttempt = -1; + + do { + let data: Awaited> | null = null; + let error: unknown = null; + + if (await didValueChange?.(...functionArguments)) { + throw new RetryError({ type: RetryErrorType.ABORT }); + } + + try { + data = await f(...functionArguments); + } catch (e) { + error = e; + } + + // disable value change check after successfull server call + // throwing error at this point could lead to stale local states + + // if (await didValueChange?.(...functionArguments)) { + // throw new RetryError({ type: 'abort' }); + // } + + if (data) return data; + + const runRetry = isRetryable?.(error) ?? true; + if (!runRetry) throw error; + + retryAttempt++; + + const isLastAttempt = retryAttempt === retryAttempts; + + if (delayBetweenRetries && !isLastAttempt) { + await sleep( + typeof delayBetweenRetries === 'function' + ? delayBetweenRetries(retryAttempt) + : delayBetweenRetries, + ); + } + } while (retryAttempt < retryAttempts); + + throw new RetryError({ type: RetryErrorType.ATTEMPT_LIMIT_REACHED }); + }; diff --git a/test/typescript/unit-test.ts b/test/typescript/unit-test.ts index 61f11e118d..86db03f623 100644 --- a/test/typescript/unit-test.ts +++ b/test/typescript/unit-test.ts @@ -9,16 +9,16 @@ import { Permission, MaxPriority, APIResponse, - AppSettingsAPIResponse, + GetApplicationResponse, UserResponse, SendFileAPIResponse, UR, Channel, - EventTypes, + EventType, ChannelState, ChannelMemberResponse, UpdateChannelAPIResponse, - PartialUserUpdate, + UpdateUserPartialRequest, PermissionObject, PolicyRequest, ConnectAPIResponse, @@ -94,10 +94,10 @@ const authType: string = client.getAuthType(); voidReturn = client.setBaseURL('https://chat.stream-io-api.com/'); const settingsPromise: Promise = client.updateAppSettings({}); -const appPromise: Promise = client.getAppSettings(); +const appPromise: Promise = client.getAppSettings(); voidPromise = client.disconnectUser(); -const updateRequest: PartialUserUpdate = { +const updateRequest: UpdateUserPartialRequest = { id: 'vishal', set: { name: 'Awesome', @@ -148,7 +148,7 @@ const file: Promise = client.sendFile( { id: 'james' }, ); -const type: EventTypes = 'user.updated'; +const type: EventType = 'user.updated'; const event: Event = { type, cid: 'channelid', diff --git a/test/unit/ChannelPaginatorsOrchestrator.test.ts b/test/unit/ChannelPaginatorsOrchestrator.test.ts index 61c4da07b4..72192894a6 100644 --- a/test/unit/ChannelPaginatorsOrchestrator.test.ts +++ b/test/unit/ChannelPaginatorsOrchestrator.test.ts @@ -169,7 +169,9 @@ describe('ChannelPaginatorsOrchestrator', () => { it('applies ownership rules to paginators when they paginate', async () => { const ch1 = makeChannel('messaging:101'); const ch2 = makeChannel('messaging:102'); - const queryChannelSpy = vi.spyOn(client, 'queryChannels').mockResolvedValue([ch1]); + const queryChannelSpy = vi + .spyOn(client, 'queryChannelsAndHydrate') + .mockResolvedValue([ch1]); const p1 = new ChannelPaginator({ client, filters: { type: 'messaging' }, diff --git a/test/unit/CooldownTimer.test.ts b/test/unit/CooldownTimer.test.ts index 4909cbe3c4..303af4c197 100644 --- a/test/unit/CooldownTimer.test.ts +++ b/test/unit/CooldownTimer.test.ts @@ -40,8 +40,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: lastOwnMessageAt.toISOString(), - updated_at: lastOwnMessageAt.toISOString(), + created_at: lastOwnMessageAt, + updated_at: lastOwnMessageAt, user: { id: client.userID as string }, }), ); @@ -77,8 +77,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: now.toISOString(), - updated_at: now.toISOString(), + created_at: now, + updated_at: now, user: { id: client.userID as string }, }), ); @@ -92,8 +92,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: now.toISOString(), - updated_at: now.toISOString(), + created_at: now, + updated_at: now, user: { id: client.userID as string }, }), ); @@ -107,8 +107,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: now.toISOString(), - updated_at: now.toISOString(), + created_at: now, + updated_at: now, user: { id: client.userID as string }, }), ); @@ -140,8 +140,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: lastOwnMessageAt.toISOString(), - updated_at: lastOwnMessageAt.toISOString(), + created_at: lastOwnMessageAt, + updated_at: lastOwnMessageAt, user: { id: client.userID as string }, }), ); @@ -167,8 +167,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: now.toISOString(), - updated_at: now.toISOString(), + created_at: now, + updated_at: now, user: { id: client.userID as string }, }), ); @@ -191,8 +191,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: lastOwnMessageAt.toISOString(), - updated_at: lastOwnMessageAt.toISOString(), + created_at: lastOwnMessageAt, + updated_at: lastOwnMessageAt, user: { id: client.userID as string }, }), ); @@ -226,8 +226,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: lastOwnMessageAt.toISOString(), - updated_at: lastOwnMessageAt.toISOString(), + created_at: lastOwnMessageAt, + updated_at: lastOwnMessageAt, user: { id: client.userID as string }, }), ); @@ -261,8 +261,8 @@ describe('CooldownTimer', () => { seedLatestWindow( channel, generateMsg({ - created_at: lastOwnMessageAt.toISOString(), - updated_at: lastOwnMessageAt.toISOString(), + created_at: lastOwnMessageAt, + updated_at: lastOwnMessageAt, user: { id: client.userID as string }, }), ); @@ -299,8 +299,8 @@ describe('CooldownTimer', () => { user: { id: client.userID as string }, message: generateMsg({ cid: channel.cid, // must match the paginator filter so message.new ingests into an interval - created_at: now.toISOString(), - updated_at: now.toISOString(), + created_at: now, + updated_at: now, user: { id: client.userID as string }, }), } as Event); diff --git a/test/unit/LiveLocationManager.test.ts b/test/unit/LiveLocationManager.test.ts index 922c55fe77..b9bc15ffb4 100644 --- a/test/unit/LiveLocationManager.test.ts +++ b/test/unit/LiveLocationManager.test.ts @@ -88,8 +88,8 @@ describe('LiveLocationManager', () => { describe('live location management', () => { it('retrieves the active live locations and registers subscriptions on init', async () => { const client = await getClientWithUser({ id: 'user-abc' }); - const getSharedLocationsSpy = vi - .spyOn(client, 'getSharedLocations') + const getUserLiveLocationsSpy = vi + .spyOn(client, 'getUserLiveLocations') .mockResolvedValue({ active_live_locations: [], duration: '' }); const manager = new LiveLocationManager({ client, @@ -97,16 +97,16 @@ describe('LiveLocationManager', () => { watchLocation, }); - expect(getSharedLocationsSpy).toHaveBeenCalledTimes(0); + expect(getUserLiveLocationsSpy).toHaveBeenCalledTimes(0); expect(manager.stateIsReady).toBeFalsy(); await manager.init(); - expect(getSharedLocationsSpy).toHaveBeenCalledTimes(1); + expect(getUserLiveLocationsSpy).toHaveBeenCalledTimes(1); expect(manager.hasSubscriptions).toBeTruthy(); // @ts-expect-error accessing private attribute expect(manager.refCount).toBe(1); await manager.init(); - expect(getSharedLocationsSpy).toHaveBeenCalledTimes(1); + expect(getUserLiveLocationsSpy).toHaveBeenCalledTimes(1); expect(manager.hasSubscriptions).toBeTruthy(); expect(manager.stateIsReady).toBeTruthy(); // @ts-expect-error accessing private attribute @@ -115,8 +115,8 @@ describe('LiveLocationManager', () => { it('unregisters subscriptions', async () => { const client = await getClientWithUser({ id: 'user-abc' }); - const getSharedLocationsSpy = vi - .spyOn(client, 'getSharedLocations') + const getUserLiveLocationsSpy = vi + .spyOn(client, 'getUserLiveLocations') .mockResolvedValue({ active_live_locations: [], duration: '' }); const manager = new LiveLocationManager({ client, @@ -132,11 +132,11 @@ describe('LiveLocationManager', () => { 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' }); - const getSharedLocationsSpy = vi - .spyOn(client, 'getSharedLocations') + const getUserLiveLocationsSpy = vi + .spyOn(client, 'getUserLiveLocations') .mockResolvedValue({ active_live_locations: [], duration: '' }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ @@ -152,11 +152,11 @@ describe('LiveLocationManager', () => { it('does not update active location if there are no coordinate updates', async () => { // starting from 0 const client = await getClientWithUser({ id: 'user-abc' }); - const getSharedLocationsSpy = vi - .spyOn(client, 'getSharedLocations') + const getUserLiveLocationsSpy = vi + .spyOn(client, 'getUserLiveLocations') .mockResolvedValue({ active_live_locations: [liveLocation], duration: '' }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); const manager = new LiveLocationManager({ client, @@ -170,12 +170,12 @@ describe('LiveLocationManager', () => { it('updates active location on coordinate updates', async () => { const client = await getClientWithUser({ id: 'user-abc' }); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ @@ -187,7 +187,6 @@ describe('LiveLocationManager', () => { await manager.init(); expect(updateLocationSpy).toHaveBeenCalledTimes(1); expect(updateLocationSpy).toHaveBeenCalledWith({ - created_by_device_id: liveLocation.created_by_device_id, message_id: liveLocation.message_id, ...newCoords, }); @@ -196,12 +195,12 @@ describe('LiveLocationManager', () => { it('does not update active location if returning to 0 locations', async () => { const client = await getClientWithUser({ id: 'user-abc' }); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ @@ -220,12 +219,12 @@ describe('LiveLocationManager', () => { it('requests the live location upon adding a first message', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [], duration: '', }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ @@ -255,12 +254,12 @@ describe('LiveLocationManager', () => { it('does not perform live location update request upon adding subsequent messages within min throttle timeout', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [], duration: '', }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ @@ -298,12 +297,12 @@ describe('LiveLocationManager', () => { it('does not request live location upon adding subsequent messages beyond min throttle timeout', async () => { vi.useFakeTimers(); const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [], duration: '', }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValueOnce(liveLocation) .mockResolvedValueOnce(liveLocation2); const newCoords = { latitude: 2, longitude: 2 }; @@ -347,12 +346,12 @@ describe('LiveLocationManager', () => { it('throttles live location update requests upon multiple watcher coords emissions under min throttle timeout', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); let watchHandler: WatchLocationHandler = () => { throw new Error('XX'); @@ -382,12 +381,12 @@ describe('LiveLocationManager', () => { it('allows live location update requests upon multiple watcher coords emissions beyond min throttle timeout', async () => { vi.useFakeTimers(); const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); let watchHandler: WatchLocationHandler = () => { throw new Error('XX'); @@ -424,7 +423,7 @@ describe('LiveLocationManager', () => { it('prevents live location update requests for expired live locations', async () => { vi.useFakeTimers(); const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [ { ...liveLocation, @@ -436,7 +435,7 @@ describe('LiveLocationManager', () => { duration: '', }); const updateLocationSpy = vi - .spyOn(client, 'updateLocation') + .spyOn(client, 'updateLiveLocation') .mockResolvedValue(liveLocation); let watchHandler: WatchLocationHandler = () => { throw new Error('XX'); @@ -474,11 +473,11 @@ describe('LiveLocationManager', () => { describe('live_location_sharing.started', () => { it('registers a new message', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -506,11 +505,11 @@ describe('LiveLocationManager', () => { describe('message.updated', () => { it('registers a new message if not yet registered', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -536,11 +535,11 @@ describe('LiveLocationManager', () => { it('updates location for registered message', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [{ ...liveLocation, end_at: new Date().toISOString() }], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -571,11 +570,11 @@ describe('LiveLocationManager', () => { it('does not register a new message if it does not contain a live location', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -596,11 +595,11 @@ describe('LiveLocationManager', () => { it('does not register a new message if it does not contain user', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -625,11 +624,11 @@ describe('LiveLocationManager', () => { it('unregisters a message if the updated message does not contain a live location', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -655,11 +654,11 @@ describe('LiveLocationManager', () => { it('unregisters a message if its live location has been changed to static location', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -686,11 +685,11 @@ describe('LiveLocationManager', () => { it('unregisters a message if the updated message has end_at in the past', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -719,11 +718,11 @@ describe('LiveLocationManager', () => { describe('live_location_sharing.stopped', () => { it('unregisters a message', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -746,11 +745,11 @@ describe('LiveLocationManager', () => { describe('message.deleted', () => { it('unregisters a message', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const newCoords = { latitude: 2, longitude: 2 }; const manager = new LiveLocationManager({ client, @@ -779,11 +778,11 @@ describe('LiveLocationManager', () => { describe('getters', async () => { it('deviceId is calculated only once', async () => { const client = await getClientWithUser(user); - vi.spyOn(client, 'getSharedLocations').mockResolvedValue({ + vi.spyOn(client, 'getUserLiveLocations').mockResolvedValue({ active_live_locations: [liveLocation], duration: '', }); - vi.spyOn(client, 'updateLocation').mockResolvedValue(liveLocation); + vi.spyOn(client, 'updateLiveLocation').mockResolvedValue(liveLocation); const getDeviceId = vi .fn() .mockReturnValueOnce(deviceId) diff --git a/test/unit/MessageComposer/LocationComposer.test.ts b/test/unit/MessageComposer/LocationComposer.test.ts index e86267ed7d..65e5a0770c 100644 --- a/test/unit/MessageComposer/LocationComposer.test.ts +++ b/test/unit/MessageComposer/LocationComposer.test.ts @@ -159,10 +159,10 @@ describe('LocationComposer', () => { created_by_device_id: deviceId, latitude: data.latitude, longitude: data.longitude, - end_at: expect.any(String), + end_at: expect.any(Date), }); - const endAt = new Date(locationComposer.validLocation!.end_at); + const endAt = locationComposer.validLocation!.end_at as Date; const expectedEndAt = new Date(Date.now() + data.durationMs); expect(endAt.getTime()).toBeCloseTo(expectedEndAt.getTime(), -2); // Within 100ms }); diff --git a/test/unit/MessageComposer/attachmentIdentity.test.ts b/test/unit/MessageComposer/attachmentIdentity.test.ts index a1d8873ded..3d136cfd4f 100644 --- a/test/unit/MessageComposer/attachmentIdentity.test.ts +++ b/test/unit/MessageComposer/attachmentIdentity.test.ts @@ -81,34 +81,40 @@ describe('attachmentIdentity', () => { }); it('should return true for attachments with mime_type not in supportedVideoFormat', () => { - const attachment = { mime_type: 'application/pdf', type: 'audio' }; + const attachment = { + custom: { mime_type: 'application/pdf' }, + type: 'audio', + }; expect(isFileAttachment(attachment, ['video/mp4'])).toBe(true); }); it('should return false for attachments with mime_type not in supportedVideoFormat but declared as video type', () => { - const attachment = { mime_type: 'application/pdf', type: 'video' }; + const attachment = { + custom: { mime_type: 'application/pdf' }, + type: 'video', + }; expect(isFileAttachment(attachment, ['video/mp4'])).toBe(false); }); it('should return false for attachments with mime_type in supportedVideoFormat', () => { - const attachment = { mime_type: 'video/mp4', type: 'video' }; + const attachment = { custom: { mime_type: 'video/mp4' }, type: 'video' }; expect(isFileAttachment(attachment, ['video/mp4'])).toBe(false); }); }); describe('isLocalFileAttachment', () => { it('should return true for local file attachments', () => { - const attachment = { type: 'file', localMetadata: { id: 'test-id' } }; + const attachment = { custom: {}, type: 'file', localMetadata: { id: 'test-id' } }; expect(isLocalFileAttachment(attachment)).toBe(true); }); it('should return false for non-local file attachments', () => { - const attachment = { type: 'file' }; + const attachment = { custom: {}, type: 'file' }; expect(isLocalFileAttachment(attachment)).toBe(false); }); it('should return false for local non-file attachments', () => { - const attachment = { type: 'image', localMetadata: { id: 'test-id' } }; + const attachment = { custom: {}, type: 'image', localMetadata: { id: 'test-id' } }; expect(isLocalFileAttachment(attachment)).toBe(false); }); }); @@ -212,29 +218,29 @@ describe('attachmentIdentity', () => { }); it('should return true for attachments with mime_type in supportedVideoFormat', () => { - const attachment = { mime_type: 'video/mp4' }; + const attachment = { custom: { mime_type: 'video/mp4' } }; expect(isVideoAttachment(attachment, ['video/mp4'])).toBe(true); }); it('should return false for attachments with mime_type not in supportedVideoFormat', () => { - const attachment = { mime_type: 'application/pdf' }; + const attachment = { custom: { mime_type: 'application/pdf' } }; expect(isVideoAttachment(attachment, ['video/mp4'])).toBe(false); }); }); describe('isLocalVideoAttachment', () => { it('should return true for local video attachments', () => { - const attachment = { type: 'video', localMetadata: { id: 'test-id' } }; + const attachment = { custom: {}, type: 'video', localMetadata: { id: 'test-id' } }; expect(isLocalVideoAttachment(attachment)).toBe(true); }); it('should return false for non-local video attachments', () => { - const attachment = { type: 'video' }; + const attachment = { custom: {}, type: 'video' }; expect(isLocalVideoAttachment(attachment)).toBe(false); }); it('should return false for local non-video attachments', () => { - const attachment = { type: 'file', localMetadata: { id: 'test-id' } }; + const attachment = { custom: {}, type: 'file', localMetadata: { id: 'test-id' } }; expect(isLocalVideoAttachment(attachment)).toBe(false); }); }); diff --git a/test/unit/MessageComposer/attachmentManager.test.ts b/test/unit/MessageComposer/attachmentManager.test.ts index ddcc5dcac4..60ee8d9ee1 100644 --- a/test/unit/MessageComposer/attachmentManager.test.ts +++ b/test/unit/MessageComposer/attachmentManager.test.ts @@ -11,7 +11,7 @@ import { LocalMessage, StreamChat, } from '../../../src'; -import { AppSettings } from '../../../src'; +import { AppResponseFields } from '../../../src'; import * as Utils from '../../../src/utils'; import { beforeEach } from 'node:test'; @@ -88,7 +88,7 @@ const setup = ({ composition, config, }: { - appSettings?: Partial; + appSettings?: Partial; composition?: DraftResponse | LocalMessage; config?: Partial; } = {}) => { @@ -1362,17 +1362,17 @@ describe('AttachmentManager', () => { ), ).resolves.toEqual({ fallback: 'test.jpg', - file_size: 0, + custom: { file_size: 0, mime_type: 'image/jpeg' }, localMetadata: { id: expect.any(String), file, uploadState: 'failed', + uploadProgress: undefined, previewUri: expect.any(String), uploadPermissionCheck: { uploadBlocked: false, }, }, - mime_type: 'image/jpeg', type: 'image', }); @@ -1966,17 +1966,17 @@ describe('AttachmentManager', () => { await expect(attachmentManager.uploadFiles([file])).resolves.toEqual([ { fallback: 'test.jpg', - file_size: 0, + custom: { file_size: 0, mime_type: 'image/jpeg' }, localMetadata: { id: expect.any(String), file, uploadState: 'failed', + uploadProgress: undefined, previewUri: expect.any(String), uploadPermissionCheck: { uploadBlocked: false, }, }, - mime_type: 'image/jpeg', type: 'image', }, ]); @@ -2275,8 +2275,7 @@ describe('AttachmentManager', () => { const file = new File([fileContent], 'test.jpg', { type: 'image/jpeg' }); const result = await attachmentManager.fileToLocalUploadAttachment(file); expect(result).toMatchObject({ - file_size: 1234, - mime_type: 'image/jpeg', + custom: { file_size: 1234, mime_type: 'image/jpeg' }, type: 'image', localMetadata: expect.objectContaining({ file, @@ -2312,8 +2311,7 @@ describe('AttachmentManager', () => { expect(createObjectURLSpy).toHaveBeenCalledWith(file); expect(result).toMatchObject({ - file_size: 3, - mime_type: 'application/pdf', + custom: { file_size: 3, mime_type: 'application/pdf' }, type: 'file', localMetadata: expect.objectContaining({ file, @@ -2348,8 +2346,7 @@ describe('AttachmentManager', () => { }; const result = await attachmentManager.fileToLocalUploadAttachment(fileReference); expect(result).toMatchObject({ - file_size: 1234, - mime_type: 'image/jpeg', + custom: { file_size: 1234, mime_type: 'image/jpeg' }, type: 'image', localMetadata: expect.objectContaining({ file: fileReference, @@ -2389,8 +2386,7 @@ describe('AttachmentManager', () => { }; const result = await attachmentManager.fileToLocalUploadAttachment(fileReference); expect(result).toMatchObject({ - file_size: 4321, - mime_type: 'video/mp4', + custom: { file_size: 4321, mime_type: 'video/mp4' }, type: 'video', localMetadata: expect.objectContaining({ file: fileReference, @@ -2398,7 +2394,6 @@ describe('AttachmentManager', () => { uploadState: 'pending', }), title: 'test.mp4', - duration: 12.34, thumb_url: 'file://thumb.jpg', }); expect(result.localMetadata.previewUri).toBe('file://test.mp4'); diff --git a/test/unit/MessageComposer/linkPreviewsManager.test.ts b/test/unit/MessageComposer/linkPreviewsManager.test.ts index e08d7af8c2..4d7860778a 100644 --- a/test/unit/MessageComposer/linkPreviewsManager.test.ts +++ b/test/unit/MessageComposer/linkPreviewsManager.test.ts @@ -30,10 +30,12 @@ vi.mock('../../src/utils', () => ({ debouncedFn.flush = vi.fn(); return debouncedFn; }), + getEnv: vi.fn(), })); vi.mock('../../src/utils/mergeWith', () => ({ mergeWith: vi.fn().mockImplementation((target, source) => ({ ...target, ...source })), + getEnv: vi.fn(), })); vi.mock('linkifyjs', () => ({ @@ -85,8 +87,9 @@ const setup = ({ vi.clearAllMocks(); // Setup mocks - const mockClient = new StreamChat('apiKey', 'apiSecret'); - mockClient.enrichURL = vi.fn().mockResolvedValue(enrichURLReturnValue); + const mockClient = new StreamChat('apiKey'); + mockClient.user = { id: 'user' }; + mockClient.getOG = vi.fn().mockResolvedValue(enrichURLReturnValue); const mockChannel = mockClient.channel('channelType', 'channelId'); mockChannel.getConfig = vi.fn().mockImplementation(() => ({ url_enrichment: true })); @@ -190,7 +193,7 @@ describe('LinkPreviewsManager', () => { } = setup(); // Mock the enrichURL to never resolve - mockClient.enrichURL = vi.fn().mockImplementation(() => new Promise(() => {})); + mockClient.getOG = vi.fn().mockImplementation(() => new Promise(() => {})); // Add a loading preview linkPreviewsManager.findAndEnrichUrls('Check out https://example.com'); @@ -395,7 +398,7 @@ describe('LinkPreviewsManager', () => { mockClient, } = setup(); let resolveEnrichment: (value: typeof enrichURLReturnValue) => void = () => {}; - mockClient.enrichURL = vi.fn( + mockClient.getOG = vi.fn( () => new Promise((resolve) => { resolveEnrichment = resolve; @@ -424,7 +427,7 @@ describe('LinkPreviewsManager', () => { mockChannel.getConfig.mockReturnValueOnce({ url_enrichment: false }); linkPreviewsManager.findAndEnrichUrls('Check out https://example.com'); let enrichPromiseResolve; - mockClient.enrichURL = vi.fn().mockImplementation(() => { + mockClient.getOG = vi.fn().mockImplementation(() => { return new Promise((resolve) => { enrichPromiseResolve = resolve; }); @@ -432,7 +435,7 @@ describe('LinkPreviewsManager', () => { linkPreviewsManager.findAndEnrichUrls('Check out https://example.com'); // Wait for the debounced function to be called await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockClient.enrichURL).not.toHaveBeenCalled(); + expect(mockClient.getOG).not.toHaveBeenCalled(); expect(linkPreviewsManager.previews.size).toBe(0); }); @@ -443,7 +446,7 @@ describe('LinkPreviewsManager', () => { } = setup({ config: { enabled: false } }); linkPreviewsManager.findAndEnrichUrls('Check out https://example.com'); let enrichPromiseResolve; - mockClient.enrichURL = vi.fn().mockImplementation(() => { + mockClient.getOG = vi.fn().mockImplementation(() => { return new Promise((resolve) => { enrichPromiseResolve = resolve; }); @@ -451,7 +454,7 @@ describe('LinkPreviewsManager', () => { linkPreviewsManager.findAndEnrichUrls('Check out https://example.com'); // Wait for the debounced function to be called await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockClient.enrichURL).not.toHaveBeenCalled(); + expect(mockClient.getOG).not.toHaveBeenCalled(); expect(linkPreviewsManager.previews.size).toBe(0); }); @@ -461,7 +464,7 @@ describe('LinkPreviewsManager', () => { mockClient, } = setup(); let enrichPromiseResolve; - mockClient.enrichURL = vi.fn().mockImplementation(() => { + mockClient.getOG = vi.fn().mockImplementation(() => { return new Promise((resolve) => { enrichPromiseResolve = resolve; }); @@ -470,7 +473,7 @@ describe('LinkPreviewsManager', () => { // Wait for the debounced function to be called await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockClient.enrichURL).toHaveBeenCalledWith(linkUrl); + expect(mockClient.getOG).toHaveBeenCalledWith({ url: linkUrl }); expect(linkPreviewsManager.previews.size).toBe(1); const preview = linkPreviewsManager.previews.get(linkUrl); @@ -497,7 +500,7 @@ describe('LinkPreviewsManager', () => { messageComposer: { linkPreviewsManager }, mockClient, } = setup(); - mockClient.enrichURL.mockRejectedValueOnce(new Error('Enrichment failed')); + mockClient.getOG.mockRejectedValueOnce(new Error('Enrichment failed')); linkPreviewsManager.findAndEnrichUrls('Check out https://example.com'); @@ -523,7 +526,7 @@ describe('LinkPreviewsManager', () => { // Wait for the debounced function to be called await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockClient.enrichURL).toHaveBeenCalledTimes(1); + expect(mockClient.getOG).toHaveBeenCalledTimes(1); expect(linkPreviewsManager.previews.size).toBe(1); }); @@ -606,7 +609,10 @@ describe('LinkPreviewsManager', () => { } = setup(); linkPreviewsManager.state.partialNext({ previews: new Map([ - [linkUrl, { og_scrape_url: linkUrl, status: LinkPreviewStatus.LOADED }], + [ + linkUrl, + { og_scrape_url: linkUrl, status: LinkPreviewStatus.LOADED, custom: {} }, + ], ]), }); const onLinkPreviewDismissed = vi.fn(); diff --git a/test/unit/MessageComposer/messageComposer.test.ts b/test/unit/MessageComposer/messageComposer.test.ts index 6d791322ca..75b9642f01 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -1,14 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { chatLoggerSystem } from '../../../src/logger'; import { AbstractOfflineDB, Channel, - ChannelAPIResponse, + ChannelStateResponseFields, ChannelConfigWithInfo, ChannelResponse, DEFAULT_COMPOSER_CONFIG, LocalMessage, MessageComposerConfig, - StaticLocationPayload, + SharedLocation, StreamChat, Thread, } from '../../../src'; @@ -40,6 +41,7 @@ vi.mock('../../../src/utils', async (importOriginal) => ({ isLocalMessage: vi.fn().mockReturnValue(true), randomId: vi.fn().mockReturnValue('test-uuid'), throttle: vi.fn().mockImplementation((fn) => fn), + getEnv: vi.fn(), })); const quotedMessage = { @@ -66,19 +68,19 @@ const getThread = (channel: Channel, client: StreamChat, threadId: string) => text: 'Test message', type: 'regular' as const, user, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), + created_at: new Date(), + updated_at: new Date(), }, channel: { - id: channel.id, + id: channel.id!, type: channel.type, cid: channel.cid, disabled: false, frozen: false, }, title: 'Test Thread', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), + created_at: new Date(), + updated_at: new Date(), channel_cid: channel.cid, latest_replies: [], thread_participants: [], @@ -102,18 +104,13 @@ const setup = ({ } = {}) => { const mockClient = new StreamChat('test-api-key'); mockClient.user = user; - mockClient.userID = user.id; const cid = 'messaging:test-channel-id'; if (channelConfig) { // @ts-expect-error incomplete channel config object mockClient.configs[cid] = channelConfig; } // Create a proper Channel instance with only the necessary attributes mocked - const mockChannel = new Channel(mockClient, 'messaging', 'test-channel-id', { - id: 'test-channel-id', - type: 'messaging', - cid: 'messaging:test-channel-id', - }); + const mockChannel = mockClient.channel('messaging', 'test-channel-id'); // Mock the getClient method vi.spyOn(mockChannel, 'getClient').mockReturnValue(mockClient); @@ -139,15 +136,10 @@ const offlineModeMessageComposerSetup = ({ } = {}) => { const mockClient = new StreamChat('test-api-key'); mockClient.user = user; - mockClient.userID = user.id; mockClient.setOfflineDBApi(new MockOfflineDB({ client: mockClient })); vi.spyOn(mockClient.offlineDb!, 'initializeDB').mockResolvedValue(false); // Create a proper Channel instance with only the necessary attributes mocked - const mockChannel = new Channel(mockClient, 'messaging', 'test-channel-id', { - id: 'test-channel-id', - type: 'messaging', - cid: 'messaging:test-channel-id', - }); + const mockChannel = mockClient.channel('messaging', 'test-channel-id'); // Mock the getClient method vi.spyOn(mockChannel, 'getClient').mockReturnValue(mockClient); @@ -164,6 +156,7 @@ const offlineModeMessageComposerSetup = ({ describe('MessageComposer', () => { afterEach(() => { + chatLoggerSystem.restoreDefaults(); vi.clearAllMocks(); }); @@ -330,7 +323,7 @@ describe('MessageComposer', () => { it('does nothing if cids do not match', () => { const response = { channel: { cid: 'messaging:other' }, - } as unknown as ChannelAPIResponse; + } as unknown as ChannelStateResponseFields; composer.initStateFromChannelResponse(response); @@ -345,7 +338,7 @@ describe('MessageComposer', () => { const response = { channel: { cid: composer.channel.cid }, draft, - } as unknown as ChannelAPIResponse; + } as unknown as ChannelStateResponseFields; composer.initStateFromChannelResponse(response); @@ -357,7 +350,7 @@ describe('MessageComposer', () => { it('clears and deletes draft if no draft in response but draftId exists in state', () => { const response = { channel: { cid: composer.channel.cid }, - } as unknown as ChannelAPIResponse; + } as unknown as ChannelStateResponseFields; const executeQuerySafelySpy = vi .spyOn(composer.client.offlineDb!, 'executeQuerySafely') .mockImplementation(vi.fn()); @@ -383,7 +376,7 @@ describe('MessageComposer', () => { const response = { channel: { cid: composer.channel.cid }, - } as unknown as ChannelAPIResponse; + } as unknown as ChannelStateResponseFields; composer.initStateFromChannelResponse(response); @@ -1278,13 +1271,13 @@ describe('MessageComposer', () => { const result = await messageComposer.compose(); - expect(result).toEqual({ + expect(result).toMatchObject({ localMessage: { attachments: [], cid: 'messaging:test-channel-id', created_at: expect.any(Date), - deleted_at: null, - error: null, + deleted_at: undefined, + error: undefined, id: 'test-uuid', mentioned_channel: false, mentioned_group_ids: [], @@ -1292,9 +1285,9 @@ describe('MessageComposer', () => { mentioned_roles: [], mentioned_users: [], parent_id: undefined, - pinned_at: null, - quoted_message: null, - reaction_groups: null, + pinned_at: undefined, + quoted_message: undefined, + reaction_groups: undefined, status: 'sending', text: 'Test message', type: 'regular', @@ -1324,9 +1317,9 @@ describe('MessageComposer', () => { const date = new Date(); const { messageComposer } = setup({ composition: { - attachments: [{ type: 'file' }], + attachments: [{ type: 'file', custom: {} }], created_at: date, - deleted_at: null, + deleted_at: undefined, id: 'test-uuid', mentioned_users: [], pinned: true, @@ -1354,13 +1347,13 @@ describe('MessageComposer', () => { const result = await messageComposer.compose(); - expect(result).toEqual({ + expect(result).toMatchObject({ localMessage: { - attachments: [{ type: 'file' }], + attachments: [{ type: 'file', custom: {} }], cid: 'messaging:test-channel-id', created_at: date, - deleted_at: null, - error: null, + deleted_at: undefined, + error: undefined, id: 'test-uuid', mentioned_channel: false, mentioned_group_ids: [], @@ -1370,7 +1363,7 @@ describe('MessageComposer', () => { parent_id: undefined, pinned: true, pinned_at: date, - quoted_message: null, + quoted_message: undefined, reaction_counts: { like: 1, }, @@ -1751,7 +1744,7 @@ describe('MessageComposer', () => { await messageComposer.createDraft(); expect(spyComposeDraft).toHaveBeenCalled(); - expect(spyCreateDraft).toHaveBeenCalledWith(mockDraft); + expect(spyCreateDraft).toHaveBeenCalledWith({ message: mockDraft }); expect(spyLogDraftUpdateTimestamp).toHaveBeenCalled(); expect(messageComposer.state.getLatestValue().draftId).toBe('test-draft-id'); }); @@ -1803,7 +1796,7 @@ describe('MessageComposer', () => { await messageComposer.createDraft(); expect(spyComposeDraft).toHaveBeenCalled(); - expect(spyCreateDraft).toHaveBeenCalledWith(mockDraft); + expect(spyCreateDraft).toHaveBeenCalledWith({ message: mockDraft }); expect(spyLogDraftUpdateTimestamp).toHaveBeenCalled(); expect(messageComposer.state.getLatestValue().draftId).toBe('test-draft-id'); @@ -1839,9 +1832,10 @@ describe('MessageComposer', () => { const spyUpsertDraft = vi .spyOn(messageComposer.client.offlineDb!, 'upsertDraft') .mockRejectedValueOnce(new Error('offline insert failed')); - const spyLogger = vi - .spyOn(messageComposer.client, 'logger') - .mockImplementation(vi.fn()); + const spyLogger = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: spyLogger, level: 'trace' }, + }); const spyLogDraftUpdateTimestamp = vi.spyOn( messageComposer, @@ -1851,14 +1845,14 @@ describe('MessageComposer', () => { await messageComposer.createDraft(); expect(spyComposeDraft).toHaveBeenCalled(); - expect(spyCreateDraft).toHaveBeenCalledWith(mockDraft); + expect(spyCreateDraft).toHaveBeenCalledWith({ message: mockDraft }); expect(spyLogDraftUpdateTimestamp).toHaveBeenCalled(); expect(messageComposer.state.getLatestValue().draftId).toBe('test-draft-id'); expect(spyUpsertDraft).toHaveBeenCalledTimes(1); expect(spyLogger).toHaveBeenCalledWith( 'error', - 'offlineDb:upsertDraft', + expect.stringContaining('Upserting the draft to the offline database failed.'), expect.objectContaining({ error: expect.any(Error), }), @@ -2008,16 +2002,17 @@ describe('MessageComposer', () => { const spyChannelDeleteDraft = vi .spyOn(mockChannel, 'deleteDraft') .mockResolvedValue({}); - const spyLogger = vi - .spyOn(messageComposer.client, 'logger') - .mockImplementation(vi.fn()); + const spyLogger = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: spyLogger, level: 'trace' }, + }); await messageComposer.deleteDraft(); expect(spyChannelDeleteDraft).toHaveBeenCalled(); expect(spyLogger).toHaveBeenCalledWith( 'error', - 'offlineDb:deleteDraft', + expect.stringContaining('Deleting the draft from the offline database failed.'), expect.objectContaining({ error: expect.any(Error), }), @@ -2116,7 +2111,7 @@ describe('MessageComposer', () => { created_by_device_id: messageComposer.locationComposer.deviceId, latitude: 1, longitude: 1, - } as StaticLocationPayload); + } as SharedLocation); expect(messageComposer.locationComposer.state.getLatestValue()).toEqual({ location: null, }); @@ -2301,7 +2296,10 @@ describe('MessageComposer', () => { const spyChannelGetDraft = vi.spyOn(mockChannel, 'getDraft'); spyChannelGetDraft.mockRejectedValue(new Error('Failed to get draft')); - const spyLogger = vi.spyOn(mockClient, 'logger'); + const spyLogger = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: spyLogger, level: 'trace' }, + }); await messageComposer.getDraft(); diff --git a/test/unit/MessageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.test.ts b/test/unit/MessageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.test.ts index 2442426f0e..57887b4b6e 100644 --- a/test/unit/MessageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.test.ts +++ b/test/unit/MessageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.test.ts @@ -8,6 +8,7 @@ import { getClientWithUser } from '../../../../test-utils/getClient'; vi.mock('../../../../../src/utils', () => ({ generateUUIDv4: vi.fn().mockReturnValue('test-uuid'), + getEnv: vi.fn(), })); const setupHandlerParams = (initialState: AttachmentPostUploadMiddlewareState) => { diff --git a/test/unit/MessageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.test.ts b/test/unit/MessageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.test.ts index f7ce1435db..c3acbde037 100644 --- a/test/unit/MessageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.test.ts +++ b/test/unit/MessageComposer/middleware/attachmentManager/preUpload/blockedUploadNotification.test.ts @@ -12,6 +12,7 @@ import { getClientWithUser } from '../../../../test-utils/getClient'; vi.mock('../../../../../src/utils', () => ({ generateUUIDv4: vi.fn().mockReturnValue('test-uuid'), + getEnv: vi.fn(), })); const setupHandlerParams = (initialState: AttachmentPreUploadMiddlewareState) => { diff --git a/test/unit/MessageComposer/middleware/attachmentManager/preUpload/serverUploadConfigCheck.test.ts b/test/unit/MessageComposer/middleware/attachmentManager/preUpload/serverUploadConfigCheck.test.ts index bb463c1e85..f51d5391a4 100644 --- a/test/unit/MessageComposer/middleware/attachmentManager/preUpload/serverUploadConfigCheck.test.ts +++ b/test/unit/MessageComposer/middleware/attachmentManager/preUpload/serverUploadConfigCheck.test.ts @@ -26,6 +26,7 @@ const setupHandlerParams = (initialState: AttachmentPreUploadMiddlewareState) => // Mock dependencies vi.mock('../../../../../src/utils', () => ({ generateUUIDv4: vi.fn().mockReturnValue('test-uuid'), + getEnv: vi.fn(), })); const setup = () => { diff --git a/test/unit/MessageComposer/middleware/messageComposer/cleanData.test.ts b/test/unit/MessageComposer/middleware/messageComposer/cleanData.test.ts index f29f8c3f02..a612d58a87 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/cleanData.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/cleanData.test.ts @@ -51,8 +51,12 @@ describe('stream-io/message-composer-middleware/data-cleanup', () => { ...stateSeed, localMessage: { ...stateSeed.localMessage, - error: null, - quoted_message: null, + deleted_at: undefined, + error: undefined, + pinned_at: undefined, + quoted_message: undefined, + reaction_groups: undefined, + user_id: undefined, type: 'regular', }, message: { @@ -75,8 +79,12 @@ describe('stream-io/message-composer-middleware/data-cleanup', () => { ...stateSeed, localMessage: { ...stateSeed.localMessage, - error: null, - quoted_message: null, + deleted_at: undefined, + error: undefined, + pinned_at: undefined, + quoted_message: undefined, + reaction_groups: undefined, + user_id: undefined, type: 'regular', }, message: { diff --git a/test/unit/MessageComposer/middleware/messageComposer/commandInjection.test.ts b/test/unit/MessageComposer/middleware/messageComposer/commandInjection.test.ts index d241decd3e..5a118b3829 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/commandInjection.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/commandInjection.test.ts @@ -4,7 +4,7 @@ import { StreamChat } from '../../../../../src/client'; import { MessageComposer } from '../../../../../src/messageComposer/messageComposer'; import { createCommandInjectionMiddleware } from '../../../../../src/messageComposer/middleware/messageComposer/commandInjection'; import { - CommandResponse, + Command, createDraftCommandInjectionMiddleware, MessageComposerMiddlewareState, MessageDraftComposerMiddlewareValueState, @@ -71,7 +71,7 @@ describe('stream-io/message-composer-middleware/command-injection', () => { get mentionedUsers() { return []; }, - setCommand: (command: CommandResponse | null) => {}, + setCommand: (command: Command | null) => {}, }; const attachmentManager = { @@ -241,7 +241,7 @@ describe('stream-io/message-composer-middleware/draft-command-injection', () => get mentionedUsers() { return []; }, - setCommand: (command: CommandResponse | null) => {}, + setCommand: (command: Command | null) => {}, }; const attachmentManager = { diff --git a/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts b/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts index 82cf8959c2..b68f4e3242 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts @@ -29,7 +29,6 @@ const setupMiddleware = ( const user = { id: 'user' }; const client = new StreamChat('apiKey'); client.user = user; - client.userID = user.id; const channelResponse = generateChannel(); const channel = client.channel( diff --git a/test/unit/MessageComposer/middleware/messageComposer/linkPreviews.test.ts b/test/unit/MessageComposer/middleware/messageComposer/linkPreviews.test.ts index 26965ac1a1..aa1917f9ca 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/linkPreviews.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/linkPreviews.test.ts @@ -18,6 +18,7 @@ import { MessageDraftComposerMiddlewareValueState, MiddlewareStatus, } from '../../../../../src'; +import { getClientWithUser } from '../../../test-utils/getClient'; const enrichURLReturnValue = { asset_url: 'https://example.com/image.jpg', @@ -71,8 +72,9 @@ const setup = ({ } = {}) => { vi.clearAllMocks(); - const mockClient = new StreamChat('apiKey', 'apiSecret'); - mockClient.enrichURL = vi.fn().mockResolvedValue(enrichURLReturnValue); + const mockClient = getClientWithUser({ id: 'user' }); + + mockClient.getOG = vi.fn().mockResolvedValue(enrichURLReturnValue); const mockChannel = mockClient.channel('messaging', 'test-channel', { members: [], @@ -584,8 +586,8 @@ const setupForDraft = ({ } = {}) => { vi.clearAllMocks(); - const mockClient = new StreamChat('apiKey', 'apiSecret'); - mockClient.enrichURL = vi.fn().mockResolvedValue(enrichURLReturnValue); + const mockClient = getClientWithUser({ id: 'user' }); + mockClient.getOG = vi.fn().mockResolvedValue(enrichURLReturnValue); const mockChannel = mockClient.channel('messaging', 'test-channel', { members: [], diff --git a/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts b/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts index 81d9326766..90f3abbc09 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/sharedLocation.test.ts @@ -65,10 +65,10 @@ describe('stream-io/message-composer-middleware/shared-location', () => { localMessage: { shared_location: { channel_cid: messageComposer.channel.cid, - created_at: expect.any(String), + created_at: expect.any(Date), created_by_device_id: messageComposer.locationComposer.deviceId, message_id: messageComposer.id, - updated_at: expect.any(String), + updated_at: expect.any(Date), user_id: user.id, ...coords, }, @@ -101,7 +101,6 @@ describe('stream-io/message-composer-middleware/shared-location', () => { it('does not inject shared_location to localMessage and message payloads if the location state is corrupted', async () => { const { messageComposer } = setup(); const middleware = createSharedLocationCompositionMiddleware(messageComposer); - // @ts-expect-error invalid location payload messageComposer.locationComposer.state.next({ location: { latitude: 1, diff --git a/test/unit/MessageComposer/middleware/pollComposer/state.test.ts b/test/unit/MessageComposer/middleware/pollComposer/state.test.ts index 01676d6d02..781071fd7a 100644 --- a/test/unit/MessageComposer/middleware/pollComposer/state.test.ts +++ b/test/unit/MessageComposer/middleware/pollComposer/state.test.ts @@ -27,6 +27,7 @@ const setupHandlerParams = (initialState: PollComposerStateChangeMiddlewareValue // Mock dependencies vi.mock('../../../../../src/utils', () => ({ generateUUIDv4: vi.fn().mockReturnValue('test-uuid'), + getEnv: vi.fn(), })); const getInitialState = (): PollComposerState => ({ @@ -39,7 +40,6 @@ const getInitialState = (): PollComposerState => ({ max_votes_allowed: '', name: '', options: [{ id: 'option-id', text: '' }], - user_id: 'user-id', voting_visibility: VotingVisibility.public, }, errors: {}, diff --git a/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts b/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts index 35d11341e9..5955ae30ae 100644 --- a/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts @@ -9,13 +9,13 @@ import { StreamChat } from '../../../../../src/client'; import { MAX_CHANNEL_MEMBER_COUNT_IN_CHANNEL_QUERY } from '../../../../../src/constants'; import type { ChannelMemberResponse, + MemberFilters, SearchUserGroupsOptions, SearchUserGroupsResponse, - Mute, + UserFilters, UserGroupResponse, + UserMuteResponse, UserResponse, - UserFilters, - MemberFilters, } from '../../../../../src/types'; import type { MentionSuggestion } from '../../../../../src/messageComposer/middleware/textComposer/types'; @@ -116,6 +116,7 @@ describe('MentionsSearchSource', () => { client = { userID: 'currentUser', + userId: 'currentUser', searchRoles: vi.fn().mockImplementation(async ({ query }: { query: string }) => ({ roles: [ { name: 'admin' }, @@ -498,7 +499,7 @@ describe('MentionsSearchSource', () => { it('should preserve special mentions while filtering muted users', () => { const source = new MentionsSearchSource(channel); - const mute: Mute = { + const mute: UserMuteResponse = { target: { id: 'user1' }, user: { id: 'currentUser' }, created_at: new Date().toISOString(), @@ -521,7 +522,7 @@ describe('MentionsSearchSource', () => { it('should return only muted users for /unmute and hide special mentions', () => { const source = new MentionsSearchSource(channel); - const mute: Mute = { + const mute: UserMuteResponse = { target: { id: 'user1' }, user: { id: 'currentUser' }, created_at: new Date().toISOString(), @@ -577,11 +578,9 @@ describe('MentionsSearchSource', () => { await source.executeQuery(); - expect(client.queryUsers).toHaveBeenCalledWith( - expect.any(Object), - expect.any(Object), - expect.objectContaining({ limit: 10, offset: 3 }), - ); + expect(client.queryUsers).toHaveBeenCalledWith({ + payload: expect.objectContaining({ limit: 10, offset: 3 }), + }); expect(client.searchUserGroups).toHaveBeenCalledWith({ id_gt: 'group-0', limit: 10, @@ -622,11 +621,11 @@ describe('MentionsSearchSource', () => { it('should prepare correct query parameters for members search', () => { const source = new MentionsSearchSource(channel); source.memberFilters = { name: { $autocomplete: 'john' } } as MemberFilters; - source.memberSort = { created_at: -1 }; + source.memberSort = [{ field: 'created_at', direction: -1 }]; const params = source.prepareQueryMembersParams('john', 5); expect(params.filters).toEqual({ name: { $autocomplete: 'john' } }); - expect(params.sort).toEqual({ created_at: -1 }); + expect(params.sort).toEqual([{ field: 'created_at', direction: -1 }]); expect(params.options).toEqual(expect.objectContaining({ limit: 10, offset: 5 })); }); @@ -668,11 +667,9 @@ describe('MentionsSearchSource', () => { source.config.mentionAllAppUsers = true; await source.query('test'); - expect(client.queryUsers).toHaveBeenCalledWith( - expect.any(Object), - expect.any(Object), - expect.objectContaining({ presence: true }), - ); + expect(client.queryUsers).toHaveBeenCalledWith({ + payload: expect.objectContaining({ presence: true }), + }); }); it('should correctly calculate Levenshtein distance for fuzzy matching', () => { diff --git a/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts b/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts index 08df97fb07..57eb7bd833 100644 --- a/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts @@ -7,13 +7,10 @@ import { } from '../../../../../src/messageComposer/messageComposer'; import { createMentionsMiddleware } from '../../../../../src/messageComposer/middleware/textComposer/mentions'; import type { TextComposerSuggestion } from '../../../../../src/messageComposer/'; -import type { - CommandResponse, - DraftResponse, - LocalMessage, -} from '../../../../../src/types'; +import type { Command, DraftResponse, LocalMessage } from '../../../../../src/types'; import { TextComposerMiddleware } from '../../../../../src'; import type { UserSuggestion } from '../../../../../src/messageComposer/middleware/textComposer/types'; +import { getClientWithUser } from '../../../test-utils/getClient'; // Mock dependencies vi.mock('../../../src/utils', () => ({ @@ -46,7 +43,7 @@ const setup = ({ vi.clearAllMocks(); // Setup mocks - const client = new StreamChat('apiKey', 'apiSecret'); + const client = getClientWithUser({ id: 'user' }); client.queryUsers = vi.fn().mockResolvedValue({ users: [] }); const channel = client.channel('channelType', 'channelId'); @@ -242,7 +239,7 @@ describe('TextComposerMiddlewareExecutor', () => { id: 'ban', name: 'ban', description: 'Ban a user', - } as TextComposerSuggestion; + } as TextComposerSuggestion; await textComposer.handleSelect(selectedSuggestion); @@ -273,7 +270,7 @@ describe('TextComposerMiddlewareExecutor', () => { id: 'ban', name: 'ban', description: 'Ban a user', - } as TextComposerSuggestion); + } as TextComposerSuggestion); expect(textComposer.text).toBe('/ba'); expect(textComposer.command).toBeNull(); @@ -313,7 +310,7 @@ describe('TextComposerMiddlewareExecutor', () => { name: 'ban', description: 'Ban a user', set: 'moderation_set', - } as TextComposerSuggestion); + } as TextComposerSuggestion); expect(textComposer.text).toBe('/ba'); expect(textComposer.command).toBeNull(); diff --git a/test/unit/MessageComposer/middleware/textComposer/command.test.ts b/test/unit/MessageComposer/middleware/textComposer/command.test.ts index aeb4d9bc6d..c5a2bfce64 100644 --- a/test/unit/MessageComposer/middleware/textComposer/command.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/command.test.ts @@ -9,6 +9,7 @@ import type { DraftResponse, LocalMessage } from '../../../../../src/types'; 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'; // Mock dependencies @@ -25,7 +26,7 @@ const setup = ({ vi.clearAllMocks(); // Setup mocks - const client = new StreamChat('apiKey', 'apiSecret'); + const client = getClientWithUser({ id: 'user' }); client.queryUsers = vi.fn().mockResolvedValue({ users: [] }); const channel = client.channel('channelType', 'channelId'); diff --git a/test/unit/MessageComposer/pollComposer.test.ts b/test/unit/MessageComposer/pollComposer.test.ts index 053a8a7ced..f50a10fbde 100644 --- a/test/unit/MessageComposer/pollComposer.test.ts +++ b/test/unit/MessageComposer/pollComposer.test.ts @@ -6,6 +6,7 @@ import { VotingVisibility } from '../../../src/types'; // Mock dependencies vi.mock('../../../src/utils', () => ({ generateUUIDv4: vi.fn().mockReturnValue('test-uuid'), + getEnv: vi.fn(), })); vi.mock('../../../src/messageComposer/middleware/pollComposer', () => ({ @@ -93,7 +94,6 @@ describe('PollComposer', () => { expect(initialState.data.max_votes_allowed).toBe(''); expect(initialState.data.name).toBe(''); expect(initialState.data.options).toEqual([{ id: 'test-uuid', text: '' }]); - expect(initialState.data.user_id).toBe('user-id'); expect(initialState.data.voting_visibility).toBe(VotingVisibility.public); expect(initialState.errors).toEqual({}); }); @@ -112,7 +112,6 @@ describe('PollComposer', () => { max_votes_allowed: '', name: '', options: [{ id: 'option-id', text: '' }], - user_id: 'user-id', voting_visibility: VotingVisibility.anonymous, }, errors: {}, @@ -126,7 +125,6 @@ describe('PollComposer', () => { expect(pollComposer.max_votes_allowed).toBe(''); expect(pollComposer.name).toBe(''); expect(pollComposer.options).toEqual([{ id: 'option-id', text: '' }]); - expect(pollComposer.user_id).toBe('user-id'); expect(pollComposer.voting_visibility).toBe(VotingVisibility.anonymous); }); }); @@ -139,7 +137,6 @@ describe('PollComposer', () => { name: 'Test Poll', max_votes_allowed: '', id: 'test-id', - user_id: 'user-id', voting_visibility: VotingVisibility.public, }, errors: {}, @@ -155,7 +152,6 @@ describe('PollComposer', () => { name: '', max_votes_allowed: '', id: 'test-id', - user_id: 'user-id', voting_visibility: VotingVisibility.public, }, errors: {}, @@ -171,7 +167,6 @@ describe('PollComposer', () => { name: 'Test Poll', max_votes_allowed: '1', // Less than 2 id: 'test-id', - user_id: 'user-id', voting_visibility: VotingVisibility.public, }, errors: {}, @@ -187,7 +182,6 @@ describe('PollComposer', () => { name: 'Test Poll', max_votes_allowed: '', id: 'test-id', - user_id: 'user-id', voting_visibility: VotingVisibility.public, }, errors: { name: 'Name is required' }, @@ -203,7 +197,6 @@ describe('PollComposer', () => { name: 'Test Poll', max_votes_allowed: '', id: 'test-id', - user_id: 'user-id', voting_visibility: VotingVisibility.public, }, errors: {}, @@ -218,7 +211,6 @@ describe('PollComposer', () => { name: 'Test Poll', max_votes_allowed: '', id: 'test-id', - user_id: 'user-id', voting_visibility: VotingVisibility.public, }, errors: { name: undefined, options: undefined }, @@ -241,7 +233,6 @@ describe('PollComposer', () => { max_votes_allowed: '5', name: 'Different Name', options: [{ id: 'different-option-id', text: 'Different Option' }], - user_id: 'different-user-id', voting_visibility: VotingVisibility.anonymous, }, errors: { name: 'Error' }, @@ -260,7 +251,6 @@ describe('PollComposer', () => { expect(currentState.data.max_votes_allowed).toBe(''); expect(currentState.data.name).toBe(''); expect(currentState.data.options).toEqual([{ id: 'test-uuid', text: '' }]); - expect(currentState.data.user_id).toBe('user-id'); expect(currentState.data.voting_visibility).toBe(VotingVisibility.public); expect(currentState.errors).toEqual({}); }); diff --git a/test/unit/MessageComposer/textComposer.test.ts b/test/unit/MessageComposer/textComposer.test.ts index 75d3df8397..fec78af64c 100644 --- a/test/unit/MessageComposer/textComposer.test.ts +++ b/test/unit/MessageComposer/textComposer.test.ts @@ -12,6 +12,7 @@ import { logChatPromiseExecution } from '../../../src/utils'; 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'; const textComposerMiddlewareExecuteOutput = { state: { @@ -46,6 +47,7 @@ vi.mock('../../../src/utils', () => ({ formatMessage: vi.fn().mockImplementation((msg) => msg), throttle: vi.fn().mockImplementation((fn) => fn), normalizeQuerySort: vi.fn().mockReturnValue([{ field: 'created_at', direction: -1 }]), + getEnv: vi.fn(), })); const setup = ({ @@ -61,7 +63,8 @@ const setup = ({ vi.clearAllMocks(); // Setup mocks - const mockClient = new StreamChat('apiKey', 'apiSecret'); + const mockClient = getClientWithUser({ id: 'user' }); + mockClient.queryUsers = vi.fn().mockResolvedValue({ users: [] }); const mockChannel = mockClient.channel('channelType', 'channelId'); diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index d8bfb36d5a..f74597c87a 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -38,7 +38,7 @@ describe('Channel count unread', function () { client = new StreamChat('apiKey'); client.user = user; - client.userID = 'user'; + client.user = { id: 'user' }; client.userMuteStatus = (targetId) => targetId.startsWith('mute'); channel = client.channel(channelResponse.channel.type, channelResponse.channel.id); @@ -221,9 +221,11 @@ describe('Channel count unread', function () { }); it('should return undefined if client user is not set (server-side client)', () => { - client = new StreamChat('apiKey', 'secret'); + // client.channel() now requires a connected user, so create the channel with the user + // set, then clear it to model a client with no connected user (userId undefined). channel = client.channel(channelResponse.channel.type, channelResponse.channel.id); channel.initialized = true; + client.user = undefined; expect(channel.lastRead()).to.be.undefined; }); }); @@ -236,7 +238,7 @@ describe('Channel isViewingLive (unread bump gating)', function () { const setupChannel = () => { const client = new StreamChat('apiKey'); client.user = user; - client.userID = user.id; + client.user = { id: user.id }; client.userMuteStatus = () => false; const channel = client.channel('messaging', 'live-mode-id'); channel.initialized = true; @@ -298,7 +300,7 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function const setupChannel = ({ isLocalUnreadCountEnabled }) => { const client = new StreamChat('apiKey', { isLocalUnreadCountEnabled }); client.user = user; - client.userID = user.id; + client.user = { id: user.id }; client.userMuteStatus = () => false; const channel = client.channel('messaging', 'live-id'); channel.initialized = true; @@ -349,7 +351,10 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function it('markReadLocally resets the count and emits a message.read-shaped message.read_locally event', function () { const { client, channel } = setupChannel({ isLocalUnreadCountEnabled: true }); - const post = vi.spyOn(client, 'post').mockResolvedValue({}); + // markReadLocally is purely local; assert it performs no HTTP request via the api seam. + const sendRequest = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); const lastMsg = generateMsg({ user: otherUser }); seedLatestWindow(channel, [lastMsg]); channel.state.unreadCount = 5; @@ -367,7 +372,7 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function expect(channel.countUnread()).to.be.equal(0); expect(channel.state.read[user.id].unread_messages).to.be.equal(0); expect(channel.state.read[user.id].last_read_message_id).to.be.equal(lastMsg.id); - expect(post.mock.calls.length).to.be.equal(0); + expect(sendRequest.mock.calls.length).to.be.equal(0); expect(onLocalRead.mock.calls.length).to.be.equal(1); const event = onLocalRead.mock.calls[0][0]; @@ -377,19 +382,19 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function expect(event.channel_type).to.be.equal(channel.type); expect(event.user.id).to.be.equal(user.id); expect(event.last_read_message_id).to.be.equal(lastMsg.id); - expect(event.created_at).to.be.a('string'); + // markReadLocally now builds the event with a Date `created_at` (not an ISO string). + expect(event.created_at).to.be.instanceof(Date); // markReadLocally returns the same dispatched event so callers (e.g. the RN SDK) can sync // their own unread UI from that read info instead of re-deriving it. expect(returned).to.equal(event); expect(returned.last_read_message_id).to.be.equal(lastMsg.id); - expect(returned.created_at).to.be.a('string'); + expect(returned.created_at).to.be.instanceof(Date); }); it('markReadLocally returns undefined and dispatches nothing when there is no connected user', function () { const { client, channel } = setupChannel({ isLocalUnreadCountEnabled: true }); client.user = undefined; - client.userID = undefined; const onLocalRead = vi.fn(); channel.on('message.read_locally', onLocalRead); @@ -401,7 +406,9 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function it('markReadLocally resets the count and creates the own read row when none exists yet (fresh livestream)', function () { const { client, channel } = setupChannel({ isLocalUnreadCountEnabled: true }); - const post = vi.spyOn(client, 'post').mockResolvedValue({}); + const sendRequest = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); const lastMsg = generateMsg({ user: otherUser }); seedLatestWindow(channel, [lastMsg]); channel.state.unreadCount = 3; @@ -413,7 +420,7 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function expect(channel.state.read[user.id]).to.be.ok; expect(channel.state.read[user.id].unread_messages).to.be.equal(0); expect(channel.state.read[user.id].last_read_message_id).to.be.equal(lastMsg.id); - expect(post.mock.calls.length).to.be.equal(0); + expect(sendRequest.mock.calls.length).to.be.equal(0); }); }); @@ -426,7 +433,7 @@ describe('Channel _handleChannelEvent', function () { beforeEach(() => { client = new StreamChat('apiKey'); client.user = user; - client.userID = user.id; + client.user = { id: user.id }; client.userMuteStatus = (targetId) => targetId.startsWith('mute'); channel = client.channel('messaging', 'id'); channel.data.own_capabilities = ['read-events']; @@ -1974,8 +1981,9 @@ describe('Channel _handleChannelEvent', function () { }); it(`should make sure that state reload doesn't wipe out existing data`, async () => { - const mock = sinon.mock(client); - mock.expects('post').returns(Promise.resolve(mockChannelQueryResponse)); + sinon + .stub(client.api, 'sendRequest') + .resolves({ body: mockChannelQueryResponse, metadata: {} }); channel.state.members = { user: { id: 'user' }, @@ -2000,10 +2008,11 @@ describe('Channel _handleChannelEvent', function () { // capabilities.changed is emitted from the channel.updated / query path. it('should dispatch "capabilities.changed" event', async () => { - const mock = sinon.mock(client); const response = mockChannelQueryResponse; channel.data.own_capabilities = response.channel.own_capabilities.slice(0, 1); - mock.expects('post').returns(Promise.resolve(response)); + const sendRequestStub = sinon + .stub(client.api, 'sendRequest') + .resolves({ body: response, metadata: {} }); const spy = sinon.spy(); channel.on('capabilities.changed', spy); @@ -2021,7 +2030,7 @@ describe('Channel _handleChannelEvent', function () { }); channel.data.own_capabilities = response.channel.own_capabilities; - mock.expects('post').returns(Promise.resolve(response)); + sendRequestStub.resolves({ body: response, metadata: {} }); spy.resetHistory(); await channel.query(); @@ -2114,7 +2123,7 @@ describe('Uninitialized Channel', () => { beforeEach(() => { client = new StreamChat('apiKey'); client.user = user; - client.userID = user.id; + client.user = { id: user.id }; client.userMuteStatus = (targetId) => targetId.startsWith('mute'); channel = client.channel('messaging', 'id'); channel.initialized = false; @@ -2188,6 +2197,8 @@ describe('Uninitialized Channel', () => { describe('Channels - Constructor', function () { const client = new StreamChat('key', 'secret'); + // client.channel() now requires a connected user (userId derives from client.user). + client.user = { id: 'thierry' }; it('canonical form', function () { const channel = client.channel('messaging', '123', { cool: true }); @@ -2201,9 +2212,13 @@ describe('Channels - Constructor', function () { expect(channel.cid).to.eql('messaging:brand_new_123'); expect(channel.id).to.eql('brand_new_123'); expect(channel.data.cool).to.eql(true); - channel = client.channel('messaging', 'brand_new_123', { custom_cool: true }); + // Re-fetching a cached channel now merges only the reserved `custom` payload onto existing + // data (getChannelById), leaving previously-set top-level data untouched. + channel = client.channel('messaging', 'brand_new_123', { + custom: { custom_cool: true }, + }); expect(channel.data.cool).to.eql(true); - expect(channel.data.custom_cool).to.eql(true); + expect(channel.data.custom.custom_cool).to.eql(true); }); it('default options', function () { @@ -2264,7 +2279,7 @@ describe('Ensure single channel per cid on client activeChannels state', () => { clientVish.connectUser = () => { clientVish.user = user; - clientVish.userID = user.id; + clientVish.user = { id: user.id }; clientVish.wsPromise = Promise.resolve(); }; @@ -2281,7 +2296,11 @@ describe('Ensure single channel per cid on client activeChannels state', () => { }); // to mock the channel.watch call - clientVish.post = () => getOrCreateChannelApi(mockedChannelResponse).response.data; + clientVish.api.sendRequest = () => + Promise.resolve({ + body: getOrCreateChannelApi(mockedChannelResponse).response.data, + metadata: {}, + }); const channelVish_copy1 = clientVish.channel('messaging', channelVishId); const cid = `${channelType}:${channelVishId}`; @@ -2305,7 +2324,11 @@ describe('Ensure single channel per cid on client activeChannels state', () => { }); // to mock the channel.watch call - clientVish.post = () => getOrCreateChannelApi(mockedChannelResponse).response.data; + clientVish.api.sendRequest = () => + Promise.resolve({ + body: getOrCreateChannelApi(mockedChannelResponse).response.data, + metadata: {}, + }); const channelVish_copy1 = clientVish.channel('messaging', channelVishId); @@ -2336,7 +2359,11 @@ describe('Ensure single channel per cid on client activeChannels state', () => { const mockedChannelResponse = generateChannel({ members: [memberVish, memberAmin], }); - clientVish.post = () => getOrCreateChannelApi(mockedChannelResponse).response.data; + clientVish.api.sendRequest = () => + Promise.resolve({ + body: getOrCreateChannelApi(mockedChannelResponse).response.data, + metadata: {}, + }); // Lets start testing const channelVish_copy1 = clientVish.channel('messaging', { @@ -2384,7 +2411,11 @@ describe('Ensure single channel per cid on client activeChannels state', () => { }); // to mock the channel.watch call - clientVish.post = () => getOrCreateChannelApi(mockedChannelResponse).response.data; + clientVish.api.sendRequest = () => + Promise.resolve({ + body: getOrCreateChannelApi(mockedChannelResponse).response.data, + metadata: {}, + }); // Case 1 =======================> const channelVish_copy1 = clientVish.channel('messaging', { @@ -2422,7 +2453,11 @@ describe('Ensure single channel per cid on client activeChannels state', () => { const mockedChannelResponse = generateChannel({ members: [memberVish, memberAmin], }); - clientVish.post = () => getOrCreateChannelApi(mockedChannelResponse).response.data; + clientVish.api.sendRequest = () => + Promise.resolve({ + body: getOrCreateChannelApi(mockedChannelResponse).response.data, + metadata: {}, + }); // Lets start testing const channelVish_copy1 = clientVish.channel('messaging', undefined, { @@ -2470,7 +2505,11 @@ describe('Ensure single channel per cid on client activeChannels state', () => { }); // to mock the channel.watch call - clientVish.post = () => getOrCreateChannelApi(mockedChannelResponse).response.data; + clientVish.api.sendRequest = () => + Promise.resolve({ + body: getOrCreateChannelApi(mockedChannelResponse).response.data, + metadata: {}, + }); // Case 1 =======================> const channelVish_copy1 = clientVish.channel('messaging', undefined, { @@ -2512,7 +2551,11 @@ describe('Ensure single channel per cid on client activeChannels state', () => { }); // to mock the channel.watch call - clientVish.post = () => getOrCreateChannelApi(mockedChannelResponse).response.data; + clientVish.api.sendRequest = () => + Promise.resolve({ + body: getOrCreateChannelApi(mockedChannelResponse).response.data, + metadata: {}, + }); // Case 1 =======================> const channelVish_copy1 = clientVish.channel('messaging', undefined, { @@ -2555,35 +2598,55 @@ describe('event subscription and unsubscription', () => { const { unsubscribe: unsubscribe1 } = channel.on('message.new', () => {}); const { unsubscribe: unsubscribe2 } = channel.on(() => {}); - expect(Object.values(channel.listeners).length).to.be.equal(2); + // channel.listeners is now a Map>; unsubscribing the last handler for a + // key deletes the key entirely. + expect(channel.listeners.size).to.be.equal(2); unsubscribe1(); - expect(channel.listeners['message.new'].length).to.be.equal(0); + expect(channel.listeners.get('message.new')?.size ?? 0).to.be.equal(0); unsubscribe2(); - expect(channel.listeners['all'].length).to.be.equal(0); + expect(channel.listeners.get('all')?.size ?? 0).to.be.equal(0); }); }); describe('Channel search', async () => { const client = await getClientWithUser(); const channel = client.channel('messaging', uuidv4()); + // search now takes a single request object `{ payload }` and forwards the payload straight to + // the generated ChatApi.search (GET /search) via client.api.sendRequest. Sort normalization is + // no longer done inside search, so the caller passes the already-shaped `{ field, direction }`. it('search with sorting by defined field', async () => { - client.get = (url, config) => { - expect(config.payload.sort).to.be.eql([{ field: 'updated_at', direction: -1 }]); - }; - await channel.search('query', { sort: [{ updated_at: -1 }] }); + const sendRequest = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); + const payload = { query: 'query', sort: [{ field: 'updated_at', direction: -1 }] }; + await channel.search({ payload }); + expect(sendRequest).toHaveBeenCalledWith('GET', '/api/v2/chat/search', undefined, { + payload, + }); }); it('search with sorting by custom field', async () => { - client.get = (url, config) => { - expect(config.payload.sort).to.be.eql([{ field: 'custom_field', direction: -1 }]); - }; - await channel.search('query', { sort: [{ custom_field: -1 }] }); + const sendRequest = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); + const payload = { query: 'query', sort: [{ field: 'custom_field', direction: -1 }] }; + await channel.search({ payload }); + expect(sendRequest).toHaveBeenCalledWith('GET', '/api/v2/chat/search', undefined, { + payload, + }); }); it('sorting and offset works', async () => { - await expect(channel.search('query', { offset: 1, sort: [{ custom_field: -1 }] })); + vi.spyOn(client.api, 'sendRequest').mockResolvedValue({ body: {}, metadata: {} }); + await expect( + channel.search({ + payload: { query: 'query', offset: 1, sort: [{ custom_field: -1 }] }, + }), + ).resolves.toBeDefined(); }); it('next and offset fails', async () => { - await expect(channel.search('query', { offset: 1, next: 'next' })).rejects.toThrow(); + await expect( + channel.search({ payload: { query: 'query', offset: 1, next: 'next' } }), + ).rejects.toThrow(); }); }); @@ -2816,11 +2879,12 @@ describe('Channel.query', async () => { generateMsg({ created_at: new Date(1700000000000 + i * 1000).toISOString() }), ), }; - const mock = sinon.mock(client); - mock.expects('post').returns(Promise.resolve(mockedChannelQueryResponse)); + const stub = sinon + .stub(client.api, 'sendRequest') + .resolves({ body: mockedChannelQueryResponse, metadata: {} }); await channel.query(); expect(Object.keys(client.activeChannels).length).to.be.equal(0); - mock.restore(); + stub.restore(); }); it('seeds the message paginator with the full latest page on query', async () => { @@ -2833,15 +2897,16 @@ describe('Channel.query', async () => { generateMsg, ), }; - const mock = sinon.mock(client); - mock.expects('post').returns(Promise.resolve(mockedChannelQueryResponse)); + const stub = sinon + .stub(client.api, 'sendRequest') + .resolves({ body: mockedChannelQueryResponse, metadata: {} }); await channel.query({}, 'latest'); // A latest-page query seeds the message paginator with the returned page. expect(channel.messagePaginator.items).to.have.length( DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE, ); expect(channel.messagePaginator.headmostItem).to.not.equal(undefined); - mock.restore(); + stub.restore(); }); it('seeds the message paginator with a partial latest page on query', async () => { @@ -2854,13 +2919,14 @@ describe('Channel.query', async () => { generateMsg, ), }; - const mock = sinon.mock(client); - mock.expects('post').returns(Promise.resolve(mockedChannelQueryResponse)); + const stub = sinon + .stub(client.api, 'sendRequest') + .resolves({ body: mockedChannelQueryResponse, metadata: {} }); await channel.query({}, 'latest'); expect(channel.messagePaginator.items).to.have.length( DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE - 1, ); - mock.restore(); + stub.restore(); }); it(`update the messageComposer config`, async () => { @@ -2868,21 +2934,27 @@ describe('Channel.query', async () => { const channel = client.channel('messaging', uuidv4()); expect(channel.messageComposer.config.location.enabled).toBe(true); - const postStub = sinon.stub(client, 'post'); - postStub.onFirstCall().resolves({ - ...mockChannelQueryResponse, - channel: { - ...mockChannelQueryResponse.channel, - config: { ...mockChannelQueryResponse.channel.config, shared_locations: false }, + const sendRequestStub = sinon.stub(client.api, 'sendRequest'); + sendRequestStub.onFirstCall().resolves({ + body: { + ...mockChannelQueryResponse, + channel: { + ...mockChannelQueryResponse.channel, + config: { ...mockChannelQueryResponse.channel.config, shared_locations: false }, + }, }, + metadata: {}, }); - postStub.onSecondCall().resolves({ - ...mockChannelQueryResponse, - channel: { - ...mockChannelQueryResponse.channel, - config: { ...mockChannelQueryResponse.channel.config, shared_locations: true }, + sendRequestStub.onSecondCall().resolves({ + body: { + ...mockChannelQueryResponse, + channel: { + ...mockChannelQueryResponse.channel, + config: { ...mockChannelQueryResponse.channel.config, shared_locations: true }, + }, }, + metadata: {}, }); await channel.query(); @@ -2897,12 +2969,12 @@ describe('send reaction flow', () => { const messageId = 'msg-456'; const reaction = { type: 'love' }; const options = { enforce_unique: true, skip_push: true }; + // Reactions are now sent as a single request object: sendReaction({ id, reaction, ...flags }). + const request = { id: messageId, reaction, ...options }; let client; let channel; - let loggerSpy; let queueTaskSpy; - let postSpy; beforeEach(async () => { client = await getClientWithUser(); @@ -2913,15 +2985,16 @@ describe('send reaction flow', () => { channel = client.channel('messaging', 'test'); - loggerSpy = vi.spyOn(client, 'logger').mockImplementation(vi.fn()); queueTaskSpy = vi.spyOn(client.offlineDb, 'queueTask').mockResolvedValue({}); - postSpy = vi.spyOn(client, 'post').mockResolvedValue({}); }); afterEach(() => { vi.resetAllMocks(); }); + // NOTE: the 'Message id is missing' / 'Reaction object is missing' validation was dropped in + // the OpenAPI-client migration; sendReaction / _sendReaction no longer throw on missing fields. + describe('sendReaction', () => { beforeEach(() => { vi.spyOn(channel, '_sendReaction').mockResolvedValue({}); @@ -2931,20 +3004,8 @@ describe('send reaction flow', () => { vi.resetAllMocks(); }); - it('throws if messageID is missing', async () => { - await expect(channel.sendReaction('', reaction)).rejects.toThrow( - 'Message id is missing', - ); - }); - - it('throws if reaction is missing or empty', async () => { - await expect(channel.sendReaction(messageId, {})).rejects.toThrow( - 'Reaction object is missing', - ); - }); - it('queues task if offlineDb exists', async () => { - await channel.sendReaction(messageId, reaction, options); + await channel.sendReaction(request); expect(queueTaskSpy).toHaveBeenCalledTimes(1); @@ -2954,7 +3015,7 @@ describe('send reaction flow', () => { channelId: 'test', channelType: 'messaging', messageId, - payload: [messageId, reaction, options], + payload: [request], type: 'send-reaction', }, }); @@ -2965,55 +3026,50 @@ describe('send reaction flow', () => { it('falls back to _sendReaction if offlineDb throws', async () => { client.offlineDb.queueTask.mockRejectedValue(new Error('Offline failure')); - await channel.sendReaction(messageId, reaction, options); + await channel.sendReaction(request); - expect(loggerSpy).toHaveBeenCalledTimes(1); expect(channel._sendReaction).toHaveBeenCalledTimes(1); - expect(channel._sendReaction).toHaveBeenCalledWith(messageId, reaction, options); + expect(channel._sendReaction).toHaveBeenCalledWith(request); }); it('falls back to _sendReaction if offlineDb is undefined', async () => { client.offlineDb = undefined; - await channel.sendReaction(messageId, reaction, options); + await channel.sendReaction(request); expect(channel._sendReaction).toHaveBeenCalledTimes(1); - expect(channel._sendReaction).toHaveBeenCalledWith(messageId, reaction, options); + expect(channel._sendReaction).toHaveBeenCalledWith(request); }); }); describe('_sendReaction', () => { - it('throws if messageID is missing', async () => { - await expect(channel._sendReaction('', reaction)).rejects.toThrow( - 'Message id is missing', - ); - }); - - it('throws if reaction is missing or empty', async () => { - await expect(channel._sendReaction(messageId, {})).rejects.toThrow( - 'Reaction object is missing', - ); - }); - - it('posts to correct URL with reaction and options', async () => { - await channel._sendReaction(messageId, reaction, options); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}/reaction`, - { - reaction, - ...options, - }, + it('sends the reaction to the correct endpoint with reaction and options', async () => { + const sendRequestSpy = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); + + await channel._sendReaction(request); + + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(sendRequestSpy).toHaveBeenCalledWith( + 'POST', + '/api/v2/chat/messages/{id}/reaction', + { id: messageId }, + undefined, + { reaction, enforce_unique: true, skip_push: true }, + 'application/json', ); }); - it('returns the response from post', async () => { - postSpy.mockResolvedValue({ message: 'ok' }); + it('returns the response from the underlying call', async () => { + vi.spyOn(client.api, 'sendRequest').mockResolvedValue({ + body: { message: { id: messageId } }, + metadata: {}, + }); - const result = await channel._sendReaction(messageId, reaction); + const result = await channel._sendReaction(request); - expect(result).toEqual({ message: 'ok' }); + expect(result.message).toMatchObject({ id: messageId }); }); }); }); @@ -3023,12 +3079,13 @@ describe('delete reaction flow', () => { const reactionType = 'love'; const user_id = 'user-abc'; + // Reactions are now deleted with a single request object: deleteReaction({ id, type, user_id? }). + const request = { id: messageId, type: reactionType }; + let client; let channel; - let loggerSpy; let queueTaskSpy; let deleteReactionSpy; - let deleteSpy; beforeEach(async () => { client = await getClientWithUser({ id: user_id }); @@ -3045,16 +3102,17 @@ describe('delete reaction flow', () => { // (channel.deleteReaction now resolves the message via messagePaginator.getItem). channel.messagePaginator.ingestItem({ id: messageId }); - loggerSpy = vi.spyOn(client, 'logger').mockImplementation(vi.fn()); queueTaskSpy = vi.spyOn(client.offlineDb, 'queueTask').mockResolvedValue({}); deleteReactionSpy = vi.spyOn(client.offlineDb, 'deleteReaction').mockResolvedValue(); - deleteSpy = vi.spyOn(client, 'delete').mockResolvedValue({}); }); afterEach(() => { vi.resetAllMocks(); }); + // NOTE: the 'Deleting a reaction requires specifying both the message and reaction type' + // validation was dropped in the OpenAPI-client migration; the throw tests were removed. + describe('deleteReaction', () => { beforeEach(() => { vi.spyOn(channel, '_deleteReaction').mockResolvedValue({}); @@ -3064,32 +3122,16 @@ describe('delete reaction flow', () => { vi.resetAllMocks(); }); - it('throws if messageID or reactionType is missing', async () => { - await expect(channel.deleteReaction('', reactionType)).rejects.toThrow( - 'Deleting a reaction requires specifying both the message and reaction type', - ); - await expect(channel.deleteReaction(messageId, '')).rejects.toThrow( - 'Deleting a reaction requires specifying both the message and reaction type', - ); - }); - it('calls offlineDb.deleteReaction and queues task if offlineDb exists', async () => { - await channel.deleteReaction(messageId, reactionType); + await channel.deleteReaction(request); expect(deleteReactionSpy).toHaveBeenCalledTimes(1); expect(queueTaskSpy).toHaveBeenCalledTimes(1); - const expectedReaction = { - created_at: '', - updated_at: '', - message_id: messageId, - type: reactionType, - user_id: user_id, - }; - + // The optimistic reaction now carries only message_id and type. expect(deleteReactionSpy).toHaveBeenCalledWith({ - message: { id: messageId }, - reaction: expectedReaction, + message: channel.messagePaginator.getItem(messageId), + reaction: { message_id: messageId, type: reactionType }, }); expect(queueTaskSpy).toHaveBeenCalledWith({ @@ -3097,7 +3139,7 @@ describe('delete reaction flow', () => { channelId: 'test', channelType: 'messaging', messageId, - payload: [messageId, reactionType], + payload: [request], type: 'delete-reaction', }, }); @@ -3106,8 +3148,8 @@ describe('delete reaction flow', () => { }); it('skips calling offlineDb.deleteReaction if the message does not exist in the state, but still queues the task', async () => { - const unknownMessageId = 'some-unknown-message-id'; - await channel.deleteReaction(unknownMessageId, reactionType); + const unknownRequest = { id: 'some-unknown-message-id', type: reactionType }; + await channel.deleteReaction(unknownRequest); expect(deleteReactionSpy).not.toHaveBeenCalled(); expect(queueTaskSpy).toHaveBeenCalledTimes(1); @@ -3115,8 +3157,8 @@ describe('delete reaction flow', () => { task: { channelId: 'test', channelType: 'messaging', - messageId: unknownMessageId, - payload: [unknownMessageId, reactionType], + messageId: unknownRequest.id, + payload: [unknownRequest], type: 'delete-reaction', }, }); @@ -3126,67 +3168,64 @@ describe('delete reaction flow', () => { it('falls back to _deleteReaction if offlineDb throws', async () => { deleteReactionSpy.mockRejectedValue(new Error('Offline failure')); - await channel.deleteReaction(messageId, reactionType); + await channel.deleteReaction(request); - expect(loggerSpy).toHaveBeenCalledTimes(1); expect(channel._deleteReaction).toHaveBeenCalledTimes(1); - expect(channel._deleteReaction).toHaveBeenCalledWith( - messageId, - reactionType, - undefined, - ); + expect(channel._deleteReaction).toHaveBeenCalledWith(request); }); it('falls back to _deleteReaction if offlineDb is undefined', async () => { client.offlineDb = undefined; - await channel.deleteReaction(messageId, reactionType); + await channel.deleteReaction(request); expect(channel._deleteReaction).toHaveBeenCalledTimes(1); - expect(channel._deleteReaction).toHaveBeenCalledWith( - messageId, - reactionType, - undefined, - ); + expect(channel._deleteReaction).toHaveBeenCalledWith(request); }); }); describe('_deleteReaction', () => { - it('throws if messageID or reactionType is missing', async () => { - await expect(channel._deleteReaction(undefined, reactionType)).rejects.toThrow( - 'Deleting a reaction requires specifying both the message and reaction type', - ); - await expect(channel._deleteReaction(messageId, undefined)).rejects.toThrow( - 'Deleting a reaction requires specifying both the message and reaction type', - ); - }); - - it('calls delete with user_id when provided', async () => { - await channel._deleteReaction(messageId, reactionType, user_id); - - expect(deleteSpy).toHaveBeenCalledTimes(1); - expect(deleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}/reaction/${encodeURIComponent(reactionType)}`, + it('calls sendRequest with user_id when provided', async () => { + const sendRequestSpy = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); + + await channel._deleteReaction({ ...request, user_id }); + + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(sendRequestSpy).toHaveBeenCalledWith( + 'DELETE', + '/api/v2/chat/messages/{id}/reaction/{type}', + { id: messageId, type: reactionType }, { user_id }, ); }); - it('calls delete with empty body if user_id is not provided', async () => { - await channel._deleteReaction(messageId, reactionType); + it('calls sendRequest with undefined user_id if user_id is not provided', async () => { + const sendRequestSpy = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); + + await channel._deleteReaction(request); - expect(deleteSpy).toHaveBeenCalledTimes(1); - expect(deleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}/reaction/${encodeURIComponent(reactionType)}`, - {}, + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(sendRequestSpy).toHaveBeenCalledWith( + 'DELETE', + '/api/v2/chat/messages/{id}/reaction/{type}', + { id: messageId, type: reactionType }, + { user_id: undefined }, ); }); - it('returns the response from delete', async () => { - deleteSpy.mockResolvedValue({ success: true }); + it('returns the response from the underlying call', async () => { + vi.spyOn(client.api, 'sendRequest').mockResolvedValue({ + body: { message: { id: messageId } }, + metadata: {}, + }); - const result = await channel._deleteReaction(messageId, reactionType); + const result = await channel._deleteReaction(request); - expect(result).toEqual({ success: true }); + expect(result.message).toMatchObject({ id: messageId }); }); }); }); @@ -3194,9 +3233,7 @@ describe('delete reaction flow', () => { describe('message sending flow', () => { let client; let channel; - let loggerSpy; let queueTaskSpy; - let postSpy; const message = { id: 'msg-123', @@ -3204,11 +3241,8 @@ describe('message sending flow', () => { user: { id: 'user-abc' }, }; - const options = { - pending: true, - skip_push: true, - pending_message_metadata: { source: 'local' }, - }; + // Messages are now sent as a single request object: sendMessage({ message, ...flags }). + const request = { message, skip_push: true }; beforeEach(async () => { client = await getClientWithUser({ id: 'user-abc' }); @@ -3219,9 +3253,7 @@ describe('message sending flow', () => { channel = client.channel('messaging', 'test'); - loggerSpy = vi.spyOn(client, 'logger').mockImplementation(vi.fn()); queueTaskSpy = vi.spyOn(client.offlineDb, 'queueTask').mockResolvedValue({}); - postSpy = vi.spyOn(client, 'post').mockResolvedValue({}); }); afterEach(() => { @@ -3238,7 +3270,7 @@ describe('message sending flow', () => { }); it('queues task if offlineDb exists and message has ID', async () => { - const result = await channel.sendMessage(message, options); + const result = await channel.sendMessage(request); expect(queueTaskSpy).toHaveBeenCalledTimes(1); expect(queueTaskSpy).toHaveBeenCalledWith({ @@ -3246,7 +3278,7 @@ describe('message sending flow', () => { channelId: 'test', channelType: 'messaging', messageId: 'msg-123', - payload: [message, options], + payload: [request], type: 'send-message', }, }); @@ -3258,53 +3290,74 @@ describe('message sending flow', () => { it('falls back to _sendMessage if offlineDb is missing', async () => { client.offlineDb = undefined; - const result = await channel.sendMessage(message, options); + const result = await channel.sendMessage(request); expect(channel._sendMessage).toHaveBeenCalledTimes(1); - expect(channel._sendMessage).toHaveBeenCalledWith(message, options); + expect(channel._sendMessage).toHaveBeenCalledWith(request); expect(result).toEqual({}); }); it('falls back to _sendMessage if message.id is missing', async () => { - const msg = { ...message, id: undefined }; + const noIdRequest = { message: { ...message, id: undefined }, skip_push: true }; - await channel.sendMessage(msg, options); + await channel.sendMessage(noIdRequest); - expect(channel._sendMessage).toHaveBeenCalledWith(msg, options); + expect(channel._sendMessage).toHaveBeenCalledWith(noIdRequest); }); it('falls back to _sendMessage if offlineDb throws', async () => { queueTaskSpy.mockRejectedValue(new Error('Queue failed')); - const result = await channel.sendMessage(message, options); + const result = await channel.sendMessage(request); - expect(loggerSpy).toHaveBeenCalledTimes(1); - expect(channel._sendMessage).toHaveBeenCalledWith(message, options); + expect(channel._sendMessage).toHaveBeenCalledWith(request); expect(result).toEqual({}); }); }); describe('_sendMessage', () => { - it('posts the message to the correct endpoint with options', async () => { - const expectedUrl = `${client.baseURL}/channels/messaging/test/message`; - - const result = await channel._sendMessage(message, options); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy).toHaveBeenCalledWith(expectedUrl, { - message, - ...options, - }); - - expect(result).toEqual({}); + it('sends the message to the correct endpoint with options', async () => { + const sendRequestSpy = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); + + await channel._sendMessage(request); + + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(sendRequestSpy).toHaveBeenCalledWith( + 'POST', + '/api/v2/chat/channels/{type}/{id}/message', + { type: 'messaging', id: 'test' }, + undefined, + { + message, + keep_channel_hidden: undefined, + skip_enrich_url: undefined, + skip_push: true, + }, + 'application/json', + ); }); it('works without options', async () => { - await channel._sendMessage(message); + const sendRequestSpy = vi + .spyOn(client.api, 'sendRequest') + .mockResolvedValue({ body: {}, metadata: {} }); - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels/messaging/test/message`, - { message }, + await channel._sendMessage({ message }); + + expect(sendRequestSpy).toHaveBeenCalledWith( + 'POST', + '/api/v2/chat/channels/{type}/{id}/message', + { type: 'messaging', id: 'test' }, + undefined, + { + message, + keep_channel_hidden: undefined, + skip_enrich_url: undefined, + skip_push: undefined, + }, + 'application/json', ); }); }); @@ -3331,50 +3384,45 @@ describe('share location', () => { const channel = client.channel('messaging', 'test'); const sendMessageSpy = vi.spyOn(channel, 'sendMessage').mockResolvedValue({}); const dispatchEventSpy = vi.spyOn(client, 'dispatchEvent').mockResolvedValue({}); - const updateLocationSpy = vi.spyOn(client, 'updateLocation').mockResolvedValue({}); + // stopLiveLocationSharing now goes through the generated client.updateLiveLocation. + const updateLiveLocationSpy = vi + .spyOn(client, 'updateLiveLocation') + .mockResolvedValue({}); return { channel, client, dispatchEventSpy, sendMessageSpy, - updateLocationSpy, + updateLiveLocationSpy, }; }; it('forwards the location object', async () => { const { channel, sendMessageSpy } = await setup(); + // sendSharedLocation now forwards a single-object sendMessage request wrapping the location. await channel.sendSharedLocation(staticLocation); expect(sendMessageSpy).toHaveBeenCalledWith({ - id: staticLocation.message_id, - shared_location: staticLocation, - user: undefined, + message: { id: staticLocation.message_id, shared_location: staticLocation }, }); await channel.sendSharedLocation(liveLocation); expect(sendMessageSpy).toHaveBeenCalledWith({ - id: liveLocation.message_id, - shared_location: liveLocation, - user: undefined, + message: { id: liveLocation.message_id, shared_location: liveLocation }, }); }); - it('injects the user object into the request payload', async () => { + it('does not inject a user into the request payload', async () => { + // The `userId`/`user` injection was dropped in the OpenAPI-client migration: + // sendSharedLocation takes only the location and forwards no user object. const { channel, sendMessageSpy } = await setup(); await channel.sendSharedLocation(staticLocation, userId); - expect(sendMessageSpy).toHaveBeenCalledWith({ - id: staticLocation.message_id, - shared_location: staticLocation, - user: { id: userId }, - }); - - await channel.sendSharedLocation(liveLocation, userId); - expect(sendMessageSpy).toHaveBeenCalledWith({ - id: liveLocation.message_id, - shared_location: liveLocation, - user: { id: userId }, + const sentArg = sendMessageSpy.mock.calls[0][0]; + expect(sentArg).to.deep.equal({ + message: { id: staticLocation.message_id, shared_location: staticLocation }, }); + expect(sentArg.message.user).to.be.undefined; }); it('emits live_location_sharing.started local event', async () => { const { channel, dispatchEventSpy, sendMessageSpy } = await setup(); @@ -3392,16 +3440,16 @@ describe('share location', () => { }); it('stops live location sharing', async () => { - const { channel, dispatchEventSpy, updateLocationSpy } = await setup(); + const { channel, dispatchEventSpy, updateLiveLocationSpy } = await setup(); - updateLocationSpy.mockResolvedValueOnce(staticLocation); + updateLiveLocationSpy.mockResolvedValueOnce(staticLocation); await channel.stopLiveLocationSharing(staticLocation); expect(dispatchEventSpy).toHaveBeenCalledWith({ live_location: expect.objectContaining(staticLocation), type: 'live_location_sharing.stopped', }); - updateLocationSpy.mockResolvedValueOnce(liveLocation); + updateLiveLocationSpy.mockResolvedValueOnce(liveLocation); await channel.stopLiveLocationSharing(liveLocation); expect(dispatchEventSpy).toHaveBeenCalledWith({ live_location: expect.objectContaining(liveLocation), diff --git a/test/unit/channel_manager.test.ts b/test/unit/channel_manager.test.ts index 74b5863229..fa5f9a5995 100644 --- a/test/unit/channel_manager.test.ts +++ b/test/unit/channel_manager.test.ts @@ -1,7 +1,7 @@ import sinon from 'sinon'; import { Channel, - ChannelAPIResponse, + ChannelStateResponseFields, ChannelManager, ChannelResponse, StreamChat, @@ -10,7 +10,9 @@ import { channelManagerEventToHandlerMapping, DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS, QueryChannelsRequestType, - QueryChannelsAPIResponse, + QueryChannelsResponse, + RequestMetadata, + EventPayload, } from '../../src'; import { generateChannel } from './test-utils/generateChannel'; @@ -24,7 +26,7 @@ import { DEFAULT_QUERY_CHANNELS_RETRY_COUNT } from '../../src/constants'; describe('ChannelManager', () => { let client: StreamChat; let channelManager: ChannelManager; - let channelsResponse: ChannelAPIResponse[]; + let channelsResponse: ChannelStateResponseFields[]; beforeEach(async () => { client = await getClientWithUser(); @@ -37,7 +39,7 @@ describe('ChannelManager', () => { ]; client.hydrateActiveChannels(channelsResponse); const channels = channelsResponse.map((c) => - client.channel(c.channel.type, c.channel.id), + client.channel(c.channel!.type, c.channel!.id), ); channelManager.state.partialNext({ channels, initialized: true }); }); @@ -66,8 +68,6 @@ describe('ChannelManager', () => { isLoading: false, isLoadingNext: false, hasNext: false, - filters: {}, - sort: {}, options: DEFAULT_CHANNEL_MANAGER_PAGINATION_OPTIONS, }); expect(state.initialized).to.be.false; @@ -135,7 +135,7 @@ describe('ChannelManager', () => { }); const clientQueryChannelsSpy = vi - .spyOn(client, 'queryChannels') + .spyOn(client, 'queryChannelsAndHydrate') .mockImplementation(async () => []); await (channelManager as any).queryChannelsRequest({}); expect(clientQueryChannelsSpy).toHaveBeenCalledOnce(); @@ -295,14 +295,14 @@ describe('ChannelManager', () => { presence: true, state: true, watch: true, + filter_conditions: { team: 'blue' }, + sort: [{ field: 'last_message_at', direction: -1 }], }; channelManager.state.partialNext({ pagination: { ...pagination, - filters: { team: 'blue' }, options, - sort: { last_message_at: -1 }, }, }); @@ -310,9 +310,7 @@ describe('ChannelManager', () => { expect(client.offlineDb!.upsertCidsForQuery).toHaveBeenCalledExactlyOnceWith({ cids: channels.map((channel) => channel.cid), - filters: { team: 'blue' }, options, - sort: { last_message_at: -1 }, }); }); }); @@ -510,17 +508,14 @@ describe('ChannelManager', () => { mockChannelPages.flat().map((obj) => [obj.cid, obj]), ); clientQueryChannelsStub = sinon - .stub(client, 'queryChannels') - .callsFake((filters, _sort, options) => { - if ( - typeof filters.cid === 'object' && - filters.cid !== null && - '$in' in filters.cid - ) { - const toReturn = (filters.cid['$in'] ?? []) as string[]; + .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 = options?.offset ?? 0; + const offset = request?.offset ?? 0; return Promise.resolve(mockChannelPages[Math.floor(offset / 10)]); }); }); @@ -569,15 +564,17 @@ describe('ChannelManager', () => { ); stateChangeSpy.resetHistory(); - await channelManager.queryChannels({ filterA: true }, { asc: 1 }); + 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, - filters: { filterA: true }, - options: {}, - sort: { asc: 1 }, + options: request, }); expect( @@ -593,20 +590,20 @@ describe('ChannelManager', () => { }); it('passes full predefined-filter query options when hydrating channels from DB', async () => { - const options = { + 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({}, [], options); + await channelManager.queryChannels(request); expect(client.offlineDb!.getChannelsForQuery).toHaveBeenCalledExactlyOnceWith({ userId: client.userID, - filters: {}, - options, - sort: [], + options: request, }); }); @@ -619,7 +616,10 @@ describe('ChannelManager', () => { ); stateChangeSpy.resetHistory(); - await channelManager.queryChannels({ filterA: true }, { asc: 1 }); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + }); expect(client.offlineDb!.getChannelsForQuery).not.toHaveBeenCalled(); expect(hydrateActiveChannelsSpy.called).to.be.false; @@ -636,7 +636,11 @@ describe('ChannelManager', () => { ); stateChangeSpy.resetHistory(); - await channelManager.queryChannels({ filterA: true }, { asc: 1 }); + 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); @@ -650,9 +654,9 @@ describe('ChannelManager', () => { expect( executeChannelsQuerySpy.calledOnceWithExactly({ - filters: { filterA: true }, - sort: { asc: 1 }, - options: {}, + filters: request.filter_conditions, + sort: request.sort, + options: request, stateOptions: {}, }), ).to.be.true; @@ -678,7 +682,10 @@ describe('ChannelManager', () => { ); stateChangeSpy.resetHistory(); - await channelManager.queryChannels({ filterA: true }, { asc: 1 }); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + }); expect(client.offlineDb!.getChannelsForQuery).toHaveBeenCalled(); expect(hydrateActiveChannelsSpy.called).to.be.true; @@ -697,7 +704,10 @@ describe('ChannelManager', () => { ); stateChangeSpy.resetHistory(); - await channelManager.queryChannels({ filterA: true }, { asc: 1 }); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + }); expect(client.offlineDb!.getChannelsForQuery).not.toHaveBeenCalled(); expect(hydrateActiveChannelsSpy.called).to.be.false; @@ -786,30 +796,29 @@ describe('ChannelManager', () => { stateChangeSpy.resetHistory(); await channelManager['executeChannelsQuery']({ - filters: { filterA: true }, - sort: { asc: 1 }, - options: { limit: 10, offset: 0 }, + 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( - clientQueryChannelsStub.calledWith( - { filterA: true }, - { asc: 1 }, - { limit: 10, offset: 0 }, - ), - ); expect(stateChangeSpy.callCount).to.equal(1); expect(stateChangeSpy.args[0][0]).to.deep.equal({ pagination: { - filters: {}, hasNext: true, isLoading: false, isLoadingNext: false, - options: { limit: 10, offset: 10 }, - sort: {}, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 10, + }, }, }); expect(channels.length).to.equal(10); @@ -827,6 +836,8 @@ describe('ChannelManager', () => { ).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' }, @@ -838,17 +849,10 @@ describe('ChannelManager', () => { }; const { pagination } = channelManager.state.getLatestValue(); channelManager.state.partialNext({ - pagination: { - ...pagination, - filters: { filterA: true }, - options: queryOptions, - sort: { asc: 1 }, - }, + pagination: { ...pagination, options: queryOptions }, }); await channelManager['executeChannelsQuery']({ - filters: { filterA: true }, - sort: { asc: 1 }, options: queryOptions, stateOptions: {}, }); @@ -857,7 +861,7 @@ describe('ChannelManager', () => { cids: mockChannelPages[0].map((channel) => channel.cid), filters: { filterA: true }, options: queryOptions, - sort: { asc: 1 }, + sort: [{ field: 'asc', direction: 1 }], }); }); @@ -865,7 +869,7 @@ describe('ChannelManager', () => { clientQueryChannelsStub.callsFake(() => mockChannelPages[2]); await channelManager['executeChannelsQuery']({ filters: { filterA: true }, - sort: { asc: 1 }, + sort: [{ field: 'asc', direction: 1 }], options: { limit: 10, offset: 0 }, }); @@ -895,7 +899,7 @@ describe('ChannelManager', () => { await channelManager['executeChannelsQuery']({ filters: { filterA: true }, - sort: { asc: 1 }, + sort: [{ field: 'asc', direction: 1 }], options: { limit: 10, offset: 0 }, }); @@ -937,7 +941,7 @@ describe('ChannelManager', () => { await channelManager['executeChannelsQuery']({ filters: { filterA: true }, - sort: { asc: 1 }, + sort: [{ field: 'asc', direction: 1 }], options: { limit: 10, offset: 0 }, }); @@ -967,7 +971,7 @@ describe('ChannelManager', () => { await channelManager['executeChannelsQuery']( { filters: { filterA: true }, - sort: { asc: 1 }, + sort: [{ field: 'asc', direction: 1 }], options: { limit: 10, offset: 0 }, }, 3, @@ -999,7 +1003,7 @@ describe('ChannelManager', () => { await channelManager['executeChannelsQuery']({ filters: { filterA: true }, - sort: { asc: 1 }, + sort: [{ field: 'asc', direction: 1 }], options: { limit: 10, offset: 0 }, }); @@ -1022,11 +1026,13 @@ describe('ChannelManager', () => { ); stateChangeSpy.resetHistory(); - await channelManager.queryChannels( - { filterA: true }, - { asc: 1 }, - { limit: 10, offset: 0 }, - ); + const request = { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 0, + }; + await channelManager.queryChannels(request); const { channels } = channelManager.state.getLatestValue(); @@ -1034,22 +1040,18 @@ describe('ChannelManager', () => { expect(stateChangeSpy.callCount).to.equal(2); expect(stateChangeSpy.args[0][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: false, isLoading: true, isLoadingNext: false, - options: { limit: 10, offset: 0 }, - sort: { asc: 1 }, + options: request, }, }); expect(stateChangeSpy.args[1][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: false, - options: { limit: 10, offset: 10 }, - sort: { asc: 1 }, + options: { ...request, offset: 10 }, }, }); expect(channels.length).to.equal(10); @@ -1057,11 +1059,12 @@ describe('ChannelManager', () => { 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( - { filterA: true }, - { asc: 1 }, - { limit: 10, offset: 0 }, - ); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 0, + }); const { channels, @@ -1082,18 +1085,25 @@ describe('ChannelManager', () => { const queryChannelsOverride = async ( ...params: Parameters ) => { - const [filters, ...restParams] = params; - filters.cid = { $in: fetchedChannels.map((c) => c.cid) }; + const [request, ...restParams] = params; + const updatedRequest = { + ...request, + filter_conditions: { + ...request?.filter_conditions, + cid: { $in: fetchedChannels.map((c) => c.cid) }, + }, + }; - return await client.queryChannels(filters, ...restParams); + return await client.queryChannelsAndHydrate(updatedRequest, ...restParams); }; channelManager.setQueryChannelsRequest(queryChannelsOverride); - await channelManager.queryChannels( - { filterA: true }, - { asc: 1 }, - { limit: 15, offset: 0 }, - ); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 15, + offset: 0, + }); const { channels, @@ -1170,11 +1180,12 @@ describe('ChannelManager', () => { }); it('should properly set the new pagination parameters and update the offset after loading next', async () => { - await channelManager.queryChannels( - { filterA: true }, - { asc: 1 }, - { limit: 10, offset: 0 }, - ); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 0, + }); const stateChangeSpy = sinon.spy(); channelManager.state.subscribeWithSelector( @@ -1192,33 +1203,40 @@ describe('ChannelManager', () => { expect(stateChangeSpy.callCount).to.equal(2); expect(stateChangeSpy.args[0][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: true, - options: { limit: 10, offset: 10 }, - sort: { asc: 1 }, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 10, + }, }, }); expect(stateChangeSpy.args[1][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: false, - options: { limit: 10, offset: 20 }, - sort: { asc: 1 }, + 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( - { filterA: true }, - { asc: 1 }, - { limit: 10, offset: 0 }, - ); + 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], @@ -1240,33 +1258,40 @@ describe('ChannelManager', () => { expect(stateChangeSpy.callCount).to.equal(2); expect(stateChangeSpy.args[0][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: true, - options: { limit: 10, offset: 10 }, - sort: { asc: 1 }, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 10, + }, }, }); expect(stateChangeSpy.args[1][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: false, - options: { limit: 10, offset: 20 }, - sort: { asc: 1 }, + 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( - { filterA: true }, - { asc: 1 }, - { limit: 10, offset: 0 }, - ); + 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], @@ -1288,33 +1313,40 @@ describe('ChannelManager', () => { expect(stateChangeSpy.callCount).to.equal(2); expect(stateChangeSpy.args[0][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: true, - options: { limit: 10, offset: 10 }, - sort: { asc: 1 }, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 10, + }, }, }); expect(stateChangeSpy.args[1][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: false, - options: { limit: 10, offset: 20 }, - sort: { asc: 1 }, + 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( - { filterA: true }, - { asc: 1 }, - { limit: 10, offset: 0 }, - ); + 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], @@ -1342,32 +1374,41 @@ describe('ChannelManager', () => { expect(stateChangeSpy.callCount).to.equal(4); expect(stateChangeSpy.args[0][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: true, - options: { limit: 10, offset: 10 }, - sort: { asc: 1 }, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 10, + }, }, }); expect(stateChangeSpy.args[1][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: true, isLoading: false, isLoadingNext: false, - options: { limit: 10, offset: 20 }, - sort: { asc: 1 }, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 20, + }, }, }); expect(stateChangeSpy.args[3][0]).to.deep.equal({ pagination: { - filters: { filterA: true }, hasNext: false, isLoading: false, isLoadingNext: false, - options: { limit: 10, offset: 25 }, - sort: { asc: 1 }, + options: { + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 25, + }, }, }); expect(channels.length).to.equal(25); @@ -1377,11 +1418,12 @@ describe('ChannelManager', () => { const { channels: initialChannels } = channelManager.state.getLatestValue(); expect(initialChannels.length).to.equal(0); - await channelManager.queryChannels( - { filterA: true }, - { asc: 1 }, - { limit: 10, offset: 0 }, - ); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 10, + offset: 0, + }); await channelManager.loadNext(); const { @@ -1412,24 +1454,31 @@ describe('ChannelManager', () => { const queryChannelsOverride = async ( ...params: Parameters ) => { - const [filters, sort, options, ...restParams] = params; - const isInitialPage = options?.offset === 0; - filters.cid = { - $in: (isInitialPage ? fetchedChannels : fetchedNextPageChannels).map( - (c) => c.cid, - ), + 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.queryChannels(filters, sort, options, ...restParams); + return await client.queryChannelsAndHydrate(updatedRequest, ...restParams); }; channelManager.setQueryChannelsRequest(queryChannelsOverride); - await channelManager.queryChannels( - { filterA: true }, - { asc: 1 }, - { limit: 15, offset: 0 }, - ); + await channelManager.queryChannels({ + filter_conditions: { filterA: true }, + sort: [{ field: 'asc', direction: 1 }], + limit: 15, + offset: 0, + }); const { channels: prevChannels, @@ -1496,9 +1545,9 @@ describe('ChannelManager', () => { sort, }: { filter: Record; - sort?: NonNullable['sort']; + sort?: NonNullable['sort']; }) => { - vi.spyOn(client, 'post').mockResolvedValueOnce({ + vi.spyOn(client, 'queryChannels').mockResolvedValueOnce({ duration: '0.01s', channels: channelsResponse, predefined_filter: { @@ -1506,9 +1555,12 @@ describe('ChannelManager', () => { filter, sort, }, - } satisfies QueryChannelsAPIResponse); + metadata: {} as RequestMetadata, + }); - await channelManager.queryChannels({}, [], { + await channelManager.queryChannels({ + filter_conditions: {}, + sort: [], predefined_filter: 'messaging_channels', }); setChannelsStub.mockClear(); @@ -1548,7 +1600,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); expect(setChannelsStub).toHaveBeenCalledTimes(0); }); @@ -1562,7 +1614,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); expect(setChannelsStub).toHaveBeenCalledTimes(0); }); @@ -1580,7 +1632,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); expect(setChannelsStub).toHaveBeenCalledTimes(0); }); @@ -1598,7 +1650,7 @@ describe('ChannelManager', () => { 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(); @@ -1620,7 +1672,7 @@ describe('ChannelManager', () => { type: 'channel.visible', channel_id: 'channel4', channel_type: 'messaging', - }); + } as EventPayload<'channel.visible'>); await clock.runAllAsync(); clock.restore(); @@ -1640,8 +1692,8 @@ describe('ChannelManager', () => { type: 'member.updated', channel_id: 'channel2', channel_type: 'messaging', - member: { user: { id: client.userID } }, - }); + member: { user: { id: client.userId! } }, + } as EventPayload<'member.updated'>); expect(setChannelsStub).toHaveBeenCalledOnce(); expect( @@ -1666,7 +1718,7 @@ describe('ChannelManager', () => { channel_id: 'channel3', channel_type: 'messaging', member: { user: { id: client.userID } }, - }); + } as EventPayload<'member.updated'>); expect(setChannelsStub).toHaveBeenCalledOnce(); expect( @@ -1675,11 +1727,16 @@ describe('ChannelManager', () => { }); it('keeps non-predefined query behavior based on caller filters and sort', async () => { - vi.spyOn(client, 'post').mockResolvedValueOnce({ + vi.spyOn(client, 'queryChannels').mockResolvedValueOnce({ duration: '0.01s', channels: channelsResponse, - } satisfies QueryChannelsAPIResponse); - await channelManager.queryChannels({ archived: false }, [], { limit: 10 }); + metadata: {} as RequestMetadata, + }); + await channelManager.queryChannels({ + filter_conditions: { archived: false }, + sort: [], + limit: 10, + }); setChannelsStub.mockClear(); setChannelMembership('channel2', { archived_at: '2024-01-15T10:30:00Z', @@ -1689,13 +1746,13 @@ describe('ChannelManager', () => { 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, 'post') + vi.spyOn(client, 'queryChannels') .mockResolvedValueOnce({ duration: '0.01s', channels: channelsResponse, @@ -1704,7 +1761,8 @@ describe('ChannelManager', () => { filter: { archived: false }, sort: [{ field: 'pinned_at', direction: -1 }], }, - } satisfies QueryChannelsAPIResponse) + metadata: {} as RequestMetadata, + }) .mockResolvedValueOnce({ duration: '0.01s', channels: [ @@ -1716,9 +1774,12 @@ describe('ChannelManager', () => { filter: { archived: false }, sort: [{ field: 'pinned_at', direction: -1 }], }, - } satisfies QueryChannelsAPIResponse); + metadata: {} as RequestMetadata, + }); - await channelManager.queryChannels({}, [], { + await channelManager.queryChannels({ + filter_conditions: {}, + sort: [], predefined_filter: 'messaging_channels', limit: 2, offset: 0, @@ -1733,13 +1794,13 @@ describe('ChannelManager', () => { 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, 'post') + vi.spyOn(client, 'queryChannels') .mockResolvedValueOnce({ duration: '0.01s', channels: channelsResponse, @@ -1748,16 +1809,24 @@ describe('ChannelManager', () => { filter: { archived: false }, sort: [{ field: 'pinned_at', direction: -1 }], }, - } satisfies QueryChannelsAPIResponse) + metadata: {} as RequestMetadata, + }) .mockResolvedValueOnce({ duration: '0.01s', channels: channelsResponse, - } satisfies QueryChannelsAPIResponse); + metadata: {} as RequestMetadata, + }); - await channelManager.queryChannels({}, [], { + await channelManager.queryChannels({ + filter_conditions: {}, + sort: [], predefined_filter: 'messaging_channels', }); - await channelManager.queryChannels({}, [], { limit: 10 }); + await channelManager.queryChannels({ + filter_conditions: {}, + sort: [], + limit: 10, + }); setChannelsStub.mockClear(); setChannelMembership('channel2', { archived_at: '2024-01-15T10:30:00Z', @@ -1767,7 +1836,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); expect(setChannelsStub).toHaveBeenCalledOnce(); expect( @@ -1780,7 +1849,7 @@ describe('ChannelManager', () => { let channelToRemove: ChannelResponse; beforeEach(() => { - channelToRemove = channelsResponse[1].channel; + channelToRemove = channelsResponse[1].channel!; }); ( @@ -1793,14 +1862,23 @@ describe('ChannelManager', () => { it('should return early if channels is undefined', () => { channelManager.state.partialNext({ channels: undefined }); - client.dispatchEvent({ type: eventType, cid: channelToRemove.cid }); - client.dispatchEvent({ type: eventType, channel: channelToRemove }); + 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 }); + client.dispatchEvent({ + type: eventType, + cid: channelToRemove.cid, + } as EventPayload); expect(setChannelsStub).toHaveBeenCalledOnce(); const channels = setChannelsStub.mock.lastCall?.[0] as Channel[]; @@ -1809,7 +1887,10 @@ describe('ChannelManager', () => { }); it('should remove the channel when event.channel?.cid matches', () => { - client.dispatchEvent({ type: eventType, channel: channelToRemove }); + client.dispatchEvent({ + type: eventType, + channel: channelToRemove, + } as EventPayload); expect(setChannelsStub).toHaveBeenCalledOnce(); expect( @@ -1819,7 +1900,9 @@ describe('ChannelManager', () => { it('should not modify the list if no channels match', () => { const { channels: prevChannels } = channelManager.state.getLatestValue(); - client.dispatchEvent({ type: eventType, cid: 'channel123' }); + client.dispatchEvent({ type: eventType, cid: 'channel123' } as EventPayload< + typeof eventType + >); const { channels: newChannels } = channelManager.state.getLatestValue(); expect(setChannelsStub).toHaveBeenCalledTimes(0); @@ -1837,7 +1920,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); expect(setChannelsStub).toHaveBeenCalledTimes(0); }); @@ -1851,7 +1934,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); const { channels: newChannels } = channelManager.state.getLatestValue(); @@ -1864,7 +1947,13 @@ describe('ChannelManager', () => { const { channels: prevChannels } = channelManager.state.getLatestValue(); channelManager.state.next((prevState) => ({ ...prevState, - pagination: { ...prevState.pagination, filters: { archived: false } }, + pagination: { + ...prevState.pagination, + options: { + ...prevState.pagination.options, + filter_conditions: { archived: false }, + }, + }, })); isChannelArchivedStub.mockReturnValueOnce(true); shouldConsiderArchivedChannelsStub.mockReturnValueOnce(true); @@ -1873,7 +1962,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); const { channels: newChannels } = channelManager.state.getLatestValue(); @@ -1886,7 +1975,13 @@ describe('ChannelManager', () => { const { channels: prevChannels } = channelManager.state.getLatestValue(); channelManager.state.next((prevState) => ({ ...prevState, - pagination: { ...prevState.pagination, filters: { archived: true } }, + pagination: { + ...prevState.pagination, + options: { + ...prevState.pagination.options, + filter_conditions: { archived: true }, + }, + }, })); isChannelArchivedStub.mockReturnValueOnce(false); shouldConsiderArchivedChannelsStub.mockReturnValueOnce(true); @@ -1895,7 +1990,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); const { channels: newChannels } = channelManager.state.getLatestValue(); @@ -1912,7 +2007,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); const { channels: newChannels } = channelManager.state.getLatestValue(); @@ -1942,7 +2037,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel4', - }); + } as EventPayload<'message.new'>); const { channels: newChannels } = channelManager.state.getLatestValue(); @@ -1965,7 +2060,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel4', - }); + } as EventPayload<'message.new'>); const stateAfter = channelManager.state.getLatestValue(); @@ -2001,7 +2096,7 @@ describe('ChannelManager', () => { type: 'message.new', channel_type: 'messaging', channel_id: 'channel2', - }); + } as EventPayload<'message.new'>); const stateAfter = channelManager.state.getLatestValue(); @@ -2040,7 +2135,7 @@ describe('ChannelManager', () => { client.dispatchEvent({ type: 'notification.message_new', channel: {} as unknown as ChannelResponse, - }); + } as EventPayload<'notification.message_new'>); await clock.runAllAsync(); @@ -2051,14 +2146,14 @@ describe('ChannelManager', () => { 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, + 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(); @@ -2075,18 +2170,27 @@ describe('ChannelManager', () => { shouldConsiderArchivedChannelsStub.mockReturnValue(true); const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); getAndWatchChannelStub.mockImplementation(async () => - client.channel(newChannelResponse.channel.type, newChannelResponse.channel.id), + client.channel( + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, + ), ); channelManager.state.next((prevState) => ({ ...prevState, - pagination: { ...prevState.pagination, filters: { archived: false } }, + pagination: { + ...prevState.pagination, + options: { + ...prevState.pagination.options, + filter_conditions: { archived: false }, + }, + }, })); client.dispatchEvent({ type: 'notification.message_new', - channel: newChannelResponse.channel as ChannelResponse, - }); + channel: newChannelResponse.channel, + } as EventPayload<'notification.message_new'>); await clock.runAllAsync(); @@ -2099,18 +2203,27 @@ describe('ChannelManager', () => { shouldConsiderArchivedChannelsStub.mockReturnValueOnce(true); const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); getAndWatchChannelStub.mockImplementation(async () => - client.channel(newChannelResponse.channel.type, newChannelResponse.channel.id), + client.channel( + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, + ), ); channelManager.state.next((prevState) => ({ ...prevState, - pagination: { ...prevState.pagination, filters: { archived: true } }, + 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(); @@ -2121,8 +2234,8 @@ describe('ChannelManager', () => { 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, + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, ); getAndWatchChannelStub.mockResolvedValueOnce(newChannel); channelManager.setOptions({ @@ -2136,7 +2249,7 @@ describe('ChannelManager', () => { client.dispatchEvent({ type: 'notification.message_new', channel: { type: 'messaging', id: 'channel4' } as unknown as ChannelResponse, - }); + } as EventPayload<'notification.message_new'>); await clock.runAllAsync(); @@ -2149,8 +2262,8 @@ describe('ChannelManager', () => { 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, + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, ); getAndWatchChannelStub.mockResolvedValueOnce(newChannel); @@ -2158,8 +2271,8 @@ describe('ChannelManager', () => { client.dispatchEvent({ type: 'notification.message_new', - channel: { type: 'messaging', id: 'channel4' } as unknown as ChannelResponse, - }); + channel: { type: 'messaging', id: 'channel4' }, + } as EventPayload<'notification.message_new'>); await clock.runAllAsync(); @@ -2188,8 +2301,8 @@ describe('ChannelManager', () => { 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, + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, ); getAndWatchChannelStub.mockResolvedValue(newChannel); @@ -2198,7 +2311,7 @@ describe('ChannelManager', () => { const event = { type: 'notification.message_new', channel: newChannelResponse.channel as ChannelResponse, - } as const; + } as EventPayload<'notification.message_new'>; // call the event 3 times client.dispatchEvent(event); client.dispatchEvent(event); @@ -2243,8 +2356,8 @@ describe('ChannelManager', () => { it('should not update the state if the event has no id and type', async () => { client.dispatchEvent({ type: 'channel.visible', - channel: {} as unknown as ChannelResponse, - }); + channel: {}, + } as EventPayload<'channel.visible'>); await clock.runAllAsync(); @@ -2256,13 +2369,16 @@ describe('ChannelManager', () => { channelManager.state.partialNext({ channels: undefined }); const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); getAndWatchChannelStub.mockImplementation(async () => - client.channel(newChannelResponse.channel.type, newChannelResponse.channel.id), + client.channel( + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, + ), ); client.dispatchEvent({ type: 'channel.visible', - channel_id: newChannelResponse.channel.id, - channel_type: newChannelResponse.channel.type, - }); + channel_id: newChannelResponse.channel!.id, + channel_type: newChannelResponse.channel!.type, + } as EventPayload<'channel.visible'>); await clock.runAllAsync(); @@ -2276,19 +2392,28 @@ describe('ChannelManager', () => { const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); getAndWatchChannelStub.mockImplementation(async () => - client.channel(newChannelResponse.channel.type, newChannelResponse.channel.id), + client.channel( + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, + ), ); channelManager.state.next((prevState) => ({ ...prevState, - pagination: { ...prevState.pagination, filters: { archived: false } }, + 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, - }); + channel_id: newChannelResponse.channel!.cid, + channel_type: newChannelResponse.channel!.type, + } as EventPayload<'channel.visible'>); await clock.runAllAsync(); @@ -2303,19 +2428,28 @@ describe('ChannelManager', () => { const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); getAndWatchChannelStub.mockImplementation(async () => - client.channel(newChannelResponse.channel.type, newChannelResponse.channel.id), + client.channel( + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, + ), ); channelManager.state.next((prevState) => ({ ...prevState, - pagination: { ...prevState.pagination, filters: { archived: true } }, + 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, - }); + channel_id: newChannelResponse.channel!.id, + channel_type: newChannelResponse.channel!.type, + } as EventPayload<'channel.visible'>); await clock.runAllAsync(); @@ -2326,8 +2460,8 @@ describe('ChannelManager', () => { 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, + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, ); getAndWatchChannelStub.mockResolvedValue(newChannel); @@ -2337,7 +2471,7 @@ describe('ChannelManager', () => { type: 'channel.visible', channel_id: 'channel4', channel_type: 'messaging', - }); + } as EventPayload<'channel.visible'>); await clock.runAllAsync(); @@ -2375,8 +2509,8 @@ describe('ChannelManager', () => { type: 'member.updated', channel_id: id ?? 'channel2', channel_type: 'messaging', - member: { user: { id: client?.userID ?? 'anonymous' } }, - }); + member: { user: { id: client?.userId ?? 'anonymous' } }, + } as EventPayload<'member.updated'>); }); afterEach(() => { @@ -2389,7 +2523,7 @@ describe('ChannelManager', () => { channel_id: 'channel2', channel_type: 'messaging', member: { user: { id: 'wrongUserID' } }, - }); + } as EventPayload<'member.updated'>); expect(setChannelsStub).toHaveBeenCalledTimes(0); client.dispatchEvent({ @@ -2397,7 +2531,7 @@ describe('ChannelManager', () => { channel_id: 'channel2', channel_type: 'messaging', member: {}, - }); + } as EventPayload<'member.updated'>); expect(setChannelsStub).toHaveBeenCalledTimes(0); }); @@ -2405,19 +2539,19 @@ describe('ChannelManager', () => { 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); }); @@ -2462,7 +2596,13 @@ describe('ChannelManager', () => { it('should handle archiving correctly', () => { channelManager.state.next((prevState) => ({ ...prevState, - pagination: { ...prevState.pagination, filters: { archived: true } }, + pagination: { + ...prevState.pagination, + options: { + ...prevState.pagination.options, + filter_conditions: { archived: true }, + }, + }, })); isChannelArchivedStub.mockReturnValueOnce(true); shouldConsiderArchivedChannelsStub.mockReturnValueOnce(true); @@ -2542,14 +2682,16 @@ describe('ChannelManager', () => { }); it('should not update state if event.channel defaults are missing', async () => { - client.dispatchEvent({ type: 'notification.added_to_channel' }); + 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); }); @@ -2557,8 +2699,8 @@ describe('ChannelManager', () => { 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, + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, ); getAndWatchChannelStub.mockResolvedValueOnce(newChannel); channelManager.setOptions({ @@ -2575,8 +2717,8 @@ describe('ChannelManager', () => { id: 'channel4', type: 'messaging', members: [{ user_id: 'user1' }], - } as unknown as ChannelResponse, - }); + }, + } as EventPayload<'notification.added_to_channel'>); await clock.runAllAsync(); @@ -2587,8 +2729,8 @@ describe('ChannelManager', () => { it('should call getAndWatchChannel with correct parameters', async () => { const newChannelResponse = generateChannel({ channel: { id: 'channel4' } }); const newChannel = client.channel( - newChannelResponse.channel.type, - newChannelResponse.channel.id, + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, ); getAndWatchChannelStub.mockResolvedValueOnce(newChannel); client.dispatchEvent({ @@ -2597,8 +2739,8 @@ describe('ChannelManager', () => { id: 'channel4', type: 'messaging', members: [{ user_id: 'user1' }], - } as unknown as ChannelResponse, - }); + }, + } as EventPayload<'notification.added_to_channel'>); await clock.runAllAsync(); @@ -2614,8 +2756,8 @@ describe('ChannelManager', () => { 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, + newChannelResponse.channel!.type, + newChannelResponse.channel!.id, ); getAndWatchChannelStub.mockResolvedValue(newChannel); @@ -2627,8 +2769,8 @@ describe('ChannelManager', () => { id: 'channel4', type: 'messaging', members: [{ user_id: 'user1' }], - } as unknown as ChannelResponse, - }); + }, + } as EventPayload<'notification.added_to_channel'>); await clock.runAllAsync(); diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index 80f15d9ad1..55daadec2f 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -14,7 +14,7 @@ describe('ChannelState clean', () => { let channel; beforeEach(() => { client = new StreamChat(); - client.userID = 'observer'; + client.user = { id: 'observer' }; channel = new Channel(client, 'live', 'stream', {}); client.activeChannels[channel.cid] = channel; }); diff --git a/test/unit/client.construction.test.ts b/test/unit/client.construction.test.ts new file mode 100644 index 0000000000..5ef9d55da6 --- /dev/null +++ b/test/unit/client.construction.test.ts @@ -0,0 +1,409 @@ +import axios from 'axios'; +import https from 'https'; +import sinon from 'sinon'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { ClientState } from '../../src/client_state'; +import { FixedSizeQueueCache } from '../../src/utils/FixedSizeQueueCache'; +import { InsightMetrics } from '../../src/insights'; +import { MessageDeliveryReporter } from '../../src/messageDelivery'; +import { Moderation } from '../../src/moderation'; +import { NotificationManager } from '../../src/notifications'; +import { PollManager } from '../../src/poll_manager'; +import { ReminderManager } from '../../src/reminders'; +import { StateStore } from '../../src/store'; +import { StreamChat } from '../../src/client'; +import { ThreadManager } from '../../src/thread_manager'; +import { TokenManager } from '../../src/token_manager'; +import { UploadManager } from '../../src/uploadManager'; +import { axiosParamsSerializer } from '../../src/utils'; + +const API_KEY = 'apiKey'; + +const snapshotLocalTestEnv = () => { + const run = process.env.STREAM_LOCAL_TEST_RUN; + const host = process.env.STREAM_LOCAL_TEST_HOST; + delete process.env.STREAM_LOCAL_TEST_RUN; + delete process.env.STREAM_LOCAL_TEST_HOST; + return () => { + if (typeof run === 'undefined') delete process.env.STREAM_LOCAL_TEST_RUN; + else process.env.STREAM_LOCAL_TEST_RUN = run; + if (typeof host === 'undefined') delete process.env.STREAM_LOCAL_TEST_HOST; + else process.env.STREAM_LOCAL_TEST_HOST = host; + }; +}; + +describe('StreamChat construction', () => { + let restoreEnv: () => void; + + beforeEach(() => { + delete (StreamChat as unknown as { _instance?: StreamChat })._instance; + restoreEnv = snapshotLocalTestEnv(); + }); + + afterEach(() => { + sinon.restore(); + restoreEnv(); + }); + + describe('signature', () => { + it('accepts just a key', () => { + const client = new StreamChat(API_KEY); + expect(client.key).to.equal(API_KEY); + expect(client.axiosInstance.defaults.timeout).to.equal(3000); + }); + + it('accepts (key, options)', () => { + const client = new StreamChat(API_KEY, { + axiosRequestConfig: { timeout: 5000 }, + }); + expect(client.key).to.equal(API_KEY); + expect(client.axiosInstance.defaults.timeout).to.equal(5000); + }); + + it('treats an omitted options argument as an empty options object', () => { + const client = new StreamChat(API_KEY); + expect(client.options.warmUp).to.equal(false); + expect(client.options.recoverStateOnReconnect).to.equal(true); + expect(client.options.disableCache).to.equal(false); + }); + }); + + describe('initial instance state', () => { + it('initializes empty collections and null connection refs', () => { + const client = new StreamChat(API_KEY); + + expect(client.listeners).to.be.instanceOf(Map); + expect(client.listeners.size).to.equal(0); + 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.wsConnection).to.be.null; + expect(client.wsPromise).to.be.null; + expect(client.setUserPromise).to.be.null; + + expect(client.anonymous).to.equal(false); + expect(client.defaultWSTimeoutWithFallback).to.equal(6000); + expect(client.defaultWSTimeout).to.equal(15000); + }); + + it('initializes blockedUsers as a StateStore with empty userIds', () => { + const client = new StreamChat(API_KEY); + expect(client.blockedUsers).to.be.instanceOf(StateStore); + expect(client.blockedUsers.getLatestValue()).to.deep.equal({ userIds: [] }); + }); + + it('does not share mutable state between instances', () => { + const a = new StreamChat(API_KEY); + const b = new StreamChat(API_KEY); + + expect(a.listeners).to.not.equal(b.listeners); + 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.blockedUsers).to.not.equal(b.blockedUsers); + expect(a.options).to.not.equal(b.options); + expect(a.axiosInstance).to.not.equal(b.axiosInstance); + }); + }); + + describe('options resolution', () => { + it('applies defaults when no options are passed', () => { + const client = new StreamChat(API_KEY); + + expect(client.options.warmUp).to.equal(false); + expect(client.options.recoverStateOnReconnect).to.equal(true); + expect(client.options.disableCache).to.equal(false); + expect(client.options.wsUrlParams).to.be.instanceOf(URLSearchParams); + expect(client.recoverStateOnReconnect).to.equal(true); + }); + + it('honors user-provided overrides', () => { + const client = new StreamChat(API_KEY, { + warmUp: true, + disableCache: true, + recoverStateOnReconnect: false, + }); + + expect(client.options.warmUp).to.equal(true); + expect(client.options.disableCache).to.equal(true); + expect(client.options.recoverStateOnReconnect).to.equal(false); + expect(client.recoverStateOnReconnect).to.equal(false); + }); + + it('passes persistUserOnConnectionFailure through to the client', () => { + const defaultClient = new StreamChat(API_KEY); + expect(defaultClient.persistUserOnConnectionFailure).to.be.undefined; + + const customClient = new StreamChat(API_KEY, { + persistUserOnConnectionFailure: true, + }); + expect(customClient.persistUserOnConnectionFailure).to.equal(true); + }); + }); + + describe('axios instantiation', () => { + let createSpy: sinon.SinonSpy< + Parameters, + ReturnType + >; + + beforeEach(() => { + createSpy = sinon.spy(axios, 'create'); + }); + + it('invokes axios.create exactly once and stores the result on axiosInstance', () => { + const client = new StreamChat(API_KEY); + expect(createSpy.calledOnce).to.be.true; + expect(client.axiosInstance).to.equal(createSpy.firstCall.returnValue); + }); + + it('passes baked-in defaults (timeout, withCredentials, paramsSerializer) into axios.create', () => { + new StreamChat(API_KEY, { browser: true }); + const config = createSpy.firstCall.args[0]!; + expect(config.timeout).to.equal(3000); + expect(config.withCredentials).to.equal(false); + expect(config.paramsSerializer).to.equal(axiosParamsSerializer); + }); + + it('bakes defaults into the axios instance defaults', () => { + const client = new StreamChat(API_KEY); + expect(client.axiosInstance.defaults.timeout).to.equal(3000); + expect(client.axiosInstance.defaults.withCredentials).to.equal(false); + expect(client.axiosInstance.defaults.paramsSerializer).to.equal( + axiosParamsSerializer, + ); + }); + + it('spreads axiosRequestConfig values into the axios.create config', () => { + const axiosRequestConfig = { + timeout: 9999, + withCredentials: true, + headers: { 'Cache-Control': 'no-cache' }, + }; + const client = new StreamChat(API_KEY, { axiosRequestConfig }); + expect(client.axiosInstance.defaults.timeout).to.equal(9999); + expect(client.axiosInstance.defaults.withCredentials).to.equal(true); + expect(client.axiosInstance.defaults.headers).to.include({ + 'Cache-Control': 'no-cache', + }); + }); + + it('keeps paramsSerializer fixed even when axiosRequestConfig tries to override it', () => { + const customSerializer = () => 'overridden'; + const client = new StreamChat(API_KEY, { + axiosRequestConfig: { paramsSerializer: customSerializer }, + }); + expect(client.axiosInstance.defaults.paramsSerializer).to.equal( + axiosParamsSerializer, + ); + expect(client.axiosInstance.defaults.paramsSerializer).to.not.equal( + customSerializer, + ); + }); + + it('preserves axiosRequestConfig on the client options', () => { + const axiosRequestConfig = { headers: { 'Cache-Control': 'no-cache' } }; + const client = new StreamChat(API_KEY, { axiosRequestConfig }); + expect(client.options.axiosRequestConfig).to.equal(axiosRequestConfig); + }); + + it('produces a paramsSerializer that matches axiosParamsSerializer behavior', () => { + const client = new StreamChat(API_KEY); + const serializer = client.axiosInstance.defaults.paramsSerializer as ( + params: Record, + ) => string; + const sample = { a: 1, b: [2, 3], skip: undefined }; + expect(serializer(sample)).to.equal(axiosParamsSerializer!(sample)); + }); + + describe('httpsAgent', () => { + it('auto-creates a keep-alive https.Agent in node mode', () => { + const client = new StreamChat(API_KEY, { browser: false }); + const httpsAgent = client.axiosInstance.defaults.httpsAgent as https.Agent; + expect(httpsAgent).to.be.instanceOf(https.Agent); + expect(httpsAgent.keepAlive).to.equal(true); + }); + + it('lets axiosRequestConfig.httpsAgent override the auto-created agent', () => { + const customAgent = new https.Agent({ keepAlive: false }); + const client = new StreamChat(API_KEY, { + browser: false, + axiosRequestConfig: { httpsAgent: customAgent }, + }); + expect(client.axiosInstance.defaults.httpsAgent).to.equal(customAgent); + }); + + it('does not auto-create an httpsAgent in browser mode', () => { + const client = new StreamChat(API_KEY, { browser: true }); + expect(client.axiosInstance.defaults.httpsAgent).to.be.undefined; + }); + }); + }); + + describe('baseURL resolution', () => { + let setBaseURLSpy: sinon.SinonSpy<[string], void>; + + beforeEach(() => { + setBaseURLSpy = sinon.spy(StreamChat.prototype, 'setBaseURL'); + }); + + it('defaults to the production baseURL with a wss WebSocket URL', () => { + const client = new StreamChat(API_KEY); + expect(client.baseURL).to.equal('https://chat.stream-io-api.com'); + expect(client.wsBaseURL).to.equal('wss://chat.stream-io-api.com'); + expect(setBaseURLSpy.calledOnce).to.be.true; + expect(setBaseURLSpy.firstCall.args[0]).to.equal('https://chat.stream-io-api.com'); + }); + + it('uses a custom baseURL when provided', () => { + const client = new StreamChat(API_KEY, { baseURL: 'http://example.com:3030' }); + expect(client.baseURL).to.equal('http://example.com:3030'); + // http -> ws, :3030 -> :8800 + expect(client.wsBaseURL).to.equal('ws://example.com:8800'); + }); + + it('overrides the baseURL when STREAM_LOCAL_TEST_RUN is set', () => { + process.env.STREAM_LOCAL_TEST_RUN = 'true'; + const client = new StreamChat(API_KEY); + expect(client.baseURL).to.equal('http://localhost:3030'); + expect(client.wsBaseURL).to.equal('ws://localhost:8800'); + // default url + override + expect(setBaseURLSpy.callCount).to.equal(2); + }); + + it('further overrides the baseURL when STREAM_LOCAL_TEST_HOST is set', () => { + process.env.STREAM_LOCAL_TEST_HOST = 'mybox.test:3030'; + const client = new StreamChat(API_KEY); + expect(client.baseURL).to.equal('http://mybox.test:3030'); + expect(client.wsBaseURL).to.equal('ws://mybox.test:8800'); + // default url + host override + expect(setBaseURLSpy.callCount).to.equal(2); + }); + + it('lets STREAM_LOCAL_TEST_HOST win over STREAM_LOCAL_TEST_RUN', () => { + process.env.STREAM_LOCAL_TEST_RUN = 'true'; + process.env.STREAM_LOCAL_TEST_HOST = 'mybox.test:3030'; + const client = new StreamChat(API_KEY); + expect(client.baseURL).to.equal('http://mybox.test:3030'); + expect(setBaseURLSpy.callCount).to.equal(3); + }); + }); + + describe('platform detection', () => { + it('auto-detects platform based on the global window', () => { + const client = new StreamChat(API_KEY); + const expectedBrowser = typeof window !== 'undefined'; + expect(client.browser).to.equal(expectedBrowser); + expect(client.node).to.equal(!expectedBrowser); + }); + + it('honors an explicit browser:true override', () => { + const client = new StreamChat(API_KEY, { browser: true }); + expect(client.browser).to.equal(true); + expect(client.node).to.equal(false); + }); + + it('honors an explicit browser:false override', () => { + const client = new StreamChat(API_KEY, { browser: false }); + expect(client.browser).to.equal(false); + expect(client.node).to.equal(true); + }); + }); + + describe('subsystem managers', () => { + it('constructs the canonical set of managers', () => { + const client = new StreamChat(API_KEY); + + expect(client.state).to.be.instanceOf(ClientState); + expect(client.notifications).to.be.instanceOf(NotificationManager); + expect(client.uploadManager).to.be.instanceOf(UploadManager); + expect(client.moderation).to.be.instanceOf(Moderation); + expect(client.tokenManager).to.be.instanceOf(TokenManager); + expect(client.threads).to.be.instanceOf(ThreadManager); + expect(client.polls).to.be.instanceOf(PollManager); + expect(client.reminders).to.be.instanceOf(ReminderManager); + expect(client.messageDeliveryReporter).to.be.instanceOf(MessageDeliveryReporter); + expect(client.messageComposerCache).to.be.instanceOf(FixedSizeQueueCache); + expect(client.insightMetrics).to.be.instanceOf(InsightMetrics); + }); + + it('reuses an externally supplied NotificationManager instead of wrapping it', () => { + const notifications = new NotificationManager(); + const client = new StreamChat(API_KEY, { notifications }); + expect(client.notifications).to.equal(notifications); + }); + + it('constructs the TokenManager with no preloaded secret', () => { + const client = new StreamChat(API_KEY); + expect(client.tokenManager.secret).to.be.undefined; + }); + + it('caps the message composer cache at 64 entries', () => { + const client = new StreamChat(API_KEY); + for (let i = 0; i < 64; i++) { + client.messageComposerCache.add(`k-${i}`, { i } as never); + } + expect(client.messageComposerCache.peek('k-0')).to.not.be.undefined; + + client.messageComposerCache.add('k-64', { i: 64 } as never); + + expect(client.messageComposerCache.peek('k-0')).to.be.undefined; + expect(client.messageComposerCache.peek('k-64')).to.deep.equal({ i: 64 }); + }); + + it('builds fresh manager instances per client', () => { + const a = new StreamChat(API_KEY); + const b = new StreamChat(API_KEY); + expect(a.threads).to.not.equal(b.threads); + expect(a.polls).to.not.equal(b.polls); + expect(a.reminders).to.not.equal(b.reminders); + expect(a.tokenManager).to.not.equal(b.tokenManager); + expect(a.moderation).to.not.equal(b.moderation); + expect(a.uploadManager).to.not.equal(b.uploadManager); + expect(a.messageDeliveryReporter).to.not.equal(b.messageDeliveryReporter); + expect(a.messageComposerCache).to.not.equal(b.messageComposerCache); + expect(a.insightMetrics).to.not.equal(b.insightMetrics); + expect(a.notifications).to.not.equal(b.notifications); + expect(a.state).to.not.equal(b.state); + }); + }); + + describe('getInstance', () => { + it('returns the same instance for repeated calls', () => { + const a = StreamChat.getInstance(API_KEY); + const b = StreamChat.getInstance(API_KEY); + expect(a).to.equal(b); + }); + + it('caches the instance on the static _instance slot', () => { + const instance = StreamChat.getInstance(API_KEY); + expect((StreamChat as unknown as { _instance: StreamChat })._instance).to.equal( + instance, + ); + }); + + it('ignores subsequent key and options after the first call', () => { + const first = StreamChat.getInstance(API_KEY, { + axiosRequestConfig: { timeout: 1111 }, + }); + const second = StreamChat.getInstance('different-key', { + axiosRequestConfig: { timeout: 9999 }, + }); + + expect(second).to.equal(first); + expect(first.key).to.equal(API_KEY); + expect(first.axiosInstance.defaults.timeout).to.equal(1111); + }); + + it('routes options through the constructor on first call', () => { + const client = StreamChat.getInstance(API_KEY, { + axiosRequestConfig: { timeout: 5000 }, + }); + expect(client.axiosInstance.defaults.timeout).to.equal(5000); + }); + }); +}); diff --git a/test/unit/client.test.js b/test/unit/client.test.js index 6f0b8713c3..b58aad385e 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -4,11 +4,15 @@ import { getClientWithUser } from './test-utils/getClient'; import * as utils from '../../src/utils'; import { StreamChat } from '../../src/client'; +import { chatLoggerSystem } from '../../src/logger'; import { ConnectionState } from '../../src/connection_fallback'; import { StableWSConnection } from '../../src/connection'; import { mockChannelQueryResponse } from './test-utils/mockChannelQueryResponse'; import { generateThreadResponse } from './test-utils/generateThreadResponse'; -import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from '../../src/constants'; +import { + DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE, + DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE, +} from '../../src/constants'; import { describe, @@ -21,7 +25,6 @@ import { vi, } from 'vitest'; import { Channel } from '../../src'; -import { normalizeQuerySort } from '../../src/utils'; import { MockOfflineDB } from './offline-support/MockOfflineDB'; describe('StreamChat getInstance', () => { @@ -84,7 +87,7 @@ describe('StreamChat getInstance', () => { }); it('should set axios request config correctly', async () => { - const client = StreamChat.getInstance('key', 'secret', { + const client = StreamChat.getInstance('key', { axiosRequestConfig: { headers: { 'Cache-Control': 'no-cache', @@ -92,42 +95,28 @@ describe('StreamChat getInstance', () => { }, }, }); + client.tokenManager.getToken = () => 'mock-token'; - let requestConfig = {}; - client.axiosInstance.get = (url, config) => { - requestConfig = config; - return { - status: 200, - }; - }; - - await client.getChannelType('messaging'); - - expect(requestConfig.headers).to.haveOwnProperty('Cache-Control', 'no-cache'); - expect(requestConfig.headers).to.haveOwnProperty('Pragma', 'no-cache'); - }); + const requestSpy = vi + .spyOn(client.axiosInstance, 'request') + .mockResolvedValueOnce({ data: {}, status: 200 }); - it('app settings do not mutate', async () => { - const client = new StreamChat('key', 'secret'); - const cert = Buffer.from('test'); - const options = { apn_config: { p12_cert: cert } }; - await expect(client.updateAppSettings(options)).rejects.toThrow(/.*/); + await client.getAppSettings(); - expect(options.apn_config.p12_cert).to.be.eql(cert); + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy.mock.calls[0][0].headers).to.haveOwnProperty( + 'Cache-Control', + 'no-cache', + ); + expect(requestSpy.mock.calls[0][0].headers).to.haveOwnProperty('Pragma', 'no-cache'); }); it('should correctly resolve _cacheEnabled', async () => { - const client1 = new StreamChat('key', 'secret', { - disableCache: true, - }); + const client1 = new StreamChat('key', { disableCache: true }); expect(client1._cacheEnabled()).to.be.equal(false); - const client2 = new StreamChat('key', 'secret', { - disableCache: false, - }); + const client2 = new StreamChat('key', { disableCache: false }); expect(client2._cacheEnabled()).to.be.equal(true); - const client3 = new StreamChat('key', { - disableCache: true, - }); + const client3 = new StreamChat('key'); expect(client3._cacheEnabled()).to.be.equal(true); }); }); @@ -274,6 +263,41 @@ describe('Client active channels cache', () => { }); }); +describe('client.channel() custom-data preservation', () => { + let client; + beforeEach(async () => { + client = await getClientWithUser(); + }); + + it("does not wipe an existing channel's custom when re-resolved with a non-custom arg", () => { + // First resolution seeds the channel's custom data (e.g. its display name). + const channel = client.channel('messaging', 'little-italy', { + custom: { name: 'Little-Italy' }, + }); + expect(channel.data.custom.name).to.equal('Little-Italy'); + + // A later `client.channel(type, id, arg)` for the SAME channel that passes other fields but + // no `custom` — as thread hydration and getChannel do (`{ members }`, or even + // `{ members: undefined }` when no members are given) — must NOT blank the channel's custom. + // Regression: getChannelById used to run `channel.data.custom = arg.custom` on any non-empty + // arg, wiping custom to `undefined` and dropping the channel's name from the channel list. + const viaMembers = client.channel('messaging', 'little-italy', { + members: [{ user_id: 'u2' }], + }); + expect(viaMembers).to.equal(channel); // same cached instance + expect(channel.data.custom.name).to.equal('Little-Italy'); + + client.channel('messaging', 'little-italy', { members: undefined }); + expect(channel.data.custom.name).to.equal('Little-Italy'); + }); + + it('applies custom when the caller actually provides it', () => { + const channel = client.channel('messaging', 'ch-custom', { custom: { name: 'Old' } }); + client.channel('messaging', 'ch-custom', { custom: { name: 'New' } }); + expect(channel.data.custom.name).to.equal('New'); + }); +}); + describe('Client openConnection', () => { let client; @@ -295,7 +319,7 @@ describe('Client openConnection', () => { }); it('should return same promise in case of multiple calls', async () => { - client.userID = 'vishal'; + client.user = { id: 'vishal' }; client._setUser({ id: 'vishal', }); @@ -433,163 +457,22 @@ describe('Detect node environment', () => { }); it('should warn when using connectUser on a node environment', async () => { - const _warn = console.warn; - let warning = ''; - console.warn = (msg) => { - warning = msg; - }; + const sinkSpy = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: sinkSpy, level: 'trace' }, + }); try { await client.connectUser({ id: 'user' }, 'fake token'); } catch (e) {} await client.disconnectUser(); - expect(warning).to.equal( - 'Please do not use connectUser server side. connectUser impacts MAU and concurrent connection usage and thus your bill. If you have a valid use-case, add "allowServerSideConnect: true" to the client options to disable this warning.', - ); - - console.warn = _warn; - }); - - it('should not warn when adding the allowServerSideConnect flag', async () => { - const client2 = new StreamChat('', '', { allowServerSideConnect: true }); - - const _warn = console.warn; - let warning = ''; - console.warn = (msg) => { - warning = msg; - }; - - try { - await client2.connectUser({ id: 'user' }, 'fake token'); - } catch (e) {} - - await client2.disconnect(); - expect(warning).to.equal(''); - - console.warn = _warn; - }); -}); - -describe('Client deleteUsers', () => { - it('should allow completely optional options', async () => { - const client = await getClientWithUser(); - - client.post = () => Promise.resolve(); - - await expect(client.deleteUsers(['_'])).resolves.toEqual(); - }); - - it('delete types - options.conversations', async () => { - const client = await getClientWithUser(); - - client.post = () => Promise.resolve(); - - await expect(client.deleteUsers(['_'], { conversations: 'hard' })).resolves.toEqual(); - await expect(client.deleteUsers(['_'], { conversations: 'soft' })).resolves.toEqual(); - await expect( - client.deleteUsers(['_'], { conversations: 'pruning' }), - ).rejects.toThrow(); - await expect(client.deleteUsers(['_'], { conversations: '' })).rejects.toThrow(); - }); - - it('delete types - options.messages', async () => { - const client = await getClientWithUser(); - - client.post = () => Promise.resolve(); - - await expect(client.deleteUsers(['_'], { messages: 'hard' })).resolves.toEqual(); - await expect(client.deleteUsers(['_'], { messages: 'soft' })).resolves.toEqual(); - await expect(client.deleteUsers(['_'], { messages: 'pruning' })).resolves.toEqual(); - await expect(client.deleteUsers(['_'], { messages: '' })).rejects.toThrow(); - }); - - it('delete types - options.user', async () => { - const client = await getClientWithUser(); - - client.post = () => Promise.resolve(); - - await expect(client.deleteUsers(['_'], { user: 'hard' })).resolves.toEqual(); - await expect(client.deleteUsers(['_'], { user: 'soft' })).resolves.toEqual(); - await expect(client.deleteUsers(['_'], { user: 'pruning' })).resolves.toEqual(); - await expect(client.deleteUsers(['_'], { user: '' })).rejects.toThrow(); - }); -}); - -describe('updateMessage should maintain data integrity', () => { - let client; - - beforeEach(async () => { - client = await getClientWithUser(); - }); - - it('should convert mentioned_users from array of user objects to array of userIds', async () => { - client.post = (url, config) => { - expect(typeof config.message.mentioned_users[0]).to.be.equal('string'); - expect(config.message.mentioned_users[0]).to.be.equal('uthred'); - }; - await client.updateMessage( - generateMsg({ - mentioned_users: [ - { - id: 'uthred', - name: 'Uthred Of Bebbanburg', - }, - ], - }), - ); - - await client.updateMessage( - generateMsg({ - mentioned_users: ['uthred'], - }), - ); - }); - - it('should allow empty mentioned_users', async () => { - client.post = (url, config) => { - expect(config.message.mentioned_users[0]).to.be.equal(undefined); - }; - - await client.updateMessage( - generateMsg({ - mentioned_users: [], - }), + expect(sinkSpy).toHaveBeenCalledWith( + 'warn', + expect.stringContaining('Do not use connectUser server-side.'), ); - client.post = (url, config) => { - expect(config.message.mentioned_users).to.be.equal(undefined); - }; - - await client.updateMessage( - generateMsg({ - text: 'test message', - mentioned_users: undefined, - }), - ); - }); - - it('should remove reserved and volatile fields before running the update', async () => { - const postSpy = sinon.stub(client, 'post'); - const updatedMessage = generateMsg({ - text: 'test message', - pinned_at: new Date().toISOString(), - mentioned_users: undefined, - }); - - await client.updateMessage(updatedMessage); - - const messageInQuery = { - attachments: updatedMessage.attachments, - mentioned_users: updatedMessage.mentioned_users, - reaction_scores: updatedMessage.reaction_scores, - silent: updatedMessage.silent, - status: updatedMessage.status, - text: updatedMessage.text, - }; - - expect(postSpy.callCount).to.equal(1); - expect(postSpy.firstCall.args[1].message).to.toMatchObject(messageInQuery); + chatLoggerSystem.restoreDefaults(); }); }); @@ -606,12 +489,16 @@ describe('message update', () => { client.setOfflineDBApi(offlineDb); await client.offlineDb.init(client.userID); - loggerSpy = vi.spyOn(client, 'logger').mockImplementation(vi.fn()); + loggerSpy = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: loggerSpy, level: 'trace' }, + }); queueTaskSpy = vi.spyOn(client.offlineDb, 'queueTask').mockResolvedValue({}); _updateMessageSpy = vi.spyOn(client, '_updateMessage').mockResolvedValue({}); }); afterEach(() => { + chatLoggerSystem.restoreDefaults(); vi.resetAllMocks(); }); @@ -622,8 +509,9 @@ describe('message update', () => { cid: 'messaging:channel-123', text: 'edited', }); + const request = { id: message.id, message, skip_enrich_url: true }; - await client.updateMessage(message, { id: 'user-123' }, { skip_enrich_url: true }); + await client.updateMessage(request); expect(queueTaskSpy).toHaveBeenCalledTimes(1); expect(queueTaskSpy).toHaveBeenCalledWith({ @@ -631,7 +519,7 @@ describe('message update', () => { channelId: 'channel-123', channelType: 'messaging', messageId: 'msg-123', - payload: [message, { id: 'user-123' }, { skip_enrich_url: true }], + payload: [request], type: 'update-message', }, }); @@ -644,13 +532,14 @@ describe('message update', () => { cid: 'invalid-cid', text: 'edited', }); + const request = { id: message.id, message }; - await client.updateMessage(message); + await client.updateMessage(request); expect(queueTaskSpy).toHaveBeenCalledWith({ task: { messageId: 'msg-123', - payload: [message, undefined, undefined], + payload: [request], type: 'update-message', }, }); @@ -661,15 +550,14 @@ describe('message update', () => { id: 'msg-123', text: 'edited', }); + const request = { id: message.id, message, skip_enrich_url: true }; client.offlineDb = undefined; - await client.updateMessage(message, 'user-123', { skip_enrich_url: true }); + await client.updateMessage(request); expect(_updateMessageSpy).toHaveBeenCalledTimes(1); - expect(_updateMessageSpy).toHaveBeenCalledWith(message, 'user-123', { - skip_enrich_url: true, - }); + expect(_updateMessageSpy).toHaveBeenCalledWith(request); }); it('routes updates with local attachment metadata through offlineDb queue handling', async () => { @@ -687,14 +575,15 @@ describe('message update', () => { }, ], }); + const request = { id: message.id, message }; - await client.updateMessage(message); + await client.updateMessage(request); expect(queueTaskSpy).toHaveBeenCalledTimes(1); expect(queueTaskSpy).toHaveBeenCalledWith({ task: { messageId: 'msg-123', - payload: [message, undefined, undefined], + payload: [request], type: 'update-message', }, }); @@ -712,14 +601,15 @@ describe('message update', () => { }, ], }); + const request = { id: message.id, message }; - await client.updateMessage(message); + await client.updateMessage(request); expect(queueTaskSpy).toHaveBeenCalledTimes(1); expect(queueTaskSpy).toHaveBeenCalledWith({ task: { messageId: 'msg-123', - payload: [message, undefined, undefined], + payload: [request], type: 'update-message', }, }); @@ -731,13 +621,14 @@ describe('message update', () => { id: 'msg-123', text: 'edited', }); + const request = { id: message.id, message }; queueTaskSpy.mockRejectedValue(new Error('Offline failure')); - await client.updateMessage(message); + await client.updateMessage(request); expect(loggerSpy).toHaveBeenCalledTimes(1); expect(_updateMessageSpy).toHaveBeenCalledTimes(1); - expect(_updateMessageSpy).toHaveBeenCalledWith(message, undefined, undefined); + expect(_updateMessageSpy).toHaveBeenCalledWith(request); }); it('logs and falls back to _updateMessage when queueTask rethrows for failed offline edits', async () => { @@ -747,67 +638,27 @@ describe('message update', () => { text: 'edited', message_text_updated_at: '2026-04-01T20:48:43.886269Z', }); + const request = { id: failedEditedMessage.id, message: failedEditedMessage }; client.wsConnection = { isHealthy: false }; queueTaskSpy.mockRejectedValue(new Error('Offline failure')); _updateMessageSpy.mockResolvedValue({ message: failedEditedMessage }); - const response = await client.updateMessage(failedEditedMessage); + const response = await client.updateMessage(request); expect(queueTaskSpy).toHaveBeenCalledTimes(1); expect(loggerSpy).toHaveBeenCalledTimes(1); expect(_updateMessageSpy).toHaveBeenCalledTimes(1); - expect(_updateMessageSpy).toHaveBeenCalledWith( - failedEditedMessage, - undefined, - undefined, - ); + expect(_updateMessageSpy).toHaveBeenCalledWith(request); expect(response.message.text).toBe('edited'); expect(response.message.status).toBe('failed'); }); }); }); -describe('Client search', async () => { - const client = await getClientWithUser(); - - it('search with sorting by defined field', async () => { - client.get = (url, config) => { - expect(config.payload.sort).to.be.eql([{ field: 'updated_at', direction: -1 }]); - }; - await client.search({ cid: 'messaging:my-cid' }, 'query', { - sort: [{ updated_at: -1 }], - }); - }); - it('search with sorting by custom field', async () => { - client.get = (url, config) => { - expect(config.payload.sort).to.be.eql([{ field: 'custom_field', direction: -1 }]); - }; - await client.search({ cid: 'messaging:my-cid' }, 'query', { - sort: [{ custom_field: -1 }], - }); - }); - it('sorting and offset works', async () => { - await expect( - client.search({ cid: 'messaging:my-cid' }, 'query', { - offset: 1, - sort: [{ custom_field: -1 }], - }), - ).resolves.toEqual(); - }); - it('next and offset fails', async () => { - await expect( - client.search({ cid: 'messaging:my-cid' }, 'query', { - offset: 1, - next: 'next', - }), - ).rejects.toThrow(Error); - }); -}); - describe('Client setLocalDevice', async () => { const device = { id: 'id1', push_provider: 'apn' }; - const client = new StreamChat('', '', { device }); + const client = new StreamChat('', { device }); it('should update device info before ws open', async () => { expect(client.options.device).to.deep.equal(device); @@ -853,7 +704,7 @@ describe('Client WSFallback', () => { .onCall(0) .resolves({ event: { connection_id: 'new_id', received_at: eventDate } }); - client.doAxiosRequest = stub; + client.api.doAxiosRequest = stub; client.wsBaseURL = 'ws://getstream.io'; const health = await client.connectUser({ id: 'amin' }, userToken); expect(health).to.be.eql({ connection_id: 'new_id', received_at: eventDate }); @@ -873,7 +724,7 @@ describe('Client WSFallback', () => { it('should fire transport.changed and health.check event', async () => { const eventDate = new Date(Date.UTC(2009, 1, 3, 23, 3, 3)); sinon.spy(client, 'dispatchEvent'); - client.doAxiosRequest = () => ({ + client.api.doAxiosRequest = () => ({ event: { type: 'health.check', connection_id: 'new_id', received_at: eventDate }, }); client.wsBaseURL = 'ws://getstream.io'; @@ -951,12 +802,13 @@ describe('StreamChat.queryChannels', async () => { generateMsg, ), })); - const mock = sinon.mock(client); - mock.expects('post').returns(Promise.resolve(mockedChannelsQueryResponse)); - await client.queryChannels(); + sinon + .stub(client, 'queryChannels') + .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); - mock.restore(); + sinon.restore(); }); it('should return hydrated channels as Channel instances from queryChannels', async () => { @@ -968,15 +820,15 @@ describe('StreamChat.queryChannels', async () => { generateMsg, ), })); - const postStub = sinon - .stub(client, 'post') - .returns(Promise.resolve({ channels: mockedChannelsQueryResponse })); - const queryChannelsResponse = await client.queryChannels(); + const stub = sinon + .stub(client, 'queryChannels') + .resolves({ channels: mockedChannelsQueryResponse }); + const queryChannelsResponse = await client.queryChannelsAndHydrate(); expect(queryChannelsResponse.length).to.be.equal(mockedChannelsQueryResponse.length); queryChannelsResponse.forEach((item) => { expect(item).to.be.instanceOf(Channel); }); - postStub.restore(); + stub.restore(); }); it('should sync channel data-backed stores when hydrating channels from queryChannels', async () => { @@ -995,11 +847,11 @@ describe('StreamChat.queryChannels', async () => { ), }, ]; - const postStub = sinon - .stub(client, 'post') - .returns(Promise.resolve({ channels: mockedChannelsQueryResponse })); + const stub = sinon + .stub(client, 'queryChannels') + .resolves({ channels: mockedChannelsQueryResponse }); - const [channel] = await client.queryChannels(); + const [channel] = await client.queryChannelsAndHydrate(); expect(channel.state.member_count).to.equal(7); expect(channel.state.ownCapabilitiesStore.getLatestValue()).to.eql({ @@ -1014,7 +866,7 @@ describe('StreamChat.queryChannels', async () => { ownCapabilities: ['send-message'], }); - postStub.restore(); + stub.restore(); }); it('does not weld a jumped/older window into the newest page when re-hydrating a shared channel on re-query', async () => { @@ -1024,16 +876,14 @@ describe('StreamChat.queryChannels', async () => { generateMsg({ id: 'm6', created_at: '2023-11-14T12:00:06.000Z' }), generateMsg({ id: 'm7', created_at: '2023-11-14T12:00:07.000Z' }), ]; - const postStub = sinon.stub(client, 'post').returns( - Promise.resolve({ - channels: [{ ...mockChannelQueryResponse, messages: newest }], - }), - ); + const stub = sinon.stub(client, 'queryChannels').resolves({ + channels: [{ ...mockChannelQueryResponse, messages: newest }], + }); // Initial query seeds the (cold) paginator with the newest window. message_limit === page // length so the seed is NOT flagged as the complete set (hasMoreTail stays true: older exist, // so an older jumped window stays a separate interval instead of merging at the tail edge). - const [channel] = await client.queryChannels({}, {}, { message_limit: 3 }); + const [channel] = await client.queryChannelsAndHydrate({ message_limit: 3 }); // Simulate the user jumping to an OLDER window, disjoint from the newest, which becomes the // active (visible) interval while the newest window stays loaded as a separate interval. @@ -1059,32 +909,14 @@ describe('StreamChat.queryChannels', async () => { // A channel-list re-query on reconnect re-hydrates the SAME channel instance with the newest // window (disjoint from the jumped one). It must NOT weld them (which would drop m3/m4 in the // middle) nor yank the user off the jumped window. - await client.queryChannels({}, {}, { message_limit: 3 }); + await client.queryChannelsAndHydrate({ message_limit: 3 }); const activeAfter = channel.messagePaginator.state .getLatestValue() .items?.map((m) => m.id); expect(activeAfter).to.eql(['m1', 'm2']); - postStub.restore(); - }); - - it('should return the raw channels response from queryChannelsRequest', async () => { - const client = await getClientWithUser(); - const mockedChannelsQueryResponse = Array.from({ length: 10 }, () => ({ - ...mockChannelQueryResponse, - messages: Array.from( - { length: DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE }, - generateMsg, - ), - })); - const postStub = sinon - .stub(client, 'post') - .returns(Promise.resolve({ channels: mockedChannelsQueryResponse })); - const queryChannelsResponse = await client.queryChannelsRequest(); - expect(queryChannelsResponse.length).to.be.equal(mockedChannelsQueryResponse.length); - expect(queryChannelsResponse).to.deep.equal(mockedChannelsQueryResponse); - postStub.restore(); + stub.restore(); }); it('seeds each queried channel paginator with its full message page', async () => { @@ -1092,22 +924,21 @@ describe('StreamChat.queryChannels', async () => { const mockedChannelsQueryResponse = Array.from({ length: 10 }, () => ({ ...mockChannelQueryResponse, messages: Array.from( - { length: DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE }, + { length: DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE }, generateMsg, ), })); - const mock = sinon.mock(client); - mock - .expects('post') - .returns(Promise.resolve({ channels: mockedChannelsQueryResponse })); - await client.queryChannels(); + sinon + .stub(client, 'queryChannels') + .resolves({ channels: mockedChannelsQueryResponse }); + await client.queryChannelsAndHydrate(); expect(Object.keys(client.activeChannels).length).to.be.greaterThan(0); Object.values(client.activeChannels).forEach((channel) => { expect(channel.messagePaginator.items).to.have.length( - DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE, + DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE, ); }); - mock.restore(); + sinon.restore(); }); it('seeds each queried channel paginator with its partial message page', async () => { @@ -1115,22 +946,21 @@ describe('StreamChat.queryChannels', async () => { const mockedChannelQueryResponse = Array.from({ length: 10 }, () => ({ ...mockChannelQueryResponse, messages: Array.from( - { length: DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE - 1 }, + { length: DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE - 1 }, generateMsg, ), })); - const mock = sinon.mock(client); - mock - .expects('post') - .returns(Promise.resolve({ channels: mockedChannelQueryResponse })); - await client.queryChannels(); + sinon + .stub(client, 'queryChannels') + .resolves({ channels: mockedChannelQueryResponse }); + await client.queryChannelsAndHydrate(); expect(Object.keys(client.activeChannels).length).to.be.greaterThan(0); Object.values(client.activeChannels).forEach((channel) => { expect(channel.messagePaginator.items).to.have.length( - DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE - 1, + DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE - 1, ); }); - mock.restore(); + sinon.restore(); }); }); @@ -1144,11 +974,10 @@ describe('StreamChat.queryThreads', () => { ); const apiResponse = { threads: [rawThread], next: undefined }; - const postStub = sinon.stub(client, 'post'); - postStub.onFirstCall().resolves(apiResponse); + sinon.stub(client, 'queryThreads').resolves(apiResponse); const hydratePollCacheSpy = sinon.spy(client.polls, 'hydratePollCache'); - const result = await client.queryThreads(); + const result = await client.queryThreadsAndHydrate(); expect(result.threads).to.have.lengthOf(1); expect(result.threads[0].id).to.equal(parentMessage.id); @@ -1156,7 +985,7 @@ describe('StreamChat.queryThreads', () => { expect(hydratePollCacheSpy.calledOnce).to.be.true; expect(hydratePollCacheSpy.calledWith([parentMessage])).to.be.true; - postStub.restore(); + sinon.restore(); }); }); @@ -1166,7 +995,7 @@ describe('StreamChat.queryReactions', () => { let postStub; const messageId = 'msg-1'; const filter = { type: { $in: ['like', 'love'] } }; - const sort = [{ created_at: -1 }]; + const sort = [{ field: 'created_at', direction: -1 }]; const options = { limit: 50 }; const offlineReactions = [ @@ -1189,7 +1018,7 @@ describe('StreamChat.queryReactions', () => { await client.offlineDb.init(client.userID); dispatchSpy = vi.spyOn(client, 'dispatchEvent'); - postStub = vi.spyOn(client, 'post').mockResolvedValueOnce(postResponse); + postStub = vi.spyOn(client, 'queryReactions').mockResolvedValueOnce(postResponse); client.offlineDb.getReactions.mockResolvedValue(offlineReactions); }); @@ -1198,7 +1027,13 @@ describe('StreamChat.queryReactions', () => { }); it('should query reactions from offlineDb and dispatch offline_reactions.queried event', async () => { - const result = await client.queryReactions(messageId, filter, sort, options); + const request = { + id: messageId, + filter, + sort, + limit: options.limit, + }; + const result = await client.queryReactionsAndHydrate(request); expect(client.offlineDb.getReactions).toHaveBeenCalledWith({ messageId, @@ -1220,74 +1055,71 @@ describe('StreamChat.queryReactions', () => { ]); expect(postStub).toHaveBeenCalledTimes(1); - expect(postStub).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}/reactions`, - { - filter, - sort: normalizeQuerySort(sort), - limit: 50, - }, - ); + expect(postStub).toHaveBeenCalledWith(request); expect(result).to.eql(postResponse); }); it('should skip querying offlineDb if options.next is true', async () => { - await client.queryReactions(messageId, filter, sort, { next: true, limit: 20 }); + const request = { + id: messageId, + filter, + sort, + next: true, + limit: 20, + }; + await client.queryReactionsAndHydrate(request); expect(client.offlineDb.getReactions).not.toHaveBeenCalled(); - - expect(postStub).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}/reactions`, - { - filter, - sort: normalizeQuerySort(sort), - next: true, - limit: 20, - }, - ); + expect(postStub).toHaveBeenCalledWith(request); }); it('should not dispatch event if offlineDb returns null', async () => { client.offlineDb.getReactions.mockResolvedValue(null); - await client.queryReactions(messageId, filter, sort, options); + const request = { + id: messageId, + filter, + sort, + limit: 50, + }; + await client.queryReactionsAndHydrate(request); expect(client.offlineDb.getReactions).toHaveBeenCalledTimes(1); expect(dispatchSpy).not.toHaveBeenCalled(); - expect(postStub).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}/reactions`, - { - filter, - sort: normalizeQuerySort(sort), - limit: 50, - }, - ); + expect(postStub).toHaveBeenCalledWith(request); }); it('should log a warning if offlineDb.getReactions throws', async () => { client.offlineDb.getReactions.mockRejectedValue(new Error('DB error')); const loggerSpy = vi.fn(); - client.logger = loggerSpy; + chatLoggerSystem.configureLoggers({ + default: { sink: loggerSpy, level: 'trace' }, + }); - await client.queryReactions(messageId, filter, sort, options); + await client.queryReactionsAndHydrate({ + id: messageId, + filter, + sort, + limit: options.limit, + }); expect(loggerSpy).toHaveBeenCalledWith( 'warn', - 'An error has occurred while querying offline reactions', + expect.stringContaining('An error occurred while querying offline reactions'), expect.objectContaining({ error: expect.any(Error), }), ); expect(dispatchSpy).not.toHaveBeenCalled(); - expect(postStub).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}/reactions`, - { - filter, - sort: normalizeQuerySort(sort), - limit: 50, - }, - ); + expect(postStub).toHaveBeenCalledWith({ + id: messageId, + filter, + sort, + limit: 50, + }); + + chatLoggerSystem.restoreDefaults(); }); }); @@ -1297,7 +1129,6 @@ describe('message deletion', () => { let client; let loggerSpy; let queueTaskSpy; - let clientDeleteSpy; beforeEach(async () => { client = await getClientWithUser(); @@ -1306,12 +1137,15 @@ describe('message deletion', () => { client.setOfflineDBApi(offlineDb); await client.offlineDb.init(client.userID); - loggerSpy = vi.spyOn(client, 'logger').mockImplementation(vi.fn()); - clientDeleteSpy = vi.spyOn(client, 'delete').mockResolvedValue({ message: {} }); + loggerSpy = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: loggerSpy, level: 'trace' }, + }); queueTaskSpy = vi.spyOn(client.offlineDb, 'queueTask').mockResolvedValue({}); }); afterEach(() => { + chatLoggerSystem.restoreDefaults(); vi.resetAllMocks(); }); @@ -1326,209 +1160,126 @@ describe('message deletion', () => { vi.resetAllMocks(); }); - it.each([ - ['undefined', undefined, {}], - ['true', true, { hardDelete: true }], - ['false', false, {}], - ['{ hardDelete: false }', { hardDelete: false }, {}], - ['{ hardDelete: true }', { hardDelete: true }, { hardDelete: true }], - ['{ deleteForMe: true }', { deleteForMe: true }, { deleteForMe: true }], - ['{ deleteForMe: false }', { deleteForMe: false }, {}], - [ - '{ hardDelete: false, deleteForMe: true }', - { hardDelete: false, deleteForMe: true }, - { deleteForMe: true }, - ], - [ - '{ hardDelete: true, deleteForMe: true }', - { hardDelete: true, deleteForMe: true }, - { deleteForMe: true }, - ], - [ - '{ hardDelete: false, deleteForMe: false }', - { hardDelete: false, deleteForMe: false }, - {}, - ], - [ - '{ hardDelete: true, deleteForMe: false }', - { hardDelete: true, deleteForMe: false }, - { hardDelete: true }, - ], - ])('should parse delete message options %s', async (_, options, expectedOptions) => { - await client.deleteMessage(messageId, options); - if (expectedOptions.hardDelete) { - expect(client.offlineDb.hardDeleteMessage).toHaveBeenCalledTimes(1); - expect(client.offlineDb.hardDeleteMessage).toHaveBeenCalledWith({ - id: messageId, - }); - expect(client.offlineDb.softDeleteMessage).not.toHaveBeenCalled(); - } else { - expect(client.offlineDb.softDeleteMessage).toHaveBeenCalledTimes(1); - expect(client.offlineDb.softDeleteMessage).toHaveBeenCalledWith({ - id: messageId, - deleteForMe: expectedOptions.deleteForMe, - }); - expect(client.offlineDb.hardDeleteMessage).not.toHaveBeenCalled(); - } + it('routes soft delete through offlineDb.softDeleteMessage and queues the task', async () => { + const request = { id: messageId }; + + await client.deleteMessage(request); + + expect(client.offlineDb.softDeleteMessage).toHaveBeenCalledTimes(1); + expect(client.offlineDb.softDeleteMessage).toHaveBeenCalledWith({ + id: messageId, + }); + expect(client.offlineDb.hardDeleteMessage).not.toHaveBeenCalled(); expect(queueTaskSpy).toHaveBeenCalledTimes(1); + expect(queueTaskSpy).toHaveBeenCalledWith({ + task: { + messageId, + payload: [request], + type: 'delete-message', + }, + }); + expect(_deleteMessageSpy).not.toHaveBeenCalled(); + }); + + it('routes hard delete through offlineDb.hardDeleteMessage and queues the task', async () => { + const request = { id: messageId, hard: true }; - const taskArg = queueTaskSpy.mock.calls[0][0]; - expect(taskArg).to.deep.equal({ + await client.deleteMessage(request); + + expect(client.offlineDb.hardDeleteMessage).toHaveBeenCalledTimes(1); + expect(client.offlineDb.hardDeleteMessage).toHaveBeenCalledWith({ + id: messageId, + }); + expect(client.offlineDb.softDeleteMessage).not.toHaveBeenCalled(); + + expect(queueTaskSpy).toHaveBeenCalledTimes(1); + expect(queueTaskSpy).toHaveBeenCalledWith({ task: { messageId, - payload: [messageId, expectedOptions], + payload: [request], type: 'delete-message', }, }); expect(_deleteMessageSpy).not.toHaveBeenCalled(); }); - it.each([ - ['undefined', undefined, {}], - ['true', true, { hardDelete: true }], - ['false', false, {}], - ['{ hardDelete: false }', { hardDelete: false }, {}], - ['{ hardDelete: true }', { hardDelete: true }, { hardDelete: true }], - ['{ deleteForMe: true }', { deleteForMe: true }, { deleteForMe: true }], - ['{ deleteForMe: false }', { deleteForMe: false }, {}], - [ - '{ hardDelete: false, deleteForMe: true }', - { hardDelete: false, deleteForMe: true }, - { deleteForMe: true }, - ], - [ - '{ hardDelete: true, deleteForMe: true }', - { hardDelete: true, deleteForMe: true }, - { deleteForMe: true }, - ], - [ - '{ hardDelete: false, deleteForMe: false }', - { hardDelete: false, deleteForMe: false }, - {}, - ], - [ - '{ hardDelete: true, deleteForMe: false }', - { hardDelete: true, deleteForMe: false }, - { hardDelete: true }, - ], - ])( - 'should fall back to _deleteMessage if offlineDb is not set and delete options is %s', - async (_, options, expectedOptions) => { - client.offlineDb = undefined; - - await client.deleteMessage(messageId, options); - - expect(_deleteMessageSpy).toHaveBeenCalledTimes(1); - expect(_deleteMessageSpy).toHaveBeenCalledWith(messageId, expectedOptions); - }, - ); + it('forwards delete_for_me to offlineDb.softDeleteMessage', async () => { + const request = { id: messageId, delete_for_me: true }; + + await client.deleteMessage(request); + + expect(client.offlineDb.softDeleteMessage).toHaveBeenCalledTimes(1); + expect(client.offlineDb.softDeleteMessage).toHaveBeenCalledWith({ + id: messageId, + deleteForMe: true, + }); + expect(client.offlineDb.hardDeleteMessage).not.toHaveBeenCalled(); + }); + + it('falls back to _deleteMessage if offlineDb is not set', async () => { + client.offlineDb = undefined; + const request = { id: messageId }; + + await client.deleteMessage(request); + + expect(_deleteMessageSpy).toHaveBeenCalledTimes(1); + expect(_deleteMessageSpy).toHaveBeenCalledWith(request); + }); - it('should log and fall back to _deleteMessage if offline delete throws', async () => { + it('logs and falls back to _deleteMessage if offline delete throws', async () => { client.offlineDb.softDeleteMessage.mockRejectedValue(new Error('Offline failure')); + const request = { id: messageId }; - await client.deleteMessage(messageId, false); + await client.deleteMessage(request); expect(loggerSpy).toHaveBeenCalledTimes(1); expect(queueTaskSpy).not.toHaveBeenCalled(); expect(_deleteMessageSpy).toHaveBeenCalledTimes(1); - expect(_deleteMessageSpy).toHaveBeenCalledWith(messageId, {}); + expect(_deleteMessageSpy).toHaveBeenCalledWith(request); }); }); describe('_deleteMessage', () => { - it('should call delete with correct URL and no params when hardDelete is false/undefined', async () => { - await client._deleteMessage(messageId); + let sendRequestSpy; - expect(clientDeleteSpy).toHaveBeenCalledTimes(1); - expect(clientDeleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}`, - {}, - ); - }); - - it('should call delete with hard=true param when hardDelete is true', async () => { - await client._deleteMessage(messageId, true); - - expect(clientDeleteSpy).toHaveBeenCalledTimes(1); - expect(clientDeleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}`, - { hard: true }, - ); + beforeEach(() => { + sendRequestSpy = vi.spyOn(client.api, 'sendRequest').mockResolvedValue({ + body: { message: { id: messageId } }, + metadata: {}, + }); }); - it.each([ - ['{}', {}], - ['{ hardDelete: true }', { hardDelete: true }], - ['{ hardDelete: false }', { hardDelete: false }], - ['{ deleteForMe: true }', { deleteForMe: true }], - ['{ deleteForMe: false }', { deleteForMe: false }], - [ - '{ hardDelete: false, deleteForMe: true }', - { hardDelete: false, deleteForMe: true }, - ], - [ - '{ hardDelete: true, deleteForMe: true }', - { hardDelete: true, deleteForMe: true }, - ], - [ - '{ hardDelete: false, deleteForMe: false }', - { hardDelete: false, deleteForMe: false }, - ], - [ - '{ hardDelete: false, deleteForMe: false }', - { hardDelete: false, deleteForMe: false }, - ], - ])('should parse delete options %s accordingly', async (_, options) => { - await client._deleteMessage(messageId, options); - - const expectedParams = - Object.values(options).length === 2 && Object.values(options).every((val) => val) - ? { delete_for_me: true, hard: true } - : Object.keys(options).length === 0 - ? {} - : options.deleteForMe - ? { delete_for_me: true } - : options.hardDelete - ? { hard: true } - : {}; - expect(clientDeleteSpy).toHaveBeenCalledTimes(1); - expect(clientDeleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}`, - expectedParams, - ); + afterEach(() => { + vi.resetAllMocks(); }); - it('should call delete with both hard and delete_for_me params when both are true', async () => { - await client._deleteMessage(messageId, { deleteForMe: true, hardDelete: true }); + it('returns the response from the underlying deleteMessage call', async () => { + const result = await client._deleteMessage({ id: messageId }); - expect(clientDeleteSpy).toHaveBeenCalledTimes(1); - expect(clientDeleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/messages/${encodeURIComponent(messageId)}`, - { hard: true, delete_for_me: true }, - ); + expect(sendRequestSpy).toHaveBeenCalledTimes(1); + expect(result.message).toMatchObject({ id: messageId }); }); - it('should return the response from delete', async () => { - clientDeleteSpy.mockResolvedValue({ - message: { id: messageId }, + it('enriches the message with type="deleted" and deleted_for_me=true when delete_for_me is set', async () => { + const result = await client._deleteMessage({ + id: messageId, + delete_for_me: true, }); - const result = await client._deleteMessage(messageId); - expect(result).toStrictEqual({ - message: { id: messageId }, + expect(result.message).toMatchObject({ + id: messageId, + deleted_for_me: true, + type: 'deleted', }); }); - it('enriches the deleted-for-me message with type="deleted" and deleted_for_me=true', async () => { - clientDeleteSpy.mockResolvedValue({ - message: { id: messageId }, - }); - const result = await client._deleteMessage(messageId, { deleteForMe: true }); + it('does not enrich the message when delete_for_me is not set', async () => { + const result = await client._deleteMessage({ id: messageId, hard: true }); - expect(result).toStrictEqual({ - message: { deleted_for_me: true, id: messageId, type: 'deleted' }, - }); + expect(result.message).toMatchObject({ id: messageId }); + expect(result.message).not.toHaveProperty('deleted_for_me'); + expect(result.message).not.toHaveProperty('type'); }); }); }); @@ -1692,11 +1443,11 @@ describe('user.messages.deleted — quoted_message regression (#1736)', () => { const setupChannelWithSelfQuote = (type, id) => { const m1 = generateMsg({ - created_at: '2020-01-01T00:00:01.000Z', + created_at: new Date('2020-01-01T00:00:01.000Z'), user: bannedUser, }); const m2 = generateMsg({ - created_at: '2020-01-01T00:00:02.000Z', + created_at: new Date('2020-01-01T00:00:02.000Z'), user: bannedUser, quoted_message: m1, quoted_message_id: m1.id, @@ -1911,108 +1662,6 @@ describe('X-Stream-Client header', () => { expect(client.getUserAgent()).toBe(first); }); - - describe('getHookEvents', () => { - let clientGetSpy; - - beforeEach(() => { - clientGetSpy = vi.spyOn(client, 'get').mockResolvedValue({}); - }); - - it('should call get with correct URL and no params when no products specified', async () => { - await client.getHookEvents(); - - expect(clientGetSpy).toHaveBeenCalledTimes(1); - expect(clientGetSpy).toHaveBeenCalledWith(`${client.baseURL}/hook/events`, {}); - }); - - it('should call get with correct URL and empty params when empty products array specified', async () => { - await client.getHookEvents([]); - - expect(clientGetSpy).toHaveBeenCalledTimes(1); - expect(clientGetSpy).toHaveBeenCalledWith(`${client.baseURL}/hook/events`, {}); - }); - - it('should call get with product params when products specified', async () => { - await client.getHookEvents(['chat', 'video']); - - expect(clientGetSpy).toHaveBeenCalledTimes(1); - expect(clientGetSpy).toHaveBeenCalledWith(`${client.baseURL}/hook/events`, { - product: 'chat,video', - }); - }); - - it('should call get with single product param', async () => { - await client.getHookEvents(['chat']); - - expect(clientGetSpy).toHaveBeenCalledTimes(1); - expect(clientGetSpy).toHaveBeenCalledWith(`${client.baseURL}/hook/events`, { - product: 'chat', - }); - }); - - it('should return the response from get', async () => { - const mockResponse = { - events: [ - { - name: 'message.new', - description: 'When a new message is added', - products: ['chat'], - }, - { - name: 'call.created', - description: 'The call was created', - products: ['video'], - }, - ], - }; - clientGetSpy.mockResolvedValue(mockResponse); - - const result = await client.getHookEvents(['chat', 'video']); - - expect(result).toEqual(mockResponse); - }); - }); -}); - -describe('markChannelsDelivered', () => { - let client; - const user = { id: 'user' }; - - beforeEach(() => { - client = new StreamChat('', ''); - - vi.spyOn(client, 'post').mockResolvedValue({ - ok: true, - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('prevents triggering the request with empty payload', async () => { - await client.markChannelsDelivered(); - expect(client.post).not.toHaveBeenCalled(); - - await client.markChannelsDelivered({}); - expect(client.post).not.toHaveBeenCalled(); - - await client.markChannelsDelivered({ latest_delivered_messages: [] }); - expect(client.post).not.toHaveBeenCalled(); - - await client.markChannelsDelivered({ user, user_id: user.id }); - expect(client.post).not.toHaveBeenCalled(); - }); - - it('triggers the request with at least on channel to report', async () => { - const delivered = [{ cid: 'cid', id: 'message-id' }]; - await client.markChannelsDelivered({ latest_delivered_messages: delivered }); - expect(client.post).toHaveBeenCalledWith( - 'https://chat.stream-io-api.com/channels/delivered', - { latest_delivered_messages: delivered }, - ); - }); }); // Regression coverage for GetStream/stream-chat-react#2599. @@ -2096,16 +1745,18 @@ describe('activeChannels eviction when the current user is removed (#2599)', () it('does not re-watch the evicted channel on recoverState', async () => { const removed = client.channel('messaging', 'removed'); const kept = client.channel('messaging', 'kept'); - const queryChannelsStub = vi.spyOn(client, 'queryChannels').mockResolvedValue([]); + const queryChannelsStub = vi + .spyOn(client, 'queryChannels') + .mockResolvedValue({ channels: [] }); client.dispatchEvent(removedFromChannelEvent(removed)); await client.recoverState(); expect(queryChannelsStub).toHaveBeenCalledTimes(1); - const [filters] = queryChannelsStub.mock.calls[0]; - expect(filters.cid.$in).to.contain(kept.cid); - expect(filters.cid.$in).not.to.contain(removed.cid); + const [options] = queryChannelsStub.mock.calls[0]; + expect(options.filter_conditions.cid.$in).to.contain(kept.cid); + expect(options.filter_conditions.cid.$in).not.to.contain(removed.cid); }); it('does not evict when another user is removed (member.removed for a different user)', () => { diff --git a/test/unit/connection.test.js b/test/unit/connection.test.js index b5ad9739dd..6c5923ed2d 100644 --- a/test/unit/connection.test.js +++ b/test/unit/connection.test.js @@ -19,8 +19,6 @@ describe('connection', function () { client.wsBaseURL = wsBaseURL; client.tokenManager = tokenManager; client._user = user; - client.userID = user.id; - client.logger = () => null; client.options.enableInsights = true; client.userAgent = 'agent'; client.clientID = 'clientID'; diff --git a/test/unit/connection_fallback.test.js b/test/unit/connection_fallback.test.js index ac3862e2ef..3f4c154680 100644 --- a/test/unit/connection_fallback.test.js +++ b/test/unit/connection_fallback.test.js @@ -7,16 +7,18 @@ import { ConnectionState, WSConnectionFallback } from '../../src/connection_fall import { describe, it, expect, afterEach, vi, beforeAll, beforeEach } from 'vitest'; describe('connection_fallback', () => { - const newClient = (overrides) => ({ - baseURL: '', - logger: () => null, - doAxiosRequest: sinon.spy(), - _buildWSPayload: sinon.stub().returns('payload'), - dispatchEvent: sinon.spy(), - handleEvent: sinon.spy(), - recoverState: sinon.spy(), - ...overrides, - }); + const newClient = (overrides) => { + const doAxiosRequest = overrides?.doAxiosRequest ?? sinon.spy(); + return { + baseURL: '', + api: { doAxiosRequest }, + _buildWSPayload: sinon.stub().returns('payload'), + dispatchEvent: sinon.spy(), + handleEvent: sinon.spy(), + recoverState: sinon.spy(), + ...overrides, + }; + }; afterEach(() => { vi.restoreAllMocks(); @@ -221,9 +223,10 @@ describe('connection_fallback', () => { const config = { timeout: 100 }; await c._req(params, config); expect( - c.client.doAxiosRequest.calledOnceWithExactly('get', '/longpoll', undefined, { + c.client.api.doAxiosRequest.calledOnceWithExactly('get', '/longpoll', undefined, { + ...config, + cancelToken: c.cancelToken.token, params, - config: { ...config, cancelToken: c.cancelToken.token }, }), ).to.be.true; }); diff --git a/test/unit/draft.test.js b/test/unit/draft.test.js index 2424864112..977127e18d 100644 --- a/test/unit/draft.test.js +++ b/test/unit/draft.test.js @@ -1,144 +1,8 @@ -import sinon from 'sinon'; -import { StreamChat } from '../../src'; -import { generateChannel } from './test-utils/generateChannel'; import { getClientWithUser } from './test-utils/getClient'; import { MockOfflineDB } from './offline-support/MockOfflineDB'; +import { chatLoggerSystem } from '../../src/logger'; import { describe, afterEach, beforeEach, it, expect, vi } from 'vitest'; -describe('Draft Messages', () => { - let client; - let channel; - const apiKey = 'test-api-key'; - const channelType = 'messaging'; - const channelID = 'test-channel'; - const userID = 'test-user'; - const parentID = 'parent-message-id'; - - const draftMessage = { - text: 'Draft message text', - attachments: [{ type: 'image', url: 'https://example.com/image.jpg' }], - mentioned_users: ['user1', 'user2'], - }; - - const draftWithParent = { - text: 'Draft message text', - attachments: [{ type: 'image', url: 'https://example.com/image.jpg' }], - mentioned_users: ['user1', 'user2'], - parent_id: parentID, - }; - - const draftResponse = { - draft: { - channel_cid: `${channelType}:${channelID}`, - created_at: '2023-01-01T00:00:00Z', - message: { - id: 'draft-id', - ...draftMessage, - }, - parent_id: parentID, - }, - }; - - beforeEach(() => { - client = new StreamChat(apiKey); - client.userID = userID; - let channelResponse = generateChannel({ - channel: { id: channelID, name: 'Test channel', members: [] }, - }).channel; - channel = client.channel(channelResponse.type, channelResponse.id); - - // Mock the methods - sinon.stub(client, 'queryDrafts').resolves(draftResponse); - sinon.stub(channel, 'createDraft').resolves(draftResponse); - sinon.stub(channel, 'getDraft').resolves(draftResponse); - sinon.stub(channel, 'deleteDraft').resolves({ duration: '0.01ms' }); - }); - - afterEach(() => { - sinon.restore(); - }); - - it('should create a draft message', async () => { - const response = await channel.createDraft(draftMessage); - - expect(channel.createDraft.calledOnce).to.be.true; - expect(channel.createDraft.firstCall.args[0]).to.deep.equal(draftMessage); - expect(response).to.deep.equal(draftResponse); - }); - - it('should create a draft message with parent ID', async () => { - const response = await channel.createDraft(draftWithParent); - - expect(channel.createDraft.calledOnce).to.be.true; - expect(channel.createDraft.firstCall.args[0]).to.deep.equal(draftWithParent); - expect(response).to.deep.equal(draftResponse); - }); - - it('should get a draft message', async () => { - const response = await channel.getDraft(parentID); - - expect(channel.getDraft.calledOnce).to.be.true; - expect(channel.getDraft.firstCall.args[0]).to.deep.equal(parentID); - expect(response).to.deep.equal(draftResponse); - }); - - it('should get a draft message with parent ID', async () => { - const response = await channel.getDraft(parentID); - - expect(channel.getDraft.calledOnce).to.be.true; - expect(channel.getDraft.firstCall.args[0]).to.deep.equal(parentID); - expect(response).to.deep.equal(draftResponse); - }); - - it('should delete a draft message', async () => { - await channel.deleteDraft(); - - expect(channel.deleteDraft.calledOnce).to.be.true; - expect(channel.deleteDraft.firstCall.args[0]).to.be.undefined; - }); - - it('should delete a draft message with parent ID', async () => { - await channel.deleteDraft(parentID); - - expect(channel.deleteDraft.calledOnce).to.be.true; - expect(channel.deleteDraft.firstCall.args[0]).to.deep.equal(parentID); - }); - - it('should query drafts', async () => { - const queryOptions = { - filter: { created_at: { $gt: '2023-01-01T00:00:00Z' } }, - limit: 10, - }; - - const queryResponse = { - drafts: [ - draftResponse.draft, - { ...draftResponse.draft, channel_cid: 'messaging:other-channel' }, - ], - next: 'next-page-token', - }; - client.queryDrafts.resolves(queryResponse); - - const response = await client.queryDrafts(queryOptions); - - expect(client.queryDrafts.calledOnce).to.be.true; - expect(client.queryDrafts.firstCall.args[0]).to.deep.equal(queryOptions); - expect(response).to.deep.equal(queryResponse); - }); - - it('should query drafts with default options', async () => { - const queryResponse = { - drafts: [draftResponse.draft], - }; - client.queryDrafts.resolves(queryResponse); - - const response = await client.queryDrafts(); - expect(client.queryDrafts.calledOnce).to.be.true; - expect(client.queryDrafts.firstCall.args[0]).to.be.undefined; - expect(response).to.deep.equal(queryResponse); - }); -}); - describe('create draft flow', () => { const draftMessage = { id: 'msg-123', @@ -150,7 +14,6 @@ describe('create draft flow', () => { let channel; let loggerSpy; let queueTaskSpy; - let postSpy; beforeEach(async () => { client = await getClientWithUser(); @@ -161,14 +24,17 @@ describe('create draft flow', () => { channel = client.channel('messaging', 'test'); - loggerSpy = vi.spyOn(client, 'logger').mockImplementation(vi.fn()); + loggerSpy = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: loggerSpy, level: 'trace' }, + }); queueTaskSpy = vi .spyOn(client.offlineDb, 'queueTask') .mockResolvedValue({ draft: draftMessage }); - postSpy = vi.spyOn(client, 'post').mockResolvedValue({ draft: draftMessage }); }); afterEach(() => { + chatLoggerSystem.restoreDefaults(); vi.resetAllMocks(); }); @@ -178,7 +44,7 @@ describe('create draft flow', () => { }); it('queues task if offlineDb exists', async () => { - await channel.createDraft(draftMessage); + await channel.createDraft({ message: draftMessage }); expect(queueTaskSpy).toHaveBeenCalledTimes(1); @@ -188,7 +54,7 @@ describe('create draft flow', () => { channelId: 'test', channelType: 'messaging', threadId: draftMessage.parent_id, - payload: [draftMessage], + payload: [{ message: draftMessage }], type: 'create-draft', }, }); @@ -199,40 +65,20 @@ describe('create draft flow', () => { it('falls back to _createDraft if offlineDb throws', async () => { client.offlineDb.queueTask.mockRejectedValue(new Error('Offline failure')); - await channel.createDraft(draftMessage); + await channel.createDraft({ message: draftMessage }); expect(loggerSpy).toHaveBeenCalledTimes(1); expect(channel._createDraft).toHaveBeenCalledTimes(1); - expect(channel._createDraft).toHaveBeenCalledWith(draftMessage); + expect(channel._createDraft).toHaveBeenCalledWith({ message: draftMessage }); }); it('falls back to _createDraft if offlineDb is undefined', async () => { client.offlineDb = undefined; - await channel.createDraft(draftMessage); + await channel.createDraft({ message: draftMessage }); expect(channel._createDraft).toHaveBeenCalledTimes(1); - expect(channel._createDraft).toHaveBeenCalledWith(draftMessage); - }); - }); - - describe('_createDraft', () => { - it('calls post with correct URL and message payload', async () => { - await channel._createDraft(draftMessage); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels/messaging/test/draft`, - { message: draftMessage }, - ); - }); - - it('returns the response from post', async () => { - postSpy.mockResolvedValue({ draft: draftMessage }); - - const result = await channel._createDraft(draftMessage); - - expect(result).toEqual({ draft: draftMessage }); + expect(channel._createDraft).toHaveBeenCalledWith({ message: draftMessage }); }); }); }); @@ -244,7 +90,6 @@ describe('delete draft flow', () => { let channel; let loggerSpy; let queueTaskSpy; - let deleteSpy; beforeEach(async () => { client = await getClientWithUser(); @@ -255,12 +100,15 @@ describe('delete draft flow', () => { channel = client.channel('messaging', 'test'); - loggerSpy = vi.spyOn(client, 'logger').mockImplementation(vi.fn()); + loggerSpy = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: loggerSpy, level: 'trace' }, + }); queueTaskSpy = vi.spyOn(client.offlineDb, 'queueTask').mockResolvedValue({}); - deleteSpy = vi.spyOn(client, 'delete').mockResolvedValue({}); }); afterEach(() => { + chatLoggerSystem.restoreDefaults(); vi.resetAllMocks(); }); @@ -307,33 +155,4 @@ describe('delete draft flow', () => { expect(channel._deleteDraft).toHaveBeenCalledWith({ parent_id }); }); }); - - describe('_deleteDraft', () => { - it('calls delete with correct URL and params', async () => { - await channel._deleteDraft({ parent_id }); - - expect(deleteSpy).toHaveBeenCalledTimes(1); - expect(deleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels/messaging/test/draft`, - { parent_id }, - ); - }); - - it('calls delete with undefined parent_id if none provided', async () => { - await channel._deleteDraft(); - - expect(deleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels/messaging/test/draft`, - { parent_id: undefined }, - ); - }); - - it('returns the response from delete', async () => { - deleteSpy.mockResolvedValue({ success: true }); - - const result = await channel._deleteDraft({ parent_id }); - - expect(result).toEqual({ success: true }); - }); - }); }); diff --git a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts index 3228388aa9..781d976aa0 100644 --- a/test/unit/messageDelivery/MessageDeliveryReporter.test.ts +++ b/test/unit/messageDelivery/MessageDeliveryReporter.test.ts @@ -1,12 +1,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { getClientWithUser } from '../test-utils/getClient'; +import { generateChannel } from '../test-utils/generateChannel'; +import { generateThreadResponse } from '../test-utils/generateThreadResponse'; import { - type APIErrorResponse, + type APIError, Channel, - ErrorFromResponse, Event, - EventAPIResponse, + MarkDeliveredResponse, + StreamAPIError, StreamChat, + StreamResponse, + Thread, } from '../../../src'; import type { AxiosResponse } from 'axios'; @@ -66,8 +70,8 @@ describe('MessageDeliveryReporter', () => { }); it('announces delivery after the buffer window', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({ ok: true } as any); // last_read < last message @@ -75,13 +79,13 @@ describe('MessageDeliveryReporter', () => { (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; client.syncDeliveredCandidates([channel]); - expect(markChannelsDeliveredSpy).not.toHaveBeenCalled(); + expect(markDeliveredSpy).not.toHaveBeenCalled(); // throttle window (MessageDeliveryReporter uses 1000ms) vi.advanceTimersByTime(1000); // trailing request is not triggered as there are no delivery candidates to report - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); - expect(markChannelsDeliveredSpy).toHaveBeenCalledWith({ + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledWith({ latest_delivered_messages: [ { cid: channel.cid, @@ -92,8 +96,8 @@ describe('MessageDeliveryReporter', () => { }); it('announces at max 100 candidates per request', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({ ok: true } as any); // last_read < last message @@ -117,10 +121,8 @@ describe('MessageDeliveryReporter', () => { client.syncDeliveredCandidates(channels); vi.advanceTimersByTime(1000); // trailing request is not triggered as there are no delivery candidates to report - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); - expect( - markChannelsDeliveredSpy.mock.calls[0][0].latest_delivered_messages.length, - ).toBe(100); + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy.mock.calls[0][0].latest_delivered_messages.length).toBe(100); // @ts-expect-error accessing protected property deliveryReportCandidates expect(client.messageDeliveryReporter.deliveryReportCandidates.size).toBe(10); expect( @@ -130,18 +132,16 @@ describe('MessageDeliveryReporter', () => { await Promise.resolve(); vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(2); - expect( - markChannelsDeliveredSpy.mock.calls[1][0].latest_delivered_messages.length, - ).toBe(10); + expect(markDeliveredSpy).toHaveBeenCalledTimes(2); + expect(markDeliveredSpy.mock.calls[1][0].latest_delivered_messages.length).toBe(10); // @ts-expect-error accessing protected property deliveryReportCandidates expect(client.messageDeliveryReporter.deliveryReportCandidates.size).toBe(0); }); it('does nothing when delivery receipts are disabled', async () => { (client as any).user.privacy_settings.delivery_receipts.enabled = false; - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({ ok: true } as any); setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); @@ -150,7 +150,7 @@ describe('MessageDeliveryReporter', () => { client.syncDeliveredCandidates([channel]); vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).not.toHaveBeenCalled(); + expect(markDeliveredSpy).not.toHaveBeenCalled(); }); it('does nothing when delievry events are disabled in channel config', async () => { @@ -161,8 +161,8 @@ describe('MessageDeliveryReporter', () => { reminders: false, updated_at: '', }; - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({ ok: true } as any); setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); @@ -171,12 +171,12 @@ describe('MessageDeliveryReporter', () => { client.syncDeliveredCandidates([channel]); vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).not.toHaveBeenCalled(); + expect(markDeliveredSpy).not.toHaveBeenCalled(); }); it('does not report if latest message is older than last_delivered_at in read state', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({ ok: true } as any); setLatest(channel, [mkMsg('m1', '2025-01-01T10:00:00Z')]); @@ -188,12 +188,53 @@ describe('MessageDeliveryReporter', () => { client.syncDeliveredCandidates([channel]); vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).not.toHaveBeenCalled(); + expect(markDeliveredSpy).not.toHaveBeenCalled(); + }); + + it('does not report delivery for threads (unsupported; branch early-returns)', () => { + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') + .mockResolvedValue({ ok: true } as any); + + const parent = mkMsg('parent', '2025-01-01T10:00:00Z'); + const channelResponse = generateChannel({ + channel: { id: 'thread-channel', members: [] }, + }).channel; + const thread = new Thread({ + client, + threadData: generateThreadResponse(channelResponse, parent), + }); + 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] = { + created_at: '', + delivery_events: true, + read_events: false, + reminders: false, + updated_at: '', + }; + // Seed the thread's head window with a newest reply that — on a channel — would be reported as a + // delivery candidate (see the channel tests above). + thread.messagePaginator.ingestPage({ + page: [mkMsg('t1', '2025-01-01T11:00:00Z')], + isHead: true, + isTail: true, + setActive: true, + }); + + client.messageDeliveryReporter.syncDeliveredCandidates([thread]); + vi.advanceTimersByTime(1000); + + // Thread delivery reporting is not yet supported: the thread branch returns before producing a + // candidate, so nothing is announced. (When enabled, it reads `messagePaginator.headItems` — the + // newest-loaded window — mirroring the channel branch.) + expect(markDeliveredSpy).not.toHaveBeenCalled(); }); it('coalesces multiple announceDeliveryBuffered calls into a single request', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); setLatest(channel, [mkMsg('m1', 1000)]); @@ -206,12 +247,12 @@ describe('MessageDeliveryReporter', () => { client.messageDeliveryReporter.announceDeliveryBuffered(); vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); }); it('updates the candidate to the newest message before the throttle fires', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date('2025-01-01T09:00:00Z') }; @@ -228,7 +269,7 @@ describe('MessageDeliveryReporter', () => { vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledWith({ + expect(markDeliveredSpy).toHaveBeenCalledWith({ latest_delivered_messages: [ { cid: channel.cid, @@ -241,10 +282,13 @@ describe('MessageDeliveryReporter', () => { it('does not start a second request while one is in-flight; queues new candidate for after', async () => { // first call stays in-flight until we resolve it let resolveFirstMarkDelivered!: ( - value: EventAPIResponse | PromiseLike | undefined, + value: + | StreamResponse + | PromiseLike | undefined> + | undefined, ) => void; - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockImplementationOnce(() => new Promise((r) => (resolveFirstMarkDelivered = r))) .mockResolvedValueOnce({ ok: true } as any); // second request @@ -274,8 +318,8 @@ describe('MessageDeliveryReporter', () => { client.syncDeliveredCandidates([ch1]); vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); - expect(markChannelsDeliveredSpy).toHaveBeenCalledWith({ + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledWith({ latest_delivered_messages: [ { cid: 'messaging:ch1', @@ -291,7 +335,7 @@ describe('MessageDeliveryReporter', () => { // Trying to announce during in-flight should be a no-op for sending vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); // Settle the first request resolveFirstMarkDelivered({ ok: true } as any); @@ -301,8 +345,8 @@ describe('MessageDeliveryReporter', () => { client.messageDeliveryReporter.announceDeliveryBuffered(); vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(2); - expect(markChannelsDeliveredSpy).toHaveBeenCalledWith({ + expect(markDeliveredSpy).toHaveBeenCalledTimes(2); + expect(markDeliveredSpy).toHaveBeenCalledWith({ latest_delivered_messages: [ { cid: 'messaging:ch2', @@ -315,30 +359,30 @@ describe('MessageDeliveryReporter', () => { it('does not send a read when the user disabled read receipts', async () => { (client as any).user.privacy_settings = { read_receipts: { enabled: false } }; const markAsReadRequestSpy = vi - .spyOn(channel, 'markAsReadRequest') + .spyOn(channel, 'markRead') .mockResolvedValue({} as any); - const result = await channel.markRead(); + const result = await channel.markReadViaReporter(); expect(markAsReadRequestSpy).not.toHaveBeenCalled(); expect(result).toBeNull(); }); - it('removes the pending delivery candidate upon channel.markRead', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + it('removes the pending delivery candidate upon channel.markReadViaReporter', async () => { + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); - vi.spyOn(channel, 'markAsReadRequest').mockResolvedValue({} as any); + vi.spyOn(channel, 'markRead').mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; setLatest(channel, [mkMsg('m1', 1000)]); client.syncDeliveredCandidates([channel]); - await channel.markRead(); + await channel.markReadViaReporter(); vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).not.toHaveBeenCalled(); + expect(markDeliveredSpy).not.toHaveBeenCalled(); }); const receiveMessages = (count: number, startId = 0) => { @@ -363,22 +407,22 @@ describe('MessageDeliveryReporter', () => { return channels; }; - const retryableError = new ErrorFromResponse('X', { + const retryableError = new StreamAPIError('X', { code: -1, response: {} as AxiosResponse, status: 400, }); - const notRetryableError = new ErrorFromResponse('X', { + const notRetryableError = new StreamAPIError('X', { code: 2, response: {} as AxiosResponse, status: 400, }); it('re-queues failed markChannelsDelivered request payloads', async () => { - const markChannelsDeliveredSpy = vi.spyOn(client, 'markChannelsDelivered'); + const markDeliveredSpy = vi.spyOn(client, 'markDelivered'); - markChannelsDeliveredSpy.mockRejectedValue(retryableError); + markDeliveredSpy.mockRejectedValue(retryableError); const channels1 = receiveMessages(110); // @ts-expect-error accessing protected property deliveryReportCandidates expect(client.messageDeliveryReporter.deliveryReportCandidates.size).toBe(110); @@ -389,7 +433,7 @@ describe('MessageDeliveryReporter', () => { // trigger mark delivered request that will fail vi.advanceTimersByTime(1000); await Promise.resolve(); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); // all the candidates have been returned back to deliveryReportCandidates // @ts-expect-error accessing protected property deliveryReportCandidates expect(client.messageDeliveryReporter.deliveryReportCandidates.size).toBe(110); @@ -420,7 +464,7 @@ describe('MessageDeliveryReporter', () => { // finish mark delivered request await Promise.resolve(); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(2); + expect(markDeliveredSpy).toHaveBeenCalledTimes(2); // all the candidates together now // @ts-expect-error accessing protected property deliveryReportCandidates expect(client.messageDeliveryReporter.deliveryReportCandidates.size).toBe(220); @@ -473,21 +517,21 @@ describe('MessageDeliveryReporter', () => { vi.advanceTimersByTime(8000); // finish mark delivered request await Promise.resolve(); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(4); + expect(markDeliveredSpy).toHaveBeenCalledTimes(4); // success resets the interval - markChannelsDeliveredSpy.mockResolvedValueOnce({ ok: true } as any); + markDeliveredSpy.mockResolvedValueOnce({ ok: true } as any); // the timeout does not increase anymore from the fourth failed retry vi.advanceTimersByTime(8000); // finish mark delivered request await Promise.resolve(); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(5); + expect(markDeliveredSpy).toHaveBeenCalledTimes(5); // after the previous success we are back to the base timeout vi.advanceTimersByTime(1000); // finish mark delivered request await Promise.resolve(); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(6); + expect(markDeliveredSpy).toHaveBeenCalledTimes(6); // @ts-expect-error accessing protected property deliveryReportCandidates expect(client.messageDeliveryReporter.deliveryReportCandidates.size).toBe(120); @@ -504,9 +548,9 @@ describe('MessageDeliveryReporter', () => { }); it('non retryable error does not schedule retry', async () => { - const markChannelsDeliveredSpy = vi.spyOn(client, 'markChannelsDelivered'); + const markDeliveredSpy = vi.spyOn(client, 'markDelivered'); - markChannelsDeliveredSpy.mockRejectedValue(notRetryableError); + markDeliveredSpy.mockRejectedValue(notRetryableError); const channels1 = receiveMessages(110); // @ts-expect-error accessing protected property deliveryReportCandidates expect(client.messageDeliveryReporter.deliveryReportCandidates.size).toBe(110); @@ -517,17 +561,17 @@ describe('MessageDeliveryReporter', () => { // trigger mark delivered request that will fail vi.advanceTimersByTime(1000); await Promise.resolve(); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); // will not retry vi.advanceTimersByTime(2000); await Promise.resolve(); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); }); it('does not remove the pending delivery candidate after failed markRead request', async () => { - const markChannelsDeliveredSpy = vi.spyOn(client, 'markChannelsDelivered'); - vi.spyOn(channel, 'markAsReadRequest').mockRejectedValue({} as any); + const markDeliveredSpy = vi.spyOn(client, 'markDelivered'); + vi.spyOn(channel, 'markRead').mockRejectedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; setLatest(channel, [mkMsg('m1', 1000)]); @@ -539,7 +583,7 @@ describe('MessageDeliveryReporter', () => { } catch (error) {} vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledWith({ + expect(markDeliveredSpy).toHaveBeenCalledWith({ latest_delivered_messages: [ { cid: channel.cid, @@ -549,9 +593,25 @@ describe('MessageDeliveryReporter', () => { }); }); + it('swallows rejections from the throttled (auto) markRead so they do not leak as unhandled rejections', async () => { + // Reproduces the fire-and-forget path: an active thread/channel auto-marks-read via + // `throttledMarkRead`, but `channel.markRead` rejects (e.g. read events disabled). The throttled + // wrapper must absorb it — otherwise it surfaces as an unhandled rejection and fails the run. + const markReadSpy = vi + .spyOn(channel, 'markRead') + .mockRejectedValue(new Error('Read events are disabled for this application')); + + expect(() => client.messageDeliveryReporter.throttledMarkRead(channel)).not.toThrow(); + + // Let the rejected markRead settle; the `.catch` in the throttled wrapper absorbs it. + // (1000ms === the reporter's MARK_AS_READ_THROTTLE_TIMEOUT.) + await vi.advanceTimersByTimeAsync(1000); + expect(markReadSpy).toHaveBeenCalledTimes(1); + }); + it('handles message.new via channel event: schedules and sends delivered for newest', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; @@ -560,7 +620,7 @@ describe('MessageDeliveryReporter', () => { // simulate incoming message.new event const ev: Event = { type: 'message.new', - created_at: new Date('2025-01-01T10:00:00Z').toISOString(), + created_at: new Date('2025-01-01T10:00:00Z'), user: otherUser, // cid must match the paginator filter so message.new ingests into an interval message: { ...mkMsg('m1', '2025-01-01T10:00:00Z'), cid: channel.cid } as any, @@ -570,8 +630,8 @@ describe('MessageDeliveryReporter', () => { vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); - expect(markChannelsDeliveredSpy).toHaveBeenCalledWith({ + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledWith({ latest_delivered_messages: [ { cid: channel.cid, @@ -582,8 +642,8 @@ describe('MessageDeliveryReporter', () => { }); it('prevents tracking own new messages', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; @@ -592,7 +652,7 @@ describe('MessageDeliveryReporter', () => { // simulate incoming message.new event const ev: Event = { type: 'message.new', - created_at: new Date('2025-01-01T10:00:00Z').toISOString(), + created_at: new Date('2025-01-01T10:00:00Z'), user: ownUser, message: mkMsg('m1', '2025-01-01T10:00:00Z') as any, }; @@ -601,12 +661,12 @@ describe('MessageDeliveryReporter', () => { vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).not.toHaveBeenCalled(); + expect(markDeliveredSpy).not.toHaveBeenCalled(); }); it('syncs delivery candidates upon own message.read event and prevents reporting delivery', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; @@ -616,7 +676,7 @@ describe('MessageDeliveryReporter', () => { const ev: Event = { type: 'message.read', - created_at: new Date('2025-01-01T10:00:00Z').toISOString(), + created_at: new Date('2025-01-01T10:00:00Z'), last_read_message_id: 'm1', message: mkMsg('m1', '2025-01-01T10:00:00Z') as any, user: ownUser, @@ -626,12 +686,12 @@ describe('MessageDeliveryReporter', () => { vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).not.toHaveBeenCalled(); + expect(markDeliveredSpy).not.toHaveBeenCalled(); }); it('does not sync delivery candidates upon other user message.read event and reports delivery', async () => { - const markChannelsDeliveredSpy = vi - .spyOn(client, 'markChannelsDelivered') + const markDeliveredSpy = vi + .spyOn(client, 'markDelivered') .mockResolvedValue({} as any); (channel.state as any).read['me'] = { last_read: new Date(0) }; @@ -641,7 +701,7 @@ describe('MessageDeliveryReporter', () => { const ev: Event = { type: 'message.read', - created_at: new Date('2025-01-01T10:00:00Z').toISOString(), + created_at: new Date('2025-01-01T10:00:00Z'), last_read_message_id: 'm1', message: mkMsg('m1', '2025-01-01T10:00:00Z') as any, user: otherUser, @@ -651,8 +711,8 @@ describe('MessageDeliveryReporter', () => { vi.advanceTimersByTime(1000); - expect(markChannelsDeliveredSpy).toHaveBeenCalledTimes(1); - expect(markChannelsDeliveredSpy).toHaveBeenCalledWith({ + expect(markDeliveredSpy).toHaveBeenCalledTimes(1); + expect(markDeliveredSpy).toHaveBeenCalledWith({ latest_delivered_messages: [ { cid: channel.cid, @@ -663,7 +723,7 @@ describe('MessageDeliveryReporter', () => { }); it('throttles markRead (leading + trailing: fires immediately, then once more on the trailing edge)', async () => { - const spy = vi.spyOn(channel, 'markAsReadRequest').mockResolvedValue({} as any); + const spy = vi.spyOn(channel, 'markRead').mockResolvedValue({} as any); // burst client.messageDeliveryReporter.throttledMarkRead(channel); @@ -676,7 +736,7 @@ describe('MessageDeliveryReporter', () => { }); it('marks read immediately on a single throttledMarkRead call (leading edge)', async () => { - const spy = vi.spyOn(channel, 'markAsReadRequest').mockResolvedValue({} as any); + const spy = vi.spyOn(channel, 'markRead').mockResolvedValue({} as any); // A single call is the common case (e.g. scrolling to the bottom once). With `leading: true` it // fires immediately on the leading edge — no delay — and a lone call schedules no extra trailing diff --git a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts index 95a66cfad4..a43c055391 100644 --- a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts +++ b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { MessageReceiptsTracker, type MsgRef, - ReadResponse, + ReadStateResponse, UserResponse, } from '../../../src'; import { StateStore } from '../../../src/store'; @@ -10,6 +10,9 @@ import type { Channel } from '../../../src/channel'; const ownUserId = 'author'; const U = (id: string): UserResponse => ({ id, name: id }); // matches UserResponse shape for the service +// Read/delivery timestamps are `Date` in the OpenAPI-aligned tracker API; this helper builds one +// from a millisecond value (the tracker no longer accepts ISO strings). +const iso = (ms: number): Date => new Date(ms); // Timeline: 4 messages with ascending timestamps const msgs = [ @@ -52,9 +55,6 @@ const createChannelMock = ({ }; }; -// ISO builders (service parses Date strings) -const iso = (ts: number) => new Date(ts).toISOString(); - // Extract ids from user arrays for easier assertions const ids = (users: any[]) => users.map((u) => u.id); @@ -99,16 +99,16 @@ describe('MessageDeliveryReadTracker', () => { // Alice read m2, delivered m1 -> delivered must be bumped to m2 // Bob delivered m3, haven't read any message -> read stays MIN, delivered m3 - const snapshot: ReadResponse[] = [ + const snapshot: ReadStateResponse[] = [ { user: alice, - last_read: iso(2000), - last_delivered_at: iso(1000), + last_read: new Date(2000), + last_delivered_at: new Date(1000), }, { user: bob, - last_read: iso(500), - last_delivered_at: iso(3000), + last_read: new Date(500), + last_delivered_at: new Date(3000), }, ]; @@ -134,11 +134,11 @@ describe('MessageDeliveryReadTracker', () => { it('includes own read state', () => { const ownUser = U(ownUserId); - const snapshot: ReadResponse[] = [ + const snapshot: ReadStateResponse[] = [ { user: ownUser, - last_read: iso(2000), - last_delivered_at: iso(1000), + last_read: new Date(2000), + last_delivered_at: new Date(1000), }, ]; @@ -155,21 +155,21 @@ describe('MessageDeliveryReadTracker', () => { expect(p0).toBeNull(); // first read at m3 - tracker.onMessageRead({ user: carol, readAt: iso(3000) }); + tracker.onMessageRead({ user: carol, readAt: new Date(3000) }); const p1 = tracker.getUserProgress('carol')!; expect(p1.lastReadRef).toEqual(ref(3000)); expect(p1.lastDeliveredRef).toEqual(ref(3000)); // bumped // older/equal reads are no-ops - tracker.onMessageRead({ user: carol, readAt: iso(2000) }); - tracker.onMessageRead({ user: carol, readAt: iso(3000) }); + tracker.onMessageRead({ user: carol, readAt: new Date(2000) }); + tracker.onMessageRead({ user: carol, readAt: new Date(3000) }); const p2 = tracker.getUserProgress('carol')!; expect(p2.lastReadRef).toEqual(ref(3000)); expect(p2.lastDeliveredRef).toEqual(ref(3000)); // later read moves forward and bumps delivered - tracker.onMessageRead({ user: carol, readAt: iso(4000) }); + tracker.onMessageRead({ user: carol, readAt: new Date(4000) }); const p3 = tracker.getUserProgress('carol')!; expect(p3.lastReadRef).toEqual(ref(4000)); expect(p3.lastDeliveredRef).toEqual(ref(4000)); @@ -184,11 +184,11 @@ describe('MessageDeliveryReadTracker', () => { tracker = new MessageReceiptsTracker({ channel: channelMock.channel }); const dave = U('dave'); - tracker.onMessageRead({ user: dave, readAt: iso(4000) }); // unknown -> ignored + tracker.onMessageRead({ user: dave, readAt: new Date(4000) }); // unknown -> ignored expect(tracker.getUserProgress('dave')).toBeNull(); // but a known read creates progress - tracker.onMessageRead({ user: dave, readAt: iso(2000) }); + tracker.onMessageRead({ user: dave, readAt: new Date(2000) }); const pd = tracker.getUserProgress('dave')!; expect(pd.lastReadRef).toEqual(ref(2000)); expect(pd.lastDeliveredRef).toEqual(ref(2000)); @@ -199,7 +199,11 @@ describe('MessageDeliveryReadTracker', () => { channelMock = createChannelMock({ findMessageByTimestamp }); tracker = new MessageReceiptsTracker({ channel: channelMock.channel }); const user = U('frank'); - tracker.onMessageRead({ user, readAt: iso(3000), lastReadMessageId: 'X' }); // unknown -> ignored + tracker.onMessageRead({ + user, + readAt: new Date(3000), + lastReadMessageId: 'X', + }); // unknown -> ignored expect(findMessageByTimestamp).not.toHaveBeenCalled(); expect(tracker.getUserProgress('frank')).toStrictEqual({ lastDeliveredRef: { @@ -219,7 +223,7 @@ describe('MessageDeliveryReadTracker', () => { it('does not ignore own message.read events', () => { const ownUser = U(ownUserId); - tracker.onMessageRead({ user: ownUser, readAt: iso(2000) }); + tracker.onMessageRead({ user: ownUser, readAt: new Date(2000) }); expect(tracker.getUserProgress(ownUserId)!.user).toStrictEqual(ownUser); }); }); @@ -228,26 +232,26 @@ describe('MessageDeliveryReadTracker', () => { it('creates user on first delivered; uses max(read, delivered)', () => { const eve = U('eve'); - tracker.onMessageDelivered({ user: eve, deliveredAt: iso(2000) }); + tracker.onMessageDelivered({ user: eve, deliveredAt: new Date(2000) }); let progressEve = tracker.getUserProgress('eve')!; expect(progressEve.lastDeliveredRef).toEqual(ref(2000)); expect(progressEve.lastReadRef.timestampMs).toBe(Number.NEGATIVE_INFINITY); // deliver older/equal -> no-op - tracker.onMessageDelivered({ user: eve, deliveredAt: iso(1000) }); - tracker.onMessageDelivered({ user: eve, deliveredAt: iso(2000) }); + tracker.onMessageDelivered({ user: eve, deliveredAt: new Date(1000) }); + tracker.onMessageDelivered({ user: eve, deliveredAt: new Date(2000) }); progressEve = tracker.getUserProgress('eve')!; expect(progressEve.lastDeliveredRef).toEqual(ref(2000)); // if read goes ahead to m3, and a delivery arrives for m2, // newDelivered = max(read, deliveredEvent) = read (m3) - tracker.onMessageRead({ user: eve, readAt: iso(3000) }); + tracker.onMessageRead({ user: eve, readAt: new Date(3000) }); progressEve = tracker.getUserProgress('eve')!; expect(progressEve.lastReadRef).toEqual(ref(3000)); expect(progressEve.lastDeliveredRef).toEqual(ref(3000)); // bumped by read // deliver at m4 -> moves forward - tracker.onMessageDelivered({ user: eve, deliveredAt: iso(4000) }); + tracker.onMessageDelivered({ user: eve, deliveredAt: new Date(4000) }); progressEve = tracker.getUserProgress('eve')!; expect(progressEve.lastDeliveredRef).toEqual(ref(4000)); expect(progressEve.lastReadRef).toEqual(ref(3000)); @@ -261,10 +265,10 @@ describe('MessageDeliveryReadTracker', () => { tracker = new MessageReceiptsTracker({ channel: channelMock.channel }); const frank = U('frank'); - tracker.onMessageDelivered({ user: frank, deliveredAt: iso(3000) }); // unknown -> ignored + tracker.onMessageDelivered({ user: frank, deliveredAt: new Date(3000) }); // unknown -> ignored expect(tracker.getUserProgress('frank')).toBeNull(); - tracker.onMessageDelivered({ user: frank, deliveredAt: iso(2000) }); // known -> creates + tracker.onMessageDelivered({ user: frank, deliveredAt: new Date(2000) }); // known -> creates const pf = tracker.getUserProgress('frank')!; expect(pf.lastDeliveredRef).toEqual(ref(2000)); }); @@ -276,7 +280,7 @@ describe('MessageDeliveryReadTracker', () => { const user = U('frank'); tracker.onMessageDelivered({ user, - deliveredAt: iso(3000), + deliveredAt: new Date(3000), lastDeliveredMessageId: 'X', }); // unknown -> ignored expect(findMessageByTimestamp).not.toHaveBeenCalled(); @@ -298,7 +302,7 @@ describe('MessageDeliveryReadTracker', () => { it('does not ignore own message.delivered events', () => { const ownUser = U(ownUserId); - tracker.onMessageDelivered({ user: ownUser, deliveredAt: iso(2000) }); + tracker.onMessageDelivered({ user: ownUser, deliveredAt: new Date(2000) }); expect(tracker.getUserProgress(ownUserId)!.user).toStrictEqual(ownUser); }); }); @@ -306,11 +310,15 @@ describe('MessageDeliveryReadTracker', () => { describe('onNotificationMarkUnread', () => { const user = U('u'); it('moves lastRead backward to the event boundary and keeps delivered unchanged (no backward move)', () => { - tracker.onMessageRead({ user, readAt: iso(3000), lastReadMessageId: 'm3' }); + tracker.onMessageRead({ + user, + readAt: new Date(3000), + lastReadMessageId: 'm3', + }); tracker.onNotificationMarkUnread({ user, - lastReadAt: iso(2000), + lastReadAt: new Date(2000), lastReadMessageId: 'm2', }); @@ -330,10 +338,14 @@ describe('MessageDeliveryReadTracker', () => { // v delivered m4 and read m2 tracker.onMessageDelivered({ user, - deliveredAt: iso(4000), + deliveredAt: new Date(4000), lastDeliveredMessageId: 'm4', }); - tracker.onMessageRead({ user, readAt: iso(2000), lastReadMessageId: 'm2' }); + tracker.onMessageRead({ + user, + readAt: new Date(2000), + lastReadMessageId: 'm2', + }); let userProgress = tracker.getUserProgress(user.id)!; expect(userProgress.lastReadRef).toEqual(ref(2000)); @@ -352,12 +364,12 @@ describe('MessageDeliveryReadTracker', () => { }); it('is a no-op when the provided last_read equals current lastReadRef', () => { - tracker.onMessageRead({ user, readAt: iso(3000) }); + tracker.onMessageRead({ user, readAt: new Date(3000) }); const before = structuredClone(tracker.getUserProgress(user.id)!); tracker.onNotificationMarkUnread({ user, - lastReadAt: iso(3000), + lastReadAt: new Date(3000), lastReadMessageId: 'm3', }); @@ -375,7 +387,7 @@ describe('MessageDeliveryReadTracker', () => { tracker.onNotificationMarkUnread({ user, - lastReadAt: iso(2000), + lastReadAt: new Date(2000), lastReadMessageId: 'm2', }); @@ -430,11 +442,11 @@ describe('MessageDeliveryReadTracker', () => { const c = U('c'); // a: read m3, delivered m3 - tracker.onMessageRead({ user: a, readAt: iso(3000) }); + tracker.onMessageRead({ user: a, readAt: new Date(3000) }); // b: delivered m3 only (not read) - tracker.onMessageDelivered({ user: b, deliveredAt: iso(3000) }); + tracker.onMessageDelivered({ user: b, deliveredAt: new Date(3000) }); // c: read m4, delivered m4 - tracker.onMessageRead({ user: c, readAt: iso(4000) }); + tracker.onMessageRead({ user: c, readAt: new Date(4000) }); // Readers of m2 => a, c expect(ids(tracker.readersForMessage(ref(2000)))).toEqual(['a', 'c']); @@ -450,8 +462,8 @@ describe('MessageDeliveryReadTracker', () => { const u1 = U('u1'); const u2 = U('u2'); - tracker.onMessageDelivered({ user: u1, deliveredAt: iso(2000) }); // delivered m2 - tracker.onMessageRead({ user: u2, readAt: iso(3000) }); // read m3 (delivered m3) + tracker.onMessageDelivered({ user: u1, deliveredAt: new Date(2000) }); // delivered m2 + tracker.onMessageRead({ user: u2, readAt: new Date(3000) }); // read m3 (delivered m3) // For m2: expect(tracker.hasUserDelivered(ref(2000), 'u1')).toBe(true); @@ -477,21 +489,25 @@ describe('MessageDeliveryReadTracker', () => { const e = U('e'); // same for delivered side // a: read m2 -> delivered m2 - tracker.onMessageRead({ user: a, readAt: iso(2000) }); + tracker.onMessageRead({ user: a, readAt: new Date(2000) }); // b: read m3 -> delivered m3 - tracker.onMessageRead({ user: b, readAt: iso(3000) }); + tracker.onMessageRead({ user: b, readAt: new Date(3000) }); // c: delivered m3 only - tracker.onMessageDelivered({ user: c, deliveredAt: iso(3000) }); + tracker.onMessageDelivered({ user: c, deliveredAt: new Date(3000) }); // d: read at ts=3000 but with a different msgId "X" (tests plateau filtering by msgId) - tracker.onMessageRead({ user: d, readAt: iso(3000), lastReadMessageId: 'X' }); + tracker.onMessageRead({ + user: d, + readAt: new Date(3000), + lastReadMessageId: 'X', + }); // e: delivered at ts=3000 but with a different msgId "X" tracker.onMessageDelivered({ user: e, - deliveredAt: iso(3000), + deliveredAt: new Date(3000), lastDeliveredMessageId: 'X', }); @@ -512,12 +528,12 @@ describe('MessageDeliveryReadTracker', () => { const user = U('x'); // x reads m2 -> last read m2 (and delivered m2) - tracker.onMessageRead({ user, readAt: iso(2000) }); + tracker.onMessageRead({ user, readAt: new Date(2000) }); expect(ids(tracker.usersWhoseLastReadIs(ref(2000)))).toEqual(['x']); expect(ids(tracker.usersWhoseLastDeliveredIs(ref(2000)))).toEqual(['x']); // x later reads m4 -> moves out of m2 group and into m4 group - tracker.onMessageRead({ user, readAt: iso(4000) }); + tracker.onMessageRead({ user, readAt: new Date(4000) }); expect(ids(tracker.usersWhoseLastReadIs(ref(2000)))).toEqual([]); expect(ids(tracker.usersWhoseLastReadIs(ref(4000)))).toEqual(['x']); @@ -586,14 +602,14 @@ describe('MessageDeliveryReadTracker', () => { const y = U('y'); // x reads m2, y reads m3 - tracker.onMessageRead({ user: x, readAt: iso(2000) }); - tracker.onMessageRead({ user: y, readAt: iso(3000) }); + tracker.onMessageRead({ user: x, readAt: new Date(2000) }); + tracker.onMessageRead({ user: y, readAt: new Date(3000) }); // Readers of m2 -> x, y expect(ids(tracker.readersForMessage(ref(2000)))).toEqual(['x', 'y']); // now x reads m4 (moves past y) - tracker.onMessageRead({ user: x, readAt: iso(4000) }); + tracker.onMessageRead({ user: x, readAt: new Date(4000) }); // Readers of m3 -> x, y? Actually only x (m4) and y (m3) both >= m3 expect(ids(tracker.readersForMessage(ref(3000)))).toEqual(['y', 'x']); // and of m4 -> x only diff --git a/test/unit/offline-support/offline_support_api.test.ts b/test/unit/offline-support/offline_support_api.test.ts index af118c3a9d..b6af100c4c 100644 --- a/test/unit/offline-support/offline_support_api.test.ts +++ b/test/unit/offline-support/offline_support_api.test.ts @@ -1,21 +1,22 @@ import { describe, expect, it, beforeEach, afterEach, vi, MockInstance } from 'vitest'; import { AbstractOfflineDB, - ChannelAPIResponse, + APIError, ChannelManager, StreamChat, Event, Channel, - MessageResponse, - ReadResponse, ChannelMemberResponse, ChannelResponse, - PendingTask, - APIErrorResponse, + ChannelStateResponseFields, + MessageResponse, OfflineDBSyncManager, - StableWSConnection, OfflineError, + PendingTask, + ReadStateResponse, + StableWSConnection, } from '../../../src'; +import { chatLoggerSystem } from '../../../src/logger'; import { generateChannel } from '../test-utils/generateChannel'; import { generateReadResponse } from '../test-utils/generateReadResponse'; @@ -316,18 +317,23 @@ describe('OfflineSupportApi', () => { offlineDb.channelExists.mockResolvedValue(false); - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const sinkSpy = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: sinkSpy, level: 'trace' }, + }); const result = await offlineDb.queriesWithChannelGuard({ event }, createQueries); - // TODO: testing against warning logs seems silly, please rethink this - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Received message.new event for a non initialized channel that is not in DB, skipping event', + expect(sinkSpy).toHaveBeenCalledWith( + 'warn', + expect.stringContaining( + 'Received a "message.new" event for a non-initialized channel that is not in the database. Skipping the event.', + ), { event }, ); expect(result).toEqual([]); - consoleWarnSpy.mockRestore(); + chatLoggerSystem.restoreDefaults(); }); it('returns createQueries result directly when channel exists and forceUpdate is false', async () => { @@ -436,8 +442,8 @@ describe('OfflineSupportApi', () => { let queriesWithChannelGuardSpy: MockInstance< typeof offlineDb.queriesWithChannelGuard >; - let channelResponse: ChannelAPIResponse; - let readResponse: ReadResponse; + let channelResponse: ChannelStateResponseFields; + let readResponse: ReadStateResponse; beforeEach(() => { queriesWithChannelGuardSpy = vi.spyOn(offlineDb, 'queriesWithChannelGuard'); @@ -446,7 +452,7 @@ describe('OfflineSupportApi', () => { channelResponse = generateChannel({ channel: { id: 'channel123', type: 'messaging' }, read: [readResponse], - } as ChannelAPIResponse); + } as ChannelStateResponseFields); client.hydrateActiveChannels([channelResponse]); // to make sure queriesWithChannelGuard always passes @@ -1254,7 +1260,7 @@ describe('OfflineSupportApi', () => { channelResponse = generateChannel({ channel: { id: 'to-truncate', type: 'messaging' }, read: [readResponse], - } as ChannelAPIResponse); + } as ChannelStateResponseFields); client.hydrateActiveChannels([channelResponse]); }); @@ -1310,7 +1316,7 @@ describe('OfflineSupportApi', () => { execute: false, reads: [ { - last_read: lastReadDate.toString(), + last_read: lastReadDate, last_read_message_id: lastReadMessageId, unread_messages: 2, user: client.user, @@ -1341,7 +1347,7 @@ describe('OfflineSupportApi', () => { execute: false, reads: [ { - last_read: lastReadDate.toString(), + last_read: lastReadDate, last_read_message_id: lastReadMessageId, unread_messages: 0, user: client.user, @@ -1383,7 +1389,7 @@ describe('OfflineSupportApi', () => { execute: false, reads: [ { - last_read: lastReadDate.toString(), + last_read: lastReadDate, last_read_message_id: lastReadMessageId, unread_messages: 0, user: client.user, @@ -1895,7 +1901,7 @@ describe('OfflineSupportApi', () => { const error = { isAxiosError: true, response: { data: { code: 999 } }, - } as AxiosError; + } as AxiosError; shouldSkipSpy.mockReturnValue(false); executeTaskSpy.mockRejectedValue(error); @@ -1910,7 +1916,7 @@ describe('OfflineSupportApi', () => { const error = { isAxiosError: true, response: { data: { code: 4 } }, - } as AxiosError; + } as AxiosError; shouldSkipSpy.mockReturnValue(true); executeTaskSpy.mockRejectedValue(error); @@ -1981,14 +1987,15 @@ describe('OfflineSupportApi', () => { }, }, ) as PendingTask; - const pendingSendOptions = { skip_enrich_url: true }; vi.spyOn(offlineDb, 'getPendingTasks').mockResolvedValue([ { id: 7, messageId: 'msg-123', payload: [ - { id: 'msg-123', status: 'sending', text: 'original' }, - pendingSendOptions, + { + message: { id: 'msg-123', status: 'sending', text: 'original' }, + skip_enrich_url: true, + }, ], type: 'send-message', } as PendingTask, @@ -2006,17 +2013,16 @@ describe('OfflineSupportApi', () => { type: 'send-message', }), }); - expect(updatePendingTaskSpy.mock.calls[0][0].task.payload[0]).toMatchObject({ + expect( + updatePendingTaskSpy.mock.calls[0][0].task.payload[0].message, + ).toMatchObject({ id: 'msg-123', status: 'sending', text: 'edited', }); expect( - updatePendingTaskSpy.mock.calls[0][0].task.payload[0], + updatePendingTaskSpy.mock.calls[0][0].task.payload[0].message, ).not.toHaveProperty('message_text_updated_at'); - expect(updatePendingTaskSpy.mock.calls[0][0].task.payload[1]).toBe( - pendingSendOptions, - ); expect(addPendingTaskSpy).not.toHaveBeenCalled(); }); @@ -2039,8 +2045,7 @@ describe('OfflineSupportApi', () => { { messageId: 'msg-123', payload: [ - { id: 'msg-123', status: 'sending', text: 'original' }, - undefined, + { message: { id: 'msg-123', status: 'sending', text: 'original' } }, ], type: 'send-message', } as PendingTask, @@ -2051,12 +2056,16 @@ describe('OfflineSupportApi', () => { await offlineDb.handleAddPendingTask({ task }); expect(updatePendingTaskSpy).not.toHaveBeenCalled(); - expect(addPendingTaskSpy).toHaveBeenCalledWith({ - messageId: 'msg-123', - payload: [{ id: 'msg-123', status: 'sending', text: 'edited' }, undefined], - type: 'send-message', - id: undefined, - }); + expect(addPendingTaskSpy).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: 'msg-123', + payload: [ + { message: { id: 'msg-123', status: 'sending', text: 'edited' } }, + ], + type: 'send-message', + id: undefined, + }), + ); }); it('does nothing for failed offline update-message tasks without a matching pending send task', async () => { @@ -2211,7 +2220,7 @@ describe('OfflineSupportApi', () => { const skippableError = { isAxiosError: true, response: { data: { code: 4 } }, - } as AxiosError; + } as AxiosError; beforeEach(() => { getPendingTasksSpy = vi @@ -2388,11 +2397,20 @@ describe('OfflineDBSyncManager', () => { const error = new Error('Sync failed'); syncAndExecutePendingTasksSpy.mockRejectedValueOnce(error); - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const sinkSpy = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: sinkSpy, level: 'trace' }, + }); await syncManager.init(); - expect(consoleSpy).toHaveBeenCalledWith('Error in DBSyncManager.init: ', error); + expect(sinkSpy).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Failed to initialize the offline DB sync manager.'), + { error }, + ); + + chatLoggerSystem.restoreDefaults(); }); }); @@ -2658,12 +2676,10 @@ describe('OfflineDBSyncManager', () => { await (syncManager as any).sync(); - expect(syncApiSpy).toHaveBeenCalledWith( - ['channel-1'], - expect.stringMatching( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/, // ISO8601 regex, YYYY-MM-DDTHH:mm:ss.sssZ - ), - ); + expect(syncApiSpy).toHaveBeenCalledWith({ + channel_cids: ['channel-1'], + last_sync_at: expect.any(Date), + }); expect(handleEventSpy).toHaveBeenCalledTimes(mockEvents.length); expect(executeSqlBatchSpy).toHaveBeenCalledWith(['query1', 'query2']); expect(upsertUserSyncStatusSpy).toHaveBeenCalled(); @@ -2678,7 +2694,7 @@ describe('OfflineDBSyncManager', () => { isAxiosError: true, code: 'ECONNABORTED', response: { data: { code: 4 } }, - } as AxiosError; + } as AxiosError; syncApiSpy.mockRejectedValueOnce(axiosError); @@ -2695,7 +2711,7 @@ describe('OfflineDBSyncManager', () => { const axiosError = { response: { data: { code: 23 } }, - } as AxiosError; + } as AxiosError; syncApiSpy.mockRejectedValueOnce(axiosError); diff --git a/test/unit/pagination/UserGroupPaginator.test.ts b/test/unit/pagination/UserGroupPaginator.test.ts index b5c83ae388..74fe6160b9 100644 --- a/test/unit/pagination/UserGroupPaginator.test.ts +++ b/test/unit/pagination/UserGroupPaginator.test.ts @@ -9,8 +9,8 @@ const createUserGroup = ( ): UserGroupResponse => ({ id: 'group-1', name: 'Backend Support', - created_at: '2026-01-01T00:00:00.000000000Z', - updated_at: '2026-01-01T00:00:00.000000000Z', + created_at: new Date('2026-01-01T00:00:00.000Z'), + updated_at: new Date('2026-01-01T00:00:00.000Z'), ...overrides, }); @@ -32,25 +32,28 @@ describe('UserGroupPaginator', () => { it('paginates listed user groups using synthesized cursors', async () => { const firstPage = [ - createUserGroup({ id: 'group-1', created_at: '2026-01-01T00:00:00.000000000Z' }), + createUserGroup({ + id: 'group-1', + created_at: new Date('2026-01-01T00:00:00.000Z'), + }), createUserGroup({ id: 'group-2', name: 'Frontend Support', - created_at: '2026-01-02T00:00:00.000000000Z', - updated_at: '2026-01-02T00:00:00.000000000Z', + created_at: new Date('2026-01-02T00:00:00.000Z'), + updated_at: new Date('2026-01-02T00:00:00.000Z'), }), ]; const secondPage = [ createUserGroup({ id: 'group-3', name: 'QA Support', - created_at: '2026-01-03T00:00:00.000000000Z', - updated_at: '2026-01-03T00:00:00.000000000Z', + created_at: new Date('2026-01-03T00:00:00.000Z'), + updated_at: new Date('2026-01-03T00:00:00.000Z'), }), ]; const querySpy = vi - .spyOn(client, 'queryUserGroups') + .spyOn(client, 'listUserGroups') .mockResolvedValueOnce({ duration: '0.01s', user_groups: firstPage }) .mockResolvedValueOnce({ duration: '0.01s', user_groups: secondPage }); @@ -63,7 +66,7 @@ describe('UserGroupPaginator', () => { expect(paginator.hasNext).toBe(true); expect(paginator.hasPrev).toBe(false); expect(JSON.parse(paginator.cursor?.tailward ?? '{}')).toEqual({ - created_at_gt: firstPage[1].created_at, + created_at_gt: firstPage[1].created_at.toISOString(), id_gt: firstPage[1].id, }); @@ -71,7 +74,7 @@ describe('UserGroupPaginator', () => { expect(querySpy).toHaveBeenNthCalledWith(2, { limit: 2, - created_at_gt: firstPage[1].created_at, + created_at_gt: firstPage[1].created_at.toISOString(), id_gt: firstPage[1].id, }); expect(paginator.items).toEqual([...firstPage, ...secondPage]); @@ -83,7 +86,7 @@ describe('UserGroupPaginator', () => { }); it('resets paginator state when team id changes', async () => { - vi.spyOn(client, 'queryUserGroups').mockResolvedValue({ + vi.spyOn(client, 'listUserGroups').mockResolvedValue({ duration: '0.01s', user_groups: [createUserGroup()], }); @@ -101,7 +104,7 @@ describe('UserGroupPaginator', () => { }); it('ignores malformed stored cursors and retries from the first page options', async () => { - const querySpy = vi.spyOn(client, 'queryUserGroups').mockResolvedValue({ + const querySpy = vi.spyOn(client, 'listUserGroups').mockResolvedValue({ duration: '0.01s', user_groups: [createUserGroup()], }); @@ -117,7 +120,7 @@ describe('UserGroupPaginator', () => { }); it('does not execute prev pagination requests', async () => { - const querySpy = vi.spyOn(client, 'queryUserGroups'); + const querySpy = vi.spyOn(client, 'listUserGroups'); const paginator = new UserGroupPaginator(client); await paginator.prev(); diff --git a/test/unit/pagination/paginators/ChannelPaginator.test.ts b/test/unit/pagination/paginators/ChannelPaginator.test.ts index df81b79c14..072354e392 100644 --- a/test/unit/pagination/paginators/ChannelPaginator.test.ts +++ b/test/unit/pagination/paginators/ChannelPaginator.test.ts @@ -706,23 +706,20 @@ describe('ChannelPaginator', () => { }); await paginator.query(); - expect(queryChannelsSpy).toHaveBeenCalledWith( - { + expect(queryChannelsSpy).toHaveBeenCalledWith({ + filter_conditions: { muted: { $eq: true, }, name: 'A', }, - { + sort: { has_unread: -1, }, - { - limit: 22, - message_limit: 3, - offset: 0, - }, - undefined, // channelStateOptions - ); + limit: 22, + message_limit: 3, + offset: 0, + }); }); }); diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 0dd4d24dee..9a4b533023 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -249,11 +249,12 @@ describe('MessagePaginator', () => { const result = await paginator.query({}); - expect(channel.getReplies).toHaveBeenCalledWith( - 'parent-1', - { id_gt: 'from-cursor', limit: 30 }, - [{ created_at: 1 }], - ); + expect(channel.getReplies).toHaveBeenCalledWith({ + parent_id: 'parent-1', + id_gt: 'from-cursor', + limit: 30, + sort: [{ field: 'created_at', direction: 1 }], + }); expect(channel.query).not.toHaveBeenCalled(); expect(result.tailward).toBe('first-reply'); expect(result.headward).toBe('last-reply'); @@ -282,11 +283,12 @@ describe('MessagePaginator', () => { const result = await paginator.query({}); - expect(channel.getReplies).toHaveBeenCalledWith( - 'parent-1', - { id_gt: 'from-cursor', limit: 30 }, - [{ created_at: -1 }], - ); + expect(channel.getReplies).toHaveBeenCalledWith({ + parent_id: 'parent-1', + id_gt: 'from-cursor', + limit: 30, + sort: [{ field: 'created_at', direction: -1 }], + }); expect(result.items.map((message) => message.id)).toEqual([ 'oldest-reply', 'middle-reply', @@ -1071,7 +1073,7 @@ describe('MessagePaginator', () => { expect(paginator.getItem(quoteCarrier.id)?.quoted_message?.text).toBe( 'after update', ); - expect(paginator.getItem(nonCarrier.id)?.quoted_message).toBeNull(); + expect(paginator.getItem(nonCarrier.id)?.quoted_message).toBeUndefined(); }); }); diff --git a/test/unit/pagination/paginators/UserGroupPaginator.test.ts b/test/unit/pagination/paginators/UserGroupPaginator.test.ts index 82d5929966..bc263f232e 100644 --- a/test/unit/pagination/paginators/UserGroupPaginator.test.ts +++ b/test/unit/pagination/paginators/UserGroupPaginator.test.ts @@ -7,8 +7,8 @@ import { getClientWithUser } from '../../test-utils/getClient'; const makeGroup = (id: string, createdAt: string): UserGroupResponse => ({ id, name: id, - created_at: createdAt, - updated_at: createdAt, + created_at: new Date(createdAt), + updated_at: new Date(createdAt), }); const response = (groups: UserGroupResponse[]) => ({ duration: '', user_groups: groups }); @@ -22,7 +22,7 @@ describe('UserGroupPaginator', () => { it('stores results in interval storage (index-addressable, headItems populated)', async () => { const paginator = new UserGroupPaginator(client, { pageSize: 2 }); - vi.spyOn(client, 'queryUserGroups').mockResolvedValue( + vi.spyOn(client, 'listUserGroups').mockResolvedValue( response([ makeGroup('a', '2020-01-01T00:00:00.000Z'), makeGroup('b', '2020-01-02T00:00:00.000Z'), @@ -43,7 +43,7 @@ describe('UserGroupPaginator', () => { it('appends forward pages and stops at a short (final) page', async () => { const paginator = new UserGroupPaginator(client, { pageSize: 2 }); - const spy = vi.spyOn(client, 'queryUserGroups'); + const spy = vi.spyOn(client, 'listUserGroups'); spy.mockResolvedValueOnce( response([ makeGroup('a', '2020-01-01T00:00:00.000Z'), @@ -70,7 +70,7 @@ describe('UserGroupPaginator', () => { it('dedupes by id when a group is returned again', async () => { const paginator = new UserGroupPaginator(client, { pageSize: 2 }); - const spy = vi.spyOn(client, 'queryUserGroups'); + const spy = vi.spyOn(client, 'listUserGroups'); spy.mockResolvedValueOnce( response([ makeGroup('a', '2020-01-01T00:00:00.000Z'), @@ -92,7 +92,7 @@ describe('UserGroupPaginator', () => { it('orders by created_at/id via the comparator even if the server returns out of order', async () => { const paginator = new UserGroupPaginator(client, { pageSize: 3 }); - vi.spyOn(client, 'queryUserGroups').mockResolvedValue( + vi.spyOn(client, 'listUserGroups').mockResolvedValue( response([ makeGroup('b', '2020-01-02T00:00:00.000Z'), makeGroup('a', '2020-01-01T00:00:00.000Z'), @@ -108,7 +108,7 @@ describe('UserGroupPaginator', () => { it('does not paginate backward (headward is exhausted)', async () => { const paginator = new UserGroupPaginator(client, { pageSize: 2 }); const spy = vi - .spyOn(client, 'queryUserGroups') + .spyOn(client, 'listUserGroups') .mockResolvedValue(response([makeGroup('a', '2020-01-01T00:00:00.000Z')])); await paginator.executeQuery({}); spy.mockClear(); diff --git a/test/unit/poll.test.js b/test/unit/poll.test.js index 500b020dfc..d582221f9e 100644 --- a/test/unit/poll.test.js +++ b/test/unit/poll.test.js @@ -146,7 +146,7 @@ const pollResponse = { // const client = sinon.createStubInstance(StreamChat); const client = new StreamChat('apiKey'); client.user = user1; -client.userID = user1.id; + describe('Poll', () => { afterEach(() => { sinon.reset(); @@ -303,7 +303,7 @@ describe('Poll', () => { }); it('should add own vote when handleVoteCasted is called', () => { - client.userID = user1.id; + client.user = user1; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const castedVote = { @@ -383,7 +383,7 @@ describe('Poll', () => { }); it('should add own answer when handleVoteCasted is called', () => { - client.userID = user1.id; + client.user = user1; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const castedVote = { @@ -457,7 +457,7 @@ describe('Poll', () => { }); it('should change own vote when handleVoteChanged is called', () => { - client.userID = user1.id; + client.user = user1; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const changedToOptionId = 'dc22dcd6-4fc8-4c92-92c2-bfd63245724c'; @@ -504,7 +504,7 @@ describe('Poll', () => { }); it('should change an answer when handleVoteChanged is called', () => { - client.userID = user2.id; + client.user = user2; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const changedAnswer = { @@ -529,7 +529,7 @@ describe('Poll', () => { }); it('should change own answer when handleVoteChanged is called', () => { - client.userID = user1.id; + client.user = user1; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const changedAnswer = { @@ -554,7 +554,7 @@ describe('Poll', () => { }); it('should remove a vote when handleVoteRemoved is called', () => { - client.userID = user1.id; + client.user = user1; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const vote_counts_by_option = { @@ -593,7 +593,7 @@ describe('Poll', () => { }); it('should remove own vote when handleVoteRemoved is called', () => { - client.userID = user1.id; + client.user = user1; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const removedVote = user1Votes[0]; @@ -635,7 +635,7 @@ describe('Poll', () => { }); it('should remove an answer when handleVoteRemoved is called', () => { - client.userID = user1.id; + client.user = user1; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const removedAnswer = user2Answer; @@ -656,7 +656,7 @@ describe('Poll', () => { }); it('should remove own answer when handleVoteRemoved is called', () => { - client.userID = user1.id; + client.user = user1; const poll = new Poll({ client, poll: pollResponse }); const originalState = poll.data; const removedAnswer = user1Answer; @@ -691,7 +691,7 @@ describe('Poll', () => { const originalState = poll.data; await poll.query(pollResponse.id); - expect(getPollStub.calledWith(pollResponse.id)).to.be.true; + expect(getPollStub.calledWith({ poll_id: pollResponse.id })).to.be.true; const { lastActivityAt: __, ...currentPollState } = poll.data; const { lastActivityAt: _, ...expectedPollState } = { ...originalState, @@ -709,7 +709,7 @@ describe('Poll', () => { const option_id = 'ba933470-c0da-4b6f-a4d2-d2176ac0d4a8'; const messageId = 'XXX'; const removePollVoteSpy = vi - .spyOn(client, 'removePollVote') + .spyOn(client, 'deletePollVote') .mockResolvedValue('removed'); const castPollVoteSpy = vi .spyOn(client, 'castPollVote') @@ -731,7 +731,7 @@ describe('Poll', () => { const option_id = 'ba933470-c0da-4b6f-a4d2-d2176ac0d4a8'; const messageId = 'XXX'; const removePollVoteSpy = vi - .spyOn(client, 'removePollVote') + .spyOn(client, 'deletePollVote') .mockResolvedValue('removed'); const castPollVoteSpy = vi .spyOn(client, 'castPollVote') @@ -741,8 +741,10 @@ describe('Poll', () => { await poll.castVote(option_id, messageId); expect(removePollVoteSpy).not.toHaveBeenCalled(); - expect(castPollVoteSpy).toHaveBeenCalledWith(messageId, pollResponse.id, { - option_id, + expect(castPollVoteSpy).toHaveBeenCalledWith({ + message_id: messageId, + poll_id: pollResponse.id, + vote: { option_id }, }); expect(addInfoNotificationSpy).not.toHaveBeenCalled(); }); @@ -755,7 +757,7 @@ describe('Poll', () => { const option_id = 'ba933470-c0da-4b6f-a4d2-d2176ac0d4a8'; const messageId = 'XXX'; const removePollVoteSpy = vi - .spyOn(client, 'removePollVote') + .spyOn(client, 'deletePollVote') .mockResolvedValue('removed'); const castPollVoteSpy = vi .spyOn(client, 'castPollVote') @@ -765,8 +767,10 @@ describe('Poll', () => { await poll.castVote(option_id, messageId); expect(removePollVoteSpy).not.toHaveBeenCalled(); - expect(castPollVoteSpy).toHaveBeenCalledWith(messageId, pollResponse.id, { - option_id, + expect(castPollVoteSpy).toHaveBeenCalledWith({ + message_id: messageId, + poll_id: pollResponse.id, + vote: { option_id }, }); expect(addInfoNotificationSpy).not.toHaveBeenCalled(); }); diff --git a/test/unit/poll_manager.test.ts b/test/unit/poll_manager.test.ts index 422f32d269..12c1978208 100644 --- a/test/unit/poll_manager.test.ts +++ b/test/unit/poll_manager.test.ts @@ -5,7 +5,7 @@ import { generateUUIDv4 as uuidv4 } from '../../src/utils'; import sinon from 'sinon'; import { - EventTypes, + EventType, FormatMessageResponse, MessageResponse, Poll, @@ -203,12 +203,11 @@ describe('PollManager', () => { generateChannel({ channel: { id: uuidv4() }, messages }), ); } - const mock = sinon.mock(client); const spy = sinon.spy(client.polls, 'hydratePollCache'); - mock - .expects('post') - .returns(Promise.resolve({ channels: mockedChannelsQueryResponse })); - await client.queryChannels({}); + sinon + .stub(client, 'queryChannels') + .resolves({ channels: mockedChannelsQueryResponse }); + await client.queryChannelsAndHydrate({}); expect(client.polls.data.size).to.equal(pollMessages.length); expect(spy.callCount).to.be.equal(5); for (let i = 0; i < 5; i++) { @@ -241,11 +240,10 @@ describe('PollManager', () => { const channelResponse = { ...channels[ci], messages }; mockedChannelsQueryResponse.push(channelResponse); } - const mock = sinon.mock(client); - mock - .expects('post') - .returns(Promise.resolve({ channels: mockedChannelsQueryResponse })); - await client.queryChannels({}); + sinon + .stub(client, 'queryChannels') + .resolves({ channels: mockedChannelsQueryResponse }); + await client.queryChannelsAndHydrate({}); expect(client.polls.data.size).to.equal(pollMessages.length); expect(spy.callCount).to.be.equal(10); for (let i = 0; i < 5; i++) { @@ -267,9 +265,8 @@ describe('PollManager', () => { ...mockChannelQueryResponse, messages, }; - const mock = sinon.mock(client); const spy = sinon.spy(client.polls, 'hydratePollCache'); - mock.expects('post').returns(Promise.resolve(mockedChannelQueryResponse)); + sinon.stub(channel, 'getOrCreate').resolves(mockedChannelQueryResponse); await channel.query(); expect(client.polls.data.size).to.equal(pollMessages.length); expect(spy.calledOnce).to.be.true; @@ -285,9 +282,8 @@ describe('PollManager', () => { ...mockChannelQueryResponse, messages, }; - const mock = sinon.mock(client); const spy = sinon.spy(client.polls, 'hydratePollCache'); - mock.expects('post').returns(Promise.resolve(mockedChannelQueryResponse)); + sinon.stub(channel, 'getOrCreate').resolves(mockedChannelQueryResponse); client.polls.hydratePollCache(prevMessages); await channel.query(); expect(client.polls.data.size).to.equal( @@ -541,7 +537,7 @@ describe('PollManager', () => { const updatedPoll = pollMessage1.poll as PollResponse; client.dispatchEvent({ - type: eventType as EventTypes, + type: eventType as EventType, poll: updatedPoll, }); diff --git a/test/unit/predefined_filters.test.ts b/test/unit/predefined_filters.test.ts deleted file mode 100644 index e01ebca026..0000000000 --- a/test/unit/predefined_filters.test.ts +++ /dev/null @@ -1,455 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { StreamChat } from '../../src/client'; -import type { - CreatePredefinedFilterOptions, - UpdatePredefinedFilterOptions, - ListPredefinedFiltersOptions, - ChannelOptions, - PredefinedFilterResponse, - ListPredefinedFiltersResponse, - APIResponse, - QueryChannelsAPIResponse, -} from '../../src/types'; - -describe('Predefined Filters', () => { - let client: StreamChat; - - beforeEach(() => { - client = new StreamChat('api_key', 'api_secret'); - }); - - describe('createPredefinedFilter', () => { - it('should create a predefined filter', async () => { - const mockResponse: PredefinedFilterResponse = { - duration: '0.01s', - predefined_filter: { - name: 'user_messaging', - operation: 'QueryChannels', - filter: { - type: 'messaging', - members: { $in: ['{{user_id}}'] }, - }, - sort: [{ field: 'last_message_at', direction: -1 }], - query_id: 12345678901234567890, - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-15T10:30:00Z', - }, - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: CreatePredefinedFilterOptions = { - name: 'user_messaging', - operation: 'QueryChannels', - filter: { - type: 'messaging', - members: { $in: ['{{user_id}}'] }, - }, - sort: [{ field: 'last_message_at', direction: -1 }], - }; - - const result = await client.createPredefinedFilter(options); - - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/predefined_filters`, - options, - ); - expect(result.predefined_filter.name).toBe('user_messaging'); - expect(result.predefined_filter.operation).toBe('QueryChannels'); - }); - - it('should throw error if called without server-side auth', async () => { - const clientWithoutSecret = new StreamChat('api_key'); - clientWithoutSecret.user = { id: 'test-user' }; - - const options: CreatePredefinedFilterOptions = { - name: 'test_filter', - operation: 'QueryChannels', - filter: { type: 'messaging' }, - }; - - await expect(clientWithoutSecret.createPredefinedFilter(options)).rejects.toThrow(); - }); - }); - - describe('getPredefinedFilter', () => { - it('should get a predefined filter by name', async () => { - const mockResponse: PredefinedFilterResponse = { - duration: '0.01s', - predefined_filter: { - name: 'user_messaging', - operation: 'QueryChannels', - filter: { type: 'messaging' }, - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-15T10:30:00Z', - }, - }; - - const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); - - const result = await client.getPredefinedFilter('user_messaging'); - - expect(getSpy).toHaveBeenCalledWith( - `${client.baseURL}/predefined_filters/user_messaging`, - ); - expect(result.predefined_filter.name).toBe('user_messaging'); - }); - - it('should properly encode filter names with special characters', async () => { - const mockResponse: PredefinedFilterResponse = { - duration: '0.01s', - predefined_filter: { - name: 'filter-with-dash', - operation: 'QueryChannels', - filter: { type: 'messaging' }, - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-15T10:30:00Z', - }, - }; - - const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); - - await client.getPredefinedFilter('filter-with-dash'); - - expect(getSpy).toHaveBeenCalledWith( - `${client.baseURL}/predefined_filters/filter-with-dash`, - ); - }); - }); - - describe('updatePredefinedFilter', () => { - it('should update a predefined filter', async () => { - const mockResponse: PredefinedFilterResponse = { - duration: '0.01s', - predefined_filter: { - name: 'user_messaging', - operation: 'QueryChannels', - filter: { type: 'team' }, - description: 'Updated description', - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-16T10:30:00Z', - }, - }; - - const putSpy = vi.spyOn(client, 'put').mockResolvedValue(mockResponse); - - const options: UpdatePredefinedFilterOptions = { - operation: 'QueryChannels', - filter: { type: 'team' }, - description: 'Updated description', - }; - - const result = await client.updatePredefinedFilter('user_messaging', options); - - expect(putSpy).toHaveBeenCalledWith( - `${client.baseURL}/predefined_filters/user_messaging`, - options, - ); - expect(result.predefined_filter.description).toBe('Updated description'); - }); - }); - - describe('deletePredefinedFilter', () => { - it('should delete a predefined filter', async () => { - const mockResponse: APIResponse = { - duration: '0.01s', - }; - - const deleteSpy = vi.spyOn(client, 'delete').mockResolvedValue(mockResponse); - - const result = await client.deletePredefinedFilter('user_messaging'); - - expect(deleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/predefined_filters/user_messaging`, - ); - expect(result.duration).toBe('0.01s'); - }); - }); - - describe('listPredefinedFilters', () => { - it('should list all predefined filters', async () => { - const mockResponse: ListPredefinedFiltersResponse = { - duration: '0.01s', - predefined_filters: [ - { - name: 'filter1', - operation: 'QueryChannels', - filter: { type: 'messaging' }, - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-15T10:30:00Z', - }, - { - name: 'filter2', - operation: 'QueryChannels', - filter: { type: 'team' }, - created_at: '2024-01-15T11:30:00Z', - updated_at: '2024-01-15T11:30:00Z', - }, - ], - }; - - const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); - - const result = await client.listPredefinedFilters(); - - expect(getSpy).toHaveBeenCalledWith(`${client.baseURL}/predefined_filters`, {}); - expect(result.predefined_filters).toHaveLength(2); - }); - - it('should pass pagination options', async () => { - const mockResponse: ListPredefinedFiltersResponse = { - duration: '0.01s', - predefined_filters: [], - next: 'next_cursor', - }; - - const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); - - const options: ListPredefinedFiltersOptions = { - limit: 10, - next: 'cursor', - }; - - await client.listPredefinedFilters(options); - - expect(getSpy).toHaveBeenCalledWith(`${client.baseURL}/predefined_filters`, { - limit: 10, - next: 'cursor', - }); - }); - - it('should serialize sort options as JSON', async () => { - const mockResponse: ListPredefinedFiltersResponse = { - duration: '0.01s', - predefined_filters: [], - }; - - const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); - - const options: ListPredefinedFiltersOptions = { - sort: [{ field: 'created_at', direction: -1 }], - limit: 20, - }; - - await client.listPredefinedFilters(options); - - expect(getSpy).toHaveBeenCalledWith(`${client.baseURL}/predefined_filters`, { - limit: 20, - sort: JSON.stringify([{ field: 'created_at', direction: -1 }]), - }); - }); - }); - - describe('queryChannels with predefined filter', () => { - beforeEach(() => { - // Mock wsPromise and connection - client.wsPromise = Promise.resolve(); - client.wsConnection = { connectionID: 'test-connection-id' } as never; - }); - - it('should query channels with a predefined filter using options', async () => { - const mockResponse: QueryChannelsAPIResponse = { - duration: '0.01s', - channels: [ - { - channel: { - id: 'channel1', - type: 'messaging', - cid: 'messaging:channel1', - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-15T10:30:00Z', - frozen: false, - disabled: false, - }, - members: [], - messages: [], - pinned_messages: [], - }, - ], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: ChannelOptions = { - predefined_filter: 'user_messaging', - filter_values: { user_id: 'user123' }, - limit: 20, - }; - - // When using predefined filter, filterConditions can be empty - await client.queryChannels({}, [], options); - - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels`, - expect.objectContaining({ - predefined_filter: 'user_messaging', - filter_values: { user_id: 'user123' }, - limit: 20, - state: true, - watch: true, - presence: false, - }), - ); - // Should NOT include filter_conditions when using predefined filter - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels`, - expect.not.objectContaining({ - filter_conditions: expect.anything(), - }), - ); - }); - - it('should include sort_values in the request', async () => { - const mockResponse: QueryChannelsAPIResponse = { - duration: '0.01s', - channels: [], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: ChannelOptions = { - predefined_filter: 'team_channels', - filter_values: { channel_type: 'messaging', team_name: 'engineering' }, - sort_values: { sort_field: 'last_message_at' }, - limit: 50, - }; - - await client.queryChannels({}, [], options); - - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels`, - expect.objectContaining({ - predefined_filter: 'team_channels', - filter_values: { channel_type: 'messaging', team_name: 'engineering' }, - sort_values: { sort_field: 'last_message_at' }, - limit: 50, - }), - ); - }); - - it('should include traditional sort when using a predefined filter', async () => { - const mockResponse: QueryChannelsAPIResponse = { - duration: '0.01s', - channels: [], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - await client.queryChannels({}, [{ last_message_at: -1 }, { created_at: 1 }], { - predefined_filter: 'user_messaging', - filter_values: { user_id: 'user123' }, - limit: 20, - }); - - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels`, - expect.objectContaining({ - predefined_filter: 'user_messaging', - filter_values: { user_id: 'user123' }, - sort: [ - { field: 'last_message_at', direction: -1 }, - { field: 'created_at', direction: 1 }, - ], - limit: 20, - }), - ); - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels`, - expect.not.objectContaining({ - filter_conditions: expect.anything(), - }), - ); - }); - - it('should use traditional filter_conditions when no predefined_filter is provided', async () => { - const mockResponse: QueryChannelsAPIResponse = { - duration: '0.01s', - channels: [], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - await client.queryChannels( - { type: 'messaging', members: { $in: ['user123'] } }, - [{ last_message_at: -1 }], - { limit: 20 }, - ); - - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels`, - expect.objectContaining({ - filter_conditions: { type: 'messaging', members: { $in: ['user123'] } }, - sort: [{ field: 'last_message_at', direction: -1 }], - limit: 20, - }), - ); - // Should NOT include predefined_filter fields - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels`, - expect.not.objectContaining({ - predefined_filter: expect.anything(), - }), - ); - }); - - it('should set watch to false when no connection ID', async () => { - client.wsConnection = null as never; - - const mockResponse: QueryChannelsAPIResponse = { - duration: '0.01s', - channels: [], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - await client.queryChannels({}, [], { - predefined_filter: 'user_messaging', - }); - - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/channels`, - expect.objectContaining({ - watch: false, - }), - ); - }); - - it('should dispatch channels.queried event', async () => { - const mockResponse: QueryChannelsAPIResponse = { - duration: '0.01s', - channels: [ - { - channel: { - id: 'channel1', - type: 'messaging', - cid: 'messaging:channel1', - created_at: '2024-01-15T10:30:00Z', - updated_at: '2024-01-15T10:30:00Z', - frozen: false, - disabled: false, - }, - members: [], - messages: [], - pinned_messages: [], - }, - ], - }; - - vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - const dispatchSpy = vi.spyOn(client, 'dispatchEvent'); - - await client.queryChannels({}, [], { - predefined_filter: 'user_messaging', - }); - - expect(dispatchSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'channels.queried', - queriedChannels: expect.objectContaining({ - isLatestMessageSet: true, - }), - }), - ); - }); - }); -}); diff --git a/test/unit/reminders/Reminder.test.ts b/test/unit/reminders/Reminder.test.ts index ad1dad2c41..0657c5cb7c 100644 --- a/test/unit/reminders/Reminder.test.ts +++ b/test/unit/reminders/Reminder.test.ts @@ -47,7 +47,7 @@ describe('Reminder', () => { const data = generateReminderResponse({ scheduleOffsetMs }); const reminder = new Reminder({ data }); const timerInitSpy = vi.spyOn(reminder.timer, 'init'); - reminder.setState({ ...data, remind_at: new Date().toISOString() }); + reminder.setState({ ...data, remind_at: new Date() }); expect(reminder.timeLeftMs).toBe(0); expect(timerInitSpy).toHaveBeenCalledTimes(1); }); @@ -61,9 +61,7 @@ describe('Reminder', () => { vi.advanceTimersByTime(scheduleOffsetMs + DEFAULT_STOP_REFRESH_BOUNDARY_MS); reminder.setState({ ...data, - remind_at: new Date( - new Date(orignalRemindAt as string).getTime() - 1000, - ).toISOString(), + remind_at: new Date(orignalRemindAt!.getTime() - 1000), }); expect(reminder.timer.timeout).toBeNull(); expect(reminder.timeLeftMs).toBe(-1 * (DEFAULT_STOP_REFRESH_BOUNDARY_MS + 1000)); diff --git a/test/unit/reminders/ReminderManager.test.ts b/test/unit/reminders/ReminderManager.test.ts index bf69e8a793..2da41191e6 100644 --- a/test/unit/reminders/ReminderManager.test.ts +++ b/test/unit/reminders/ReminderManager.test.ts @@ -1,11 +1,14 @@ import { DEFAULT_REMINDER_MANAGER_CONFIG, DEFAULT_STOP_REFRESH_BOUNDARY_MS, - EventTypes, + EventPayload, + ListenerKeys, + MessageResponse, Reminder, ReminderManager, - ReminderResponse, + ReminderResponseData, ReminderState, + RequestMetadata, StreamChat, } from '../../../src'; import { describe, expect, it, vi } from 'vitest'; @@ -21,21 +24,19 @@ export const generateReminderResponse = ({ data, scheduleOffsetMs, }: { - data?: Partial; + data?: Partial; scheduleOffsetMs?: number; -} = {}): ReminderResponse => { - const created_at = new Date().toISOString(); - const basePayload: ReminderResponse = { +} = {}): ReminderResponseData => { + const created_at = new Date(); + const basePayload = { ...baseData, created_at, message: { id: baseData.message_id, type: 'regular' }, updated_at: created_at, user: { id: baseData.user_id }, - }; + } as ReminderResponseData; if (typeof scheduleOffsetMs === 'number') { - basePayload.remind_at = new Date( - new Date(created_at).getTime() + scheduleOffsetMs, - ).toISOString(); + basePayload.remind_at = new Date(created_at.getTime() + scheduleOffsetMs); } return { ...basePayload, @@ -43,13 +44,14 @@ export const generateReminderResponse = ({ }; }; -const generateReminderEvent = (type: EventTypes, reminder: ReminderResponse) => ({ - ...baseData, - cid: baseData.channel_cid, - created_at: new Date().toISOString(), - reminder, - type, -}); +const generateReminderEvent = (type: ListenerKeys, reminder: ReminderResponseData) => + ({ + ...baseData, + cid: baseData.channel_cid, + created_at: new Date(), + reminder, + type, + }) as EventPayload; describe('ReminderManager', () => { describe('constructor', () => { @@ -156,8 +158,7 @@ describe('ReminderManager', () => { }); it('does not add new reminders if client cache is disabled', () => { - const secret = 'secret'; - const client = new StreamChat('api-key', secret, { disableCache: true }); + const client = new StreamChat('api-key', { disableCache: true }); const manager = new ReminderManager({ client }); const reminderResponse = generateReminderResponse(); @@ -251,7 +252,7 @@ describe('ReminderManager', () => { }), type: 'regular' as const, }, - ]; + ] as MessageResponse[]; manager.hydrateState(messages); expect(manager.reminders.size).toBe(2); @@ -266,7 +267,7 @@ describe('ReminderManager', () => { const manager = new ReminderManager({ client }); manager.registerSubscriptions(); const reminderResponse = generateReminderResponse(); - const type: EventTypes = 'reminder.created'; + const type: ListenerKeys = 'reminder.created'; client.dispatchEvent(generateReminderEvent(type, reminderResponse)); expect(manager.reminders.size).toBe(1); expect(manager.reminders.get(reminderResponse.message_id)).toBeInstanceOf(Reminder); @@ -289,7 +290,7 @@ describe('ReminderManager', () => { const scheduleOffsetMs = 62 * 1000; const now = new Date().getTime(); const reminderResponse = generateReminderResponse({ scheduleOffsetMs }); - const type: EventTypes = 'reminder.created'; + const type: ListenerKeys = 'reminder.created'; client.dispatchEvent(generateReminderEvent(type, reminderResponse)); const reminder = manager.getFromState(reminderResponse.message_id); expect(reminder).toBeInstanceOf(Reminder); @@ -317,8 +318,8 @@ describe('ReminderManager', () => { const reminderResponse = generateReminderResponse(); manager.upsertToState({ data: reminderResponse }); - reminderResponse.remind_at = '1970-01-01'; - const type: EventTypes = 'reminder.updated'; + reminderResponse.remind_at = new Date('1970-01-01'); + const type: ListenerKeys = 'reminder.updated'; const now = new Date(); client.dispatchEvent(generateReminderEvent(type, reminderResponse)); expect(manager.reminders.size).toBe(1); @@ -344,7 +345,7 @@ describe('ReminderManager', () => { manager.upsertToState({ data: reminderResponse }); manager.registerSubscriptions(); - const type: EventTypes = 'reminder.deleted'; + const type: ListenerKeys = 'reminder.deleted'; client.dispatchEvent(generateReminderEvent(type, reminderResponse)); expect(manager.reminders.size).toBe(0); @@ -354,7 +355,7 @@ describe('ReminderManager', () => { const manager = new ReminderManager({ client }); manager.registerSubscriptions(); let reminderResponse = undefined; - let type: EventTypes = 'reminder.created'; + let type: ListenerKeys = 'reminder.created'; // @ts-expect-error passing undefined to mandatory param client.dispatchEvent(generateReminderEvent(type, reminderResponse)); expect(manager.reminders.size).toBe(0); @@ -369,16 +370,16 @@ describe('ReminderManager', () => { it('creates a reminder server-side and updates the state', async () => { const client = new StreamChat('api-key'); const manager = new ReminderManager({ client }); - const reminderResponse = generateReminderResponse(); - const postSpy = vi - .spyOn(client, 'post') - .mockResolvedValueOnce({ reminder: reminderResponse }); + const reminderResponse = { + ...generateReminderResponse(), + metadata: {} as RequestMetadata, + }; + vi.spyOn(client, 'createReminder').mockResolvedValueOnce(reminderResponse); const stateUpdateSpy = vi .spyOn(manager, 'upsertToState') .mockReturnValueOnce(undefined); await manager.createReminder({ - messageId: reminderResponse.message_id, - user_id: reminderResponse.user_id, + message_id: reminderResponse.message_id, }); expect(stateUpdateSpy).toHaveBeenCalledWith({ data: reminderResponse, @@ -389,15 +390,16 @@ describe('ReminderManager', () => { const client = new StreamChat('api-key'); const manager = new ReminderManager({ client }); const reminderResponse = generateReminderResponse(); - const postSpy = vi - .spyOn(client, 'patch') - .mockResolvedValueOnce({ reminder: reminderResponse }); + vi.spyOn(client, 'updateReminder').mockResolvedValueOnce({ + duration: '0ms', + reminder: reminderResponse, + metadata: {} as RequestMetadata, + }); const stateUpdateSpy = vi .spyOn(manager, 'upsertToState') .mockReturnValueOnce(undefined); await manager.updateReminder({ - messageId: reminderResponse.message_id, - user_id: reminderResponse.user_id, + message_id: reminderResponse.message_id, }); expect(stateUpdateSpy).toHaveBeenCalledWith({ data: reminderResponse }); }); @@ -405,7 +407,10 @@ describe('ReminderManager', () => { const client = new StreamChat('api-key'); const manager = new ReminderManager({ client }); const messageId = 'messageId'; - const postSpy = vi.spyOn(client, 'delete').mockResolvedValueOnce(undefined); + vi.spyOn(client, 'deleteReminder').mockResolvedValueOnce({ + duration: '0ms', + metadata: {} as RequestMetadata, + }); const stateUpdateSpy = vi .spyOn(manager, 'removeFromState') .mockReturnValueOnce(undefined); @@ -416,7 +421,7 @@ describe('ReminderManager', () => { it('creates a reminder if not present in state', async () => { const client = new StreamChat('api-key'); const manager = new ReminderManager({ client }); - const payload = { messageId: 'message_id', user_id: 'user_id' }; + const payload = { message_id: 'message_id' }; const createReminderSpy = vi .spyOn(manager, 'createReminder') .mockResolvedValue(undefined); @@ -427,15 +432,13 @@ describe('ReminderManager', () => { expect(createReminderSpy).toHaveBeenCalledWith(payload); expect(updateReminderSpy).not.toHaveBeenCalledWith(payload); }); - it('updates a reminder after failed create request if exists server-side', async () => { + it('updates a reminder after failed create request when a reminder already exists for the message', async () => { const client = new StreamChat('api-key'); const manager = new ReminderManager({ client }); - const payload = { messageId: 'message_id', user_id: 'user_id' }; - const createReminderSpy = vi - .spyOn(manager, 'createReminder') - .mockRejectedValue( - new Error('already has reminder created for this message_id'), - ); + const payload = { message_id: 'message_id' }; + vi.spyOn(manager, 'createReminder').mockRejectedValue( + new Error('already has reminder created for this message_id'), + ); const updateReminderSpy = vi .spyOn(manager, 'updateReminder') .mockResolvedValue(undefined); @@ -447,7 +450,7 @@ describe('ReminderManager', () => { const manager = new ReminderManager({ client }); const reminder = generateReminderResponse(); manager.upsertToState({ data: reminder }); - const payload = { messageId: reminder.message_id, user_id: reminder.user_id }; + const payload = { message_id: reminder.message_id }; const createReminderSpy = vi .spyOn(manager, 'createReminder') .mockResolvedValue(undefined); @@ -458,12 +461,12 @@ describe('ReminderManager', () => { expect(createReminderSpy).not.toHaveBeenCalledWith(payload); expect(updateReminderSpy).toHaveBeenCalledWith(payload); }); - it('creates a reminder after failed update request if does not exist server-side', async () => { + it('creates a reminder after failed update request when the reminder no longer exists', async () => { const client = new StreamChat('api-key'); const manager = new ReminderManager({ client }); const reminder = generateReminderResponse(); manager.upsertToState({ data: reminder }); - const payload = { messageId: reminder.message_id, user_id: reminder.user_id }; + const payload = { message_id: reminder.message_id }; const createReminderSpy = vi .spyOn(manager, 'createReminder') .mockResolvedValue(undefined); @@ -484,7 +487,7 @@ describe('ReminderManager', () => { const reminders = Array.from({ length: 4 }, (_, i) => generateReminderResponse({ data: { message_id: `message_id_${i}` } }), ); - const queryReturnValue: PaginationQueryReturnValue = { + const queryReturnValue: PaginationQueryReturnValue = { items: reminders, }; vi.spyOn(manager.paginator, 'query').mockResolvedValue(queryReturnValue); @@ -501,7 +504,7 @@ describe('ReminderManager', () => { const reminders = Array.from({ length: 4 }, (_, i) => generateReminderResponse({ data: { message_id: `messag_id_${i}` } }), ); - const queryReturnValue: PaginationQueryReturnValue = { + const queryReturnValue: PaginationQueryReturnValue = { items: reminders, }; vi.spyOn(manager.paginator, 'query').mockResolvedValue(queryReturnValue); diff --git a/test/unit/reminders/reminder.api.test.js b/test/unit/reminders/reminder.api.test.js deleted file mode 100644 index e0314ceb29..0000000000 --- a/test/unit/reminders/reminder.api.test.js +++ /dev/null @@ -1,428 +0,0 @@ -import { StreamChat } from '../../../src'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const user1 = { - id: 'user1', - role: 'user', - created_at: '2024-01-01T00:00:00.000Z', - updated_at: '2024-01-01T00:00:00.000Z', - name: 'Test User 1', -}; - -const reminderResponse = { - id: 'reminder1', - remind_at: '2025-04-12T23:20:50.52Z', - user_id: user1.id, - user: user1, - channel_cid: 'messaging:123', - message_id: 'message123', - created_at: '2024-01-01T00:00:00.000Z', - updated_at: '2024-01-01T00:00:00.000Z', -}; - -describe('Reminder', () => { - let client; - - beforeEach(() => { - client = new StreamChat('api_key'); - client.user = user1; - client.userID = user1.id; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('createReminder', () => { - it('should create a reminder successfully', async () => { - const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce(reminderResponse); - - const result = await client.createReminder({ - messageId: 'message123', - remind_at: '2025-04-12T23:20:50.52Z', - }); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/message123/reminders`, - ); - expect(postSpy.mock.calls[0][1]).toEqual({ - remind_at: '2025-04-12T23:20:50.52Z', - }); - expect(result).toEqual(reminderResponse); - }); - - it('should create a reminder without remind_at', async () => { - const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce(reminderResponse); - - await client.createReminder({ messageId: 'message123' }); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy.mock.calls[0][1]).toEqual({}); - }); - - it('should create a reminder with null remind_at', async () => { - const reminderWithNullDate = { - ...reminderResponse, - remind_at: null, - }; - const postSpy = vi - .spyOn(client, 'post') - .mockResolvedValueOnce(reminderWithNullDate); - - const result = await client.createReminder({ - messageId: 'message123', - remind_at: null, - }); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/message123/reminders`, - ); - expect(postSpy.mock.calls[0][1]).toEqual({ - remind_at: null, - }); - expect(result).toEqual(reminderWithNullDate); - }); - - it('should create a reminder with undefined remind_at', async () => { - const reminderWithoutDate = { - ...reminderResponse, - remind_at: undefined, - }; - const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce(reminderWithoutDate); - - const result = await client.createReminder({ - messageId: 'message123', - remind_at: undefined, - }); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/message123/reminders`, - ); - expect(postSpy.mock.calls[0][1]).toEqual({ - remind_at: undefined, - }); - expect(result).toEqual(reminderWithoutDate); - }); - }); - - describe('updateReminder', () => { - it('should update a reminder successfully', async () => { - const updatedReminder = { - ...reminderResponse, - remind_at: '2025-05-12T23:20:50.52Z', - }; - const patchStub = vi.spyOn(client, 'patch').mockResolvedValueOnce(updatedReminder); - - const result = await client.updateReminder({ - messageId: 'message123', - remind_at: '2025-05-12T23:20:50.52Z', - }); - - expect(patchStub).toHaveBeenCalledTimes(1); - expect(patchStub.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/message123/reminders`, - ); - expect(patchStub.mock.calls[0][1]).toEqual({ - remind_at: '2025-05-12T23:20:50.52Z', - }); - expect(result).toEqual(updatedReminder); - }); - - it('should update a reminder to remove remind_at', async () => { - const updatedReminder = { - ...reminderResponse, - remind_at: null, - }; - const patchStub = vi.spyOn(client, 'patch').mockResolvedValueOnce(updatedReminder); - - const result = await client.updateReminder({ - messageId: 'message123', - remind_at: null, - }); - - expect(patchStub).toHaveBeenCalledTimes(1); - expect(patchStub.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/message123/reminders`, - ); - expect(patchStub.mock.calls[0][1]).toEqual({ - remind_at: null, - }); - expect(result).toEqual(updatedReminder); - }); - - it('should update a reminder with undefined remind_at', async () => { - const updatedReminder = { - ...reminderResponse, - remind_at: undefined, - }; - const patchStub = vi.spyOn(client, 'patch').mockResolvedValueOnce(updatedReminder); - - const result = await client.updateReminder({ - messageId: 'message123', - remind_at: undefined, - }); - - expect(patchStub).toHaveBeenCalledTimes(1); - expect(patchStub.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/message123/reminders`, - ); - expect(patchStub.mock.calls[0][1]).toEqual({ - remind_at: undefined, - }); - expect(result).toEqual(updatedReminder); - }); - }); - - describe('deleteReminder', () => { - it('should delete a reminder successfully', async () => { - const deleteStub = vi.spyOn(client, 'delete').mockResolvedValueOnce({}); - - await client.deleteReminder('message123'); - - expect(deleteStub).toHaveBeenCalledTimes(1); - expect(deleteStub.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/message123/reminders`, - ); - expect(deleteStub.mock.calls[0][1]).toEqual({}); - }); - - it('should delete a reminder with user_id', async () => { - const deleteStub = vi.spyOn(client, 'delete').mockResolvedValueOnce({}); - - await client.deleteReminder('message123', 'user1'); - - expect(deleteStub).toHaveBeenCalledTimes(1); - expect(deleteStub.mock.calls[0][1]).toEqual({ user_id: 'user1' }); - }); - }); - - describe('queryReminders', () => { - it('should query reminders successfully', async () => { - const queryResponse = { - reminders: [reminderResponse], - next: 'next_page_token', - }; - const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce(queryResponse); - - const result = await client.queryReminders({ - filter_conditions: { - channel_cid: 'messaging:123', - remind_at: { $gt: '2024-01-01T00:00:00.000Z' }, - }, - sort: [{ remind_at: 1 }], - limit: 10, - }); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy.mock.calls[0][0]).toBe(`${client.baseURL}/reminders/query`); - expect(postSpy.mock.calls[0][1]).toEqual({ - filter_conditions: { - channel_cid: 'messaging:123', - remind_at: { $gt: '2024-01-01T00:00:00.000Z' }, - }, - sort: [{ field: 'remind_at', direction: 1 }], - limit: 10, - }); - expect(result).toEqual(queryResponse); - }); - - it('should query reminders with empty options', async () => { - const queryResponse = { reminders: [] }; - const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce(queryResponse); - - const result = await client.queryReminders(); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy.mock.calls[0][1]).toEqual({}); - expect(result).toEqual(queryResponse); - }); - }); - - describe('Reminder Events', () => { - it('should handle reminder.created event', () => { - const eventHandler = vi.fn(); - client.on('reminder.created', eventHandler); - - const reminderEvent = { - type: 'reminder.created', - reminder: reminderResponse, - }; - - client.dispatchEvent(reminderEvent); - - expect(eventHandler).toHaveBeenCalledTimes(1); - expect(eventHandler.mock.calls[0][0]).toEqual(reminderEvent); - }); - - it('should handle reminder.updated event', () => { - const eventHandler = vi.fn(); - client.on('reminder.updated', eventHandler); - - const reminderEvent = { - type: 'reminder.updated', - reminder: { - ...reminderResponse, - remind_at: '2025-05-12T23:20:50.52Z', - }, - }; - - client.dispatchEvent(reminderEvent); - - expect(eventHandler).toHaveBeenCalledTimes(1); - expect(eventHandler.mock.calls[0][0]).toEqual(reminderEvent); - }); - - it('should handle reminder.deleted event', () => { - const eventHandler = vi.fn(); - client.on('reminder.deleted', eventHandler); - - const reminderEvent = { - type: 'reminder.deleted', - reminder: reminderResponse, - }; - - client.dispatchEvent(reminderEvent); - - expect(eventHandler).toHaveBeenCalledTimes(1); - expect(eventHandler.mock.calls[0][0]).toEqual(reminderEvent); - }); - - it('should handle notification.reminder_due event', () => { - const eventHandler = vi.fn(); - client.on('notification.reminder_due', eventHandler); - - const reminderEvent = { - type: 'notification.reminder_due', - reminder: reminderResponse, - }; - - client.dispatchEvent(reminderEvent); - - expect(eventHandler).toHaveBeenCalledTimes(1); - expect(eventHandler.mock.calls[0][0]).toEqual(reminderEvent); - }); - }); - - describe('reminder feature flag in channel config', () => { - let channelType; - let channel; - let message; - - beforeEach(async () => { - // Create a unique channel type name - channelType = 'reminders-test-' + Math.random().toString(36).substring(2, 10); - - // Create a new channel type - vi.spyOn(client, 'createChannelType').mockResolvedValueOnce({ - name: channelType, - user_message_reminders: false, // Initially disabled - }); - - await client.createChannelType({ - name: channelType, - user_message_reminders: false, - }); - - // Create a channel with this type - channel = client.channel(channelType, 'test-channel'); - - // Mock the channel.create method - vi.spyOn(channel, 'create').mockResolvedValueOnce({ - channel: { - id: 'test-channel', - type: channelType, - cid: `${channelType}:test-channel`, - config: { - user_message_reminders: false, // Feature flag disabled - }, - }, - }); - - await channel.create(); - - // Mock the client.configs to return the channel config - client.configs = { - [`${channelType}:test-channel`]: { - user_message_reminders: false, // Feature flag disabled - }, - }; - - // Create a test message - message = { - id: 'test-message', - text: 'Hello, world!', - user: user1, - }; - }); - - it('should fail to create a reminder when user_message_reminders is disabled', async () => { - // Mock the post method to simulate an error response - const postSpy = vi.spyOn(client, 'post').mockRejectedValueOnce({ - code: 403, - message: 'User message reminders are not enabled for this channel', - status: 403, - }); - - try { - await client.createReminder({ - messageId: 'test-message', - remind_at: '2025-04-12T23:20:50.52Z', - }); - // If we reach here, the test should fail - expect.fail('Expected createReminder to throw an error'); - } catch (error) { - expect(error.code).toBe(403); - expect(error.message).toBe( - 'User message reminders are not enabled for this channel', - ); - } - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/test-message/reminders`, - ); - }); - - it('should successfully create a reminder after enabling user_message_reminders', async () => { - // Update the channel type to enable user_message_reminders - vi.spyOn(client, 'updateChannelType').mockResolvedValueOnce({ - name: channelType, - user_message_reminders: true, // Now enabled - }); - - await client.updateChannelType(channelType, { - user_message_reminders: true, - }); - - // Update the client.configs to reflect the updated channel config - client.configs = { - [`${channelType}:test-channel`]: { - user_message_reminders: true, // Feature flag enabled - }, - }; - - // Mock the post method to simulate a successful response - const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce({ - ...reminderResponse, - message_id: 'test-message', - }); - - const result = await client.createReminder({ - messageId: 'test-message', - remind_at: '2025-04-12T23:20:50.52Z', - }); - - expect(postSpy).toHaveBeenCalledTimes(1); - expect(postSpy.mock.calls[0][0]).toBe( - `${client.baseURL}/messages/test-message/reminders`, - ); - expect(postSpy.mock.calls[0][1]).toEqual({ - remind_at: '2025-04-12T23:20:50.52Z', - }); - expect(result.message_id).toBe('test-message'); - }); - }); -}); diff --git a/test/unit/retention_policy.test.ts b/test/unit/retention_policy.test.ts deleted file mode 100644 index f8827680ab..0000000000 --- a/test/unit/retention_policy.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { StreamChat } from '../../src/client'; -import type { - GetRetentionPolicyRunsOptions, - GetRetentionPolicyRunsResponse, -} from '../../src/types'; - -describe('Retention Policy Runs', () => { - let client: StreamChat; - - beforeEach(() => { - client = new StreamChat('api_key', 'api_secret'); - }); - - describe('getRetentionPolicyRuns', () => { - it('should query runs with default options', async () => { - const mockResponse: GetRetentionPolicyRunsResponse = { - duration: '0.05s', - runs: [ - { - app_pk: 1, - policy: 'old-messages', - date: '2026-03-30', - stats: { messages_deleted: 150 }, - }, - ], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const result = await client.getRetentionPolicyRuns(); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/retention_policy/runs`, {}); - expect(result.runs).toHaveLength(1); - expect(result.runs[0].policy).toBe('old-messages'); - expect(result.runs[0].stats.messages_deleted).toBe(150); - }); - - it('should query runs with filter_conditions on policy', async () => { - const mockResponse: GetRetentionPolicyRunsResponse = { - duration: '0.03s', - runs: [ - { - app_pk: 1, - policy: 'inactive-channels', - date: '2026-03-29', - stats: { channels_deleted: 42 }, - }, - { - app_pk: 1, - policy: 'inactive-channels', - date: '2026-03-28', - stats: { channels_deleted: 38 }, - }, - ], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: GetRetentionPolicyRunsOptions = { - filter_conditions: { policy: { $eq: 'inactive-channels' } }, - }; - - const result = await client.getRetentionPolicyRuns(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/retention_policy/runs`, { - filter_conditions: { policy: { $eq: 'inactive-channels' } }, - }); - expect(result.runs).toHaveLength(2); - expect(result.runs.every((r) => r.policy === 'inactive-channels')).toBe(true); - }); - - it('should query runs with filter_conditions on date range', async () => { - const mockResponse: GetRetentionPolicyRunsResponse = { - duration: '0.04s', - runs: [ - { - app_pk: 1, - policy: 'old-messages', - date: '2026-03-15', - stats: { messages_deleted: 200 }, - }, - ], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: GetRetentionPolicyRunsOptions = { - filter_conditions: { - $and: [ - { date: { $gte: '2026-03-01T00:00:00Z' } }, - { date: { $lte: '2026-03-31T00:00:00Z' } }, - ], - }, - }; - - const result = await client.getRetentionPolicyRuns(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/retention_policy/runs`, { - filter_conditions: { - $and: [ - { date: { $gte: '2026-03-01T00:00:00Z' } }, - { date: { $lte: '2026-03-31T00:00:00Z' } }, - ], - }, - }); - expect(result.runs).toHaveLength(1); - expect(result.runs[0].date).toBe('2026-03-15'); - }); - - it('should query runs with sort', async () => { - const mockResponse: GetRetentionPolicyRunsResponse = { - duration: '0.03s', - runs: [ - { - app_pk: 1, - policy: 'old-messages', - date: '2026-03-30', - stats: { messages_deleted: 100 }, - }, - { - app_pk: 1, - policy: 'old-messages', - date: '2026-03-29', - stats: { messages_deleted: 120 }, - }, - ], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: GetRetentionPolicyRunsOptions = { - sort: [{ field: 'date', direction: -1 }], - }; - - const result = await client.getRetentionPolicyRuns(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/retention_policy/runs`, { - sort: [{ field: 'date', direction: -1 }], - }); - expect(result.runs).toHaveLength(2); - }); - - it('should query runs with pagination using next cursor', async () => { - const mockResponse: GetRetentionPolicyRunsResponse = { - duration: '0.02s', - runs: [ - { - app_pk: 1, - policy: 'old-messages', - date: '2026-03-20', - stats: { messages_deleted: 80 }, - }, - ], - next: 'next_cursor_value', - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: GetRetentionPolicyRunsOptions = { - limit: 1, - }; - - const result = await client.getRetentionPolicyRuns(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/retention_policy/runs`, { - limit: 1, - }); - expect(result.runs).toHaveLength(1); - expect(result.next).toBe('next_cursor_value'); - }); - - it('should paginate using next cursor from previous response', async () => { - const mockResponse: GetRetentionPolicyRunsResponse = { - duration: '0.02s', - runs: [ - { - app_pk: 1, - policy: 'old-messages', - date: '2026-03-19', - stats: { messages_deleted: 60 }, - }, - ], - prev: 'prev_cursor_value', - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: GetRetentionPolicyRunsOptions = { - limit: 1, - next: 'next_cursor_value', - }; - - const result = await client.getRetentionPolicyRuns(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/retention_policy/runs`, { - limit: 1, - next: 'next_cursor_value', - }); - expect(result.runs).toHaveLength(1); - expect(result.prev).toBe('prev_cursor_value'); - }); - - it('should combine filter_conditions, sort, and pagination', async () => { - const mockResponse: GetRetentionPolicyRunsResponse = { - duration: '0.04s', - runs: [ - { - app_pk: 1, - policy: 'old-messages', - date: '2026-03-30', - stats: { messages_deleted: 300 }, - }, - ], - next: 'abc123', - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: GetRetentionPolicyRunsOptions = { - filter_conditions: { policy: { $eq: 'old-messages' } }, - sort: [{ field: 'date', direction: -1 }], - limit: 5, - }; - - const result = await client.getRetentionPolicyRuns(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/retention_policy/runs`, { - filter_conditions: { policy: { $eq: 'old-messages' } }, - sort: [{ field: 'date', direction: -1 }], - limit: 5, - }); - expect(result.runs).toHaveLength(1); - expect(result.next).toBe('abc123'); - }); - - it('should handle empty runs response', async () => { - const mockResponse: GetRetentionPolicyRunsResponse = { - duration: '0.01s', - runs: [], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: GetRetentionPolicyRunsOptions = { - filter_conditions: { policy: { $eq: 'old-messages' } }, - }; - - const result = await client.getRetentionPolicyRuns(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/retention_policy/runs`, { - filter_conditions: { policy: { $eq: 'old-messages' } }, - }); - expect(result.runs).toHaveLength(0); - expect(result.next).toBeUndefined(); - expect(result.prev).toBeUndefined(); - }); - - it('should throw error if called without server-side auth', async () => { - const clientWithoutSecret = new StreamChat('api_key'); - clientWithoutSecret.user = { id: 'test-user' }; - - await expect(clientWithoutSecret.getRetentionPolicyRuns()).rejects.toThrow(); - }); - }); -}); diff --git a/test/unit/search/ChannelMemberSearchSource.test.ts b/test/unit/search/ChannelMemberSearchSource.test.ts index 8cc1e884dd..b36584737b 100644 --- a/test/unit/search/ChannelMemberSearchSource.test.ts +++ b/test/unit/search/ChannelMemberSearchSource.test.ts @@ -161,7 +161,7 @@ describe('ChannelMemberSearchSource', () => { it('passes filters, sort, and options to channel.queryMembers', async () => { const filters: MemberFilters = { user_id: 'user-2' }; - const sort: MemberSort = [{ name: 1 }]; + const sort: MemberSort = [{ field: 'name', direction: 1 }]; searchSource.filters = filters; searchSource.sort = sort; searchSource.searchOptions = { user_id_gt: 'user-0' }; @@ -169,18 +169,18 @@ describe('ChannelMemberSearchSource', () => { // @ts-expect-error accessing protected method await searchSource.query('John'); - expect(channel.queryMembers).toHaveBeenCalledWith( - { - ...getAutocompleteFilters('John'), - user_id: 'user-2', - }, - sort, - { + expect(channel.queryMembers).toHaveBeenCalledWith({ + payload: { + filter_conditions: { + ...getAutocompleteFilters('John'), + user_id: 'user-2', + }, + sort, user_id_gt: 'user-0', limit: searchSource.pageSize, offset: searchSource.offset, }, - ); + }); }); it('returns items from query', async () => { @@ -198,9 +198,8 @@ describe('ChannelMemberSearchSource', () => { expect(searchSource.items).toEqual(mockMembers); expect(searchSource.searchQuery).toBe(''); - expect(channel.queryMembers).toHaveBeenCalledWith({}, [], { - limit: 10, - offset: 0, + expect(channel.queryMembers).toHaveBeenCalledWith({ + payload: { filter_conditions: {}, sort: [], limit: 10, offset: 0 }, }); }); @@ -209,11 +208,14 @@ describe('ChannelMemberSearchSource', () => { await vi.advanceTimersByTimeAsync(300); expect(searchSource.searchQuery).toBe('john'); - expect(channel.queryMembers).toHaveBeenCalledWith( - getAutocompleteFilters('john'), - [], - { limit: 10, offset: 0 }, - ); + expect(channel.queryMembers).toHaveBeenCalledWith({ + payload: { + filter_conditions: getAutocompleteFilters('john'), + sort: [], + limit: 10, + offset: 0, + }, + }); }); it('debounces rapid search calls and only executes the last query', async () => { @@ -224,11 +226,14 @@ describe('ChannelMemberSearchSource', () => { await vi.advanceTimersByTimeAsync(300); expect(channel.queryMembers).toHaveBeenCalledTimes(1); - expect(channel.queryMembers).toHaveBeenCalledWith( - getAutocompleteFilters('john'), - [], - { limit: 10, offset: 0 }, - ); + expect(channel.queryMembers).toHaveBeenCalledWith({ + payload: { + filter_conditions: getAutocompleteFilters('john'), + sort: [], + limit: 10, + offset: 0, + }, + }); }); it('resets state for a new search query', async () => { @@ -239,11 +244,14 @@ describe('ChannelMemberSearchSource', () => { await vi.advanceTimersByTimeAsync(300); expect(searchSource.searchQuery).toBe('second'); - expect(channel.queryMembers).toHaveBeenLastCalledWith( - getAutocompleteFilters('second'), - [], - { limit: 10, offset: 0 }, - ); + expect(channel.queryMembers).toHaveBeenLastCalledWith({ + payload: { + filter_conditions: getAutocompleteFilters('second'), + sort: [], + limit: 10, + offset: 0, + }, + }); }); it('paginates without starting a new search query', async () => { @@ -270,9 +278,8 @@ describe('ChannelMemberSearchSource', () => { paginatedSource.search(); await vi.advanceTimersByTimeAsync(300); - expect(queryMembersMock).toHaveBeenNthCalledWith(2, {}, [], { - limit: 2, - offset: 2, + expect(queryMembersMock).toHaveBeenNthCalledWith(2, { + payload: { filter_conditions: {}, sort: [], limit: 2, offset: 2 }, }); expect(paginatedSource.items).toEqual([...firstPage, ...secondPage]); expect(paginatedSource.hasNext).toBe(false); diff --git a/test/unit/search/ChannelSearchSource.test.ts b/test/unit/search/ChannelSearchSource.test.ts index 1a84c2fa92..129cb1c04f 100644 --- a/test/unit/search/ChannelSearchSource.test.ts +++ b/test/unit/search/ChannelSearchSource.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, MockInstance } from 'v import { ChannelSearchSource } from '../../../src/search/ChannelSearchSource'; import type { Channel } from '../../../src/channel'; import type { StreamChat } from '../../../src/client'; -import type { ChannelAPIResponse, ChannelFilters } from '../../../src/types'; +import type { ChannelStateResponseFields, ChannelFilters } from '../../../src/types'; import { generateChannel } from '../test-utils/generateChannel'; import { getClientWithUser } from '../test-utils/getClient'; @@ -10,16 +10,21 @@ describe('ChannelSearchSource', () => { const user = { id: 'user-123' }; let client: StreamChat; let searchSource: ChannelSearchSource; - let queryChannelsMock: MockInstance; + let queryChannelsMock: MockInstance; let channels: Channel[]; - const mockChannels: ChannelAPIResponse[] = [generateChannel(), generateChannel()]; + const mockChannels: ChannelStateResponseFields[] = [ + generateChannel(), + generateChannel(), + ]; beforeEach(() => { client = getClientWithUser(user); channels = mockChannels.map((data) => client.channel(data.channel.type, data.channel.id), ); - queryChannelsMock = vi.spyOn(client, 'queryChannels').mockResolvedValue(channels); + queryChannelsMock = vi + .spyOn(client, 'queryChannelsAndHydrate') + .mockResolvedValue(channels); searchSource = new ChannelSearchSource(client); }); @@ -138,7 +143,7 @@ describe('ChannelSearchSource', () => { searchQuery ? { 'member.user.name': { $autocomplete: searchQuery } } : null, }, }); - searchSource.sort = { last_message_at: -1 }; + searchSource.sort = [{ field: 'last_message_at', direction: -1 }]; searchSource.searchOptions = { message_limit: 5 }; // @ts-expect-error accessing protected property @@ -146,14 +151,19 @@ describe('ChannelSearchSource', () => { expect(queryChannelsMock).toHaveBeenCalledWith( { - 'member.user.name': { - $autocomplete: 'channel search', - }, // custom - members: { $in: [user.id] }, // static default - name: { $autocomplete: 'channel search' }, // dynamic default + filter_conditions: { + 'member.user.name': { + $autocomplete: 'channel search', + }, + members: { $in: [user.id] }, + name: { $autocomplete: 'channel search' }, + }, + sort: [{ field: 'last_message_at', direction: -1 }], + message_limit: 5, + limit: searchSource.pageSize, + offset: searchSource.offset, }, - { last_message_at: -1 }, - { message_limit: 5, limit: searchSource.pageSize, offset: searchSource.offset }, + { withResponse: false }, ); }); @@ -171,7 +181,7 @@ describe('ChannelSearchSource', () => { }); it('works without client.userID', async () => { - searchSource.client.userID = undefined; + searchSource.client.user = undefined; const spyBuildFilters = vi .spyOn(searchSource.filterBuilder, 'buildFilters') .mockReturnValue({}); diff --git a/test/unit/search/MessageSearchSource.test.ts b/test/unit/search/MessageSearchSource.test.ts index 0246d48646..2befc8a804 100644 --- a/test/unit/search/MessageSearchSource.test.ts +++ b/test/unit/search/MessageSearchSource.test.ts @@ -10,7 +10,7 @@ describe('MessageSearchSource', () => { let client: StreamChat; let searchSource: MessageSearchSource; let searchMock: MockInstance; - let queryChannelsMock: MockInstance; + let queryChannelsMock: MockInstance; let messages: MessageResponse[]; let searchResponse: SearchAPIResponse; @@ -22,7 +22,7 @@ describe('MessageSearchSource', () => { next: 'next-token', } as any; searchMock = vi.spyOn(client, 'search').mockResolvedValue(searchResponse); - queryChannelsMock = vi.spyOn(client, 'queryChannels').mockResolvedValue([]); + queryChannelsMock = vi.spyOn(client, 'queryChannelsAndHydrate').mockResolvedValue([]); searchSource = new MessageSearchSource(client); }); @@ -203,7 +203,7 @@ describe('MessageSearchSource', () => { }); it('returns empty items when client.userID is missing', async () => { - searchSource['client'].userID = undefined; + searchSource['client'].user = undefined; // @ts-expect-error protected access const result = await searchSource.query('test'); expect(result).toEqual({ items: [] }); @@ -224,17 +224,17 @@ describe('MessageSearchSource', () => { // @ts-expect-error protected access const result = await searchSource.query(''); - expect(searchMock).toHaveBeenCalledWith( - expect.objectContaining({ - members: { $in: [user.id] }, - }), - { type: 'regular' }, - expect.objectContaining({ + expect(searchMock).toHaveBeenCalledWith({ + payload: expect.objectContaining({ + filter_conditions: { + members: { $in: [user.id] }, + }, + message_filter_conditions: { type: 'regular' }, limit: searchSource.pageSize, next: undefined, - sort: { created_at: -1 }, + sort: [{ field: 'created_at', direction: -1 }], }), - ); + }); expect(result.items).toEqual(messages); expect(result.next).toBe('next-token'); }); @@ -243,28 +243,31 @@ describe('MessageSearchSource', () => { searchSource.messageSearchFilters = { 'mentioned_users.id': { $contains: 'abc' } }; searchSource.messageSearchChannelFilters = { type: 'messaging' }; searchSource.channelQueryFilters = { type: 'abc' }; - searchSource.messageSearchSort = { created_at: 1 }; + searchSource.messageSearchSort = [{ field: 'created_at', direction: 1 }]; searchSource.state.partialNext({ next: 'next-token-old' }); // @ts-expect-error protected access await searchSource.query('hello'); - expect(searchMock).toHaveBeenCalledWith( - expect.objectContaining({ - members: { $in: [user.id] }, - type: 'messaging', - }), - expect.objectContaining({ - 'mentioned_users.id': { $contains: 'abc' }, - type: 'regular', - text: 'hello', - }), - expect.objectContaining({ + expect(searchMock).toHaveBeenCalledWith({ + payload: expect.objectContaining({ + filter_conditions: { + members: { $in: [user.id] }, + type: 'messaging', + }, + message_filter_conditions: { + 'mentioned_users.id': { $contains: 'abc' }, + type: 'regular', + text: 'hello', + }, limit: searchSource.pageSize, next: 'next-token-old', - sort: { created_at: 1 }, // note: merges created_at with default -1, order may vary + sort: [ + { field: 'created_at', direction: -1 }, + { field: 'created_at', direction: 1 }, + ], }), - ); + }); }); it('overrides the static filters with dynamic ones', async () => { @@ -288,29 +291,32 @@ describe('MessageSearchSource', () => { searchQuery ? { type: { $in: [searchQuery] } } : null, }, }); - searchSource.messageSearchSort = { created_at: 1 }; + searchSource.messageSearchSort = [{ field: 'created_at', direction: 1 }]; searchSource.state.partialNext({ next: 'next-token-old' }); const searchQuery = 'hello'; // @ts-expect-error protected access await searchSource.query(searchQuery); - expect(searchMock).toHaveBeenCalledWith( - expect.objectContaining({ - members: { $in: [user.id] }, - type: { $in: [searchQuery] }, - }), - expect.objectContaining({ - 'mentioned_users.id': { $contains: searchQuery }, - type: 'regular', - text: searchQuery, - }), - expect.objectContaining({ + expect(searchMock).toHaveBeenCalledWith({ + payload: expect.objectContaining({ + filter_conditions: { + members: { $in: [user.id] }, + type: { $in: [searchQuery] }, + }, + message_filter_conditions: { + 'mentioned_users.id': { $contains: searchQuery }, + type: 'regular', + text: searchQuery, + }, limit: searchSource.pageSize, next: 'next-token-old', - sort: { created_at: 1 }, // note: merges created_at with default -1, order may vary + sort: [ + { field: 'created_at', direction: -1 }, + { field: 'created_at', direction: 1 }, + ], }), - ); + }); }); it('overrides the message type', async () => { @@ -320,20 +326,20 @@ describe('MessageSearchSource', () => { // @ts-expect-error protected access await searchSource.query('hello'); - expect(searchMock).toHaveBeenCalledWith( - expect.objectContaining({ - members: { $in: [user.id] }, - }), - expect.objectContaining({ - type: 'deleted', - text: 'hello', - }), - expect.objectContaining({ + expect(searchMock).toHaveBeenCalledWith({ + payload: expect.objectContaining({ + filter_conditions: { + members: { $in: [user.id] }, + }, + message_filter_conditions: { + type: 'deleted', + text: 'hello', + }, limit: searchSource.pageSize, next: 'next-token-old', - sort: { created_at: -1 }, // note: merges created_at with default -1, order may vary + sort: [{ field: 'created_at', direction: -1 }], }), - ); + }); }); it('calls queryChannels when some cids are missing locally', async () => { @@ -349,11 +355,10 @@ describe('MessageSearchSource', () => { // @ts-expect-error protected access await searchSource.query('query'); - expect(queryChannelsMock).toHaveBeenCalledWith( - { cid: { $in: ['cid2'] }, type: 'abc' }, - { last_message_at: -1 }, - undefined, - ); + expect(queryChannelsMock).toHaveBeenCalledWith({ + filter_conditions: { cid: { $in: ['cid2'] }, type: 'abc' }, + sort: [{ direction: -1, field: 'last_message_at' }], + }); }); it('does not call queryChannels if all channels are loaded locally', async () => { @@ -389,11 +394,10 @@ describe('MessageSearchSource', () => { // @ts-expect-error protected access await searchSource.query('query'); - expect(queryChannelsMock).toHaveBeenCalledWith( - { cid: { $in: ['cid2'] }, type: 'efg' }, - { last_message_at: -1 }, - undefined, - ); + expect(queryChannelsMock).toHaveBeenCalledWith({ + filter_conditions: { cid: { $in: ['cid2'] }, type: 'efg' }, + sort: [{ direction: -1, field: 'last_message_at' }], + }); }); it('returns items and next from search', async () => { diff --git a/test/unit/search/SearchController.test.js b/test/unit/search/SearchController.test.js index 3a37ff93f2..52851dd745 100644 --- a/test/unit/search/SearchController.test.js +++ b/test/unit/search/SearchController.test.js @@ -11,7 +11,7 @@ import { generateUser } from '../test-utils/generateUser'; import { generateChannel } from '../test-utils/generateChannel'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { ErrorFromResponse } from '../../../src'; +import { StreamAPIError, StreamChat } from '../../../src'; import { APIErrorCodes } from '../../../src/errors'; describe('SearchController', () => { @@ -229,14 +229,11 @@ describe('BaseSearchSource and implementations', () => { const results = [{ id: 'result' }]; beforeEach(() => { - mockClient = { - user: { id: 'current-user' }, - userID: 'current-user', - queryUsers: sinon.stub().resolves({ users }), - queryChannels: sinon.stub().resolves(channels), - search: sinon.stub().resolves({ results, next: null }), - activeChannels: {}, - }; + mockClient = new StreamChat(''); + mockClient.user = { id: 'current-user' }; + sinon.stub(mockClient, 'queryUsers').resolves({ users }); + sinon.stub(mockClient, 'queryChannels').resolves(channels); + sinon.stub(mockClient, 'search').resolves({ results, next: null }); }); describe('BaseSearchSource', () => { @@ -434,7 +431,7 @@ describe('BaseSearchSource and implementations', () => { }); vi.spyOn(searchSource, 'query').mockRejectedValue( - new ErrorFromResponse('anything', { + new StreamAPIError('anything', { code: APIErrorCodes[4], response: {}, status: 400, @@ -935,14 +932,16 @@ describe('BaseSearchSource and implementations', () => { userSource.activate(); await userSource.executeQuery('test'); - sinon.assert.calledWith( - mockClient.queryUsers, - { - $or: [{ id: { $autocomplete: 'test' } }, { name: { $autocomplete: 'test' } }], + sinon.assert.calledWith(mockClient.queryUsers, { + payload: { + filter_conditions: { + $or: [{ id: { $autocomplete: 'test' } }, { name: { $autocomplete: 'test' } }], + }, + sort: [{ field: 'id', direction: 1 }], + limit: 10, + offset: 0, }, - { id: 1 }, - { limit: 10, offset: 0 }, - ); + }); }); }); @@ -959,12 +958,14 @@ describe('BaseSearchSource and implementations', () => { sinon.assert.calledWith( mockClient.queryChannels, - { - members: { $in: ['current-user'] }, - name: { $autocomplete: 'test' }, - }, - {}, - { limit: 10, offset: 0 }, + sinon.match({ + filter_conditions: { + members: { $in: ['current-user'] }, + name: { $autocomplete: 'test' }, + }, + limit: 10, + offset: 0, + }), ); }); }); @@ -977,7 +978,7 @@ describe('BaseSearchSource and implementations', () => { }); it('returns empty results if no user ID', async () => { - mockClient.userID = null; + mockClient.user = undefined; messageSource.activate(); await messageSource.executeQuery('test'); expect(messageSource.items).to.be.empty; @@ -989,9 +990,13 @@ describe('BaseSearchSource and implementations', () => { sinon.assert.calledWith( mockClient.search, - { members: { $in: ['current-user'] } }, - { text: 'test', type: 'regular' }, - { limit: 10, next: undefined, sort: { created_at: -1 } }, + sinon.match({ + payload: { + filter_conditions: { members: { $in: ['current-user'] } }, + message_filter_conditions: { type: 'regular', text: 'test' }, + limit: 10, + }, + }), ); }); @@ -1006,8 +1011,9 @@ describe('BaseSearchSource and implementations', () => { sinon.assert.calledWith( mockClient.queryChannels, - { cid: { $in: ['missing-channel'] } }, - { last_message_at: -1 }, + sinon.match({ + filter_conditions: { cid: { $in: ['missing-channel'] } }, + }), ); }); diff --git a/test/unit/search/UserSearchSource.test.ts b/test/unit/search/UserSearchSource.test.ts index 17d6721f05..a5ee4a0815 100644 --- a/test/unit/search/UserSearchSource.test.ts +++ b/test/unit/search/UserSearchSource.test.ts @@ -160,61 +160,77 @@ describe('UserSearchSource', () => { searchQuery ? { name: { $autocomplete: searchQuery } } : null, }, }); - searchSource.sort = { created_at: -1 } as UserSort; + searchSource.sort = [{ field: 'created_at', direction: -1 }]; searchSource.searchOptions = { presence: true }; // @ts-expect-error accessing protected method await searchSource.query('John'); - expect(queryUsersMock).toHaveBeenCalledWith( - { - $or: [{ id: { $autocomplete: 'John' } }, { name: { $autocomplete: 'John' } }], - name: { $autocomplete: 'John' }, - role: { $eq: 'admin' }, + expect(queryUsersMock).toHaveBeenCalledWith({ + payload: { + filter_conditions: { + $or: [{ id: { $autocomplete: 'John' } }, { name: { $autocomplete: 'John' } }], + name: { $autocomplete: 'John' }, + role: { $eq: 'admin' }, + }, + sort: [ + { field: 'created_at', direction: -1 }, + { field: 'id', direction: 1 }, + ], + presence: true, + limit: searchSource.pageSize, + offset: searchSource.offset, }, - { id: 1, created_at: -1 }, - { presence: true, limit: searchSource.pageSize, offset: searchSource.offset }, - ); + }); }); it('appends a default id sort when sort is an array without an id key', async () => { - searchSource.sort = [{ created_at: -1 }] as UserSort; + searchSource.sort = [{ field: 'created_at', direction: -1 }]; // @ts-expect-error accessing protected method await searchSource.query('John'); - expect(queryUsersMock).toHaveBeenCalledWith( - expect.anything(), - [{ created_at: -1 }, { id: 1 }], - expect.anything(), - ); + expect(queryUsersMock).toHaveBeenCalledWith({ + payload: expect.objectContaining({ + sort: [ + { field: 'created_at', direction: -1 }, + { field: 'id', direction: 1 }, + ], + }), + }); }); it('leaves the sort array unchanged when it already contains an id key', async () => { - const sort = [{ id: -1 }, { created_at: -1 }] as UserSort; + const sort: UserSort = [ + { field: 'id', direction: -1 }, + { field: 'created_at', direction: -1 }, + ]; searchSource.sort = sort; // @ts-expect-error accessing protected method await searchSource.query('John'); - expect(queryUsersMock).toHaveBeenCalledWith( - expect.anything(), - [{ id: -1 }, { created_at: -1 }], - expect.anything(), - ); + expect(queryUsersMock).toHaveBeenCalledWith({ + payload: expect.objectContaining({ + sort: [ + { field: 'id', direction: -1 }, + { field: 'created_at', direction: -1 }, + ], + }), + }); }); it('uses only the default id sort when sort is an empty array', async () => { - searchSource.sort = [] as UserSort; + searchSource.sort = []; // @ts-expect-error accessing protected method await searchSource.query('John'); - expect(queryUsersMock).toHaveBeenCalledWith( - expect.anything(), - [{ id: 1 }], - expect.anything(), - ); + expect(queryUsersMock).toHaveBeenCalledWith({ + payload: expect.objectContaining({ + sort: [{ field: 'id', direction: 1 }], + }), + }); }); it('returns items from query', async () => { diff --git a/test/unit/team_usage_stats.test.ts b/test/unit/team_usage_stats.test.ts deleted file mode 100644 index 0fa53ac5e2..0000000000 --- a/test/unit/team_usage_stats.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { StreamChat } from '../../src/client'; -import type { - QueryTeamUsageStatsOptions, - QueryTeamUsageStatsResponse, -} from '../../src/types'; - -describe('Team Usage Stats', () => { - let client: StreamChat; - - beforeEach(() => { - client = new StreamChat('api_key', 'api_secret'); - }); - - describe('queryTeamUsageStats', () => { - it('should query team usage stats with default options', async () => { - const mockResponse: QueryTeamUsageStatsResponse = { - duration: '0.05s', - teams: [ - { - team: 'team-1', - users_daily: { total: 100 }, - messages_daily: { total: 500 }, - translations_daily: { total: 10 }, - image_moderations_daily: { total: 5 }, - concurrent_users: { total: 25 }, - concurrent_connections: { total: 30 }, - users_total: { total: 1000 }, - users_last_24_hours: { total: 50 }, - users_last_30_days: { total: 200 }, - users_month_to_date: { total: 150 }, - users_engaged_last_30_days: { total: 180 }, - users_engaged_month_to_date: { total: 120 }, - messages_total: { total: 50000 }, - messages_last_24_hours: { total: 250 }, - messages_last_30_days: { total: 5000 }, - messages_month_to_date: { total: 3500 }, - }, - ], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const result = await client.queryTeamUsageStats(); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/stats/team_usage`, {}); - expect(result.teams).toHaveLength(1); - expect(result.teams[0].team).toBe('team-1'); - expect(result.teams[0].users_daily.total).toBe(100); - }); - - it('should query team usage stats with month option', async () => { - const mockResponse: QueryTeamUsageStatsResponse = { - duration: '0.05s', - teams: [ - { - team: 'team-1', - users_daily: { total: 100 }, - messages_daily: { total: 500 }, - translations_daily: { total: 10 }, - image_moderations_daily: { total: 5 }, - concurrent_users: { total: 25 }, - concurrent_connections: { total: 30 }, - users_total: { total: 1000 }, - users_last_24_hours: { total: 50 }, - users_last_30_days: { total: 200 }, - users_month_to_date: { total: 150 }, - users_engaged_last_30_days: { total: 180 }, - users_engaged_month_to_date: { total: 120 }, - messages_total: { total: 50000 }, - messages_last_24_hours: { total: 250 }, - messages_last_30_days: { total: 5000 }, - messages_month_to_date: { total: 3500 }, - }, - ], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: QueryTeamUsageStatsOptions = { - month: '2026-01', - }; - - const result = await client.queryTeamUsageStats(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/stats/team_usage`, { - month: '2026-01', - }); - expect(result.teams).toHaveLength(1); - }); - - it('should query team usage stats with date range for daily breakdown', async () => { - const mockResponse: QueryTeamUsageStatsResponse = { - duration: '0.05s', - teams: [ - { - team: 'team-1', - users_daily: { - daily: [ - { date: '2026-01-03', value: 35 }, - { date: '2026-01-02', value: 32 }, - { date: '2026-01-01', value: 30 }, - ], - total: 97, - }, - messages_daily: { - daily: [ - { date: '2026-01-03', value: 180 }, - { date: '2026-01-02', value: 170 }, - { date: '2026-01-01', value: 150 }, - ], - total: 500, - }, - translations_daily: { total: 10 }, - image_moderations_daily: { total: 5 }, - concurrent_users: { total: 25 }, - concurrent_connections: { total: 30 }, - users_total: { total: 1000 }, - users_last_24_hours: { total: 50 }, - users_last_30_days: { total: 200 }, - users_month_to_date: { total: 150 }, - users_engaged_last_30_days: { total: 180 }, - users_engaged_month_to_date: { total: 120 }, - messages_total: { total: 50000 }, - messages_last_24_hours: { total: 250 }, - messages_last_30_days: { total: 5000 }, - messages_month_to_date: { total: 3500 }, - }, - ], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: QueryTeamUsageStatsOptions = { - start_date: '2026-01-01', - end_date: '2026-01-03', - }; - - const result = await client.queryTeamUsageStats(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/stats/team_usage`, { - start_date: '2026-01-01', - end_date: '2026-01-03', - }); - expect(result.teams).toHaveLength(1); - expect(result.teams[0].users_daily.daily).toHaveLength(3); - expect(result.teams[0].users_daily.daily![0].date).toBe('2026-01-03'); - expect(result.teams[0].users_daily.daily![0].value).toBe(35); - }); - - it('should query team usage stats with pagination options', async () => { - const mockResponse: QueryTeamUsageStatsResponse = { - duration: '0.05s', - teams: [ - { - team: 'team-2', - users_daily: { total: 50 }, - messages_daily: { total: 250 }, - translations_daily: { total: 5 }, - image_moderations_daily: { total: 2 }, - concurrent_users: { total: 10 }, - concurrent_connections: { total: 15 }, - users_total: { total: 500 }, - users_last_24_hours: { total: 25 }, - users_last_30_days: { total: 100 }, - users_month_to_date: { total: 75 }, - users_engaged_last_30_days: { total: 90 }, - users_engaged_month_to_date: { total: 60 }, - messages_total: { total: 25000 }, - messages_last_24_hours: { total: 125 }, - messages_last_30_days: { total: 2500 }, - messages_month_to_date: { total: 1750 }, - }, - ], - next: 'next_cursor_value', - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const options: QueryTeamUsageStatsOptions = { - limit: 10, - next: 'cursor_value', - }; - - const result = await client.queryTeamUsageStats(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/stats/team_usage`, { - limit: 10, - next: 'cursor_value', - }); - expect(result.teams).toHaveLength(1); - expect(result.next).toBe('next_cursor_value'); - }); - - it('should handle multiple teams in response', async () => { - const mockResponse: QueryTeamUsageStatsResponse = { - duration: '0.05s', - teams: [ - { - team: 'team-1', - users_daily: { total: 100 }, - messages_daily: { total: 500 }, - translations_daily: { total: 10 }, - image_moderations_daily: { total: 5 }, - concurrent_users: { total: 25 }, - concurrent_connections: { total: 30 }, - users_total: { total: 1000 }, - users_last_24_hours: { total: 50 }, - users_last_30_days: { total: 200 }, - users_month_to_date: { total: 150 }, - users_engaged_last_30_days: { total: 180 }, - users_engaged_month_to_date: { total: 120 }, - messages_total: { total: 50000 }, - messages_last_24_hours: { total: 250 }, - messages_last_30_days: { total: 5000 }, - messages_month_to_date: { total: 3500 }, - }, - { - team: 'team-2', - users_daily: { total: 50 }, - messages_daily: { total: 250 }, - translations_daily: { total: 5 }, - image_moderations_daily: { total: 2 }, - concurrent_users: { total: 10 }, - concurrent_connections: { total: 15 }, - users_total: { total: 500 }, - users_last_24_hours: { total: 25 }, - users_last_30_days: { total: 100 }, - users_month_to_date: { total: 75 }, - users_engaged_last_30_days: { total: 90 }, - users_engaged_month_to_date: { total: 60 }, - messages_total: { total: 25000 }, - messages_last_24_hours: { total: 125 }, - messages_last_30_days: { total: 2500 }, - messages_month_to_date: { total: 1750 }, - }, - { - team: '', // Users not assigned to any team - users_daily: { total: 20 }, - messages_daily: { total: 100 }, - translations_daily: { total: 1 }, - image_moderations_daily: { total: 0 }, - concurrent_users: { total: 5 }, - concurrent_connections: { total: 5 }, - users_total: { total: 100 }, - users_last_24_hours: { total: 10 }, - users_last_30_days: { total: 40 }, - users_month_to_date: { total: 30 }, - users_engaged_last_30_days: { total: 35 }, - users_engaged_month_to_date: { total: 25 }, - messages_total: { total: 5000 }, - messages_last_24_hours: { total: 50 }, - messages_last_30_days: { total: 500 }, - messages_month_to_date: { total: 350 }, - }, - ], - next: 'next_page_cursor', - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const result = await client.queryTeamUsageStats({ limit: 30 }); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/stats/team_usage`, { - limit: 30, - }); - expect(result.teams).toHaveLength(3); - expect(result.teams[0].team).toBe('team-1'); - expect(result.teams[1].team).toBe('team-2'); - expect(result.teams[2].team).toBe(''); // Empty string for unassigned users - expect(result.next).toBe('next_page_cursor'); - }); - - it('should handle empty teams response', async () => { - const mockResponse: QueryTeamUsageStatsResponse = { - duration: '0.01s', - teams: [], - }; - - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - - const result = await client.queryTeamUsageStats({ month: '2020-01' }); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/stats/team_usage`, { - month: '2020-01', - }); - expect(result.teams).toHaveLength(0); - expect(result.next).toBeUndefined(); - }); - - it('should throw error if called without server-side auth', async () => { - const clientWithoutSecret = new StreamChat('api_key'); - clientWithoutSecret.user = { id: 'test-user' }; - - await expect(clientWithoutSecret.queryTeamUsageStats()).rejects.toThrow(); - }); - }); -}); diff --git a/test/unit/test-utils/generateChannel.ts b/test/unit/test-utils/generateChannel.ts index 4d8449fbb7..f7f2fce676 100644 --- a/test/unit/test-utils/generateChannel.ts +++ b/test/unit/test-utils/generateChannel.ts @@ -1,14 +1,18 @@ import { generateUUIDv4 as uuidv4 } from '../../../src/utils'; -import { ChannelAPIResponse, ChannelConfigWithInfo, ChannelResponse } from '../../../src'; +import { + ChannelStateResponseFields, + ChannelConfigWithInfo, + ChannelResponse, +} from '../../../src'; export const generateChannel = ( options: Partial< - Omit & { + Omit & { channel?: Partial; config?: ChannelConfigWithInfo; } > = { channel: {} }, -): ChannelAPIResponse => { +): ChannelStateResponseFields => { const { channel: optionsChannel, config, ...optionsBesidesChannel } = options; const idFromOptions = optionsChannel && optionsChannel.id; const type = (optionsChannel && optionsChannel.type) || 'messaging'; @@ -26,22 +30,22 @@ export const generateChannel = ( id, type, cid: `${type}:${id}`, - created_at: '2020-04-28T11:20:48.578147Z', - updated_at: '2020-04-28T11:20:48.578147Z', + created_at: new Date('2020-04-28T11:20:48.578147Z'), + updated_at: new Date('2020-04-28T11:20:48.578147Z'), created_by: { id: 'vishal', role: 'user', - created_at: '2020-04-27T13:05:13.847572Z', - updated_at: '2020-04-28T11:21:08.357468Z', - last_active: '2020-04-28T11:21:08.353026Z', + created_at: new Date('2020-04-27T13:05:13.847572Z'), + updated_at: new Date('2020-04-28T11:21:08.357468Z'), + last_active: new Date('2020-04-28T11:21:08.353026Z'), banned: false, online: false, }, frozen: false, disabled: false, config: { - created_at: '2020-04-24T11:36:43.859020368Z', - updated_at: '2020-04-24T11:36:43.859022903Z', + created_at: new Date('2020-04-24T11:36:43.859020368Z'), + updated_at: new Date('2020-04-24T11:36:43.859022903Z'), name: 'messaging', typing_events: true, read_events: true, diff --git a/test/unit/test-utils/generateMessage.ts b/test/unit/test-utils/generateMessage.ts index 6834e01830..7d61af48ce 100644 --- a/test/unit/test-utils/generateMessage.ts +++ b/test/unit/test-utils/generateMessage.ts @@ -1,20 +1,20 @@ import { generateUUIDv4 as uuidv4 } from '../../../src/utils'; -import type { MessageResponse } from '../../../src'; +import type { MessageResponse, UserResponse } from '../../../src'; export const generateMsg = ( - msg: Partial & { date?: string } = {}, + msg: Partial & { date?: Date } = {}, ): MessageResponse => { - const date = msg?.date || new Date().toISOString(); + const date = msg?.date ?? new Date(); return { id: uuidv4(), text: uuidv4(), html: '

x

\n', type: 'regular', - user: { id: 'id' }, + user: { id: 'id' } as UserResponse, attachments: [], latest_reactions: [], own_reactions: [], - reaction_counts: null, + reaction_counts: {}, reaction_scores: {}, reply_count: 0, created_at: date, diff --git a/test/unit/test-utils/generateMessageDraft.ts b/test/unit/test-utils/generateMessageDraft.ts index 8e0612485b..57151ea125 100644 --- a/test/unit/test-utils/generateMessageDraft.ts +++ b/test/unit/test-utils/generateMessageDraft.ts @@ -12,7 +12,7 @@ export const generateMessageDraft = ({ return { channel, channel_cid: channel.cid, - created_at: new Date().toISOString(), + created_at: new Date(), message: generateMsg(), ...customMsgDraft, } as DraftResponse; diff --git a/test/unit/test-utils/generatePendingTask.js b/test/unit/test-utils/generatePendingTask.js index 2a78e46624..c8eb86aecd 100644 --- a/test/unit/test-utils/generatePendingTask.js +++ b/test/unit/test-utils/generatePendingTask.js @@ -15,26 +15,27 @@ export const generatePendingTask = (type, id = 1, options = {}, payloadOptions = export const generatePendingTaskPayload = (type, options = {}) => { if (type === 'send-reaction') { const messageId = options.messageId ?? '123'; - const reaction = options.reaction ?? { type: 'wow', message_id: messageId }; - return { type, payload: [messageId, reaction] }; + const reaction = options.reaction ?? { type: 'wow' }; + return { type, payload: [{ id: messageId, reaction }] }; } if (type === 'delete-reaction') { const messageId = options.messageId ?? '123'; const reactionType = options.reactionType ?? 'wow'; - return { type, payload: [messageId, reactionType] }; + return { type, payload: [{ id: messageId, type: reactionType }] }; } if (type === 'delete-message') { const messageId = options.messageId ?? '123'; - return { type, payload: [messageId] }; + return { type, payload: [{ id: messageId }] }; } if (type === 'update-message') { const message = options.message ?? generateMsg({ id: options.messageId ?? '123' }); - return { type, payload: [message, options.user, options.updateOptions] }; + const request = { id: message.id, message, ...(options.updateOptions ?? {}) }; + return { type, payload: [request] }; } const message = options.message ?? generateMsg(); - return { type, payload: [message] }; + return { type, payload: [{ message }] }; }; diff --git a/test/unit/test-utils/generateReadResponse.js b/test/unit/test-utils/generateReadResponse.js index 352ff87b31..92cdc7ee1d 100644 --- a/test/unit/test-utils/generateReadResponse.js +++ b/test/unit/test-utils/generateReadResponse.js @@ -3,7 +3,7 @@ import { generateUser } from './generateUser'; export const generateReadResponse = (options = {}) => { const userResponse = options.user ?? generateUser(); return { - last_read: new Date().toISOString(), + last_read: new Date(), user: userResponse, last_read_message_id: '123321', unread_messages: 0, diff --git a/test/unit/test-utils/generateThreadResponse.js b/test/unit/test-utils/generateThreadResponse.js index 2866f6542c..108a16bad9 100644 --- a/test/unit/test-utils/generateThreadResponse.js +++ b/test/unit/test-utils/generateThreadResponse.js @@ -4,10 +4,10 @@ export const generateThreadResponse = (channel, parent, opts = {}) => { parent_message: parent, channel, title: 'title', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), + created_at: new Date(), + updated_at: new Date(), channel_cid: channel.cid, - last_message_at: new Date().toISOString(), + last_message_at: new Date(), deleted_at: undefined, read: [], reply_count: 0, diff --git a/test/unit/test-utils/generateUser.js b/test/unit/test-utils/generateUser.js index 01843d4f54..bbaaa9c783 100644 --- a/test/unit/test-utils/generateUser.js +++ b/test/unit/test-utils/generateUser.js @@ -6,8 +6,8 @@ export const generateUser = (options = {}) => { name: uuidv4(), image: uuidv4(), role: 'user', - created_at: '2020-04-27T13:39:49.331742Z', - updated_at: '2020-04-27T13:39:49.332087Z', + created_at: new Date('2020-04-27T13:39:49.331742Z'), + updated_at: new Date('2020-04-27T13:39:49.332087Z'), banned: false, online: false, ...options, diff --git a/test/unit/test-utils/getClient.js b/test/unit/test-utils/getClient.js index f78871b2d6..462e72d3e3 100644 --- a/test/unit/test-utils/getClient.js +++ b/test/unit/test-utils/getClient.js @@ -3,12 +3,12 @@ import { generateUUIDv4 as uuidv4 } from '../../../src/utils'; export const getClientWithUser = (user) => { const chatClient = new StreamChat(''); - + chatClient.tokenManager.getToken = () => 'mock-token'; const clientUser = user || { id: uuidv4() }; chatClient.connectUser = () => { chatClient.user = clientUser; - chatClient.userID = clientUser.id; + chatClient.wsPromise = Promise.resolve(); // sending a promise, since connectUser in actual SDK is an async function. diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index bda52ca123..6d53670b7f 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -12,12 +12,11 @@ import { StreamChat, Thread, ThreadManager, - ThreadResponse, + ThreadStateResponse, THREAD_MANAGER_INITIAL_STATE, ThreadFilters, ThreadSort, } from '../../src'; -import { THREAD_RESPONSE_RESERVED_KEYS } from '../../src/thread'; import { describe, it, beforeEach, expect, afterEach } from 'vitest'; @@ -34,7 +33,7 @@ describe('Threads 2.0', () => { channelOverrides = {}, parentMessageOverrides = {}, ...overrides - }: Partial & { + }: Partial & { channelOverrides?: Partial; parentMessageOverrides?: Partial; } = {}) { @@ -86,7 +85,7 @@ describe('Threads 2.0', () => { client = new StreamChat('apiKey'); client._setUser({ id: TEST_USER_ID }); channelResponse = generateChannel({ - channel: { id: uuidv4(), name: 'Test channel', members: [] }, + channel: { id: uuidv4(), members: [], custom: { name: 'Test channel' } }, }).channel as ChannelResponse; channel = client.channel(channelResponse.type, channelResponse.id); channel.initialized = true; @@ -264,13 +263,13 @@ describe('Threads 2.0', () => { it('updates optimistically added message', () => { const optimisticMessage = makeReply({ text: 'aaa', - created_at: '2020-01-01T00:00:00Z', - }); + created_at: new Date('2020-01-01T00:00:00Z'), + }) as MessageResponse; const message = makeReply({ text: 'bbb', - created_at: '2020-01-01T00:00:10Z', - }); + created_at: new Date('2020-01-01T00:00:10Z'), + }) as MessageResponse; const thread = createTestThread({ latest_replies: [optimisticMessage, message], @@ -279,7 +278,7 @@ describe('Threads 2.0', () => { const updatedMessage: MessageResponse = { ...optimisticMessage, text: 'ccc', - created_at: '2020-01-01T00:00:20Z', + created_at: new Date('2020-01-01T00:00:20Z'), }; const repliesBefore = repliesOf(thread); @@ -318,7 +317,7 @@ describe('Threads 2.0', () => { { id: 'participant-1' }, ] as unknown as ThreadResponse['thread_participants']; const updatedMessage = generateMsg({ - deleted_at: new Date().toISOString(), + deleted_at: new Date(), id: parentMessageResponse.id, reply_count: 10, text: 'aaa', @@ -329,7 +328,9 @@ describe('Threads 2.0', () => { const stateAfter = thread.state.getLatestValue(); expect(stateAfter.deletedAt).to.be.not.null; - expect(stateAfter.deletedAt!.toISOString()).to.equal(updatedMessage.deleted_at); + expect(stateAfter.deletedAt!.toISOString()).to.equal( + updatedMessage.deleted_at!.toISOString(), + ); expect(stateAfter.replyCount).to.equal(updatedMessage.reply_count); expect(stateAfter.participants).to.have.lengthOf(1); expect(stateAfter.participants?.[0].user_id).to.equal('participant-1'); @@ -472,14 +473,16 @@ describe('Threads 2.0', () => { describe('reload', () => { it('sizes getThread reply_limit to the loaded reply count, falling back to pageSize when unloaded', async () => { - const stub = sinon.stub(client, 'getThread').resolves(createTestThread()); + const stub = sinon.stub(client, 'getThread').resolves({ + thread: generateThreadResponse(channelResponse, parentMessageResponse), + }); // Unloaded (minimal) thread → falls back to pageSize. const minimalThread = createMinimalThread(); expect(minimalThread.messagePaginator.state.getLatestValue().items).to.be .undefined; await minimalThread.reload(); - expect(stub.firstCall.args[1]?.reply_limit).to.equal( + expect(stub.firstCall.args[0]?.reply_limit).to.equal( minimalThread.messagePaginator.pageSize, ); @@ -494,7 +497,7 @@ describe('Threads 2.0', () => { reply_count: 20, }); await loadedThread.reload(); - expect(stub.secondCall.args[1]?.reply_limit).to.equal(7); + expect(stub.secondCall.args[0]?.reply_limit).to.equal(7); expect(loadedThread.messagePaginator.pageSize).to.not.equal(7); }); }); @@ -507,7 +510,7 @@ describe('Threads 2.0', () => { { length: 5 }, (_, i) => generateMsg({ - created_at: new Date(createdAt + 1000 * i).toISOString(), + created_at: new Date(createdAt + 1000 * i), }) as MessageResponse, ); const thread = createTestThread({ latest_replies: messages }); @@ -532,12 +535,12 @@ describe('Threads 2.0', () => { describe('markAsRead', () => { let stubbedChannelMarkRead: sinon.SinonStub< - Parameters, - ReturnType + Parameters, + ReturnType >; beforeEach(() => { - stubbedChannelMarkRead = sinon.stub(channel, 'markAsReadRequest').resolves(); + stubbedChannelMarkRead = sinon.stub(channel, 'markRead').resolves(); }); it('does nothing if unread count of the current user is zero', async () => { @@ -593,7 +596,7 @@ describe('Threads 2.0', () => { expect(repliesOf(thread).map((reply) => reply.id)).to.include(older.id); // ...and the request was made against this thread's parent (the replies endpoint). expect(getRepliesStub.calledOnce).to.be.true; - expect(getRepliesStub.firstCall.args[0]).to.equal(thread.id); + expect(getRepliesStub.firstCall.args[0].parent_id).to.equal(thread.id); }); it('clears hasMoreTail once toTail() reaches the start of the reply list', async () => { @@ -670,7 +673,7 @@ describe('Threads 2.0', () => { thread.registerSubscriptions(); const reloadedReply = makeReply({ created_at: '2020-03-01T00:00:01.000Z' }); - const stubbedGetThread = sinon.stub(client, 'getThread').resolves( + const stubbedGetThread = sinon.stub(client, 'getThreadAndHydrate').resolves( createTestThread({ latest_replies: [initialReply, reloadedReply], reply_count: 2, @@ -736,14 +739,13 @@ describe('Threads 2.0', () => { const customKey1 = uuidv4(); const customKey2 = uuidv4(); - const thread = createTestThread({ [customKey1]: 1, [customKey2]: { key: 1 } }); + const thread = createTestThread({ + custom: { [customKey1]: 1, [customKey2]: { key: 1 } }, + }); thread.registerSubscriptions(); const stateBefore = thread.state.getLatestValue(); - expect(stateBefore.custom).to.not.have.keys( - Object.keys(THREAD_RESPONSE_RESERVED_KEYS), - ); expect(stateBefore.custom).to.have.keys([customKey1, customKey2]); expect(stateBefore.custom[customKey1]).to.equal(1); @@ -753,16 +755,13 @@ describe('Threads 2.0', () => { channelResponse, generateMsg({ id: parentMessageResponse.id }), { - [customKey1]: 2, + custom: { [customKey1]: 2 }, }, ), }); const stateAfter = thread.state.getLatestValue(); - expect(stateAfter.custom).to.not.have.keys( - Object.keys(THREAD_RESPONSE_RESERVED_KEYS), - ); expect(stateAfter.custom).to.not.have.property(customKey2); expect(stateAfter.custom[customKey1]).to.equal(2); }); @@ -798,6 +797,7 @@ describe('Threads 2.0', () => { client.dispatchEvent({ type: 'user.watching.stop', + cid: channelResponse.cid, channel: channelResponse, user: { id: TEST_USER_ID }, }); @@ -830,7 +830,7 @@ describe('Threads 2.0', () => { thread: generateThreadResponse( channelResponse, generateMsg(), - ) as ThreadResponse, + ) as ThreadStateResponse, }); const stateAfter = thread.state.getLatestValue(); @@ -861,7 +861,7 @@ describe('Threads 2.0', () => { thread: generateThreadResponse( channelResponse, generateMsg({ id: parentMessageResponse.id }), - ) as ThreadResponse, + ) as ThreadStateResponse, created_at: createdAt.toISOString(), }); @@ -1200,7 +1200,7 @@ describe('Threads 2.0', () => { (_, i) => generateMsg({ parent_id: parentMessageResponse.id, - created_at: new Date(createdAt + 1000 * i).toISOString(), + created_at: new Date(createdAt + 1000 * i), }) as MessageResponse, ); const thread = createTestThread({ latest_replies: messages }); @@ -1241,7 +1241,7 @@ describe('Threads 2.0', () => { message: { ...messageToDelete, type: 'deleted', - deleted_at: deletedAt.toISOString(), + deleted_at: deletedAt, }, }); @@ -1265,7 +1265,7 @@ describe('Threads 2.0', () => { const parentMessage = generateMsg({ id: thread.id, - deleted_at: new Date().toISOString(), + deleted_at: new Date(), type: 'deleted', }) as MessageResponse; @@ -1277,10 +1277,12 @@ describe('Threads 2.0', () => { const stateAfter = thread.state.getLatestValue(); expect(stateAfter.deletedAt).to.be.a('date'); - expect(stateAfter.deletedAt!.toISOString()).to.equal(parentMessage.deleted_at); + expect(stateAfter.deletedAt!.toISOString()).to.equal( + parentMessage.deleted_at!.toISOString(), + ); expect(stateAfter.parentMessage.deleted_at).to.be.a('date'); expect(stateAfter.parentMessage.deleted_at!.toISOString()).to.equal( - parentMessage.deleted_at, + parentMessage.deleted_at!.toISOString(), ); }); @@ -1756,9 +1758,12 @@ describe('Threads 2.0', () => { }); }); - it('reloads after connection drop', () => { + it('reloads after connection drop if the thread list was activated at least once', () => { const thread = createTestThread(); - threadManager.state.partialNext({ threads: [thread] }); + threadManager.state.partialNext({ + threads: [thread], + wasActivatedAtLeastOnce: true, + }); threadManager.registerSubscriptions(); const stub = sinon.stub(client, 'queryThreads').resolves({ threads: [], @@ -1783,6 +1788,33 @@ describe('Threads 2.0', () => { clock.restore(); }); + it('does not reload after connection drop if the thread list was never activated', () => { + const thread = createTestThread(); + threadManager.state.partialNext({ threads: [thread] }); + threadManager.registerSubscriptions(); + const stub = sinon.stub(client, 'queryThreadsAndHydrate').resolves({ + threads: [], + next: undefined, + }); + const clock = sinon.useFakeTimers(); + + client.dispatchEvent({ + type: 'connection.changed', + online: false, + }); + + const { lastConnectionDropAt } = threadManager.state.getLatestValue(); + expect(lastConnectionDropAt).to.be.a('date'); + + client.dispatchEvent({ type: 'connection.recovered' }); + clock.runAll(); + + expect(stub.called).to.be.false; + + threadManager.unregisterSubscriptions(); + clock.restore(); + }); + it('reloads list on activation', () => { const stub = sinon.stub(threadManager, 'reload').resolves(); threadManager.activate(); @@ -1832,7 +1864,7 @@ describe('Threads 2.0', () => { >; beforeEach(() => { - stubbedQueryThreads = sinon.stub(client, 'queryThreads').resolves({ + stubbedQueryThreads = sinon.stub(client, 'queryThreadsAndHydrate').resolves({ threads: [], next: undefined, }); @@ -1968,7 +2000,7 @@ describe('Threads 2.0', () => { const newThread = createTestThread({ thread_participants: [ { user_id: 'u1' }, - ] as ThreadResponse['thread_participants'], + ] as ThreadStateResponse['thread_participants'], }); threadManager.state.partialNext({ threads: [existingThread], @@ -2156,7 +2188,10 @@ describe('Threads 2.0', () => { }); it('applies sort parameters correctly', async () => { - const sort: ThreadSort = [{ created_at: -1 }, { last_message_at: 1 }]; + const sort: ThreadSort = [ + { field: 'created_at', direction: -1 }, + { field: 'last_message_at', direction: 1 }, + ]; await threadManager.queryThreads({ sort }); @@ -2176,7 +2211,7 @@ describe('Threads 2.0', () => { created_by_user_id: { $eq: 'user1' }, updated_at: { $gte: '2024-01-01T00:00:00Z' }, }; - const sort: ThreadSort = [{ last_message_at: -1 }]; + const sort: ThreadSort = [{ field: 'last_message_at', direction: -1 }]; await threadManager.queryThreads({ filter, sort }); diff --git a/test/unit/user_groups.test.ts b/test/unit/user_groups.test.ts deleted file mode 100644 index ccba4a00e7..0000000000 --- a/test/unit/user_groups.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { StreamChat } from '../../src/client'; -import type { - AddUserGroupMembersOptions, - AddUserGroupMembersResponse, - APIResponse, - CreateUserGroupOptions, - CreateUserGroupResponse, - DeleteUserGroupOptions, - GetUserGroupOptions, - GetUserGroupResponse, - QueryUserGroupsOptions, - QueryUserGroupsResponse, - RemoveUserGroupMembersOptions, - RemoveUserGroupMembersResponse, - SearchUserGroupsOptions, - SearchUserGroupsResponse, - UpdateUserGroupOptions, - UpdateUserGroupResponse, - UserGroupResponse, -} from '../../src/types'; - -const createUserGroup = ( - overrides: Partial = {}, -): UserGroupResponse => ({ - id: 'group-1', - name: 'Backend Support', - created_at: '2026-01-01T00:00:00.000000000Z', - updated_at: '2026-01-01T00:00:00.000000000Z', - ...overrides, -}); - -describe('User Groups', () => { - let client: StreamChat; - - beforeEach(() => { - client = new StreamChat('api_key'); - }); - - describe('queryUserGroups', () => { - it('should query user groups with cursor options', async () => { - const mockResponse: QueryUserGroupsResponse = { - duration: '0.01s', - user_groups: [createUserGroup()], - }; - const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); - const options: QueryUserGroupsOptions = { - limit: 10, - id_gt: 'group-0', - created_at_gt: '2025-12-31T23:59:59.000000000Z', - team_id: 'engineering', - }; - - const result = await client.queryUserGroups(options); - - expect(getSpy).toHaveBeenCalledWith(`${client.baseURL}/usergroups`, options); - expect(result.user_groups).toHaveLength(1); - expect(result.user_groups[0].id).toBe('group-1'); - }); - }); - - describe('createUserGroup', () => { - it('should create a user group', async () => { - const mockResponse: CreateUserGroupResponse = { - duration: '0.01s', - user_group: createUserGroup(), - }; - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - const options: CreateUserGroupOptions = { - id: 'backend-support', - name: 'Backend Support', - description: 'On-call backend engineers', - team_id: 'engineering', - member_ids: ['tom', 'sara'], - }; - - const result = await client.createUserGroup(options); - - expect(postSpy).toHaveBeenCalledWith(`${client.baseURL}/usergroups`, options); - expect(result.user_group.id).toBe('group-1'); - }); - }); - - describe('getUserGroup', () => { - it('should get a user group by id', async () => { - const mockResponse: GetUserGroupResponse = { - duration: '0.01s', - user_group: createUserGroup(), - }; - const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); - const options: GetUserGroupOptions = { - team_id: 'engineering', - }; - - const result = await client.getUserGroup('backend-support', options); - - expect(getSpy).toHaveBeenCalledWith( - `${client.baseURL}/usergroups/backend-support`, - options, - ); - expect(result.user_group.name).toBe('Backend Support'); - }); - }); - - describe('searchUserGroups', () => { - it('should search user groups with prefix cursor options', async () => { - const mockResponse: SearchUserGroupsResponse = { - duration: '0.01s', - user_groups: [createUserGroup()], - }; - const getSpy = vi.spyOn(client, 'get').mockResolvedValue(mockResponse); - const options: SearchUserGroupsOptions = { - query: 'backend', - limit: 5, - name_gt: 'Backend Ops', - id_gt: 'group-0', - team_id: 'engineering', - }; - - const result = await client.searchUserGroups(options); - - expect(getSpy).toHaveBeenCalledWith(`${client.baseURL}/usergroups/search`, options); - expect(result.user_groups).toHaveLength(1); - expect(result.user_groups[0].name).toBe('Backend Support'); - }); - }); - - describe('updateUserGroup', () => { - it('should update a user group', async () => { - const mockResponse: UpdateUserGroupResponse = { - duration: '0.01s', - user_group: createUserGroup({ description: 'Updated description' }), - }; - const putSpy = vi.spyOn(client, 'put').mockResolvedValue(mockResponse); - const options: UpdateUserGroupOptions = { - description: 'Updated description', - name: 'Backend Support', - team_id: 'engineering', - }; - - const result = await client.updateUserGroup('backend-support', options); - - expect(putSpy).toHaveBeenCalledWith( - `${client.baseURL}/usergroups/backend-support`, - options, - ); - expect(result.user_group.description).toBe('Updated description'); - }); - }); - - describe('deleteUserGroup', () => { - it('should delete a user group', async () => { - const mockResponse: APIResponse = { - duration: '0.01s', - }; - const deleteSpy = vi.spyOn(client, 'delete').mockResolvedValue(mockResponse); - const options: DeleteUserGroupOptions = { - team_id: 'engineering', - }; - - const result = await client.deleteUserGroup('backend-support', options); - - expect(deleteSpy).toHaveBeenCalledWith( - `${client.baseURL}/usergroups/backend-support`, - options, - ); - expect(result.duration).toBe('0.01s'); - }); - }); - - describe('addUserGroupMembers', () => { - it('should add members to a user group', async () => { - const mockResponse: AddUserGroupMembersResponse = { - duration: '0.01s', - user_group: createUserGroup(), - }; - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - const options: AddUserGroupMembersOptions = { - member_ids: ['tom', 'sara'], - as_admin: true, - team_id: 'engineering', - }; - - const result = await client.addUserGroupMembers('backend-support', options); - - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/usergroups/backend-support/members`, - options, - ); - expect(result.user_group.id).toBe('group-1'); - }); - }); - - describe('removeUserGroupMembers', () => { - it('should remove members from a user group', async () => { - const mockResponse: RemoveUserGroupMembersResponse = { - duration: '0.01s', - user_group: createUserGroup(), - }; - const postSpy = vi.spyOn(client, 'post').mockResolvedValue(mockResponse); - const options: RemoveUserGroupMembersOptions = { - member_ids: ['tom', 'sara'], - team_id: 'engineering', - }; - - const result = await client.removeUserGroupMembers('backend-support', options); - - expect(postSpy).toHaveBeenCalledWith( - `${client.baseURL}/usergroups/backend-support/members/delete`, - options, - ); - expect(result.user_group.id).toBe('group-1'); - }); - }); -}); diff --git a/test/unit/utils.test.js b/test/unit/utils.test.js index 52fb78056c..64abfed9f5 100644 --- a/test/unit/utils.test.js +++ b/test/unit/utils.test.js @@ -24,56 +24,6 @@ describe.skip('generateUUIDv4', () => { // }); }); -describe('test if sort is deterministic', () => { - it('test sort object', () => { - let sort = normalizeQuerySort({ - created_at: 1, - has_unread: -1, - }); - expect(sort).to.have.length(2); - expect(sort[0].field).to.be.equal('created_at'); - expect(sort[0].direction).to.be.equal(1); - expect(sort[1].field).to.be.equal('has_unread'); - expect(sort[1].direction).to.be.equal(-1); - sort = normalizeQuerySort({ - has_unread: -1, - created_at: 1, - }); - expect(sort[0].field).to.be.equal('has_unread'); - expect(sort[0].direction).to.be.equal(-1); - expect(sort[1].field).to.be.equal('created_at'); - expect(sort[1].direction).to.be.equal(1); - }); - it('test sort array', () => { - let sort = normalizeQuerySort([{ created_at: 1 }, { has_unread: -1 }]); - expect(sort).to.have.length(2); - expect(sort[0].field).to.be.equal('created_at'); - expect(sort[0].direction).to.be.equal(1); - expect(sort[1].field).to.be.equal('has_unread'); - expect(sort[1].direction).to.be.equal(-1); - sort = normalizeQuerySort([{ has_unread: -1 }, { created_at: 1 }]); - expect(sort[0].field).to.be.equal('has_unread'); - expect(sort[0].direction).to.be.equal(-1); - expect(sort[1].field).to.be.equal('created_at'); - expect(sort[1].direction).to.be.equal(1); - }); - it('test sort array with multi-field objects', () => { - let sort = normalizeQuerySort([ - { created_at: 1, has_unread: -1 }, - { last_active: 1, deleted_at: -1 }, - ]); - expect(sort).to.have.length(4); - expect(sort[0].field).to.be.equal('created_at'); - expect(sort[0].direction).to.be.equal(1); - expect(sort[1].field).to.be.equal('has_unread'); - expect(sort[1].direction).to.be.equal(-1); - expect(sort[2].field).to.be.equal('last_active'); - expect(sort[2].direction).to.be.equal(1); - expect(sort[3].field).to.be.equal('deleted_at'); - expect(sort[3].direction).to.be.equal(-1); - }); -}); - describe('axiosParamsSerializer', () => { const testCases = [ { @@ -130,7 +80,7 @@ describe('reaction groups fallback', () => { reaction_scores: scores, }); - expect(message.reaction_groups).to.deep.equal({ + expect(message.reaction_groups).toMatchObject({ love: { count: 1, sum_scores: 1, diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index a5f3b854d0..f0f5f84726 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -28,8 +28,9 @@ import { sleep, } from '../../src/utils'; -import type { ChannelFilters, ChannelSortBase, MessageResponse } from '../../src'; +import type { ChannelFilters, ChannelOwnCapability, ChannelSort } from '../../src'; import { StreamChat, Channel } from '../../src'; +import { chatLoggerSystem } from '../../src/logger'; describe('findIndexInSortedArray', () => { it('finds index in the middle of haystack (asc)', () => { @@ -224,10 +225,9 @@ describe('getAndWatchChannel', () => { ...Array.from({ length: 2 }, () => generateChannel()), generateChannel({ channel: { type: 'messaging' }, members: mockedMembers }), ]; - const mock = sandbox.mock(client); - mock - .expects('post') - .returns(Promise.resolve({ channels: mockedChannelsQueryResponse })); + sandbox + .stub(client, 'queryChannels') + .resolves({ channels: mockedChannelsQueryResponse }); }); afterEach(() => { @@ -235,14 +235,14 @@ describe('getAndWatchChannel', () => { }); it('should throw an error if neither channel nor type is provided', async () => { - await client.queryChannels({}); + 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.queryChannels({}); + await client.queryChannelsAndHydrate({}); await expect( getAndWatchChannel({ client, type: 'test-type', id: undefined, members: [] }), ).rejects.toThrow( @@ -251,7 +251,7 @@ describe('getAndWatchChannel', () => { }); it('should return an existing channel if provided', async () => { - const channels = await client.queryChannels({}); + const channels = await client.queryChannelsAndHydrate({}); const channel = channels[0]; const watchStub = sandbox.stub(channel, 'watch'); const result = await getAndWatchChannel({ @@ -266,7 +266,7 @@ describe('getAndWatchChannel', () => { }); it('should return the channel if only type and id are provided', async () => { - const channels = await client.queryChannels({}); + const channels = await client.queryChannelsAndHydrate({}); const channel = channels[0]; const { id, type } = channel; const watchStub = sandbox.stub(channel, 'watch'); @@ -286,7 +286,7 @@ describe('getAndWatchChannel', () => { }); it('should return the channel if only type and members are provided', async () => { - const channels = await client.queryChannels({}); + const channels = await client.queryChannelsAndHydrate({}); const channel = channels[2]; const { type } = channel; const members = Object.keys(channel.state.members); @@ -299,14 +299,17 @@ describe('getAndWatchChannel', () => { options: {}, }); expect(channelSpy.calledOnce).to.be.true; - // @ts-ignore - expect(channelSpy.calledWith(type, undefined, { members })).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.queryChannels({}); + const channels = await client.queryChannelsAndHydrate({}); const channel = channels[0]; const { id, type, cid } = channel; // @ts-ignore @@ -404,21 +407,17 @@ describe('Channel pinning and archiving utils', () => { }); it('should extract correct sort value from an array', () => { - const sort = [{ pinned_at: -1 }, { created_at: 1 }] as unknown as ChannelSortBase; + 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 extract correct sort value from an object', () => { - const sort = { pinned_at: 1 } as unknown as ChannelSortBase; - expect(extractSortValue({ atIndex: 0, targetKey: 'pinned_at', sort })).to.equal( - 1, - ); - }); - it('should return null if key does not match targetKey', () => { - const sort = { created_at: 1 } as unknown as ChannelSortBase; + const sort: ChannelSort = [{ field: 'created_at', direction: 1 }]; expect(extractSortValue({ atIndex: 0, targetKey: 'pinned_at', sort })).to.be.null; }); }); @@ -429,18 +428,21 @@ describe('Channel pinning and archiving utils', () => { }); it('should return false if pinned_at is not a number', () => { - const sort = [{ pinned_at: 'invalid' }]; + 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 = [{ created_at: 1 }, { pinned_at: 1 }] as unknown as ChannelSortBase; + 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 = [{ pinned_at: 1 }] as unknown as ChannelSortBase; - const sort2 = [{ pinned_at: -1 }] as unknown as ChannelSortBase; + 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; }); @@ -448,22 +450,17 @@ describe('Channel pinning and archiving utils', () => { describe('findPinnedAtSortOrder', () => { it('should return null if sort is undefined', () => { - expect(findPinnedAtSortOrder({ sort: null as unknown as ChannelSortBase })).to.be + expect(findPinnedAtSortOrder({ sort: null as unknown as ChannelSort })).to.be .null; }); it('should return null if pinned_at is not present', () => { - const sort = [{ created_at: 1 }] as unknown as ChannelSortBase; + const sort: ChannelSort = [{ field: 'created_at', direction: 1 }]; expect(findPinnedAtSortOrder({ sort })).to.be.null; }); - it('should return pinned_at if found in an object', () => { - const sort = { pinned_at: -1 } as unknown as ChannelSortBase; - expect(findPinnedAtSortOrder({ sort })).to.equal(-1); - }); - it('should return pinned_at if found in an array', () => { - const sort = [{ pinned_at: 1 }] as unknown as ChannelSortBase; + const sort: ChannelSort = [{ field: 'pinned_at', direction: 1 }]; expect(findPinnedAtSortOrder({ sort })).to.equal(1); }); }); @@ -571,7 +568,7 @@ describe('promoteChannel', () => { const result = promoteChannel({ channels, channelToMove: channels[0], - sort: {}, + sort: [], }); expect(result).to.deep.equal(channels); @@ -592,7 +589,7 @@ describe('promoteChannel', () => { const result = promoteChannel({ channels, channelToMove, - sort: [{ pinned_at: 1 }], + sort: [{ field: 'pinned_at', direction: 1 }], }); expect(result).to.deep.equal(channels); @@ -614,7 +611,7 @@ describe('promoteChannel', () => { const result = promoteChannel({ channels, channelToMove, - sort: {}, + sort: [], }); expect(result.map((c) => c.id)).to.deep.equal(['channel3', 'channel1', 'channel2']); @@ -636,7 +633,7 @@ describe('promoteChannel', () => { const result = promoteChannel({ channels, channelToMove, - sort: {}, + sort: [], channelToMoveIndexWithinChannels: 2, }); @@ -660,7 +657,7 @@ describe('promoteChannel', () => { const result = promoteChannel({ channels, channelToMove, - sort: {}, + sort: [], }); expect(result.map((c) => c.id)).to.deep.equal([ @@ -688,7 +685,7 @@ describe('promoteChannel', () => { const result = promoteChannel({ channels, channelToMove, - sort: {}, + sort: [], channelToMoveIndexWithinChannels: -1, }); @@ -723,7 +720,7 @@ describe('promoteChannel', () => { const result = promoteChannel({ channels, channelToMove, - sort: [{ pinned_at: -1 }], + sort: [{ field: 'pinned_at', direction: -1 }], }); expect(result.map((c) => c.id)).to.deep.equal([ @@ -757,7 +754,7 @@ describe('promoteChannel', () => { const result = promoteChannel({ channels, channelToMove, - sort: {}, + sort: [], }); expect(result.map((c) => c.id)).to.deep.equal([ @@ -956,15 +953,22 @@ describe('runDetached', () => { it('calls default onError when no onErrorCallback is provided', async () => { const error = new Error('oops'); const callback = Promise.reject(error); - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const sinkSpy = vi.fn(); + chatLoggerSystem.configureLoggers({ + default: { sink: sinkSpy, level: 'trace' }, + }); runDetached(callback, { context: 'MyContext' }); await new Promise((resolve) => setImmediate(resolve)); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('An error has occurred in context MyContext'), + expect(sinkSpy).toHaveBeenCalledWith( + 'error', + expect.stringContaining('An error occurred in context "MyContext"'), + expect.objectContaining({ error }), ); + + chatLoggerSystem.restoreDefaults(); }); it('does not fail if onSuccessCallback is missing', async () => { @@ -1036,10 +1040,9 @@ describe('sleep', () => { }); describe('channelHasReadEvents', () => { - const makeChannel = (own_capabilities?: string[]) => { + const makeChannel = (own_capabilities?: ChannelOwnCapability[]) => { const client = new StreamChat('apiKey'); client.user = { id: 'user' }; - client.userID = 'user'; const channel = client.channel('messaging', 'cap-id'); channel.data = { own_capabilities }; return channel; @@ -1068,11 +1071,10 @@ describe('channelTracksReadLocally', () => { own_capabilities, }: { isLocalUnreadCountEnabled?: boolean; - own_capabilities?: string[]; + own_capabilities?: ChannelOwnCapability[]; }) => { const client = new StreamChat('apiKey', { isLocalUnreadCountEnabled }); client.user = { id: 'user' }; - client.userID = 'user'; const channel = client.channel('messaging', 'cap-id'); channel.data = { own_capabilities }; return { client, channel }; diff --git a/test/unit/webhook-compression.test.ts b/test/unit/webhook-compression.test.ts deleted file mode 100644 index d6cfdbf742..0000000000 --- a/test/unit/webhook-compression.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import crypto from 'crypto'; -import zlib from 'zlib'; - -import { describe, it, expect, beforeEach } from 'vitest'; - -import { StreamChat } from '../../src/client'; -import { - decodeSnsPayload, - decodeSqsPayload, - gunzipPayload, - parseEvent, - parseSns, - parseSqs, - verifyAndParseWebhook, - verifySignature, - InvalidWebhookError, - InvalidWebhookErrorMessages, -} from '../../src/signing'; - -const JSON_BODY = '{"type":"message.new","message":{"text":"the quick brown fox"}}'; -const API_SECRET = 'tsec2'; - -const sign = (body: Buffer | string) => - crypto.createHmac('sha256', Buffer.from(API_SECRET, 'utf8')).update(body).digest('hex'); - -const gzip = (body: Buffer | string) => - zlib.gzipSync(Buffer.isBuffer(body) ? body : Buffer.from(body)); - -const base64 = (body: Buffer | string) => - (Buffer.isBuffer(body) ? body : Buffer.from(body)).toString('base64'); - -const snsEnvelope = (innerMessage: string) => - JSON.stringify({ - Type: 'Notification', - MessageId: '22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324', - TopicArn: 'arn:aws:sns:us-east-1:123456789012:stream-webhooks', - Message: innerMessage, - Timestamp: '2026-05-11T10:00:00.000Z', - SignatureVersion: '1', - MessageAttributes: { - 'X-Signature': { Type: 'String', Value: '' }, - }, - }); - -describe('Webhook verification + parsing', () => { - let client: StreamChat; - - beforeEach(() => { - client = new StreamChat('api_key', API_SECRET); - }); - - describe('verifyWebhook (legacy boolean helper, unchanged)', () => { - it('validates a plain JSON body with its HMAC signature', () => { - expect(client.verifyWebhook(JSON_BODY, sign(JSON_BODY))).toBe(true); - }); - - it('rejects when signature is wrong', () => { - expect(client.verifyWebhook(JSON_BODY, 'deadbeef')).toBe(false); - }); - }); - - describe('verifySignature', () => { - it('returns true for matching HMAC', () => { - expect(verifySignature(JSON_BODY, sign(JSON_BODY), API_SECRET)).toBe(true); - }); - - it('returns false for mismatched signature', () => { - expect(verifySignature(JSON_BODY, '0'.repeat(64), API_SECRET)).toBe(false); - }); - - it('returns false for wrong secret', () => { - const sig = crypto.createHmac('sha256', 'other').update(JSON_BODY).digest('hex'); - expect(verifySignature(JSON_BODY, sig, API_SECRET)).toBe(false); - }); - - it('rejects signatures computed over compressed bytes', () => { - const compressed = gzip(JSON_BODY); - expect(verifySignature(JSON_BODY, sign(compressed), API_SECRET)).toBe(false); - }); - }); - - describe('gunzipPayload', () => { - it('passes through plain bytes unchanged', () => { - const out = gunzipPayload(JSON_BODY); - expect(out.toString('utf8')).toBe(JSON_BODY); - }); - - it('passes through Buffer input unchanged', () => { - const out = gunzipPayload(Buffer.from(JSON_BODY)); - expect(out.toString('utf8')).toBe(JSON_BODY); - }); - - it('inflates gzip-magic bytes', () => { - const out = gunzipPayload(gzip(JSON_BODY)); - expect(out.toString('utf8')).toBe(JSON_BODY); - }); - - it('returns Buffer in all cases', () => { - expect(Buffer.isBuffer(gunzipPayload(JSON_BODY))).toBe(true); - expect(Buffer.isBuffer(gunzipPayload(gzip(JSON_BODY)))).toBe(true); - }); - - it('handles empty input', () => { - expect(gunzipPayload(Buffer.alloc(0)).length).toBe(0); - }); - - it('throws InvalidWebhookError on truncated gzip with magic', () => { - const bad = Buffer.concat([Buffer.from([0x1f, 0x8b]), Buffer.from([0, 0, 0])]); - expect(() => gunzipPayload(bad)).toThrow(InvalidWebhookError); - expect(() => gunzipPayload(bad)).toThrow(InvalidWebhookErrorMessages.gzipFailed); - }); - }); - - describe('decodeSqsPayload', () => { - it('decodes base64 only (no compression)', () => { - expect(decodeSqsPayload(base64(JSON_BODY)).toString('utf8')).toBe(JSON_BODY); - }); - - it('decodes base64 + gzip', () => { - expect(decodeSqsPayload(base64(gzip(JSON_BODY))).toString('utf8')).toBe(JSON_BODY); - }); - - it('throws InvalidWebhookError on malformed base64', () => { - expect(() => decodeSqsPayload('!!!not-base64!!!')).toThrow(InvalidWebhookError); - expect(() => decodeSqsPayload('!!!not-base64!!!')).toThrow( - InvalidWebhookErrorMessages.invalidBase64, - ); - }); - }); - - describe('decodeSnsPayload', () => { - it('treats a pre-extracted Message identically to decodeSqsPayload', () => { - const wrapped = base64(gzip(JSON_BODY)); - expect(decodeSnsPayload(wrapped).equals(decodeSqsPayload(wrapped))).toBe(true); - }); - - it('round-trips base64 + gzip (pre-extracted Message)', () => { - expect(decodeSnsPayload(base64(gzip(JSON_BODY))).toString('utf8')).toBe(JSON_BODY); - }); - - it('unwraps a full SNS HTTP notification envelope', () => { - const wrapped = base64(gzip(JSON_BODY)); - const envelope = snsEnvelope(wrapped); - expect(decodeSnsPayload(envelope).toString('utf8')).toBe(JSON_BODY); - }); - - it('handles whitespace before the envelope JSON', () => { - const wrapped = base64(gzip(JSON_BODY)); - const envelope = `\n ${snsEnvelope(wrapped)}`; - expect(decodeSnsPayload(envelope).toString('utf8')).toBe(JSON_BODY); - }); - }); - - describe('parseEvent', () => { - it('parses Buffer payload into a typed event', () => { - const ev = parseEvent(Buffer.from(JSON_BODY)); - expect(ev.type).toBe('message.new'); - expect(ev.message?.text).toBe('the quick brown fox'); - }); - - it('parses string payload', () => { - const ev = parseEvent(JSON_BODY); - expect(ev.type).toBe('message.new'); - }); - - it('still parses unknown event types at runtime', () => { - const ev = parseEvent('{"type":"a.future.event","custom":42}'); - expect(ev.type).toBe('a.future.event'); - }); - - it('throws InvalidWebhookError on malformed JSON', () => { - expect(() => parseEvent('not json')).toThrow(InvalidWebhookError); - expect(() => parseEvent('not json')).toThrow( - InvalidWebhookErrorMessages.invalidJson, - ); - }); - }); - - describe('verifyAndParseWebhook', () => { - it('parses a plain HTTP webhook with a valid signature', () => { - const ev = client.verifyAndParseWebhook(JSON_BODY, sign(JSON_BODY)); - expect(ev.type).toBe('message.new'); - expect(ev.message?.text).toBe('the quick brown fox'); - }); - - it('parses a gzip-compressed HTTP webhook', () => { - const ev = client.verifyAndParseWebhook(gzip(JSON_BODY), sign(JSON_BODY)); - expect(ev.type).toBe('message.new'); - }); - - it('throws InvalidWebhookError on signature mismatch', () => { - expect(() => client.verifyAndParseWebhook(JSON_BODY, 'deadbeef')).toThrow( - InvalidWebhookError, - ); - expect(() => client.verifyAndParseWebhook(JSON_BODY, 'deadbeef')).toThrow( - InvalidWebhookErrorMessages.signatureMismatch, - ); - }); - - it('rejects a gzip body when the signature was computed over compressed bytes', () => { - const compressed = gzip(JSON_BODY); - expect(() => client.verifyAndParseWebhook(compressed, sign(compressed))).toThrow( - InvalidWebhookError, - ); - }); - - it('throws InvalidWebhookError when the client has no API secret', () => { - const secretless = new StreamChat('api_key'); - expect(() => secretless.verifyAndParseWebhook(JSON_BODY, 'sig')).toThrow( - InvalidWebhookError, - ); - }); - - it('also works as a package-level function', () => { - const ev = verifyAndParseWebhook(JSON_BODY, sign(JSON_BODY), API_SECRET); - expect(ev.type).toBe('message.new'); - }); - }); - - describe('parseSqs', () => { - it('parses a base64-only SQS body', () => { - const ev = client.parseSqs(base64(JSON_BODY)); - expect(ev.type).toBe('message.new'); - }); - - it('parses a base64 + gzip SQS body', () => { - const wrapped = base64(gzip(JSON_BODY)); - const ev = client.parseSqs(wrapped); - expect(ev.type).toBe('message.new'); - }); - - it('also works as a package-level function', () => { - const wrapped = base64(gzip(JSON_BODY)); - const ev = parseSqs(wrapped); - expect(ev.type).toBe('message.new'); - }); - - it('surfaces malformed base64 as InvalidWebhookError', () => { - expect(() => client.parseSqs('!!!not-base64!!!')).toThrow(InvalidWebhookError); - }); - - it('does not require an API secret on the client', () => { - const secretless = new StreamChat('api_key'); - const wrapped = base64(gzip(JSON_BODY)); - expect(secretless.parseSqs(wrapped).type).toBe('message.new'); - }); - }); - - describe('parseSns', () => { - it('parses a pre-extracted base64 + gzip SNS message', () => { - const wrapped = base64(gzip(JSON_BODY)); - const ev = client.parseSns(wrapped); - expect(ev.type).toBe('message.new'); - }); - - it('produces the same event as parseSqs (pre-extracted Message)', () => { - const wrapped = base64(gzip(JSON_BODY)); - expect(client.parseSns(wrapped)).toEqual(client.parseSqs(wrapped)); - }); - - it('parses a full SNS HTTP notification envelope', () => { - const wrapped = base64(gzip(JSON_BODY)); - const envelope = snsEnvelope(wrapped); - const ev = client.parseSns(envelope); - expect(ev.type).toBe('message.new'); - }); - - it('also works as a package-level function', () => { - const wrapped = base64(gzip(JSON_BODY)); - const ev = parseSns(wrapped); - expect(ev.type).toBe('message.new'); - }); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index 74b01d4861..46b3ff41e8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,7 +23,7 @@ "outDir": "./dist/types", "rootDir": "./src", - "lib": ["ES2020", "DOM", "ES2022.Error"], + "lib": ["ES2022", "DOM", "ES2022.Error"], "moduleResolution": "bundler", "module": "Preserve", "target": "ES2020" diff --git a/v9-to-v10-migration-guide-client-construction.md b/v9-to-v10-migration-guide-client-construction.md new file mode 100644 index 0000000000..a27d447d5e --- /dev/null +++ b/v9-to-v10-migration-guide-client-construction.md @@ -0,0 +1,149 @@ +# v9 → v10 Migration Guide — Client Construction + +> Scope: this guide covers **only** changes to `StreamChat` construction (`new StreamChat(...)` and `StreamChat.getInstance(...)`) and the shape of `StreamChatOptions`. Other v10 changes will be documented separately. + +## TL;DR + +- `secret` is gone. The constructor and `getInstance` no longer accept it. **v10 does not support server-side use.** +- The constructor and `getInstance` are now a single signature: `(key, options?)`. The `(key, secret, options?)` overload has been removed. +- `StreamChatOptions` no longer extends `AxiosRequestConfig`. Axios-level fields (`timeout`, `httpsAgent`, `withCredentials`, headers, etc.) must now be passed via the dedicated `axiosRequestConfig` property. +- The same axios defaults (`timeout: 3000`, `withCredentials: false`, keep-alive `httpsAgent` in node) are still applied, but in v10 they can be overridden through `axiosRequestConfig`. In v9 they could not be — `axiosRequestConfig` only affected per-request calls. +- `paramsSerializer` cannot be overridden. Any `paramsSerializer` passed in `axiosRequestConfig` is ignored; the client always uses its internal `axiosParamsSerializer`. + +## Server-side users — stop here + +v10 removes all server-side functionality (secret-based auth, server-side JWT signing, etc.). If your integration uses `stream-chat` with a `secret` on a backend, **do not migrate to v10**. Switch to the dedicated server SDK: + +- https://github.com/GetStream/stream-node + +For client-side / React Native / browser apps that previously called `new StreamChat(key)` without a secret, keep reading. + +## Constructor signature + +### Removed: the `secret` parameter and its overload + +```ts +// v9 — all of these worked +new StreamChat(API_KEY); +new StreamChat(API_KEY, 'a-secret'); +new StreamChat(API_KEY, { timeout: 5000 }); +new StreamChat(API_KEY, 'a-secret', { timeout: 5000 }); +new StreamChat(API_KEY, undefined, { timeout: 5000 }); +new StreamChat(API_KEY, ''); // empty string was treated as "no secret" +``` + +```ts +// v10 — only this shape is valid +new StreamChat(API_KEY); +new StreamChat(API_KEY, options); +``` + +Same change applies to `StreamChat.getInstance`: + +```ts +// v9 +StreamChat.getInstance(API_KEY, 'a-secret', { timeout: 5000 }); + +// v10 +StreamChat.getInstance(API_KEY, { axiosRequestConfig: { timeout: 5000 } }); +``` + +### Removed: `client.secret` + +The `secret` field on the client instance no longer exists. The internal `_isUsingServerAuth()` method has also been removed; any guard that branched on it should be deleted (the branch was always the server-side path). + +## `StreamChatOptions` no longer extends `AxiosRequestConfig` + +In v9, `StreamChatOptions = AxiosRequestConfig & { ... }`. That meant you could pass axios fields directly at the top level: + +```ts +// v9 +new StreamChat(API_KEY, { + timeout: 5000, + withCredentials: true, + httpsAgent: customAgent, + headers: { 'Cache-Control': 'no-cache' }, +}); +``` + +In v10, axios fields must go through the dedicated `axiosRequestConfig` property: + +```ts +// v10 +new StreamChat(API_KEY, { + axiosRequestConfig: { + timeout: 5000, + withCredentials: true, + httpsAgent: customAgent, + headers: { 'Cache-Control': 'no-cache' }, + }, +}); +``` + +The full mapping for top-level axios fields previously accepted in v9 → `axiosRequestConfig.` in v10. + +### `axiosRequestConfig` now actually configures the axios instance + +In v9, `axiosRequestConfig` was stored on `client.options` but **not** applied to `axios.create` during construction — it was only spread into per-request calls. As a result, defaults like `timeout: 3000` could not be overridden through it. + +In v10, `axiosRequestConfig` is spread into the `axios.create` call during construction, so it can override the baked-in defaults: + +```ts +const client = new StreamChat(API_KEY, { + axiosRequestConfig: { timeout: 9999, withCredentials: true }, +}); +client.axiosInstance.defaults.timeout; // 9999 +client.axiosInstance.defaults.withCredentials; // true +``` + +The defaults (`timeout: 3000`, `withCredentials: false`, keep-alive `https.Agent` in node) still apply when `axiosRequestConfig` does not set them. + +### `httpsAgent` location moved + +```ts +// v9 — top-level +new StreamChat(API_KEY, { browser: false, httpsAgent: customAgent }); + +// v10 — under axiosRequestConfig +new StreamChat(API_KEY, { + browser: false, + axiosRequestConfig: { httpsAgent: customAgent }, +}); +``` + +In both versions, node mode (`browser: false` or auto-detected) auto-creates a keep-alive `https.Agent` when none is supplied. Browser mode does not. + +### `paramsSerializer` is fixed + +Any `paramsSerializer` passed via `axiosRequestConfig` is silently dropped. The client always uses its internal `axiosParamsSerializer`: + +```ts +const client = new StreamChat(API_KEY, { + axiosRequestConfig: { paramsSerializer: () => 'overridden' }, +}); +client.axiosInstance.defaults.paramsSerializer; // === axiosParamsSerializer (NOT the override) +``` + +If you relied on a custom serializer, file an issue — there is no supported way to change this in v10. + +## Unchanged behavior worth confirming + +These are intentionally listed so agents don't "fix" them during migration: + +- `new StreamChat(key)` still works with no options. +- `StreamChat.getInstance(key)` still returns the same cached instance on repeated calls and ignores the `key`/`options` of subsequent calls. +- All non-axios options are unchanged: `allowServerSideConnect`, `baseURL`, `browser`, `device`, `disableCache`, `enableInsights`, `enableWSFallback`, `notifications`, `persistUserOnConnectionFailure`, `recoverStateOnReconnect`, `warmUp`, `wsConnection`, `wsUrlParams`. +- `STREAM_LOCAL_TEST_RUN` / `STREAM_LOCAL_TEST_HOST` env-var overrides on `baseURL` still work the same way. +- `browser` auto-detection (`typeof window !== 'undefined'`) and the `browser: true | false` override still work the same way. +- The subsystem managers constructed on the client (`state`, `notifications`, `uploadManager`, `moderation`, `tokenManager`, `threads`, `polls`, `reminders`, `messageDeliveryReporter`, `messageComposerCache`, `insightMetrics`) are identical in v10. + +## Mechanical migration recipe + +1. If the call site passes a secret, **stop** — migrate that code to `stream-node` instead. +2. Remove any `secret` argument and any `undefined`/`''` placeholders in the second slot: + - `new StreamChat(key, undefined, opts)` → `new StreamChat(key, opts)` + - `new StreamChat(key, '', opts)` → `new StreamChat(key, opts)` + - `StreamChat.getInstance(key, undefined, opts)` → `StreamChat.getInstance(key, opts)` +3. For each option key in the `options` object, check whether it's an axios field (`timeout`, `withCredentials`, `httpsAgent`, `headers`, `adapter`, `proxy`, `responseType`, etc. — anything from `AxiosRequestConfig`). If yes, move it under a new `axiosRequestConfig` sub-object. +4. Remove any reads of `client.secret` and any branches gated on `client._isUsingServerAuth()`. +5. Drop any custom `paramsSerializer` you were passing — it has no effect in v10. diff --git a/v9-to-v10-migration-guide-logging.md b/v9-to-v10-migration-guide-logging.md new file mode 100644 index 0000000000..ae58c2d773 --- /dev/null +++ b/v9-to-v10-migration-guide-logging.md @@ -0,0 +1,239 @@ +# v9 → v10 Migration Guide — Logging + +> Scope: this guide covers **only** the logging system replacement — the removal of the v9 `logger` option / `client.logger()` / `_log()` surface, and the introduction of the scoped `@stream-io/logger`-based system exposed as `chatLoggerSystem`. Construction, method-signature, and sort changes are covered in separate guides. + +## TL;DR + +- The `logger` option on `StreamChatOptions` is **removed**. `new StreamChat(key, { logger: fn })` no longer type-checks — the field is silently dropped at runtime. +- The `client.logger` field is **removed**. Any `client.logger('info', 'msg', extra)` call no longer compiles. +- The v9 `Logger` and `LogLevel` types (from `stream-chat`) are **removed**. Import their replacements from `stream-chat`'s new logger surface (re-exported from `./logger`): `LogLevel`, `Sink`, `ConfigureLoggersOptions`, `LogLevelEnum`, `ScopedLogger`, `ChatLoggerScope`, `chatLoggerSystem`. +- Log-level enum expanded from **3** values (`'info' | 'warn' | 'error'`) to **5** (`'trace' | 'debug' | 'info' | 'warn' | 'error'`). +- Configure logging by calling `chatLoggerSystem.configureLoggers({...})` **before** constructing the client (there is no constructor option for it — `logLevel` / `logOptions` fields do not exist on `StreamChatOptions`). +- Internal `_log()` methods (notably on `StableWSConnection`) are gone. If you subclassed or spied on them, switch to the scoped loggers. + +## What ships in v10 + +```ts +// src/logger.ts — re-exported from the package root +import { + chatLoggerSystem, // LoggerSystem + LogLevelEnum, // numeric enum: trace=0 debug=1 info=2 warn=3 error=4 + type ChatLoggerScope, // union of the 15 built-in scopes (see table below) + type ConfigureLoggersOptions, + type LogLevel, // 'trace' | 'debug' | 'info' | 'warn' | 'error' + type Sink, // (logLevel, message, ...data) => void + type ScopedLogger, // = Logger +} from 'stream-chat'; +``` + +`chatLoggerSystem` is a **module-level singleton**. It is created once when `./logger` is imported and is shared across every `StreamChat` instance (and across every SDK internal caller). Configuration is process-wide, not per-client. + +The default sink writes to `console.{trace,debug,info,warn,error}` (with a React-Native-safe fallback for `warn`/`error`). Default level is `'info'`. + +### Built-in scopes + +Every internal module attaches to one of these scopes via `chatLoggerSystem.getLogger('')`: + +| Scope | Emitted by | +| --------------------- | ----------------------------------------------------------------------------------------------------- | +| `api-client` | `src/api-client.ts` — HTTP request/response tracing | +| `channel` | `src/channel.ts` | +| `channel-manager` | `src/channel_manager.ts` | +| `client` | `src/client.ts` — connection lifecycle, event dispatch | +| `connection` | `src/connection.ts` — primary WS transport | +| `connection-fallback` | `src/connection_fallback.ts` — long-poll transport | +| `message-composer` | `src/messageComposer/messageComposer.ts` | +| `offline-db` | `src/offline-support/*` **and** offline-DB paths in `client.ts` / `channel.ts` / `messageComposer.ts` | +| `state-store` | reserved — declared in the scope union, not yet emitted | +| `text-composer` | `src/messageComposer/middleware/textComposer/*` | +| `thread` | `src/thread.ts` | +| `thread-manager` | `src/thread_manager.ts` | +| `token-manager` | `src/token_manager.ts` | +| `upload-manager` | `src/uploadManager.ts` | +| `utils` | `src/utils.ts` — `logChatPromiseExecution`, `isOnline`, `messageSetPagination`, `runDetached` | + +Unknown scope names fall through to `'default'`. The `ChatLoggerScope` union narrows autocomplete but is not enforced at runtime. + +## Removed surface + +| v9 | v10 | +| ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `StreamChatOptions.logger` | **REMOVED** — no field on `StreamChatOptions` | +| `client.logger` (instance field) | **REMOVED** | +| `type Logger = (level, message, extra?) => void` | **REMOVED** — no direct replacement (write a `Sink` instead) | +| `type LogLevel = 'info' \| 'error' \| 'warn'` | **REPLACED** — same name, now `'trace' \| 'debug' \| 'info' \| 'warn' \| 'error'` | +| `isFunction(inputOptions.logger)` guard in constructor | gone | +| `StableWSConnection._log(msg, extra?, level?)` | **REMOVED** — use `chatLoggerSystem.getLogger('connection')` | +| `extraData.tags: string[]` convention (`{ tags: ['channel', 'offlineDb'], error }`) | replaced by scope + `.withExtraTags(...)` (see below) | +| Structured extra as second positional arg (`(level, msg, { tags, error, event })`) | passed as rest args after the message (`.error('msg', { error })`) | + +## The v9 → v10 shape shift + +### v9 — a single `logger` function receives everything + +```ts +// v9 +type LogLevel = 'info' | 'error' | 'warn'; +type Logger = ( + logLevel: LogLevel, + message: string, + extraData?: Record, +) => void; + +const client = new StreamChat('api_key', { + logger: (level, message, extraData) => { + // extraData contains a `tags: string[]` array plus arbitrary context + console.log(level, message, extraData); + }, +}); + +client.logger('info', 'anything I want to log', { + tags: ['channel', 'offlineDb'], + error: someError, +}); +``` + +The SDK routed _all_ internal log calls into this single function; disambiguation was done via the `extraData.tags` array (values like `'api'`, `'api_request'`, `'api_response'`, `'client'`, `'channel'`, `'connection'`, `'event'`). + +### v10 — scoped loggers with per-scope sinks and levels + +```ts +// v10 — configure the shared logger system before creating the client +import { chatLoggerSystem, type Sink } from 'stream-chat'; + +const sink: Sink = (logLevel, message, ...rest) => { + // `message` is prefixed with `[](): ` by the system + // `rest` is whatever the SDK passes after the message (e.g. `{ error }`) + myLogger[logLevel](message, ...rest); +}; + +chatLoggerSystem.configureLoggers({ + default: { level: 'info', sink }, +}); + +const client = new StreamChat('api_key'); +``` + +SDK-internal call sites look like this (do not call these yourself unless you're extending the SDK): + +```ts +const logger = chatLoggerSystem.getLogger('connection'); +logger.withExtraTags('_reconnect').info('Initiating a reconnect.'); +// → sink receives: 'warn' | 'info' | ... , '[connection](_reconnect): Initiating a reconnect.', ...rest +``` + +## Configuring logging in v10 + +There is **no constructor option** for logging in v10 (an earlier commit briefly added `logLevel` / `logOptions` to `StreamChatOptions`; both were dropped before release — do not rely on them). Configure `chatLoggerSystem` directly. Because it is a module-level singleton, configuration applies to every `StreamChat` you construct afterwards. + +### Route all output to your own logger + +```ts +import { chatLoggerSystem, type Sink } from 'stream-chat'; + +const sink: Sink = (level, message, ...rest) => myLogger[level](message, ...rest); + +chatLoggerSystem.configureLoggers({ + default: { level: 'info', sink }, +}); +``` + +### Raise the level globally (silence everything below `warn`) + +```ts +chatLoggerSystem.configureLoggers({ default: { level: 'warn' } }); +``` + +### Debug one subsystem without touching the others + +```ts +chatLoggerSystem.configureLoggers({ + connection: { level: 'trace' }, + 'connection-fallback': { level: 'trace' }, +}); +// leaves `default` and every other scope at 'info' +``` + +### Reset one scope back to defaults, or reset everything + +```ts +chatLoggerSystem.configureLoggers({ + connection: { level: null, sink: null }, // remove per-scope overrides +}); + +chatLoggerSystem.restoreDefaults(); // wipe all overrides, restore default sink + 'info' +``` + +### `configureLoggers` semantics you must know + +- Passing `{ level: 'warn' }` sets the level. Passing `{ level: null }` **deletes** the override (falls back to `'default'`). Same for `sink`. +- The `default` scope can be overridden but **cannot be deleted** — `{ default: { level: null } }` is a no-op. +- Undefined values are ignored — only explicit `null` clears an override. +- Configuration is not additive across calls to `configureLoggers` for the _same_ key — the last call wins per (scope, field). Untouched scopes keep their prior override. +- `chatLoggerSystem` is process-wide. Two `StreamChat` instances in the same process share it; there is no per-instance override. + +### Sink signature + +```ts +type Sink = (logLevel: LogLevel, message: string, ...data: any[]) => void; +``` + +- `logLevel` is the string form (`'trace' | 'debug' | 'info' | 'warn' | 'error'`), not the enum. +- `message` arrives already prefixed by the system with `[](): ` (tags parenthetical is omitted when empty). +- `...data` is whatever the SDK passed after the message (e.g. `{ error }`, `{ event }`, `{ wsURL }`). +- Use `LogLevelEnum` to compare severity numerically: `LogLevelEnum[record.logLevel] >= LogLevelEnum.warn`. + +## Mechanical migration recipe + +For every call site: + +1. **Delete the `logger` option** from `new StreamChat(key, options)` and `StreamChat.getInstance(key, options)`. If you were forwarding to your own logger, replace it with a top-level `chatLoggerSystem.configureLoggers({ default: { sink: yourSink } })` call (once, at bootstrap). +2. **Rewrite `client.logger(level, message, extra)` calls.** If it was your own instrumentation code, drop it — the SDK's internal call sites already log through `chatLoggerSystem`. If you must emit into the same stream, use `chatLoggerSystem.getLogger('')[level](message, extra)`. +3. **Remove any reads of `client.logger`.** Tests and integrations that asserted on `client.logger` being a function must be deleted or rewritten against `chatLoggerSystem`. See the `client.construction-old.test.ts` block in this branch for a concrete v9 example that must be dropped. +4. **Delete the `logger` field from `StreamChatOptions` type-satisfaction sites.** If you had `const opts: StreamChatOptions = { logger, timeout: 5000 }`, drop `logger`. +5. **Replace `import type { Logger } from 'stream-chat'`.** Write a `Sink` instead: + + ```ts + // v9 + import type { Logger } from 'stream-chat'; + const myLogger: Logger = (level, msg, extra) => { … }; + + // v10 + import { type Sink } from 'stream-chat'; + const mySink: Sink = (level, msg, ...rest) => { … }; + ``` + +6. **Update `LogLevel` consumers.** If your code was `switch(level) { case 'info': … case 'warn': … case 'error': … }`, add `case 'trace':` and `case 'debug':` (or let them fall through to a default branch). Any exhaustive union check on `LogLevel` will now fail without those cases. +7. **Drop the `tags` array convention.** In v9, extras looked like `{ tags: ['channel', 'offlineDb'], error }`. In v10, the scope already carries the primary category (`offline-db`, `channel`, …) and `.withExtraTags('sendMessage', channelCid)` adds call-site tags — you don't reconstruct them by hand. When translating an internal callsite, pick the scope that matches the module and use `.withExtraTags(...)` for the finer-grained context. +8. **Remove `_log()` calls in any code that subclassed SDK internals.** Every `_log(msg, extra?, level?)` in v9 mapped to `this.client.logger(level ?? 'info', ':' + msg, { tags: […], ...extra })`. Replace with the appropriate scoped logger. Example (from `StableWSConnection`): + + ```ts + // v9 + this._log(`connect() - established with healthcheck ${hc}`); + + // v10 + const logger = chatLoggerSystem.getLogger('connection'); + logger + .withExtraTags('connect') + .info(`Established a WebSocket connection. Health check: ${hc}.`); + ``` + +## Type-import mapping cheat sheet + +| v9 import from `stream-chat` | v10 replacement | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `Logger` | REMOVED — write a `Sink` instead | +| `LogLevel` | still exported (same name), but the union is now 5 values, not 3 | +| — | `Sink` — new. Sink function signature. | +| — | `LogLevelEnum` — new. Numeric enum, useful for severity comparisons in sinks. | +| — | `ConfigureLoggersOptions` — new. Argument shape for `chatLoggerSystem.configureLoggers`. | +| — | `ChatLoggerScope` — new. String union of the 15 built-in scopes. | +| — | `ScopedLogger` — new. Alias for `Logger` (the return type of `chatLoggerSystem.getLogger(scope)`). | +| — | `chatLoggerSystem` — new. The shared `LoggerSystem` singleton. | + +## Things that did NOT change + +- The categories of information the SDK logs (WS lifecycle, event dispatch, API request/response, offline-DB failures, upload errors, composer state) are unchanged in v10 — only the transport and the shape of the record. +- No log call is now silent-by-default that was noisy in v9 within the shared 3-level range; several v9 `.info` calls became `.debug` (e.g. `client.on/off` listener attach/detach, `openConnection` already-connecting guard). If you rely on those, lower the level to `'debug'` for that scope. +- The default sink still writes to the `console`. No behavior change for callers that never installed a custom `logger` in v9 — they now see richer output (with the `[scope](tags):` prefix), but the surface is still `console`. +- Log messages are not part of the semver contract. Do not pattern-match on message strings in production; use `logLevel` + `scope` (via the `[scope]` prefix) instead. diff --git a/v9-to-v10-migration-guide-methods.md b/v9-to-v10-migration-guide-methods.md new file mode 100644 index 0000000000..7cfb1d934c --- /dev/null +++ b/v9-to-v10-migration-guide-methods.md @@ -0,0 +1,981 @@ +# v9 → v10 Migration Guide — Method Signatures + +> Scope: this guide covers **method signature changes** on `StreamChat`, `Channel`, `ChannelState`, `Moderation`, and `StableWSConnection`. Construction changes are in `v9-to-v10-migration-guide-client-construction.md`. Server-side surfaces are gone in v10 — server-side callers should switch to `@stream-io/node-sdk` (https://github.com/GetStream/stream-node) and ignore this guide. +> +> This document is written for AI agents doing mechanical rewrites. Each entry has the exact v9 signature and the exact v10 replacement. Removed methods are labeled **REMOVED** with the recommended replacement (or "no replacement" when the entire feature is dropped). +> +> **Sort arguments:** every `sort` argument shown below has also changed shape — the v9 `{ field_name: direction }` object form is gone, replaced by `SortParamRequest[]` (`[{ field, direction }]`). This guide shows sort values in the new shape but does **not** re-explain the sort migration itself. For the sort shape change, the full field→field/direction rewrite recipe, and the removed `Sort` / `*SortBase` / `normalizeQuerySort` imports, see `v9-to-v10-migration-guide-sort.md` — agents rewriting call sites that pass a `sort` must consult that guide. + +## Global renames applied everywhere + +Before applying any per-method entry below, apply these repo-wide renames — they are consistent across every class: + +| v9 | v10 | Notes | +| ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `userID` (param and field) | `userId` | Field: `client.userID` still readable via a deprecated getter; assignment (`client.userID = …`) no longer compiles. | +| `clientID` (param and field) | `clientId` | Field: `client.clientID` kept as a deprecated getter+setter. | +| `messageID`, `targetID`, `targetMessageID`, `targetUserID`, `flaggedUserID`, `entityCreatorID`, `wsID`, `channelID`, `channelId` parameter | `messageId`, `targetId`, `targetMessageId`, `targetUserId`, `flaggedUserId`, `entityCreatorId`, `wsId`, `channelId` | Named-parameter rename only; call sites using positional args are unaffected. | +| `parent_id` parameter on `keystroke` / `stopTyping` | `parentId` | Positional; call sites unaffected. | +| `Event` (type) | `Event` (still exported; shape changed) | `Event` is now `WSEvent \| LocalEvent \| keyof CustomEventTypes`. The name is unchanged; the wire shape is what's different. Internal handlers that took an untyped `Event` are the same. `EventPayload<'…'>` narrows to a specific event type. | +| `EventTypes` (type import) | `EventType \| string` (via generic) | The public alias renamed to singular `EventType = Event['type'] \| 'all'`. Callers annotating handlers as `EventHandler` are safe; callers importing `EventTypes` need to switch to `EventType`. `CustomEventTypes` module augmentation is unchanged — augment it to add custom event-type keys. | +| `Logger` option / `client.logger()` | `chatLoggerSystem` from `./logger` | See "Logging" note at the end of the guide. | + +The `secret` parameter, `client.secret`, `client._isUsingServerAuth()`, and all server-only methods are gone. Where a v9 method took a `user_id?` / `userID?` / `currentUserID?` override, that argument has been dropped in v10 (the connected user is always used). + +--- + +## StreamChat + +### Removed — no replacement in this SDK (server-side, use `@stream-io/node-sdk`) + +The following `StreamChat` methods no longer exist. All were server-side or admin-only. Rewrites should either delete the call site or move it to the server SDK: + +`updateAppSettings`, `revokeUserToken`, `revokeUsersToken`, `testPushSettings`, `testSQSSettings`, `testSNSSettings`, `createToken`, `devToken`, user-groups mutations (`createUserGroup` / `getUserGroup` / `searchUserGroups` / `updateUserGroup` / `deleteUserGroup` / `addUserGroupMembers` / `removeUserGroupMembers`) — the read path is renamed, see below, `upsertPushProvider`, `deletePushProvider`, `listPushProviders`, `setPushPreferences`, `_queryFlags`, `_queryFlagReports`, `_reviewFlagReport`, `queryFutureChannelBans`-write paths, `getHookEvents`, `partialUpdateUser`, `deleteUser`, `restoreUsers`, `reactivateUser`, `reactivateUsers`, `deactivateUser`, `deactivateUsers`, `exportUser`, `getSharedLocations`, `translate`, `translateMessage`, `updateFlags`, `queryCampaigns`, `_createImportURL`, `_createImport`, `_getImport`, `_listImports`, `commitMessage`, `queryTeamUsageStats`, `updateLocation`, `updateChannelsBatch`, `deletePredefinedFilter`, `setRetentionPolicy`, `deleteRetentionPolicy`, `getRetentionPolicy`, `getRetentionPolicyRuns`, hand-rolled reminder client methods (`createReminder`/`updateReminder`/`deleteReminder` — see note under `Reminder` handling; the inherited `queryReminders` from `ChatApi` remains but with the generated request shape, not the v9 `QueryRemindersOptions`), `createCommand`/`getCommand`/`updateCommand`/`deleteCommand`/`listCommands`/`createChannelType`/`getChannelType`/`updateChannelType`/`deleteChannelType`/`listChannelTypes`/`exportChannel`/`exportChannels`/`exportUsers`/`getExportChannelStatus`/`getTask`/`enrichURL`/`sendUserCustomEvent`, `deleteChannels`, `deleteUsers`, `createRole`/`listRoles`/`deleteRole` (only `searchRoles` remains, inherited), `getPermission`/`createPermission`/`updatePermission`/`deletePermission`/`listPermissions`, `getBlockList` (only `listBlockLists`/`createBlockList`/`updateBlockList`/`deleteBlockList` remain, inherited), `verifyWebhook`, `verifyAndParseWebhook`, `parseSqs`, `parseSns` (moved — see below), `campaign`, `segment`, `channelBatchUpdater`, `validateServerSideAuth`, `createSegment`, `createUserSegment`, `createChannelSegment`, `getSegment`, `updateSegment`, `addSegmentTargets`, `querySegmentTargets`, `removeSegmentTargets`, `querySegments`, `deleteSegment`, `segmentTargetExists`, `createCampaign`, `getCampaign`, `startCampaign`, `updateCampaign`, `deleteCampaign`, `stopCampaign`, `_normalizeDate`. Note: `queryDrafts`, `queryPolls`, `queryPollVotes`, `queryMessageFlags`, and `markChannelsDelivered` — all of which were hand-rolled in v9 — now come from `ChatApi` inheritance with generated request shapes; they still exist on `client`. + +### Renamed / signature-changed + +#### `client.queryChannels` + +```ts +// v9 — three overloads with positional filter/sort/options +client.queryChannels(filter, sort?, options?, stateOptions?): Promise; +client.queryChannels(filter, options?, stateOptions?): Promise; + +// v10 — split into two methods +client.queryChannels(request?: QueryChannelsRequest): Promise; // raw response (inherited from ChatApi) +client.queryChannelsAndHydrate( + options?: QueryChannelsRequest, + stateOptions?: ChannelStateOptions, +): Promise; // v9 behavior lives here +client.queryChannelsAndHydrate( + options, + stateOptions: ChannelStateOptions & { withResponse: true }, +): Promise; // returns Channels + raw response +``` + +Rewrite: + +```ts +// v9 +const channels = await client.queryChannels( + { type: 'messaging' }, + { last_message_at: -1 }, + { limit: 20 }, +); + +// v10 +const channels = await client.queryChannelsAndHydrate({ + filter_conditions: { type: 'messaging' }, + sort: [{ field: 'last_message_at', direction: -1 }], + limit: 20, +}); +``` + +Sort now uses `Gen_SortParamRequest[]` (`{ field, direction }`), not the v9 record form. See `v9-to-v10-migration-guide-sort.md` for the full sort migration. + +#### `client.queryReactions` + +```ts +// v9 +client.queryReactions(messageID, filter, sort?, options?); + +// v10 — inherited from ChatApi +client.queryReactions(request: QueryReactionsRequest); // raw response +client.queryReactionsAndHydrate(request: QueryReactionsRequest); // wraps offline-db merge +``` + +Use `queryReactionsAndHydrate` where v9 code depended on the offline-db reaction reconciliation; otherwise use inherited `queryReactions`. + +#### `client.queryUsers` + +```ts +// v9 +client.queryUsers(filterConditions, sort?, options?); + +// v10 — inherited/overridden +client.queryUsers(request?: { payload?: Gen_QueryUsersPayload }); +// payload: { filter_conditions, sort, limit, offset, presence, ... } +``` + +#### `client.search` + +```ts +// v9 +client.search(filterConditions, query, options?); + +// v10 +client.search(request?: { payload?: SearchPayload }); +// payload combines filter_conditions, message_filter_conditions, query, sort, limit, next, ... +``` + +#### `client.queryThreads` / `client.getThread` + +```ts +// v9 +client.queryThreads(options?); // returned hydrated Thread[] +client.getThread(messageId, options?); // returned hydrated Thread + +// v10 +client.queryThreads(request?); // inherited, raw QueryThreadsResponse +client.queryThreadsAndHydrate(options?); // v9 behavior +client.getThread(request: { message_id }); // inherited, raw +client.getThreadAndHydrate(messageId, options?); // v9 behavior +``` + +Callers that want hydrated `Thread` instances (the v9 default) must call the `*AndHydrate` variants. + +#### `client.updateMessage` / `client.deleteMessage` + +```ts +// v9 +client.updateMessage(message, userId?, options?); +client.deleteMessage(messageID, hardDelete?); + +// v10 — inherited/overridden from ChatApi +client.updateMessage(request: Parameters[0] & { message: { cid?: string } }); +// request: { id, message, skip_enrich_url? } +client.deleteMessage(request: { id: string; hard?: boolean; delete_for_me?: boolean }); +``` + +Note: `hardDelete` boolean is now `hard` on the request. `user_id` override is gone. + +#### `client.partialUpdateMessage` / `client.ephemeralUpdateMessage` / `client.undeleteMessage` + +```ts +// v9 +client.partialUpdateMessage(messageID, updates, userId?, options?); +client.ephemeralUpdateMessage(messageID, updates, userId?, options?); +client.undeleteMessage(messageID, userID); + +// v10 +client.updateMessagePartial(request: UpdateMessagePartialRequest); // inherited; no user_id override +// ephemeralUpdateMessage: REMOVED — call updateMessagePartial with the ephemeral payload directly. +// undeleteMessage: REMOVED — no client-side replacement (was server-side). +``` + +#### `client.getMessage` + +```ts +// v9 +client.getMessage(messageID, options?); + +// v10 — inherited +client.getMessage(request: { id: string }); +``` + +Options like `show_deleted_message` are no longer accepted here (server-side only). + +#### `client.pinMessage` / `client.unpinMessage` + +Unchanged behavior; parameter name normalized: + +```ts +// v9 +client.pinMessage(messageOrMessageId, timeoutOrExpirationDate?, pinnedAt?); +client.unpinMessage(messageOrMessageId); + +// v10 — same signatures; `userId` positional (v9 fourth arg) is removed +client.pinMessage(messageOrMessageId, timeoutOrExpirationDate?, pinnedAt?); +client.unpinMessage(messageOrMessageId); +``` + +#### `client.markChannelsRead` (and alias `markAllRead`) + +```ts +// v9 +client.markChannelsRead(data?: MarkChannelsReadOptions); +client.markAllRead(data?); // alias — REMOVED + +// v10 — inherited +client.markChannelsRead(request?: Gen_MarkChannelsReadRequest); +``` + +#### `client.markChannelsDelivered` + +```ts +// v9 +client.markChannelsDelivered(data: MarkDeliveredOptions); + +// v10 +client.markChannelsDelivered(request?: Gen_MarkDeliveredRequest); +// v10 short-circuits when `latest_delivered_messages` is empty; still available. +``` + +#### `client.upsertUser` / `client.upsertUsers` (+ aliases `updateUser` / `updateUsers`) + +```ts +// v9 +client.upsertUser(user); +client.upsertUsers([user1, user2]); +client.updateUser(user); // alias — REMOVED +client.updateUsers([user1]); // alias — REMOVED (name reused for the new bulk method) + +// v10 — inherited +client.updateUsers({ users: { [user.id]: user } }); +// `users` is a Record keyed by user ID, not an array. +``` + +Mechanical rewrite for a single user: + +```ts +// v9 +await client.upsertUser({ id: 'u1', name: 'A' }); + +// v10 +await client.updateUsers({ users: { u1: { id: 'u1', name: 'A' } } }); +``` + +#### `client.partialUpdateUsers` + +```ts +// v9 +client.partialUpdateUsers(users: PartialUserUpdate[]); + +// v10 — inherited +client.updateUsersPartial({ users: PartialUserUpdate[] }); +``` + +#### `client.addDevice` / `client.getDevices` / `client.removeDevice` + +```ts +// v9 +client.addDevice(id, pushProvider, userID?, pushProviderName?); +client.getDevices(userID?); +client.removeDevice(id, userID?); + +// v10 — inherited (userID param dropped; server-only) +client.createDevice({ id, push_provider, push_provider_name?, hardware_id? }); +client.listDevices(); +client.deleteDevice({ id }); +``` + +`userID` is gone from all three — server-side callers using the target-user form must move to `@stream-io/node-sdk`. + +#### `client.getUnreadCount` / `client.getUnreadCountBatch` + +```ts +// v9 +client.getUnreadCount(userID?); // could query for another user server-side +client.getUnreadCountBatch(userIDs); // server-side + +// v10 — inherited +client.unreadCounts(); // connected user only +// getUnreadCountBatch: no replacement — was server-side only. +``` + +#### `client.banUser` / `client.unbanUser` / `client.shadowBan` / `client.removeShadowBan` + +```ts +// v9 +client.banUser(targetUserID, options?); +client.unbanUser(targetUserID, options?); +client.shadowBan(targetUserID, options?); +client.removeShadowBan(targetUserID, options?); + +// v10 — same shape; positional rename only +client.banUser(targetUserId, options?); +client.unbanUser(targetUserId, options?); +client.shadowBan(targetUserId, options?); +client.removeShadowBan(targetUserId, options?); +``` + +#### `client.blockUser` / `client.unBlockUser` / `client.getBlockedUsers` + +```ts +// v9 +client.blockUser(blockedUserID, user_id?); // user_id was server-side override +client.unBlockUser(blockedUserID, userID?); // note the mixed-case original name +client.getBlockedUsers(user_id?); + +// v10 +client.blockUser(blockedUserId); // takes only the target +client.unblockUser(blockedUserId); // renamed to lowercase `b` +client.getBlockedUsers(); // no user_id override +``` + +**Rename:** `unBlockUser` → `unblockUser` (lowercase `b`). + +#### `client.muteUser` / `client.unmuteUser` + +```ts +// v9 +client.muteUser(targetID, userID?, options?); // userID was server-side override +client.unmuteUser(targetID, currentUserID?); + +// v10 +client.muteUser(targetId, options?); +client.unmuteUser(targetId); +``` + +#### `client.flagMessage` / `client.flagUser` / `client.unflagMessage` / `client.unflagUser` / `client.unblockMessage` + +```ts +// v9 +client.flagMessage(targetMessageID, options?: { reason?; user_id? }); +client.flagUser(targetID, options?: { reason?; user_id? }); +client.unflagMessage(targetMessageID, options?: { user_id? }); +client.unflagUser(targetID, options?: { user_id? }); +client.unblockMessage(targetMessageID, options?: { user_id? }); + +// v10 +client.flagMessage(targetMessageId, options?: { reason? }); +client.flagUser(targetId, options?: { reason? }); +client.unflagMessage(targetMessageId); +client.unflagUser(targetId); +client.unblockMessage(targetMessageId); +``` + +`user_id` overrides dropped everywhere. + +#### `client.userMuteStatus` + +```ts +// v9 +client.userMuteStatus(targetID); + +// v10 +client.userMuteStatus(targetId); +``` + +#### `client.getChannelById` / `client.channel(...)` overload + +```ts +// v9 +client.channel(channelType, channelID?, custom?); +client.channel(channelType, custom?); +client.getChannelById(channelType, channelID, custom); + +// v10 — same overload shape; positional param renamed +client.channel(channelType, channelId?, custom?); +client.channel(channelType, custom?); +client.getChannelById(channelType, channelId, custom); +``` + +#### `client.setAnonymousUser` alias + +```ts +// v9 +client.setAnonymousUser = this.connectAnonymousUser; // REMOVED + +// v10 +await client.connectAnonymousUser(); +``` + +#### `client.doAxiosRequest` / `client.dispatchEvent` / `client.errorFromResponse` / `client.sendFile` + +```ts +// v9 — direct methods on the client +client.doAxiosRequest(type, url, data?, options?); +client.dispatchEvent(event); +client.errorFromResponse(response); +client.sendFile(url, uri, name?, contentType?, user?, axiosRequestConfig?); + +// v10 +client.api.doAxiosRequest(type, url, data?, options?); +client.dispatchEvent(event: Event); // Event union expanded to WSEvent | LocalEvent | keyof CustomEventTypes +client.api.errorFromResponse(response); // moved to ApiClient +client.api.sendFile(url, uri, name?, contentType?, user?, axiosRequestConfig?); +``` + +`client.api` is a new getter returning the internal `ApiClient` instance. + +#### `client.uploadFile` / `client.uploadImage` + +```ts +// v9 +client.uploadFile(uri, name?, contentType?, user?, axiosRequestConfig?); +client.uploadImage(uri, name?, contentType?, user?, axiosRequestConfig?); + +// v10 — TWO shapes now exist, pick the right one: +client.uploadFile(request: { file? }); // inherited from ChatApi — generated payload +client.uploadImage(request: { file? }); // inherited from ChatApi + +client.uploadFile_(uri, name?, contentType?, user?, axiosRequestConfig?); // v9 positional args preserved under trailing-underscore name +client.uploadImage_(uri, name?, contentType?, user?, axiosRequestConfig?); +``` + +`uploadFile_` and `uploadImage_` are the direct replacements for v9 code that passed positional args (uri + name + contentType + user + axios config). Ports should prefer these unless the caller wants to switch to the request-object shape. + +#### `client.deleteFile` / `client.deleteImage` + +```ts +// v9 +client.deleteFile(url); +client.deleteImage(url); + +// v10 — inherited +client.deleteFile(request?: { url? }); +client.deleteImage(request?: { url? }); +``` + +#### `client.revokeTokens` + +```ts +// v9 +client.revokeTokens(before: Date | string | null); + +// v10 +client.revokeTokens(before?: Date | null); // string form dropped +``` + +#### `client.getAppSettings` + +Still present but the return type changed (`Gen_GetApplicationResponse` wrapped as `StreamResponse<...>`); no signature change. + +#### `client.partialUpdateThread` + +Unchanged signature: `partialUpdateThread(messageId, partialThreadObject)`. + +#### `client.hydrateActiveChannels` + +Unchanged. + +#### `client.setLocalDevice` / `client.setBaseURL` / `client.setUserAgent` / `client.getUserAgent` + +Unchanged. `setUserAgent` is still marked `@deprecated` — prefer setting `sdkIdentifier`. + +#### `client.createChannelManager` / `client.setOfflineDBApi` / `client.setMessageComposerSetupFunction` + +Unchanged (composer setup function is new in v10 but not a rename). + +#### `client._enrichAxiosOptions` / `client._logApiRequest` / `client._logApiError` / `client._normalizeDate` / `client._setupConnection` + +Removed. Callers should not rely on these internals; `_setupConnection` was an alias for `openConnection`. + +#### `client.recoverState` / `client.connect` / `client._sayHi` / `client._buildWSPayload` + +Signatures unchanged. + +#### `client.queryUserGroups` + +```ts +// v9 — hand-rolled GET on `/usergroups` +client.queryUserGroups(options?: QueryUserGroupsOptions): Promise; +// QueryUserGroupsResponse = APIResponse & { user_groups: UserGroupResponse[] } + +// v10 — inherited from ChatApi (same underlying endpoint) +client.listUserGroups(request?: ListUserGroupsOptions): Promise>; +``` + +Mechanical rewrite: + +```ts +// v9 +const { user_groups } = await client.queryUserGroups({ team_id: 'engineering' }); + +// v10 +const { user_groups } = await client.listUserGroups({ team_id: 'engineering' }); +``` + +`UserGroupPaginator` still exists and now calls `listUserGroups` internally — consumers using the paginator do not need to change anything. Direct callers of `queryUserGroups` must rename to `listUserGroups`. The request shape is identical (`{ limit?, id_gt?, created_at_gt?, team_id? }`); the response gains a `metadata: RequestMetadata` field via the `StreamResponse<...>` wrapper. See the type-renames guide for the `QueryUserGroupsOptions` / `QueryUserGroupsResponse` type entries. + +#### `client.sync` + +```ts +// v9 +client.sync(channel_cids: string[], last_sync_at: string, options?: SyncOptions); + +// v10 — inherited (payload object) +client.sync(request: { channel_cids, last_sync_at, ... }); +``` + +#### `client.createBlockList` / `client.listBlockLists` / `client.updateBlockList` / `client.deleteBlockList` + +```ts +// v9 +client.createBlockList(blockList: BlockList); +client.listBlockLists(data?: { team? }); +client.getBlockList(name, data?: { team? }); // REMOVED +client.updateBlockList(name, data: { words; team? }); +client.deleteBlockList(name, data?: { team? }); + +// v10 — inherited (request objects) +client.createBlockList(request); +client.listBlockLists(request?); +// getBlockList: no replacement. +client.updateBlockList(request); +client.deleteBlockList(request); +``` + +#### Webhook / SNS / SQS helpers + +Moved off the client to module-level exports (`src/signing.ts`): + +```ts +// v9 +client.verifyWebhook(requestBody, xSignature); +client.verifyAndParseWebhook(rawBody, signature); +client.parseSqs(messageBody); +client.parseSns(notificationBody); + +// v10 — module exports; return WSEvent +import { verifySignature, verifyAndParseWebhook, parseSqs, parseSns } from 'stream-chat'; + +verifySignature(body, signature, secret); +verifyAndParseWebhook(rawBody, signature, secret); +parseSqs(messageBody); // SQS deliveries carry no application-level HMAC — decode-only +parseSns(notificationBody); // SNS deliveries carry no application-level HMAC — decode-only +``` + +The v9 `verifyWebhook` / `verifyAndParseWebhook` reused `client.secret` implicitly; the v10 module-level replacements require the secret to be passed in. `parseSqs` / `parseSns` do not take a `secret` — Stream never attaches an application-level HMAC to SQS/SNS deliveries; use `verifyAndParseWebhook` for HTTP webhooks when you need signature verification. + +--- + +## Channel + +### Constructor and lifecycle + +`getClient()`, `getConfig()`, `clean()`, `_channelURL()`, `_checkInitialized()`, `_initializeState(...)`, `_disconnect()`, and `create(options?)` are unchanged. + +### Removed with a rename → note + +- `channel._update(payload)` — REMOVED. Use `channel.update(request)` (inherited from `ChannelApi`). +- `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). + +### Signature-changed methods + +#### `channel.sendMessage` + +```ts +// v9 +channel.sendMessage(message: Message, options?: SendMessageOptions); + +// v10 +channel.sendMessage(request: Gen_SendMessageRequest); +// { message, skip_enrich_url?, skip_push?, keep_channel_hidden?, ... } +``` + +Mechanical rewrite: + +```ts +// v9 +await channel.sendMessage({ text: 'hi' }, { skip_push: true }); + +// v10 +await channel.sendMessage({ message: { text: 'hi' }, skip_push: true }); +``` + +#### `channel.sendEvent` + +```ts +// v9 +channel.sendEvent(event: Event); + +// v10 +channel.sendEvent(request: { event: Event }); +// Event now unions the generated WSEvent, the SDK-only LocalEvent, and keyof CustomEventTypes. +``` + +#### `channel.search` + +```ts +// v9 +channel.search(query: MessageFilters | string, options?); + +// v10 +channel.search(request?: { payload?: SearchPayload }); +``` + +#### `channel.queryMembers` + +```ts +// v9 +channel.queryMembers(filterConditions, sort?, options?); + +// v10 +channel.queryMembers(request?: { payload?: Partial }); +// payload accepts filter_conditions, sort ([{field, direction}]), limit, offset +``` + +For rewriting the `sort` value, see `v9-to-v10-migration-guide-sort.md`. + +#### `channel.sendReaction` / `channel._sendReaction` / `channel.deleteReaction` / `channel._deleteReaction` + +```ts +// v9 +channel.sendReaction(messageID, reaction: Reaction, options?); +channel.deleteReaction(messageID, reactionType, user_id?); + +// v10 +channel.sendReaction(request: Parameters[0]); +// { id: messageId, reaction, enforce_unique?, skip_push? } +channel.deleteReaction(request: Parameters[0]); +// { id: messageId, type: reactionType } +``` + +`user_id` overrides dropped. `_sendReaction` and `_deleteReaction` take the same shape as their public counterparts. + +#### `channel.getReactions` + +```ts +// v9 +channel.getReactions(message_id, options: { limit?; offset? }); + +// v10 +channel.getReactions(request: Parameters[0]); +// { id: messageId, limit?, offset? } +``` + +#### `channel.getReplies` + +```ts +// v9 +channel.getReplies(parent_id, options?, sort?); + +// v10 +channel.getReplies(request: GetRepliesRequest); +// { parent_id, id_gt?, id_lt?, id_gte?, id_lte?, limit?, offset?, sort?, ... } +``` + +`sort` inside the request uses `Gen_SortParamRequest[]` (`{ field, direction }`). See `v9-to-v10-migration-guide-sort.md`. + +#### `channel.update` + +```ts +// v9 +channel.update(channelData?, updateMessage?, options?); + +// v10 (override) +channel.update(request?: Gen_UpdateChannelRequest); +// { data?, message?, skip_push?, hide_history?, ... } +``` + +Mechanical rewrite: + +```ts +// v9 +await channel.update({ name: 'X' }, { text: 'renamed' }); + +// v10 +await channel.update({ data: { name: 'X' }, message: { text: 'renamed' } }); +``` + +#### `channel.updatePartial` + +Same signature: `updatePartial(update: PartialUpdateChannel)`. Internally now calls `updateChannelPartial` (inherited). + +#### `channel.delete` / `channel.truncate` + +```ts +// v9 +channel.delete(options?: { hard_delete? }); +channel.truncate(options?: TruncateOptions); + +// v10 — inherited from ChannelApi +channel.delete(request?: { hard_delete? }); +channel.truncate(request?: TruncateChannelRequest); +// TruncateChannelRequest: { message?, skip_push?, hard_delete?, truncated_at?, user_id? } +``` + +#### `channel.acceptInvite` / `channel.rejectInvite` + +```ts +// v9 +channel.acceptInvite(options?: UpdateChannelOptions); +channel.rejectInvite(options?: UpdateChannelOptions); + +// v10 — options type renamed +channel.acceptInvite(options?: ChannelUpdateOptions); +channel.rejectInvite(options?: ChannelUpdateOptions); +``` + +`ChannelUpdateOptions` = `Omit`. + +#### `channel.mute` / `channel.unmute` + +```ts +// v9 +channel.mute(opts?: { expiration?; user_id? }); +channel.unmute(opts?: { user_id? }); + +// v10 +channel.mute(options?: Gen_MuteChannelRequest); // { channel_cids?, expiration?, user? } +channel.unmute(options?: Gen_UnmuteChannelRequest); // { channel_cids?, user? } +``` + +#### `channel.archive` / `channel.unarchive` / `channel.pin` / `channel.unpin` + +```ts +// v9 +channel.archive(opts?: { user_id? }); +channel.unarchive(opts?: { user_id? }); +channel.pin(opts?: { user_id? }); +channel.unpin(opts?: { user_id? }); + +// v10 — arguments removed; always acts on the connected user +channel.archive(); +channel.unarchive(); +channel.pin(); +channel.unpin(); +``` + +These now delegate to `channel.updateMemberPartial({ set: { archived: true } })` (etc.) internally. + +#### `channel.muteStatus` / `channel.sendAction` / `channel.keystroke` / `channel.stopTyping` + +```ts +// v9 +channel.muteStatus(): { muted: boolean; createdAt: Date | null; expiresAt: Date | null }; +channel.sendAction(messageID, formData); +channel.keystroke(parent_id?, options?: { user_id }); +channel.stopTyping(parent_id?, options?: { user_id }); + +// v10 — same shape; positional rename to `messageId` / `parentId` +channel.muteStatus(); // same return shape +channel.sendAction(messageId, formData); +channel.keystroke(parentId?, options?); +channel.stopTyping(parentId?, options?); +``` + +#### `channel.markRead` / `channel.markAsReadRequest` + +**Semantic swap** — read carefully: + +```ts +// v9 +channel.markRead(data?: MarkReadOptions); // batched through MessageDeliveryReporter +channel.markAsReadRequest(data?: MarkReadOptions); // direct API call + +// v10 +channel.markRead(data?: MarkReadRequest); // direct API call (override, requires _checkInitialized + read_events) +channel.markReadViaReporter(data?: MarkReadRequest); // batched through MessageDeliveryReporter — v9 markRead behavior +``` + +`MarkReadOptions` (v9) → `MarkReadRequest` (v10 generated type). See the type-renames guide. + +Migration rule: if you want to preserve the v9 batching behavior, rename `markRead` → `markReadViaReporter`. If your v9 code was calling `markAsReadRequest`, rename it to `markRead`. + +#### `channel.markUnread` + +```ts +// v9 +channel.markUnread(data: MarkUnreadOptions); + +// v10 — inherited/override; data is optional +channel.markUnread(data?: MarkUnreadRequest); +``` + +#### `channel.stopWatching` + +```ts +// v9 +channel.stopWatching(); + +// v10 — override +channel.stopWatching(request?: Gen_ChannelStopWatchingRequest); +``` + +#### `channel.hide` / `channel.show` + +```ts +// v9 +channel.hide(userId: string | null = null, clearHistory = false); +channel.show(userId: string | null = null); + +// v10 — override; positional args replaced with a request payload +channel.hide(request?: Gen_HideChannelRequest); // { clear_history?, user_id?, ... } +channel.show(request?: Gen_ShowChannelRequest); // { user_id?, ... } +``` + +Mechanical rewrite: + +```ts +// v9 +await channel.hide(null, true); +// v10 +await channel.hide({ clear_history: true }); +``` + +#### `channel.banUser` / `channel.unbanUser` / `channel.shadowBan` / `channel.removeShadowBan` + +Same signatures, positional rename only: + +```ts +channel.banUser(targetUserId, options); +channel.unbanUser(targetUserId, options?); +channel.shadowBan(targetUserId, options); +channel.removeShadowBan(targetUserId); +``` + +#### `channel.vote` / `channel.removeVote` + +```ts +// v9 +channel.vote(messageId, pollId, vote: PollVoteData); +channel.removeVote(messageId, pollId, voteId); + +// v10 +channel.vote(request: Parameters[0]); +// { message_id, poll_id, vote: { option_id?, answer_text? } } +channel.removeVote(request: Parameters[0]); +// { message_id, poll_id, vote_id } +``` + +#### `channel.createDraft` / `channel._createDraft` / `channel.deleteDraft` / `channel._deleteDraft` / `channel.getDraft` + +```ts +// v9 +channel.createDraft(message: DraftMessagePayload); +channel.deleteDraft(options?: { parent_id? }); +channel.getDraft(options?: { parent_id? }); + +// v10 — inherited/override with generated shape +channel.createDraft(request: Gen_CreateDraftRequest); // { message: DraftPayload } +channel.deleteDraft(request?: { parent_id? }); +channel.getDraft(request?: { parent_id? }); // inherited unchanged +channel._createDraft(request); // same shape +channel._deleteDraft(request?); // same shape +``` + +Mechanical rewrite for `createDraft`: + +```ts +// v9 +await channel.createDraft({ text: 'draft' }); + +// v10 +await channel.createDraft({ message: { text: 'draft' } }); +``` + +#### `channel.on` / `channel.off` + +```ts +// v9 — signatures +channel.on(eventType: EventTypes, callback: EventHandler): { unsubscribe: () => void }; +channel.on(callback: EventHandler): { unsubscribe: () => void }; +channel.off(eventType: EventTypes, callback: EventHandler): void; +channel.off(callback: EventHandler): void; + +// v10 +channel.on(eventType: T, callback: EventHandler): { unsubscribe: () => void }; +channel.on(callback: EventHandler): { unsubscribe: () => void }; +channel.off(eventType: T, callback: EventHandler): void; +channel.off(callback: EventHandler): void; +``` + +Callers that imported `EventTypes` need to switch to `EventType` (`EventType = Event['type'] | 'all'`). The `CustomEventTypes` interface is still exported — augment it to add custom event-type keys, same as v9. + +#### `channel.sendFile` / `channel.sendImage` / `channel.deleteFile` / `channel.deleteImage` / `channel.getPinnedMessages` / `channel.getMessagesById` / `channel.lastRead` / `channel.countUnread` / `channel.countUnreadMentions` / `channel.lastMessage` / `channel.watch` / `channel.query` + +Signatures unchanged. + +#### `channel._handleChannelEvent` / `channel._callChannelListeners` + +Both still take `Event` — the union shape of `Event` itself changed (now `WSEvent | LocalEvent | keyof CustomEventTypes`), but the parameter type name did not. + +--- + +## ChannelState + +Mostly unchanged. The relevant tweaks: + +- `formatMessage`: v9 accepted `MessageResponse | MessageResponseBase | LocalMessage`. v10 accepts only `MessageResponse | LocalMessage` (`MessageResponseBase` no longer exists). +- `deleteUserMessages(...)` internally: `deletedAt` propagation now passes `undefined` where v9 defaulted to `null` — check for `null` guards in downstream code. +- `removeReaction(reaction, message?)` return shape unchanged. +- All other methods (`addMessageSorted`, `addMessagesSorted`, `addPinnedMessages`, `addPinnedMessage`, `removePinnedMessage`, `addReaction`, `_addReactionToState`, `_addOwnReactionToMessage`, `_removeOwnReactionFromMessage`, `_removeReactionFromState`, `_updateQuotedMessageReferences`, `removeQuotedMessageReferences`, `_updateMessage`, `setIsUpToDate`, `_addToMessageList`, `removeMessage`, `removeMessageFromArray`, `updateUserMessages`, `filterErrorMessages`, `clean`, `clearMessages`, `initMessages`, `loadMessageIntoState`, `findMessage`, `findMessageByTimestamp`, `pruneOldest`) — signatures unchanged. + +--- + +## Moderation + +`Moderation` now `extends ModerationApi`. All complex admin methods were removed; the kept methods have positional-param renames only. + +### Removed — no replacement in this SDK + +- `moderation.muteUser(targetID, options?: ModerationMuteOptions)` — REMOVED (previously used `POST /api/v2/moderation/mute` directly). Use the inherited `moderation.mute(request: MuteRequest)` from `ModerationApi` (accepts `{ target_ids, timeout?, ... }`). +- `moderation.getUserModerationReport` — REMOVED. +- `moderation.queryReviewQueue` — the class-level implementation is gone but `queryReviewQueue` is inherited from `ModerationApi` (request-object shape). +- `moderation.upsertConfig` / `getConfig` / `deleteConfig` / `queryConfigs` — the class-level implementations are gone; `upsertConfig` / `getConfig` / `deleteConfig` / `queryModerationConfigs` (note the last is renamed) are inherited from `ModerationApi`. +- `moderation.submitAction` — inherited from `ModerationApi`. +- `moderation.check` / `moderation.checkUserProfile` — REMOVED. +- `moderation.addCustomFlags` / `moderation.addCustomMessageFlags` — REMOVED. +- `moderation.upsertModerationRule` / `queryModerationRules` / `getModerationRule` / `deleteModerationRule` — REMOVED. + +### Signature-changed + +#### `moderation.flagUser` / `moderation.flagMessage` + +```ts +// v9 +moderation.flagUser(flaggedUserID, reason, options?); +moderation.flagMessage(messageID, reason, options?); + +// v10 — positional rename only; body still built internally +moderation.flagUser(flaggedUserId, reason, options?); +moderation.flagMessage(messageId, reason, options?); +``` + +#### `moderation.flag` + +```ts +// v9 +moderation.flag(entityType, entityId, entityCreatorID, reason, options?); // custom method + +// v10 — inherited +moderation.flag(request: FlagRequest); +// { entity_type, entity_id, entity_creator_id, reason, ...options } +``` + +#### `moderation.unmuteUser` + +```ts +// v9 +moderation.unmuteUser(targetID, options: { user_id? }); + +// v10 — user_id override dropped (server-side only) +moderation.unmuteUser(targetId); +``` + +--- + +## StableWSConnection + +Parameter renames only (`wsID` → `wsId`) and the internal `_log(msg, extra, level)` method has been removed. Everything else (`connect`, `disconnect`, `_connect`, `_reconnect`, `_waitForHealthy`, `_buildUrl`, `onopen` / `onmessage` / `onclose` / `onerror`, `onlineStatusChanged`, `_setHealth`, `_errorFromWSEvent`, `_destroyCurrentWSConnection`, `_setupConnectionPromise`, `scheduleNextPing`, `scheduleConnectionCheck`) keeps the v9 signature. + +```ts +// v9 +_log(msg, extra?, level?); // REMOVED + +// v10 — use the module-scoped logger instead +import { chatLoggerSystem } from './logger'; +const logger = chatLoggerSystem.getLogger('connection'); +logger.info(msg, extra); +``` + +--- + +## Property renames on `StreamChat` (referenced by other classes) + +| v9 | v10 | Availability | +| -------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `client.userID` | `client.userId` | Getter `userID` deprecated. Assignment (`client.userID = …`) no longer supported. | +| `client.clientID` | `client.clientId` | Getter+setter `clientID` deprecated. | +| `client.secret` | — | REMOVED. | +| `client.logger` | — | REMOVED — see logging note below. | +| `client.appSettingsPromise` type | `Promise>` | Wrapper type changed. | +| `client._user` type | `ClientUser` | Type alias replacing v9's `OwnUserResponse \| UserResponse`. | +| `client.api` | new | Public getter that returns the internal `ApiClient` for `doAxiosRequest` / `sendFile` / `errorFromResponse`. | + +`_setToken`, `_setUser`, `_setupConnection` are still present but `_setUser` now takes `TokenManagerMinimalUser`; `_setupConnection` is REMOVED. + +--- + +## Logging (applies to every class) + +`options.logger` (function) and `client.logger(level, msg, extra?)` are gone. To capture logs in v10, configure the shared `chatLoggerSystem` before constructing the client: + +```ts +import { chatLoggerSystem, type Sink } from 'stream-chat'; + +const sink: Sink = (level, message, ...rest) => { + /* forward to your logger; message is prefixed with `[](): ` */ +}; + +chatLoggerSystem.configureLoggers({ + default: { level: 'info', sink }, +}); +``` + +Class-internal call sites use scoped loggers such as `chatLoggerSystem.getLogger('client')`, `'channel'`, `'connection'`, `'api-client'`, `'thread'`, `'thread-manager'`, `'upload-manager'`, `'offline-db'`, `'state-store'`, `'token-manager'`, `'message-composer'`, `'text-composer'`, `'utils'`, `'channel-manager'`, `'connection-fallback'`. See `v9-to-v10-migration-guide-logging.md` for the full logging system reference. diff --git a/v9-to-v10-migration-guide-other.md b/v9-to-v10-migration-guide-other.md new file mode 100644 index 0000000000..0695515e4f --- /dev/null +++ b/v9-to-v10-migration-guide-other.md @@ -0,0 +1,427 @@ +# v9 → v10 Migration Guide — Everything Else + +> Scope: this guide catches breaking changes **not** covered by the four topic-specific guides: +> +> - `v9-to-v10-migration-guide-client-construction.md` (constructor & options) +> - `v9-to-v10-migration-guide-logging.md` (`chatLoggerSystem`, sinks, scopes) +> - `v9-to-v10-migration-guide-methods.md` (per-method signatures on `StreamChat`, `Channel`, `ChannelState`, `Moderation`, `StableWSConnection`) +> - `v9-to-v10-migration-guide-sort.md` (`SortParamRequest[]` shape) +> +> Read those first. This guide covers **exports, removed feature modules, event-type shape, filter constraints, small state/composer shape changes, and residual type/property renames** that the topic guides do not. + +## TL;DR + +- **Server-side is gone.** If you construct with a `secret` or call server-only admin endpoints, switch to `@stream-io/node-sdk`. The construction guide has the full list — every feature module below that was server-only is dropped for the same reason. +- One barrel removed from the package root, one added: **`./events` is gone; `./logger` is new.** The `./campaign`, `./channel_batch_updater`, and `./segment` barrels are still exported but the modules are emptied (they contain only a comment pointing at the server SDK) — importing anything by name from them will fail. +- `Event` (type name) is kept, but its shape widened: `Event = WSEvent | LocalEvent | keyof CustomEventTypes`. `EventPayload<''>` narrows to a specific event. +- `EventTypes` (plural) renamed to `EventType` (singular). `CustomEventTypes` interface is unchanged — augment it to add custom event-type keys, same as v9. +- 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). +- `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`. + +--- + +## Public export surface + +`src/index.ts` barrel changes: + +| Removed export barrel | Reason | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `export * from './events'` | `src/events.ts` deleted along with `EVENT_MAP`. Event-type set is now derived from the generated event decoders, no longer a hand-rolled map. | + +| Emptied module (barrel still present, no named exports) | Reason | +| ------------------------------------------------------- | ------------------------------------------------------------- | +| `./campaign` | `Campaign` was a server-side admin surface; module is a stub. | +| `./segment` | Same as `campaign`. | +| `./channel_batch_updater` | `ChannelBatchUpdater` was a server-side admin surface. | + +| Added export barrel | What it exposes | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `export * from './logger'` | `chatLoggerSystem`, `LogLevel`, `LogLevelEnum`, `Sink`, `ScopedLogger`, `ChatLoggerScope`, `ConfigureLoggersOptions`. See logging guide. | + +Any consumer doing `import { Campaign, Segment, ChannelBatchUpdater, EVENT_MAP } from 'stream-chat'` will fail to resolve. Delete those imports; there is no drop-in replacement in this SDK. `CustomEventTypes` is still exported from `stream-chat` and its interface is unchanged — augment it to declare custom event-type keys the same way as in v9. + +--- + +## Removed feature modules / subsystems + +Beyond the individual server-side methods listed in the methods guide, entire subsystems are gone. If your app used one of these, the client-side wrapper is not coming back — move to `@stream-io/node-sdk`: + +| Subsystem | v9 shape | v10 status | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Campaigns** | `client.campaign`, `queryCampaigns`, `createCampaign`, `startCampaign`, `stopCampaign`, `updateCampaign`, `deleteCampaign`, `getCampaign` | removed | +| **Segments** | `client.segment`, `createSegment`, `createUserSegment`, `createChannelSegment`, `updateSegment`, `getSegment`, `deleteSegment`, `querySegments`, `segmentTargetExists`, `addSegmentTargets`, `removeSegmentTargets`, `querySegmentTargets` | removed | +| **`ChannelBatchUpdater`** | `client.channelBatchUpdater`, `client.updateChannelsBatch(...)` | removed | +| **Retention policies** | `setRetentionPolicy`, `deleteRetentionPolicy`, `getRetentionPolicy`, `getRetentionPolicyRuns` | removed | +| **Team usage stats** | `queryTeamUsageStats` | removed | +| **User groups** | `createUserGroup`, `getUserGroup`, `searchUserGroups`, `updateUserGroup`, `deleteUserGroup`, `addUserGroupMembers`, `removeUserGroupMembers` | mutations removed. Read path is now `listUserGroups` (v9 `queryUserGroups` renamed — see methods guide); `UserGroupPaginator` remains and delegates to `listUserGroups` internally. | +| **Predefined filters (client)** | `deletePredefinedFilter`, `PredefinedFilterSort(Param)` types, `mapPredefinedFilterSortToChannelSort` helper | removed. Read paths remain via the generated API. | +| **Reminder client batch API** | `client.createReminder`, `client.updateReminder`, `client.deleteReminder`, `client.queryReminders` (v9 `QueryRemindersOptions` shape) | hand-rolled `createReminder`/`updateReminder`/`deleteReminder` removed from `StreamChat`. `queryReminders` is still available via `ChatApi` inheritance but takes the generated `QueryRemindersRequest` shape. `ReminderManager` remains — use it. See "Reminders" below for shape change. | +| **Push provider admin** | `upsertPushProvider`, `deletePushProvider`, `listPushProviders`, `setPushPreferences` | removed | +| **Roles / Permissions admin** | `createRole`, `listRoles`, `deleteRole`, `getPermission`, `createPermission`, `updatePermission`, `deletePermission`, `listPermissions` | removed. `searchRoles` remains, inherited from the generated API. | +| **Channel-types admin** | `createChannelType`, `getChannelType`, `updateChannelType`, `deleteChannelType`, `listChannelTypes` | removed | +| **Commands admin** | `createCommand`, `getCommand`, `updateCommand`, `deleteCommand`, `listCommands` | removed | +| **Imports / Exports** | `_createImport`, `_createImportURL`, `_getImport`, `_listImports`, `exportChannel`, `exportChannels`, `exportUsers`, `getExportChannelStatus`, `getTask` | removed | +| **App-settings mutations** | `updateAppSettings`, `testPushSettings`, `testSQSSettings`, `testSNSSettings`, `translate`, `translateMessage`, `getHookEvents` | removed. `getAppSettings` remains. | +| **User admin** | `partialUpdateUser`, `deleteUser`, `restoreUsers`, `reactivateUser(s)`, `deactivateUser(s)`, `exportUser`, `revokeUserToken`, `revokeUsersToken`, `sendUserCustomEvent`, `deleteUsers` | removed | +| **Flag admin** | `_queryFlags`, `_queryFlagReports`, `_reviewFlagReport`, `updateFlags` | removed. `queryMessageFlags` remains via `ChatApi` inheritance (generated request shape). User/message flagging by the connected user remains via `client.flagMessage` / `client.flagUser`. | +| **Webhook / SQS / SNS helpers** | `client.verifyWebhook`, `client.verifyAndParseWebhook`, `client.parseSqs`, `client.parseSns` (used `client.secret` implicitly) | Moved to module exports on `./signing`, `secret` now required explicitly. See methods guide for signatures. | +| **Misc.** | `commitMessage`, `undeleteMessage`, `getSharedLocations`, `updateLocation`, `getUnreadCountBatch`, `getBlockList`, `enrichURL`, `_normalizeDate`, `validateServerSideAuth`, `_setupConnection`, `_enrichAxiosOptions`, `_logApiRequest`, `_logApiError` | removed | + +If your call site was gated on `client._isUsingServerAuth()` (which is also removed), delete the branch — it was only ever true on the server-side path. + +--- + +## Event system + +`src/events.ts` — the single-source `EVENT_MAP` — is **deleted**. Event types are now driven by the generated event decoders (`src/gen/model-decoders/event-decoder-mapping.ts`) plus a small local overlay. The public `Event` type is kept but its definition changed: + +### Union types you'll see + +```ts +// Wire events (over WS) — every generated event type. +type WSEvent = /* union of all generated Gen_*Event shapes */; + +// SDK-only events not received over the wire. +type LocalEvent = ( + | ({ type: 'live_location_sharing.started' } & { message: MessageResponse }) + | ({ type: 'live_location_sharing.stopped' } & { live_location?: SharedLocationResponseData }) + | ({ type: 'channels.queried' } & { + queriedChannels: { + channels: ChannelStateResponseFields[]; + isLatestMessageSet: boolean; + }; + }) + | ({ type: 'transport.changed' } & { mode: string }) + | ({ type: 'connection.changed' } & { online: boolean }) + | { type: 'connection.recovered' } + | ({ type: 'offline_reactions.queried' } & { offlineReactions: ReactionResponse[] }) + | ({ type: 'capabilities.changed' } & { + cid: string; + own_capabilities: ChannelOwnCapability[]; + }) + | ({ type: 'message.read_locally' } & { + channel_type: string; + cid: string; + created_at: Date; + channel_id?: string; + last_read_message_id?: string; + team?: string; + user?: UserResponse; + }) +) & { received_at?: Date }; + +// Public alias — same name as in v9, wider shape. +export type Event = WSEvent | LocalEvent | keyof CustomEventTypes; +export type EventType = Event['type'] | 'all'; +export type EventHandler = (event: Extract) => void; + +export type EventPayload = Extract< + Event, + { type: T } +>; +``` + +### v9 → v10 replacement table + +| v9 | v10 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `import { Event } from 'stream-chat'` | still `import { Event } from 'stream-chat'` — the alias is retained; the union it resolves to widened to `WSEvent \| LocalEvent \| keyof CustomEventTypes`. | +| `Event` as a callback argument type | `Event` still works; prefer `EventPayload<'message.new'>` for narrowed events. | +| `import { EventTypes } from 'stream-chat'` | `import { EventType } from 'stream-chat'` — singular; same shape (`Event['type'] \| 'all'`) | +| `import { EVENT_MAP } from 'stream-chat'` | removed — no runtime table. Match on `event.type` directly. | +| `interface CustomEventTypes { my_custom: 'my_custom'; ... }` (module augmentation) | unchanged — augment `CustomEventTypes` exactly the same way. The interface is still exported from `stream-chat`. | +| Hand-rolled `ReminderEvent`, `PollEvent`, `PollUpdatedEvent`, `PollVoteCastedEvent`, `PollClosedEvent`, `PollAnswerCastedEvent`, `VoteChangedEvent`, `VoteCastedEvent`, `VoteRemovedEvent`, `AnswerCastedEvent`, and similar aliases | replaced by `EventPayload<'reminder.created' \| 'reminder.updated' \| ...>` etc. `ReminderManager.ReminderEvent` now aliases to `EventPayload<`reminder.${string}` \| 'notification.reminder_due'>`. | + +### Narrowing a listener + +```ts +// v9 +client.on('message.new', (event: Event) => { + event.message; // any-typed +}); + +// v10 +client.on('message.new', (event) => { + event.message; // narrowed via EventPayload<'message.new'> +}); + +// Or explicit: +import type { EventPayload } from 'stream-chat'; +const handler = (event: EventPayload<'message.new'>) => event.message; +``` + +### Custom event types (module augmentation) + +The `CustomEventTypes` module-augmentation contract is unchanged from v9: + +```ts +declare module 'stream-chat' { + interface CustomEventTypes { + my_app_custom: 'my_app_custom'; + } +} +``` + +Because the v10 generic on `channel.on` accepts any `string`, unknown listener keys still type-check without augmentation, but the event payload will not be narrowed. Augmenting `CustomEventTypes` adds the custom key to `Event['type']`, which flows through `EventType` and `EventHandler` narrowing. + +> **Larger topic** — the event system rewrite (removed hand-rolled event types across `poll`, `poll_manager`, `thread`, `reminders`, live-location, and the client itself; the shift from a hand-maintained `EVENT_MAP` to generated decoders) touches enough call sites that it may warrant a dedicated guide. Flag me if you want one written. + +--- + +## Filter payloads — per-endpoint operator constraints + +New generated types under `src/gen/models/filter-conditions.ts` narrow what operators are legal per field per endpoint: + +``` +QueryBannedUsersPayloadFilterConditions +QueryChannelsRequestFilterConditions +QueryMembersPayloadFilterConditions +QueryMessageFlagsPayloadFilterConditions +QueryReactionsRequestFilter +QueryThreadsRequestFilter +QueryUsersPayloadFilterConditions +SearchPayloadFilterConditions +SearchPayloadMessageFilterConditions +``` + +Each entry looks like `{ field_name: { type: ; operators: '$eq' | '$in' | ... } }`. The public request types (`QueryChannelsRequest`, `QueryReactionsRequest`, `QueryBannedUsersPayload`, ...) are wrapped with `WithTypedFilters` so `filter_conditions` at the call site can only use operator/value combinations declared in the corresponding constraint. + +**Breaking effect:** any v9 filter object that used an operator not declared for a given field will stop type-checking: + +```ts +// v9 — accepted (typing was permissive) +client.queryChannels({ frozen: { $exists: true } as any }, sort); + +// v10 — QueryChannelsRequestFilterConditions.frozen only declares `{ type: boolean; operators: '$eq' }`. +// This now fails to compile — use { frozen: true } or { frozen: { $eq: true } } instead. +``` + +Field-name typos in `filter_conditions` are now compile errors for endpoints that ship a constraint type (previously only some endpoints narrowed field names). If you were relying on the v9 permissive shape, casting through `as any` is the escape hatch; the correct fix is to use the declared operators. + +`ChannelFilters`, `MessageFilters`, `ReactionFilters`, `UserFilters` etc. still exist as convenience aliases but derive from the constrained request types. + +--- + +## State shape changes + +### `ChannelState.membership` + +```ts +// v9 +membership: ChannelMemberResponse; // initialized to {} +if (channel.state.membership.role === 'admin') { ... } // OK + +// v10 +membership: ChannelMemberResponse | undefined; // initialized to undefined +if (channel.state.membership?.role === 'admin') { ... } // must guard +``` + +Unguarded reads of `channel.state.membership.` now crash on freshly-constructed channels. Add `?.` or a `membership &&` guard at every read site. + +### `ChannelState.typing` + +```ts +// v9 +typing: Record; + +// v10 +typing: Record>; +``` + +Any code that inspected the typing entry's fields is now narrowed to typing-event fields only. Reading `state.typing[userId].message` etc. no longer compiles. + +### `ChannelState.read` (`ChannelReadStatus`) + +The per-user record now composes the generated `ReadStateResponse` plus an SDK-only `first_unread_message_id`: + +```ts +type ChannelReadStatus = Record< + string, + ReadStateResponse & { first_unread_message_id?: string } +>; +``` + +Field names are unchanged (`last_read`, `unread_messages`, `user`, `last_read_message_id`, `last_delivered_at`, `last_delivered_message_id`), but `user` is now `UserResponseCommonFields`-shaped (from the generator) rather than the v9 `UserResponse`. Downstream code that reads fields off `read[uid].user` should be fine; code that assigned back onto it may not. + +### `ChannelState.formatMessage` + +`MessageResponseBase` is removed from the type signature (see methods guide, ChannelState section). Callers passing a hand-rolled `MessageResponseBase`-typed value must cast or reshape to `MessageResponse | LocalMessage`. + +--- + +## Composer & attachment shape + +### Attachment previews — flat metadata moved under `.custom` + +`AttachmentManager.fileToLocalUploadAttachment` (and downstream identity checks) no longer place `mime_type`, `file_size`, or `duration` at the attachment root. They are nested under `custom`: + +```ts +// v9 — flat +{ mime_type: 'image/png', file_size: 1024, type: 'image', duration: 3.5, ... } + +// v10 — nested +{ custom: { mime_type: 'image/png', file_size: 1024, duration: 3.5 }, type: 'image', ... } +``` + +Consequences: + +- `isFileAttachment(a)` and `isVideoAttachment(a)` now read `(a as FileAttachment).custom?.mime_type`. +- `duration` is only populated for `type === 'voiceRecording'` (v9 populated it whenever a `FileReference` carried one). +- Any UI code reading `attachment.mime_type` / `attachment.file_size` from a preview built by the composer must switch to `attachment.custom?.mime_type` / `attachment.custom?.file_size`. + +### `LocationComposer` preview + +```ts +// v9 +export type LiveLocationPreview = Omit & { + durationMs?: number; +}; +// end_at was set to `new Date(...).toISOString()` + +// v10 +export type StaticLocationPreview = StaticLocationPayload & { message_id?: string }; +export type LiveLocationPreview = Omit & { + durationMs?: number; + message_id?: string; +}; +// end_at is now a Date (or undefined when durationMs is not a number) +``` + +If your app called `preview.end_at.toISOString()` or passed `end_at` directly to a `