fuzzyworld

north-star / files / src / features / friends / sync.ts

sync.ts

3.0 KB · history · blame

1import { ref, onValue } from 'firebase/database';
2import type { Database } from 'firebase/database';
3import { decryptJson } from '../../core/crypto';
4
5type FriendRecord = {
6 id?: string;
7 name?: string;
8 color?: string;
9 position?: { lat: number; lon: number };
10 timestamp?: number;
11 lastSeen?: number;
12 encryptedState?: string;
13 encryptedPosition?: string;
14 [key: string]: unknown;
15};
16
17/**
18 * Subscribes to the friends node for a room, decrypting each record
19 * as needed. Calls onUpdate(friends) after every snapshot resolves.
20 *
21 * @param {object} opts
22 * @param {object} opts.db - Firebase Database instance
23 * @param {string} opts.roomId
24 * @param {CryptoKey|null} opts.roomKey - AES-GCM key, or null for unencrypted rooms
25 * @param {function} opts.onUpdate - (friends: object[]) => void
26 * @returns {{ friends: object[] }} Shared array reference, mutated in place on each update
27 */
28export function subscribeToFriends({
29 db,
30 roomId,
31 roomKey,
32 onUpdate
33}: {
34 db: Database;
35 roomId: string;
36 roomKey: CryptoKey | null;
37 onUpdate: (friends: FriendRecord[]) => void;
38}) {
39 const friendsRef = ref(db, `rooms/${roomId}/friends`);
40 const friends: FriendRecord[] = [];
41 let snapshotVersion = 0;
42
43 onValue(friendsRef, async (snapshot) => {
44 const version = ++snapshotVersion;
45
46 const friendsData = snapshot.val();
47 if (friendsData) {
48 const rawFriends = Object.values(friendsData as Record<string, FriendRecord>);
49 const decrypted = await Promise.all(rawFriends.map(async (p): Promise<FriendRecord | null> => {
50 if (p.encryptedState && roomKey) {
51 try {
52 const decryptedState = await decryptJson(roomKey, p.encryptedState) as Record<string, unknown>;
53 return { ...decryptedState, lastSeen: p.lastSeen };
54 } catch (err) {
55 console.error('Failed to decrypt friend state', p.id, err);
56 return null;
57 }
58 }
59 if (p.encryptedPosition && roomKey) {
60 try {
61 const position = await decryptJson(roomKey, p.encryptedPosition) as { lat: number; lon: number };
62 const { encryptedPosition: _stripped, ...rest } = p;
63 return { ...rest, position };
64 } catch (err) {
65 console.error('Failed to decrypt legacy friend position', p.id, err);
66 return null;
67 }
68 }
69 return p;
70 }));
71 if (version !== snapshotVersion) return;
72 friends.length = 0;
73 friends.push(...decrypted.filter((item): item is FriendRecord => item !== null));
74 } else {
75 if (version !== snapshotVersion) return;
76 friends.length = 0;
77 }
78
79 onUpdate(friends);
80 });
81
82 return { friends };
83}