/** * Ably client wrapper for the chat widget. * * Owns the `BaseRealtime` lifecycle (connect/disconnect), the channel * registry (notifications + per-conversation message + per-conversation * typing), and the race-safe attach/detach flow. * * Writes connection state into the store; reads channel routing from the * store. Does NOT mutate `messages` / `channels` / `typing` signals * directly — those flow through the `applyMessage` / `applyNotification` * / `applyTyping` handlers in `ably-handlers.ts`. * * See: docs/modules/chat/widget-architecture.md (Ably layer) */ import { BaseRealtime } from "ably/modular"; import type { ApiRequestInstance } from "../chat-api"; import type { ChatStore } from "../store/chat-store"; import { type TypingPayload } from "./ably-handlers"; /** * Hooks the controller plugs into the client so domain decisions stay in * the controller (e.g. how to react to an inbound message). The client * only owns the Ably mechanics. */ export interface AblyClientHooks { getDistinctId: () => string; onIdentityChange: () => void; onNewChannelNotification: () => void; onAgentMessageWhileOpen: () => void; onMessageTrack: (m: import("../../../utils/globals").ChatMessage) => void; onMessageDispatched: (m: import("../../../utils/globals").ChatMessage) => void; onTypingDispatched: (isTyping: boolean, senderName: string) => void; } /** * Wire `BaseRealtime` lazily — keeps tests free of the constructor side * effect of opening a WebSocket. */ export type BaseRealtimeFactory = (options: ConstructorParameters[0]) => BaseRealtime; export declare class AblyClient { private readonly instance; private readonly store; private readonly hooks; private _client; private _notificationsChannel; private _conversationChannel; private _typingChannel; /** * Latest in-flight pre-attach teardown. `_doAttach` must `await` this so a * newly-requested attach never calls `auth.authorize()` while a previous * channel is still attached — Ably re-validates ALL attached channels * against the new token and the previous one would fail with 40160. */ private _teardownInFlight; /** * Bumped on every `disconnectAll()` so in-flight `ensureConnected()` / * token refresh can bail without logging Ably 80017 as a hard failure * when the widget closes mid-handshake. */ private _connectEpoch; private _ensureConnectedPromise; private _factory; constructor(instance: ApiRequestInstance, store: ChatStore, hooks: AblyClientHooks, factory?: BaseRealtimeFactory); /** * Expose the live clientId (used by `authCallback` mismatch guard and by * the controller's identity-change detector on the `connected` event). */ get clientId(): string | null; /** * Whether we already own a live (or live-ish) client. Multiple calls to * `ensureConnected()` are idempotent — the first one creates the client, * the rest no-op. */ get isLive(): boolean; /** Currently-attached per-conversation channel id (null on list view). */ get currentChannelId(): string | null; /** * Idempotently bootstrap the Ably client and attach to the project * notifications channel. Safe to call from `open()` and on first load. */ ensureConnected(): Promise; private _isConnectEpochStale; private _runEnsureConnected; /** * Refresh the Ably token, optionally scoping it to a specific channel. * Refuses to authorize on a `failed` connection (terminal — only fix is * to recreate the client, which the identity-change hook does). */ refreshToken(channelId?: string): Promise; /** * Subscribe to per-conversation messages + typing for `channelId`. * * Atomic swap semantics: `realtimeChannelId` and `realtimeAttached` * still reflect the **last successful attach** for the entire duration * of this method — they only flip to `channelId` after both the main * and typing channels have attached and subscribed. This means * `realtimeReady` (and therefore the "Connecting…" banner) does not * blink during normal channel switches; the worst case is a slightly * stale `realtimeChannelId` for a few ms while the new channel * negotiates, which is harmless because: * * - Stale inbound frames are filtered by the `_conversationChannel !== * mainChannel` ref guard inside the subscribe callbacks. * - `realtimeReady` already requires `realtimeChannelId === channel.value.id`, * so once the new channel.value is set the computed signal correctly * reads false until the swap completes. * * Concurrent navigations (rapid switch / goToChannelList) are caught * by `wasSuperseded()` and the `pendingRealtimeChannelId` guard before * the final write. * * Before refreshing the token this method awaits any previously-attached * conversation channels' detach, so the `auth.authorize()` call in * `refreshToken()` cannot trigger an Ably 40160 capability check against * the old channel. */ attachConversation(channelId: string): Promise; /** * Detach + release the per-conversation channels. Safe to call from * `close()`, `goToChannelList()`, and rapid switching paths. * * Tracks the in-flight teardown on `_teardownInFlight` so the next * `attachConversation()` can await it before refreshing the token. */ detachConversation(): Promise; /** Synchronously clear the channel refs and kick off async detach. * * Does NOT touch `realtimeChannelId` / `realtimeAttached` — those reflect * the last successful attach and are owned by `attachConversation()` * (which swaps them atomically when the new channel is fully ready) and * by `disconnectAll()` (which clears them on full teardown). Leaving * them intact during a channel-switch detach is what kills the * "Connecting…" flash; see widget-architecture.md §"Atomic realtime * swap". */ private _teardownActive; /** Publish a typing event on the active typing channel. Best-effort. */ publishTyping(payload: TypingPayload & { sender_id: string; }): void; /** * Full teardown — detach all channels, close the Ably connection, reset * the store's realtime signals. Used on `destroy()` and identity change. */ disconnectAll(): Promise; /** Close and drop a client that lost the connect race (widget closed mid-handshake). */ private _discardClient; /** * After a `connected` event, if the live clientId no longer matches the * distinct id we authorized for (e.g. the page slept through an identity * change), trigger a full client recreate. */ private _checkClientIdDrift; /** * Unsubscribe + detach. We intentionally do NOT call * `client.channels.release()` here. * * Ably's `channels.release(name)` removes the channel object from the * client's local registry. Any inbound WebSocket frame that arrives for * that name afterwards triggers the library's * "received event for non-existent channel" warning, because dispatch * looks the channel up in the same registry. In-flight typing and * message frames are easy to land after a detach on a rapid switch, so * release-on-switch deterministically surfaces the warning. * * Per Ably's guidance, `release()` is for permanent cleanup. For * conversation switching we just `unsubscribe()` + `detach()`; the * channel object stays in the registry with no listeners and any * residual frames are silently ignored. Full cleanup happens in * `disconnectAll()` via `client.close()`, which tears down the * connection without per-channel release. */ private _teardownChannel; }