"use client"; /** * PresenceEmitter (TASK-0093) — this tab's presence state → the realtime * client's `presence` frames. * * The web client is the (b) control socket (one token-only socket, in-band * `page:` channels): the server tracks this tab's presence room as the * LAST page/database channel it subscribed, so switching pages (unsubscribe * + subscribe) moves the entry — no explicit "switch room" frame is needed. * * State: * - `cursor` — the focused block's caret (`{blockId, offset, selection?}`), * or null (nothing focused / selection left the page). The client sends * ONLY these two fields — identity is server-stamped (the wire schema * forbids it), so there is nothing to leak and nothing to reconcile. * - `viewing` — `document.visibilityState === "visible"` (a background * tab's cursor hides, the avatar stays; doc §5.1). * * Throttling (doc §5.2 best-effort + the < 500 ms propagation budget): * caret MOVES are a continuous stream, sent at most once per * `minIntervalMs` (120 ms — well under the budget, gentle on the socket) * with a trailing flush so the FINAL position is never dropped; caret * CLEARS (`cursor: null`) and `viewing` changes are discrete events, sent * immediately. The timer is a plain `setTimeout` (the page is alive only * in a browser tab; the module is `"use client"`). * * Pure + injectable (`send`, `now`, `timer`) so it is unit-testable * without a socket or a DOM (AGENTS rule 25 — deterministic behavior; * rule 23 — domain logic independent from the UI). */ import type { OwnPresence, PresenceSelection } from "./types"; export interface PresenceSendFn { (state: OwnPresence): void; } export interface PresenceEmitterOptions { /** The realtime client's `sendPresence` (the wire boundary). */ readonly send: PresenceSendFn; /** Injectable clock (deterministic tests). */ readonly now?: () => number; /** Cursor-send throttling window (default 120 ms). */ readonly minIntervalMs?: number; /** Injectable timer (tests drive the trailing flush by hand). */ readonly timer?: { setTimeout: (fn: () => void, ms: number) => ReturnType; clearTimeout: (id: ReturnType) => void; }; } /** The caret position inside one block (Tiptap's `state.selection` → here). */ export interface CaretPosition { blockId: string; offset: number; selection?: PresenceSelection; } export class PresenceEmitter { private readonly send: PresenceSendFn; private readonly now: () => number; private readonly minIntervalMs: number; private readonly timer: NonNullable; private cursor: CaretPosition | null = null; private viewing = true; /** Starts at -∞ so the FIRST caret always sends immediately (the throttle * only limits the cadence AFTER the first frame — never delays the * initial presence assertion). */ private lastSentAt = Number.NEGATIVE_INFINITY; private pending: ReturnType | null = null; constructor(opts: PresenceEmitterOptions) { this.send = opts.send; this.now = opts.now ?? (() => Date.now()); this.minIntervalMs = opts.minIntervalMs ?? 120; // Default timer: the injected one (unit tests) is used verbatim. In a real // browser the host `setTimeout`/`clearTimeout` REQUIRE `window` as their // receiver — storing them as a bare detached object // (`{ setTimeout: setTimeout }`) and calling `this.timer.setTimeout(...)` // throws `Illegal invocation` (the unit tests never hit this because they // always inject a `timer`, and Node's global `setTimeout` is permissive). // Bind to `window` so the call-site (`this.timer.setTimeout(...)`) keeps // the correct `this`. SSR/Node (no `window`) falls back to the globals. this.timer = opts.timer ?? (typeof window !== "undefined" ? { setTimeout: window.setTimeout.bind(window), clearTimeout: window.clearTimeout.bind(window) } : { setTimeout: setTimeout, clearTimeout: clearTimeout }); } /** * The focused editor's caret moved (Tiptap `selectionUpdate`), or focus * left the page (`null` — the editor blur path). * * Caret MOVES are a continuous stream → throttled (≤ 1 frame / * `minIntervalMs`, trailing flush). Caret CLEARS (`null`) are discrete * events, like viewing changes → sent immediately (the cursor must * vanish within the < 500 ms budget, not wait out the throttle window). */ setCaret(caret: CaretPosition | null): void { const prev = this.cursor; if ( prev?.blockId === caret?.blockId && prev?.offset === caret?.offset && prev?.selection?.toOffset === caret?.selection?.toOffset ) { return; // unchanged — no frame } this.cursor = caret; if (caret === null) { this.sendNow(); return; } this.scheduleCursorSend(); } /** The tab's visibility changed (pagehide/pageshow/visibilitychange). */ setViewing(viewing: boolean): void { if (this.viewing === viewing) return; this.viewing = viewing; // Viewing changes are never throttled (the avatar/cursor must react // immediately — a backgrounded tab's cursor hides NOW). this.sendNow(); } /** The current state (a test/diagnostic view). */ get state(): OwnPresence { return { cursor: this.cursor, viewing: this.viewing }; } /** * Re-send the current state immediately (the caller invokes this when the * socket (re)connects — the client's `sendPresence` no-ops while the * socket is not OPEN, so a frame emitted before connect would otherwise * be lost and this tab's avatar/cursor would stay hidden until the next * caret move). Cancels a pending trailing flush (superseded state). */ assert(): void { this.sendNow(); } private scheduleCursorSend(): void { const elapsed = this.now() - this.lastSentAt; if (elapsed >= this.minIntervalMs) { this.sendNow(); return; } // Trailing flush: the final caret position must not be dropped. if (this.pending === null) { this.pending = this.timer.setTimeout(() => { this.pending = null; this.sendNow(); }, this.minIntervalMs - elapsed); } } private sendNow(): void { if (this.pending !== null) { this.timer.clearTimeout(this.pending); this.pending = null; } this.lastSentAt = this.now(); this.send({ cursor: this.cursor, viewing: this.viewing }); } /** Stop pending timers (the page unmounts / the tab closes). The server * drops the peer on the socket close (or the explicit leave). */ dispose(): void { if (this.pending !== null) { this.timer.clearTimeout(this.pending); this.pending = null; } } }