1
31
32import { presenceColor } from "@factory/shared/presence";
33
34import type { PeerInfo, PresenceIdentity } from "./types.js";
35
36export interface AwarenessSelf {
37 readonly userId: string;
38 readonly name: string;
39 readonly color: string;
40}
41
42export interface AwarenessOptions {
43
44 readonly self?: PresenceIdentity;
45
46 readonly hasBlock?: (blockId: string) => boolean;
47
49 readonly pruneAfterMs?: number;
50 readonly now?: () => number;
51
52 readonly onPeersChange?: (peers: readonly PeerInfo[]) => void;
53}
54
55const DEFAULT_PRUNE_MS = 60_000;
56
57
64export class AwarenessStore {
65 private readonly self: AwarenessSelf | null;
66 private readonly peerMap = new Map<string, PeerInfo>();
67
68
69 private readonly hasBlock: ((blockId: string) => boolean) | undefined;
70 private readonly pruneAfterMs: number;
71 private readonly now: () => number;
72 private readonly onPeersChange: ((peers: readonly PeerInfo[]) => void) | undefined;
73 private selfCursor: { blockId: string; offset: number } | null = null;
74 private selfViewing = true;
75
76 constructor(opts: AwarenessOptions = {}) {
77 this.self = opts.self
78 ? {
79 userId: opts.self.userId,
80 name: opts.self.name,
81 color: presenceColor(opts.self.userId),
82 }
83 : null;
84 this.hasBlock = opts.hasBlock;
85 this.pruneAfterMs = opts.pruneAfterMs ?? DEFAULT_PRUNE_MS;
86 this.now = opts.now ?? (() => Date.now());
87 this.onPeersChange = opts.onPeersChange;
88 }
89
90
91 get selfPeer(): AwarenessSelf | null {
92 return this.self;
93 }
94
95
96 get peers(): readonly PeerInfo[] {
97 return [...this.peerMap.values()];
98 }
99
100
106 applyRoster(roster: readonly { user: { id: string; name: string; color: string } }[]): void {
107 const seen = new Set<string>();
108 const now = this.now();
109 for (const entry of roster) {
110 const id = entry.user.id;
111 seen.add(id);
112 if (this.self !== null && id === this.self.userId) continue;
113 const existing = this.peerMap.get(id);
114 const peer: PeerInfo = {
115 userId: id,
116 name: entry.user.name,
117
118
119 color: entry.user.color !== "" ? entry.user.color : presenceColor(id),
120 cursor: existing?.cursor ?? null,
121 selection: existing?.selection ?? null,
122 viewing: existing?.viewing ?? true,
123 lastSeen: existing?.lastSeen ?? now,
124 };
125 this.peerMap.set(id, peer);
126 }
127
128 for (const id of [...this.peerMap.keys()]) {
129 if (!seen.has(id)) this.peerMap.delete(id);
130 }
131 this.emit();
132 }
133
134
140 applyPresence(
141 userId: string,
142 state: {
143 user?: { id?: string; name?: string; color?: string };
144 cursor?: { blockId: string; offset: number } | null;
145 selection?: string | null;
146 viewing?: boolean;
147 },
148 ): void {
149 if (this.self === null) return;
150
151
152 if (state.user?.id !== undefined && state.user.id !== userId) return;
153 if (userId === this.self.userId) return;
154
155 let cursor: PeerInfo["cursor"] = null;
156 if (state.cursor !== null && state.cursor !== undefined) {
157 const valid = this.hasBlock === undefined || this.hasBlock(state.cursor.blockId);
158 if (valid) cursor = { ...state.cursor };
159 }
160 const existing = this.peerMap.get(userId);
161 const peer: PeerInfo = {
162 userId,
163 name: state.user?.name ?? existing?.name ?? `User ${userId.slice(0, 6)}`,
164 color: state.user?.color ?? existing?.color ?? presenceColor(userId),
165 cursor,
166 selection: state.selection ?? null,
167 viewing: state.viewing ?? existing?.viewing ?? true,
168 lastSeen: this.now(),
169 };
170 this.peerMap.set(userId, peer);
171 this.emit();
172 }
173
174
175 removePeer(userId: string): void {
176 if (this.peerMap.delete(userId)) this.emit();
177 }
178
179
184 pruneSilent(): readonly string[] {
185 const cutoff = this.now() - this.pruneAfterMs;
186 const pruned: string[] = [];
187 for (const [id, peer] of this.peerMap) {
188 if (peer.lastSeen < cutoff) {
189 this.peerMap.delete(id);
190 pruned.push(id);
191 }
192 }
193 if (pruned.length > 0) this.emit();
194 return pruned;
195 }
196
197
198 setSelfCursor(cursor: { blockId: string; offset: number } | null): void {
199 if (this.self === null || cursor === null) {
200 this.selfCursor = cursor;
201 return;
202 }
203
204
205 if (this.hasBlock !== undefined && !this.hasBlock(cursor.blockId)) {
206 this.selfCursor = null;
207 return;
208 }
209 this.selfCursor = { ...cursor };
210 }
211
212
213 setSelfViewing(viewing: boolean): void {
214 this.selfViewing = viewing;
215 }
216
217
218 selfState(): {
219 name?: string | undefined;
220 color?: string | undefined;
221 cursor: { blockId: string; offset: number } | null;
222 selection: string | null;
223 } {
224 return {
225 name: this.self?.name,
226 color: this.self?.color,
227 cursor: this.selfCursor,
228 selection: null,
229 };
230 }
231
232 get selfViewingState(): boolean {
233 return this.selfViewing;
234 }
235
236 private emit(): void {
237 this.onPeersChange?.([...this.peerMap.values()]);
238 }
239}
240