Skip to content
File

Blob: src/client/hooks/use-sync.ts

typescript131 lines
1import { useCallback, useSyncExternalStore } from "react";
2import type YProvider from "y-partyserver/provider";
3import type { Awareness } from "y-protocols/awareness";
4import { getDocSyncStatus, type SyncStatus } from "@/client/lib/doc-sync-provider";
5 
6export type { SyncStatus } from "@/client/lib/doc-sync-provider";
7 
8export function useSyncStatus(
9 provider: YProvider | null,
10 online: boolean,
11): {
12 status: SyncStatus;
13 synced: boolean;
14} {
15 const subscribe = useCallback(
16 (callback: () => void) => {
17 if (!provider) return () => {};
18 
19 provider.on("status", callback);
20 provider.on("synced", callback);
21 
22 return () => {
23 provider.off("status", callback);
24 provider.off("synced", callback);
25 };
26 },
27 [provider],
28 );
29 
30 const getSnapshot = useCallback(() => getSyncSnapshotKey(provider), [provider]);
31 const snapshot = useSyncExternalStore(subscribe, getSnapshot, () => DISCONNECTED_SYNC_SNAPSHOT);
32 const [providerStatus, synced] = parseSyncSnapshotKey(snapshot);
33 
34 return {
35 status: online ? providerStatus : "disconnected",
36 synced,
37 };
38}
39 
40export interface AwarenessUser {
41 userId: string | null;
42}
43 
44export interface AwarenessState {
45 user?: AwarenessUser;
46 [key: string]: unknown;
47}
48 
49export function useAwareness(awareness: Awareness | null): Map<number, AwarenessState> {
50 const subscribe = useCallback(
51 (callback: () => void) => {
52 if (!awareness) return () => {};
53 
54 const handleChange = () => {
55 callback();
56 };
57 
58 awareness.on("change", handleChange);
59 return () => awareness.off("change", handleChange);
60 },
61 [awareness],
62 );
63 
64 const getSnapshot = useCallback(() => getAwarenessSnapshot(awareness), [awareness]);
65 
66 return useSyncExternalStore(subscribe, getSnapshot, () => EMPTY_MAP);
67}
68 
69const EMPTY_MAP = new Map<number, AwarenessState>();
70const DISCONNECTED_SYNC_SNAPSHOT = "disconnected:0";
71 
72function getSyncSnapshotKey(provider: YProvider | null): string {
73 if (!provider) return DISCONNECTED_SYNC_SNAPSHOT;
74 return `${getDocSyncStatus(provider, true)}:${provider.synced ? "1" : "0"}`;
75}
76 
77function parseSyncSnapshotKey(key: string): [SyncStatus, boolean] {
78 const [status, synced] = key.split(":");
79 return [(status as SyncStatus) || "disconnected", synced === "1"];
80}
81 
82interface AwarenessSnapshotCache {
83 snapshot: Map<number, AwarenessState>;
84}
85 
86const awarenessSnapshotCaches = new WeakMap<Awareness, AwarenessSnapshotCache>();
87 
88function getAwarenessSnapshotCache(awareness: Awareness): AwarenessSnapshotCache {
89 let cache = awarenessSnapshotCaches.get(awareness);
90 if (!cache) {
91 cache = {
92 snapshot: EMPTY_MAP,
93 };
94 awarenessSnapshotCaches.set(awareness, cache);
95 }
96 return cache;
97}
98 
99function getAwarenessSnapshot(awareness: Awareness | null): Map<number, AwarenessState> {
100 if (!awareness) return EMPTY_MAP;
101 
102 const cache = getAwarenessSnapshotCache(awareness);
103 const states = awareness.getStates() as Map<number, AwarenessState>;
104 if (!areAwarenessSnapshotsEqual(cache.snapshot, states)) {
105 cache.snapshot = new Map(states);
106 }
107 return cache.snapshot;
108}
109 
110function areAwarenessSnapshotsEqual(previous: Map<number, AwarenessState>, next: Map<number, AwarenessState>): boolean {
111 if (previous.size !== next.size) return false;
112 for (const [clientId, state] of next) {
113 if (!Object.is(previous.get(clientId), state)) return false;
114 }
115 return true;
116}
117 
118const COLORS = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#9d6ee8", "#ec4899", "#06b6d4", "#f97316"];
119 
120function hashToColor(seed: string): string {
121 let hash = 0;
122 for (let i = 0; i < seed.length; i++) {
123 hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0;
124 }
125 return COLORS[Math.abs(hash) % COLORS.length];
126}
127 
128export function awarenessColor(userId: string | null, clientId: number): string {
129 return hashToColor(userId ?? `anon:${clientId}`);
130}