Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ Conventions the agent must follow
All source is TypeScript under src/. Keep strict types and update .d.ts surface when needed.
Run yarn lint-fix and ensure zero ESLint errors (flat config).

Paginators hold their loaded items as **intervals**: call them that in names, comments and docs,
never "windows" ("window" stays for the browser global and time spans). The terms (interval,
head/tail, active interval, anchored head interval, logical head/tail interval) are defined in the
doc comment on `BasePaginator` (`src/pagination/paginators/BasePaginator.ts`).

#### Generated code (src/gen)

`src/gen` is generated from the OpenAPI spec by `yarn generate-client`. The only post-processing this repo applies to it is mapping `custom` fields to the `Custom*Data` interfaces (`scripts/apply-custom-data-types.mts`).
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,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, config?)` — 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_batch_updater.ts`** — per-channel object and its in-memory state. **Messages are NOT stored on `channel.state`.** The message list, thread replies, and pinned messages each live in a paginator — `channel.messagePaginator`, `thread.messagePaginator`, and `channel.pinnedMessagesPaginator` — which are the single source of truth (interval storage + a canonical `ItemIndex`). Read them via `channel.messagePaginator.state.items` (the active window) / `.getItem(id)` / `.latestMessage` (the channel's latest message, skipping what does not count as one, e.g. thread-only replies) / `.anchoredHeadItems` and `.anchoredHeadItem` (the loaded window that reaches the newest message, and its newest item; empty and `undefined` while only an older, jumped-to window is loaded), 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 is `channel.messagePaginator.latestMessageAt`: the later of `latestMessage.created_at` and `queriedLatestMessageAt`, the `last_message_at` of the latest channel query, which comes without the message (both live on its `aggregateState` store; events only ever update `latestMessage`). See `ai-docs/v10-breaking-changes.md#channel-state`.
- **`channel.ts` (~2.5k lines) + `channel_state.ts` (~1.1k) + `channel_batch_updater.ts`** — per-channel object and its in-memory state. **Messages are NOT stored on `channel.state`.** The message list, thread replies, and pinned messages each live in a paginator — `channel.messagePaginator`, `thread.messagePaginator`, and `channel.pinnedMessagesPaginator` — which are the single source of truth (interval storage + a canonical `ItemIndex`). Read them via `channel.messagePaginator.state.items` (the active interval) / `.getItem(id)` / `.latestMessage` (the channel's latest message, skipping what does not count as one, e.g. thread-only replies) / `.anchoredHeadItems` and `.anchoredHeadItem` (the anchored head interval, the loaded interval that reaches the newest message, and its newest item; empty and `undefined` while only an older, jumped-to interval is loaded), 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 is `channel.messagePaginator.latestMessageAt`: the later of `latestMessage.created_at` and `queriedLatestMessageAt`, the `last_message_at` of the latest channel query, which comes without the message (both live on its `aggregateState` store; events only ever update `latestMessage`). See `ai-docs/v10-breaking-changes.md#channel-state`.
- **`ChannelManager.ts`** — channel _lists_. Holds one or more `ChannelPaginator`s (`state.paginators`), keeps them in sync with WS events through an `EventHandlerPipeline` per event type, and arbitrates ownership when a channel matches several lists (`ownershipResolver` / `createPriorityOwnershipResolver`). Replaced the old `channel_manager.ts` (single hand-sorted `state.channels` list with named handler overrides) in v10 — see `ai-docs/v10-breaking-changes.md#channel-manager`. Filtering and ordering are the paginator's job: `matchesFilter()` runs the filter compiler over `Channel` field resolvers and ordering comes from a comparator compiled from `sort`. The manager is instantiated by the `StreamChat` constructor and lives as long as the client (`client.channelManager`) — it is not configurable through the client options; register lists with `insertPaginator({ paginator, index? })`, detach them with `removePaginator(paginatorOrId)` and set cross-list ownership with `setOwnershipResolver(resolverOrPriorityIds?)`. `setPaginators(paginators)` is the primitive the other two build on — use it (or `clearPaginators()`) for batches, since it publishes one state update instead of one per paginator, and skips the update entirely when the set is unchanged. Registration and loaded data have different owners: `disconnectUser` calls `resetPaginatorStates()`, which discards each list's channels (they belong to the user going away) while leaving the lists themselves registered, since which lists exist is the integrator's configuration. Event handling stays customizable: `ChannelManagerOptions.eventHandlers` replaces the default map wholesale at construction (start from `getDefaultHandlers()` to enrich it instead), and `addEventHandler` / `setEventHandlers` / `removeEventHandlers` adjust the pipelines afterwards — which is the only route for `client.channelManager`, since the client constructs it without options. The exported `ignoreEventsForUnknownChannels` handler, inserted at `index: 0`, is how a list opts out of pulling in channels it has not loaded.
- **`connection/wsConnection/StableWSConnection.ts` (`StableWSConnection`)** — the realtime socket. With `client.wsConnection.config.enableWSFallback` (a `wsConnection` config key; a `StreamChatOptions` entry in v9), `client.wsConnection.connect()` switches to v9's `WSConnectionFallback` (`src/connection/wsConnection/WSConnectionFallback.ts`, long-poll on `/api/v2/longpoll`, held on `client.wsConnection.fallback`) when the socket fails with a network error — which it reports after the full `connectTimeoutMs`, where v9 cut it to 6s — dispatches `connection.fallback_activated`, and stays on it, across `disconnectUser()` too. That class is v9's, adapted only where v10 changed what it relied on: it writes `client.wsConnection.state`, drives `ConnectionIdManager`, takes network status from `WSConnection`'s one subscription through `_applyNetworkStatus` (ignored while disconnected, where v9 stopped listening for good at its first disconnect), exposes `isConnecting`, and sets `connection_id` on its own polls and close. It calls the generated `client.longPoll()`, with per-request `timeout`s through `StreamRequestOptions` and an `AbortController` for cancellation; `ApiClient.resolveUrl` maps a local API's `:3030` to `:8900` for that path. The socket connects to `/api/v2/connect`, which authenticates off the **first frame the client sends** (`client._buildWSAuthMessage()`) rather than the query string, and answers with a `connection.ok` hello event instead of v1's `health.check`. It runs a 25s ping and a 35s connection check, and reconnects on close/error, publishing every status transition into `client.wsConnection.state`. `connection.ok` (`ConnectedEvent`) and `connection.error` (`ConnectionErrorEvent`) come from the generated `WSEvent` union like every other wire event.
**It no longer registers `window` listeners.** The offline/online edge now arrives from `client.networkConnection` (see below), which inverts the old dependency: the socket is a _consumer_ of network status rather than the thing that detects it. Its own settings — connect timeout, `enableWSFallback`, ping interval, connection-check grace period, the UI's offline-notification delay, `webSocketImpl`, `urlParams` and an injectable socket — live in `client.wsConnection.config`, not in `StreamChatOptions`; `client.defaultWSTimeout` is gone. Every instance is created by `WSConnection.connect()`, which also disconnects the one it replaces, and read live from that config so an `updateConfig` reaches an already-open socket. It holds its `WSConnection` parent rather than the client, and reaches the client through it.
Expand Down
6 changes: 3 additions & 3 deletions src/CooldownTimer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,9 @@ export class CooldownTimer extends WithSubscriptions {
const canSkipCooldown = (own_capabilities ?? []).includes('skip-slow-mode');

const paginator = this.channel.messagePaginator;
// The newest messages: the window that reaches the newest message, and the live messages received
// while it was not loaded. Without that window an own message may sit in the part not loaded, so
// the timestamp already known is kept rather than cleared.
// The newest messages: the interval that reaches the newest message, and the live messages
// received while it was not loaded. Without that interval an own message may sit in the part
// not loaded, so the timestamp already known is kept rather than cleared.
const ownLatestMessageTimestamp =
this.findOwnLatestMessageTimestamp({
messages: [...paginator.anchoredHeadItems, ...paginator.logicalHeadItems],
Expand Down
34 changes: 17 additions & 17 deletions src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1312,9 +1312,9 @@ export class Channel extends ChannelApi {
* offline support the reset is persisted.
*
* The event's `last_read_message_id` is the newest server-confirmed message, whatever its type,
* of the window holding the channel's newest messages
* (`messagePaginator.anchoredHeadServerConfirmedItem`). While that window is not loaded (e.g.
* only a jumped-to window is), the event carries none and the read covers everything up to its
* of the interval holding the channel's newest messages
* (`messagePaginator.anchoredHeadServerConfirmedItem`). While that interval is not loaded (e.g.
* only a jumped-to interval is), the event carries none and the read covers everything up to its
* `created_at`.
*
* @returns The dispatched `message.read_locally` event, or `undefined` if there is no connected
Expand Down Expand Up @@ -1438,7 +1438,7 @@ export class Channel extends ChannelApi {
}

/**
* Re-watches the channel and refreshes its whole loaded message window (the channel analogue of
* Re-watches the channel and refreshes its whole loaded message interval (the channel analogue of
* {@link Thread.reload}). Used on reconnect. Hard deletes reach other clients through no event, so
* the watch query reconciles the loaded newest messages with the re-queried page
* (`messagePaginator.reconcileNewestMessages`, against the loaded IDs captured before its await).
Expand All @@ -1452,7 +1452,7 @@ export class Channel extends ChannelApi {
this._reloading = true;
try {
const paginator = this.messagePaginator;
// The reload refreshes the window that reaches the newest message, sized to what it holds.
// The reload refreshes the interval that reaches the newest message, sized to what it holds.
const requestedLimit =
Math.max(paginator.anchoredHeadItems.length, paginator.pageSize ?? 0) ||
undefined;
Expand Down Expand Up @@ -1561,10 +1561,10 @@ export class Channel extends ChannelApi {
}

/**
* The channel's newest messages that are loaded: the window that reaches the newest message (the
* paginator's anchored head), or, while that window is not loaded, the messages received live since.
* A window reached by a jump is never part of it, as the messages between it and the newest are
* unknown.
* The channel's newest messages that are loaded: the interval that reaches the newest message (the
* paginator's anchored head), or, while that interval is not loaded, the messages received live
* since. An interval reached by a jump is never part of it, as the messages between it and the
* newest are unknown.
*/
private newestKnownMessages() {
const paginator = this.messagePaginator;
Expand Down Expand Up @@ -1753,13 +1753,13 @@ export class Channel extends ChannelApi {
/**
* Sends the channel query and takes in what the response says about the channel itself: its id,
* the cid it is stored under, its config, and its `last_message_at`, against which the message
* paginator's head window is checked before any page is placed (see
* paginator's head interval is checked before any page is placed (see
* `MessagePaginator.unmarkStaleHead`). The request of {@link runChannelQuery} and of the message
* paginator's own queries, which run it as a tracked query ({@link runTrackedQuery}); it does not
* track the query itself. Refused for a disposed instance, before anything is sent.
*
* `placesNewestPage`: the caller places the newest page with this response, which brings the
* newest messages up to date, so the head window is not checked.
* newest messages up to date, so the head interval is not checked.
*
* @internal
*/
Expand Down Expand Up @@ -1803,7 +1803,7 @@ export class Channel extends ChannelApi {
// a page reaches the newest message (`_initializeState` sets it too, after).
this.messagePaginator.setQueriedLatestMessageAt(channel.last_message_at);
// Checked before any page is placed, as a page that reaches the newest message marks its own
// window as the head.
// interval as the head.
if (!placesNewestPage && this.isHeadLeftWithoutRefresh(queryPayload)) {
this.messagePaginator.unmarkStaleHead();
}
Expand All @@ -1812,10 +1812,10 @@ export class Channel extends ChannelApi {
}

/**
* Whether nothing but this query brings the head window up to date, so its `last_message_at` may
* show the window stale: the watch was stopped (`NotWatching`), or this query itself watches
* Whether nothing but this query brings the head interval up to date, so its `last_message_at` may
* show the interval stale: the watch was stopped (`NotWatching`), or this query itself watches
* again after a dropped connection (`WasWatching`), leaving nothing to re-watch with the newest
* page. While the channel is watched, a message the window lacks is still on its way; after a
* page. While the channel is watched, a message the interval lacks is still on its way; after a
* dropped connection, the re-watch that follows places the newest page (see
* `ConnectionRecoveryManager`).
*/
Expand Down Expand Up @@ -2327,7 +2327,7 @@ export class Channel extends ChannelApi {
this.pinnedMessagesPaginator.removeItem({ id: event.message.id });
} else {
// A soft delete changes neither `created_at` nor `pinned`: one store write reaches every
// holder, where an `ingestItem` per collection would re-emit each window.
// holder, where an `ingestItem` per collection would re-emit each active interval.
const store = this.getClient().messageStore;
if (store.has(event.message.id)) store.upsert(formattedMessage);
}
Expand Down Expand Up @@ -2359,7 +2359,7 @@ export class Channel extends ChannelApi {
const isThreadMessage = isThreadOnlyReply(event.message);
if (!isThreadMessage) {
// `ingestItem` advances the tracked latest message. A message arriving while the viewer
// is in an older window lands in the head interval, leaving the view as it is.
// is in an older interval lands in the head interval, leaving the view as it is.
const formattedMessage = formatMessage(event.message);
this.messagePaginator.ingestItem(formattedMessage);
// Added only when pinned (`matchesFilter`).
Expand Down
2 changes: 1 addition & 1 deletion src/channel_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export type ChannelActivationState = {
/**
* Whether a consumer declared this channel as the one it is reading, via `channel.activate()`. Its
* own state then takes precedence over bulk writes: channel-list hydration places no page in the
* message list of an `active` channel (`channel.reload()` owns that window).
* message list of an `active` channel (`channel.reload()` owns its loaded interval).
*/
active: boolean;
};
Expand Down
4 changes: 2 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1577,8 +1577,8 @@ export class StreamChat extends ChatApi {
}
// from the read state the response brought, as `channel.watch()` does
if (placedFirstPage) c.messagePaginator.seedUnreadSnapshot();
// Unless the newest page was placed, which brings the window up to date, the head window is
// checked against this response's `last_message_at`, as `Channel.requestChannelState` does
// Unless the newest page was placed, which brings the interval up to date, the head interval
// is checked against this response's `last_message_at`, as `Channel.requestChannelState` does
// for a channel query; an on-screen channel is left to its own `channel.reload()`, as above.
if (
willInitialize &&
Expand Down
2 changes: 1 addition & 1 deletion src/entityStore/StoreBackedItemIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export class StoreBackedItemIndex<T> implements ItemIndexApi<T> {
owner?.onIdChanged?.(oldId, newId);
},
onEntityRemoved: (id, entity) => {
// Owner first: dropping the item from its windows still reads it through this index.
// Owner first: dropping the item from its intervals still reads it through this index.
owner?.onEntityRemoved?.(id, entity);
this.memberIds.delete(id);
},
Expand Down
4 changes: 2 additions & 2 deletions src/messageDelivery/MessageDeliveryReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,8 @@ export class MessageDeliveryReporter {
const ownUserId = this.client.user?.id;
if (!ownUserId) return;

// The newest message the collection knows, whether or not the window holding it is loaded: a
// message received live while an older window is active is still delivered.
// The newest message the collection knows, whether or not the interval holding it is loaded: a
// message received live while an older interval is active is still delivered.
let latestMessage: LocalMessage | null = null;
// Unix nanoseconds, comparable with a message's `created_at`.
let lastDeliveredAt: TimestampNS | undefined;
Expand Down
Loading
Loading