1
20import type { Presence } from "./types";
21
22export interface PresenceAnnouncement {
23 text: string;
24 at: number;
25}
26
27export interface PresenceAnnouncerOptions {
28 readonly now?: () => number;
29
30 readonly minIntervalMs?: number;
31}
32
33interface PeerState {
34 editing: boolean;
35 name: string;
36}
37
38
40const RANK_JOIN = 0;
41const RANK_EDIT_START = 1;
42const RANK_EDIT_STOP = 2;
43const RANK_LEAVE = 3;
44
45export class PresenceAnnouncer {
46 private readonly now: () => number;
47 private readonly minIntervalMs: number;
48
49 private readonly last = new Map<string, PeerState>();
50 private lastEmitAt = Number.NEGATIVE_INFINITY;
51
52 private pending: string | null = null;
53
54 constructor(opts: PresenceAnnouncerOptions = {}) {
55 this.now = opts.now ?? (() => Date.now());
56 this.minIntervalMs = opts.minIntervalMs ?? 1000;
57 }
58
59
69 update(roster: readonly Presence[]): PresenceAnnouncement | null {
70 const seen = new Set<string>();
71
72
73
74 const changes: { text: string; rank: number }[] = [];
75 const consider = (text: string, rank: number): void => {
76 for (const c of changes) {
77 if (rank >= c.rank) return;
78 }
79 changes.push({ text, rank });
80 };
81
82 for (const p of roster) {
83 seen.add(p.userId);
84 const editing = p.cursor !== null && (p.viewing ?? true);
85 const prev = this.last.get(p.userId);
86 if (prev === undefined) {
87 consider(`${p.name} joined`, RANK_JOIN);
88 } else if (prev.editing !== editing) {
89 consider(
90 editing ? `${p.name} is editing this page` : `${p.name} stopped editing`,
91 editing ? RANK_EDIT_START : RANK_EDIT_STOP,
92 );
93 }
94 this.last.set(p.userId, { editing, name: p.name });
95 }
96
97
98 for (const [userId, state] of this.last) {
99 if (seen.has(userId)) continue;
100 this.last.delete(userId);
101 consider(`${state.name} left`, RANK_LEAVE);
102 }
103
104 if (changes.length === 0) return null;
105 const top = changes.reduce((a, b) => (b.rank < a.rank ? b : a));
106
107 const at = this.now();
108 if (at - this.lastEmitAt < this.minIntervalMs) {
109 if (this.pending === null) this.pending = top.text;
110 return null;
111 }
112 this.pending = null;
113 this.lastEmitAt = at;
114 return { text: top.text, at };
115 }
116
117
118 flush(): PresenceAnnouncement | null {
119 const text = this.pending;
120 this.pending = null;
121 if (text === null) return null;
122 this.lastEmitAt = this.now();
123 return { text, at: this.now() };
124 }
125
126
127 reset(): void {
128 this.last.clear();
129 this.pending = null;
130 this.lastEmitAt = Number.NEGATIVE_INFINITY;
131 }
132}
133