1"use client"; 2 3/** 4 * PresenceEmitter (TASK-0093) — this tab's presence state → the realtime 5 * client's `presence` frames. 6 * 7 * The web client is the (b) control socket (one token-only socket, in-band 8 * `page:<id>` channels): the server tracks this tab's presence room as the 9 * LAST page/database channel it subscribed, so switching pages (unsubscribe 10 * + subscribe) moves the entry — no explicit "switch room" frame is needed. 11 * 12 * State: 13 * - `cursor` — the focused block's caret (`{blockId, offset, selection?}`), 14 * or null (nothing focused / selection left the page). The client sends 15 * ONLY these two fields — identity is server-stamped (the wire schema 16 * forbids it), so there is nothing to leak and nothing to reconcile. 17 * - `viewing` — `document.visibilityState === "visible"` (a background 18 * tab's cursor hides, the avatar stays; doc §5.1). 19 * 20 * Throttling (doc §5.2 best-effort + the < 500 ms propagation budget): 21 * caret MOVES are a continuous stream, sent at most once per 22 * `minIntervalMs` (120 ms — well under the budget, gentle on the socket) 23 * with a trailing flush so the FINAL position is never dropped; caret 24 * CLEARS (`cursor: null`) and `viewing` changes are discrete events, sent 25 * immediately. The timer is a plain `setTimeout` (the page is alive only 26 * in a browser tab; the module is `"use client"`). 27 * 28 * Pure + injectable (`send`, `now`, `timer`) so it is unit-testable 29 * without a socket or a DOM (AGENTS rule 25 — deterministic behavior; 30 * rule 23 — domain logic independent from the UI). 31 */ 32import type { OwnPresence, PresenceSelection } from "./types"; 33 34export interface PresenceSendFn { 35 (state: OwnPresence): void; 36} 37 38export interface PresenceEmitterOptions { 39 /** The realtime client's `sendPresence` (the wire boundary). */ 40 readonly send: PresenceSendFn; 41 /** Injectable clock (deterministic tests). */ 42 readonly now?: () => number; 43 /** Cursor-send throttling window (default 120 ms). */ 44 readonly minIntervalMs?: number; 45 /** Injectable timer (tests drive the trailing flush by hand). */ 46 readonly timer?: { 47 setTimeout: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>; 48 clearTimeout: (id: ReturnType<typeof setTimeout>) => void; 49 }; 50} 51 52/** The caret position inside one block (Tiptap's `state.selection` → here). */ 53export interface CaretPosition { 54 blockId: string; 55 offset: number; 56 selection?: PresenceSelection; 57} 58 59export class PresenceEmitter { 60 private readonly send: PresenceSendFn; 61 private readonly now: () => number; 62 private readonly minIntervalMs: number; 63 private readonly timer: NonNullable<PresenceEmitterOptions["timer"]>; 64 private cursor: CaretPosition | null = null; 65 private viewing = true; 66 /** Starts at -∞ so the FIRST caret always sends immediately (the throttle 67 * only limits the cadence AFTER the first frame — never delays the 68 * initial presence assertion). */ 69 private lastSentAt = Number.NEGATIVE_INFINITY; 70 private pending: ReturnType<typeof setTimeout> | null = null; 71 72 constructor(opts: PresenceEmitterOptions) { 73 this.send = opts.send; 74 this.now = opts.now ?? (() => Date.now()); 75 this.minIntervalMs = opts.minIntervalMs ?? 120; 76 // Default timer: the injected one (unit tests) is used verbatim. In a real 77 // browser the host `setTimeout`/`clearTimeout` REQUIRE `window` as their 78 // receiver — storing them as a bare detached object 79 // (`{ setTimeout: setTimeout }`) and calling `this.timer.setTimeout(...)` 80 // throws `Illegal invocation` (the unit tests never hit this because they 81 // always inject a `timer`, and Node's global `setTimeout` is permissive). 82 // Bind to `window` so the call-site (`this.timer.setTimeout(...)`) keeps 83 // the correct `this`. SSR/Node (no `window`) falls back to the globals. 84 this.timer = 85 opts.timer ?? 86 (typeof window !== "undefined" 87 ? { 88 setTimeout: window.setTimeout.bind(window), 89 clearTimeout: window.clearTimeout.bind(window) 90 } 91 : { setTimeout: setTimeout, clearTimeout: clearTimeout }); 92 } 93 94 /** 95 * The focused editor's caret moved (Tiptap `selectionUpdate`), or focus 96 * left the page (`null` — the editor blur path). 97 * 98 * Caret MOVES are a continuous stream → throttled (≤ 1 frame / 99 * `minIntervalMs`, trailing flush). Caret CLEARS (`null`) are discrete 100 * events, like viewing changes → sent immediately (the cursor must 101 * vanish within the < 500 ms budget, not wait out the throttle window). 102 */ 103 setCaret(caret: CaretPosition | null): void { 104 const prev = this.cursor; 105 if ( 106 prev?.blockId === caret?.blockId && 107 prev?.offset === caret?.offset && 108 prev?.selection?.toOffset === caret?.selection?.toOffset 109 ) { 110 return; // unchanged — no frame 111 } 112 this.cursor = caret; 113 if (caret === null) { 114 this.sendNow(); 115 return; 116 } 117 this.scheduleCursorSend(); 118 } 119 120 /** The tab's visibility changed (pagehide/pageshow/visibilitychange). */ 121 setViewing(viewing: boolean): void { 122 if (this.viewing === viewing) return; 123 this.viewing = viewing; 124 // Viewing changes are never throttled (the avatar/cursor must react 125 // immediately — a backgrounded tab's cursor hides NOW). 126 this.sendNow(); 127 } 128 129 /** The current state (a test/diagnostic view). */ 130 get state(): OwnPresence { 131 return { cursor: this.cursor, viewing: this.viewing }; 132 } 133 134 /** 135 * Re-send the current state immediately (the caller invokes this when the 136 * socket (re)connects — the client's `sendPresence` no-ops while the 137 * socket is not OPEN, so a frame emitted before connect would otherwise 138 * be lost and this tab's avatar/cursor would stay hidden until the next 139 * caret move). Cancels a pending trailing flush (superseded state). 140 */ 141 assert(): void { 142 this.sendNow(); 143 } 144 145 private scheduleCursorSend(): void { 146 const elapsed = this.now() - this.lastSentAt; 147 if (elapsed >= this.minIntervalMs) { 148 this.sendNow(); 149 return; 150 } 151 // Trailing flush: the final caret position must not be dropped. 152 if (this.pending === null) { 153 this.pending = this.timer.setTimeout(() => { 154 this.pending = null; 155 this.sendNow(); 156 }, this.minIntervalMs - elapsed); 157 } 158 } 159 160 private sendNow(): void { 161 if (this.pending !== null) { 162 this.timer.clearTimeout(this.pending); 163 this.pending = null; 164 } 165 this.lastSentAt = this.now(); 166 this.send({ cursor: this.cursor, viewing: this.viewing }); 167 } 168 169 /** Stop pending timers (the page unmounts / the tab closes). The server 170 * drops the peer on the socket close (or the explicit leave). */ 171 dispose(): void { 172 if (this.pending !== null) { 173 this.timer.clearTimeout(this.pending); 174 this.pending = null; 175 } 176 } 177} 178