/** * @factory/collaboration — the client-side awareness (presence) store * (TASK-0093; the canvas's Zustand store is a thin projection of this — * the package owns the state machine, the store owns the rendering). * * Rules implemented here (the R1–R5 contract of TASK-0093): * R1 — the presence state is `userId → PresenceMeta` (display name, * deterministic color from `@factory/shared/presence` — the * 20-color palette, shared so server/client agree). The color is * NEVER the only identifier (the name label is mandatory — the * a11y rule: "color is never the only identifier"). * R3 — cursor validity: a frame's cursor is applied only when the * referenced block is in the doc (the doc exposes * `hasBlock(blockId)`); a stale cursor (its block was removed by * the CRDT) is dropped silently (no error, no stale render). * R5 — viewing is null on blur (an unfocused tab sends * `viewing: false` — the avatar dims; the cursor stays valid). * Prune — a peer silent for `pruneAfterMs` (the server's 90 s; the * client prunes its own view earlier for a snappy UI — 60 s, * recorded) is removed; a re-`hello` roster re-broadcasts the * full set (a joiner gets everyone < 500 ms — the server's job, * the store just absorbs the roster). * * The self peer is tracked under `selfId` but excluded from * {@link AwarenessStore.peers} (the bar shows "N others" — the a11y * contract: the bar is absent when 0 others). * * Deterministic (no `Date.now()` inside state transitions — the clock is * injected; rule 25). */ import { presenceColor } from "@factory/shared/presence"; import type { PeerInfo, PresenceIdentity } from "./types.js"; export interface AwarenessSelf { readonly userId: string; readonly name: string; readonly color: string; } export interface AwarenessOptions { /** The self identity (absence = presence disabled, all no-ops). */ readonly self?: PresenceIdentity; /** The doc's block-set probe (cursor validity, R3). */ readonly hasBlock?: (blockId: string) => boolean; /** The peer silence limit before pruning (default 60 s; the server's * own prune is 90 s — the client's is earlier for a snappy UI). */ readonly pruneAfterMs?: number; readonly now?: () => number; /** Peer roster/changes (the Zustand store subscribes). */ readonly onPeersChange?: (peers: readonly PeerInfo[]) => void; } const DEFAULT_PRUNE_MS = 60_000; /** * The awareness state machine. Frame intake is via {@link AwarenessStore.applyPresence} * (the provider routes the `awareness` binary frames through the * y-protocols awareness update, then extracts the per-peer state into * here — the extraction lives on the canvas where the y-protocols binding * is; this store is pure state + validity rules + the self cursor API). */ export class AwarenessStore { private readonly self: AwarenessSelf | null; private readonly peerMap = new Map(); // `| undefined` (not `?`): exactOptionalPropertyTypes — the constructor // may legitimately assign `undefined` (option absent = feature off). private readonly hasBlock: ((blockId: string) => boolean) | undefined; private readonly pruneAfterMs: number; private readonly now: () => number; private readonly onPeersChange: ((peers: readonly PeerInfo[]) => void) | undefined; private selfCursor: { blockId: string; offset: number } | null = null; private selfViewing = true; constructor(opts: AwarenessOptions = {}) { this.self = opts.self ? { userId: opts.self.userId, name: opts.self.name, color: presenceColor(opts.self.userId), } : null; this.hasBlock = opts.hasBlock; this.pruneAfterMs = opts.pruneAfterMs ?? DEFAULT_PRUNE_MS; this.now = opts.now ?? (() => Date.now()); this.onPeersChange = opts.onPeersChange; } /** The self identity (null when presence is disabled). */ get selfPeer(): AwarenessSelf | null { return this.self; } /** The peer roster (self excluded — the "N others" bar input). */ get peers(): readonly PeerInfo[] { return [...this.peerMap.values()]; } /** * Absorb a roster (the `hello` roomPeers + the join re-broadcast). The * roster carries users only (id/name/color — no cursors; cursors ride * the awareness frames). Existing cursor state for a peer is kept when * the peer is still in the roster. */ applyRoster(roster: readonly { user: { id: string; name: string; color: string } }[]): void { const seen = new Set(); const now = this.now(); for (const entry of roster) { const id = entry.user.id; seen.add(id); if (this.self !== null && id === this.self.userId) continue; const existing = this.peerMap.get(id); const peer: PeerInfo = { userId: id, name: entry.user.name, // The roster color wins only when present; the shared hash is the // fallback (server-stamped truth, TASK-0093 decision recorded). color: entry.user.color !== "" ? entry.user.color : presenceColor(id), cursor: existing?.cursor ?? null, selection: existing?.selection ?? null, viewing: existing?.viewing ?? true, lastSeen: existing?.lastSeen ?? now, }; this.peerMap.set(id, peer); } // Peers no longer in the roster left the room. for (const id of [...this.peerMap.keys()]) { if (!seen.has(id)) this.peerMap.delete(id); } this.emit(); } /** * Apply one peer's awareness state (a y-protocols awareness frame * decoded to its per-peer fields). Stale-cursor rule (R3): the cursor * is applied only when the block still exists in the doc — a removed * block drops the cursor silently. */ applyPresence( userId: string, state: { user?: { id?: string; name?: string; color?: string }; cursor?: { blockId: string; offset: number } | null; selection?: string | null; viewing?: boolean; }, ): void { if (this.self === null) return; // Anti-impersonation (mirrors the server's per-frame id check): the // frame's user.id must equal the frame's owner. if (state.user?.id !== undefined && state.user.id !== userId) return; if (userId === this.self.userId) return; // self rides the local state let cursor: PeerInfo["cursor"] = null; if (state.cursor !== null && state.cursor !== undefined) { const valid = this.hasBlock === undefined || this.hasBlock(state.cursor.blockId); if (valid) cursor = { ...state.cursor }; } const existing = this.peerMap.get(userId); const peer: PeerInfo = { userId, name: state.user?.name ?? existing?.name ?? `User ${userId.slice(0, 6)}`, color: state.user?.color ?? existing?.color ?? presenceColor(userId), cursor, selection: state.selection ?? null, viewing: state.viewing ?? existing?.viewing ?? true, lastSeen: this.now(), }; this.peerMap.set(userId, peer); this.emit(); } /** Remove a peer explicitly (a leave frame / roster drop). */ removePeer(userId: string): void { if (this.peerMap.delete(userId)) this.emit(); } /** * Drop peers silent for longer than `pruneAfterMs` (the client-side * 60 s; the server's 90 s prune is the backstop — the re-join roster * heals any over-prune). Returns the pruned ids. */ pruneSilent(): readonly string[] { const cutoff = this.now() - this.pruneAfterMs; const pruned: string[] = []; for (const [id, peer] of this.peerMap) { if (peer.lastSeen < cutoff) { this.peerMap.delete(id); pruned.push(id); } } if (pruned.length > 0) this.emit(); return pruned; } /** The self cursor (the canvas updates it on selection change). */ setSelfCursor(cursor: { blockId: string; offset: number } | null): void { if (this.self === null || cursor === null) { this.selfCursor = cursor; return; } // Self-cursor validity too: a stale block is dropped (no network // frame is sent for an invalid cursor). if (this.hasBlock !== undefined && !this.hasBlock(cursor.blockId)) { this.selfCursor = null; return; } this.selfCursor = { ...cursor }; } /** R5: the tab blurred — viewing goes null (the avatar dims). */ setSelfViewing(viewing: boolean): void { this.selfViewing = viewing; } /** The self awareness payload (what the provider broadcasts). */ selfState(): { name?: string | undefined; color?: string | undefined; cursor: { blockId: string; offset: number } | null; selection: string | null; } { return { name: this.self?.name, color: this.self?.color, cursor: this.selfCursor, selection: null, }; } get selfViewingState(): boolean { return this.selfViewing; } private emit(): void { this.onPeersChange?.([...this.peerMap.values()]); } }