/** * PresenceAnnouncer (TASK-0093, spec R5) — the polite live-region text for * the page's presence: join/leave + "editing" state, **rate-limited to * ≤ 1 announcement per second** and only on STATE CHANGE. * * Why: a peer typing fires cursor frames at ~8/s (the 120 ms emitter * throttle); a screen reader must not be flooded. The announcer collapses * the high-frequency stream to state transitions ("Ana is editing this * page" / "Ana stopped editing" / "Ana joined" / "Ana left") and emits at * most once per `minIntervalMs` (1000 ms). * * State model: a peer is "editing" while it has a non-null cursor AND is * viewing. The peer set is derived from the store's roster (others only — * the local user is never announced). * * Pure + injectable clock (AGENTS rule 25 — deterministic; rule 23 — * domain logic independent from the UI). The hook (use-presence-announcer) * owns the DOM live region and feeds this class. */ import type { Presence } from "./types"; export interface PresenceAnnouncement { text: string; at: number; } export interface PresenceAnnouncerOptions { readonly now?: () => number; /** Minimum spacing between announcements (default 1000 ms = ≤ 1/s). */ readonly minIntervalMs?: number; } interface PeerState { editing: boolean; name: string; } /** Rank = priority (lower wins when several changes land in one update): * join(0) > editing-start(1) > editing-stop(2) > leave(3). */ const RANK_JOIN = 0; const RANK_EDIT_START = 1; const RANK_EDIT_STOP = 2; const RANK_LEAVE = 3; export class PresenceAnnouncer { private readonly now: () => number; private readonly minIntervalMs: number; /** userId → last tracked state (undefined = never seen). */ private readonly last = new Map(); private lastEmitAt = Number.NEGATIVE_INFINITY; /** The queued announcement (the last change landed within the window). */ private pending: string | null = null; constructor(opts: PresenceAnnouncerOptions = {}) { this.now = opts.now ?? (() => Date.now()); this.minIntervalMs = opts.minIntervalMs ?? 1000; } /** * Feed the current roster (the page's OTHER peers — the local user * already excluded). Returns the announcement to post NOW (or null): * * - the highest-priority state change in this update (join > * editing-start > editing-stop > leave) is picked deterministically; * - if one was already posted within `minIntervalMs`, the change is * QUEUED and flushed by a later update (or `flush()`); * - the state map always advances (a queued change is not re-detected). */ update(roster: readonly Presence[]): PresenceAnnouncement | null { const seen = new Set(); // The changes in this update (the lowest rank wins; a collection — a // single `let` re-assigned inside the `consider` closure defeats the // null-narrowing under noUncheckedIndexedAccess). const changes: { text: string; rank: number }[] = []; const consider = (text: string, rank: number): void => { for (const c of changes) { if (rank >= c.rank) return; // an equal-or-lower-priority change already recorded } changes.push({ text, rank }); }; for (const p of roster) { seen.add(p.userId); const editing = p.cursor !== null && (p.viewing ?? true); const prev = this.last.get(p.userId); if (prev === undefined) { consider(`${p.name} joined`, RANK_JOIN); } else if (prev.editing !== editing) { consider( editing ? `${p.name} is editing this page` : `${p.name} stopped editing`, editing ? RANK_EDIT_START : RANK_EDIT_STOP, ); } this.last.set(p.userId, { editing, name: p.name }); } // Leaves: peers that were tracked but are no longer in the roster. for (const [userId, state] of this.last) { if (seen.has(userId)) continue; this.last.delete(userId); consider(`${state.name} left`, RANK_LEAVE); } if (changes.length === 0) return null; const top = changes.reduce((a, b) => (b.rank < a.rank ? b : a)); const at = this.now(); if (at - this.lastEmitAt < this.minIntervalMs) { if (this.pending === null) this.pending = top.text; // highest-priority wins return null; } this.pending = null; this.lastEmitAt = at; return { text: top.text, at }; } /** Flush a queued announcement (idle tick / unmount). */ flush(): PresenceAnnouncement | null { const text = this.pending; this.pending = null; if (text === null) return null; this.lastEmitAt = this.now(); return { text, at: this.now() }; } /** Reset (page switch — the state map is per-page). */ reset(): void { this.last.clear(); this.pending = null; this.lastEmitAt = Number.NEGATIVE_INFINITY; } }