diff --git a/apps/desktop/src/hooks/useSoundboardSync.ts b/apps/desktop/src/hooks/useSoundboardSync.ts new file mode 100644 index 0000000..512b8dc --- /dev/null +++ b/apps/desktop/src/hooks/useSoundboardSync.ts @@ -0,0 +1,276 @@ +import { useEffect, useRef, useState } from 'react'; + +import { getCryptoBackend } from '@chat-app/shared/crypto'; +import { + decryptSoundEnvelope, + downloadSoundCiphertext, + encryptSoundBlob, + listOwnSounds, + type RemoteSound, + upsertSound, + uploadSoundCiphertext, +} from '@chat-app/shared/chat'; + +import { useAuth } from '../context/AuthContext'; +import { + deleteRawStoredSound, + getRawStoredSound, + listSounds, + putRawStoredSound, + type SoundboardEntry, + subscribeSoundboardChanges, +} from '../lib/soundboardStorage'; +import { supabase } from '../lib/supabase'; +import { cachedUserKey } from '../lib/userIdentity'; + +export type SyncBadge = 'synced' | 'uploading' | 'downloading' | 'error'; + +const PUSH_DEBOUNCE_MS = 500; +const DELETE_GRACE_MS = 5000; + +function storagePathFor(userId: string, soundId: string): string { + return userId + '/' + soundId + '.bin'; +} + +export function useSoundboardSync(): { + badges: Map; + initialPullDone: boolean; +} { + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [badges, setBadges] = useState>(new Map()); + const [initialPullDone, setInitialPullDone] = useState(false); + const debounceRef = useRef(null); + + function setBadge(id: string, badge: SyncBadge): void { + setBadges((cur) => { + const next = new Map(cur); + next.set(id, badge); + return next; + }); + } + + useEffect(() => { + if (!userId) { + setInitialPullDone(false); + setBadges(new Map()); + return; + } + let cancelled = false; + let teardown: (() => void) | null = null; + + const init = async () => { + const priv = await cachedUserKey(userId); + if (!priv) return; + const pub = getCryptoBackend().scalarMultBase(priv); + + try { + await runDiff(userId, priv, pub); + } catch (err) { + console.error('soundboard initial diff failed', err); + } + if (cancelled) return; + setInitialPullDone(true); + + const unsubLocal = subscribeSoundboardChanges(() => { + if (debounceRef.current !== null) window.clearTimeout(debounceRef.current); + debounceRef.current = window.setTimeout(() => { + debounceRef.current = null; + void runDiff(userId, priv, pub).catch((err) => { + console.error('soundboard push diff failed', err); + }); + }, PUSH_DEBOUNCE_MS); + }); + + const channel = supabase + .channel('soundboards:' + userId) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'user_soundboards', + filter: 'user_id=eq.' + userId, + }, + () => { + void runDiff(userId, priv, pub).catch((err) => { + console.error('soundboard realtime pull failed', err); + }); + }, + ) + .subscribe(); + + teardown = () => { + unsubLocal(); + void supabase.removeChannel(channel); + if (debounceRef.current !== null) { + window.clearTimeout(debounceRef.current); + debounceRef.current = null; + } + }; + }; + + void init(); + return () => { + cancelled = true; + teardown?.(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId]); + + async function runDiff(uid: string, priv: Uint8Array, pub: Uint8Array): Promise { + const [localList, remoteList] = await Promise.all([ + listSounds(), + listOwnSounds(supabase), + ]); + const remoteById = new Map(); + for (const r of remoteList) remoteById.set(r.id, r); + const localById = new Map(); + for (const l of localList) localById.set(l.id, l); + + // Push pass + for (const local of localList) { + const remote = remoteById.get(local.id); + const localIso = new Date(local.updatedAt).toISOString(); + if (!remote) { + await uploadAndUpsert(local, uid, priv, pub, localIso); + } else { + const remoteMs = Date.parse(remote.updatedAt); + if (local.updatedAt > remoteMs) { + await upsertMetadataOnly(local, remote, localIso); + } + } + } + + // Pull pass + for (const remote of remoteList) { + const local = localById.get(remote.id); + const remoteMs = Date.parse(remote.updatedAt); + if (!local) { + await pullAndStore(remote, priv, pub); + } else if (remoteMs > local.updatedAt) { + const stored = await getRawStoredSound(remote.id); + if (stored) { + await putRawStoredSound({ + ...stored, + name: remote.name, + mime: remote.mime, + size: remote.size, + category: remote.category, + hotkey: remote.hotkey, + gain: remote.gain, + order: remote.sortOrder, + updatedAt: remoteMs, + }); + setBadge(remote.id, 'synced'); + } + } else { + setBadge(remote.id, 'synced'); + } + } + + // Remote-absence → local delete (with grace window for fresh adds). + const now = Date.now(); + for (const local of localList) { + if (!remoteById.has(local.id) && now - local.updatedAt > DELETE_GRACE_MS) { + await deleteRawStoredSound(local.id); + } + } + } + + async function uploadAndUpsert( + local: SoundboardEntry, + uid: string, + priv: Uint8Array, + pub: Uint8Array, + localIso: string, + ): Promise { + setBadge(local.id, 'uploading'); + try { + const stored = await getRawStoredSound(local.id); + if (!stored) return; + const ciphertext = await encryptSoundBlob(stored.blob, pub, priv); + const path = storagePathFor(uid, local.id); + await uploadSoundCiphertext(supabase, path, ciphertext); + await upsertSound(supabase, { + id: local.id, + name: local.name, + mime: local.mime, + size: local.size, + category: local.category, + hotkey: local.hotkey, + gain: local.gain, + sortOrder: local.order, + storagePath: path, + updatedAtIso: localIso, + }); + setBadge(local.id, 'synced'); + } catch (err) { + console.error('soundboard upload failed', err); + setBadge(local.id, 'error'); + } + } + + async function upsertMetadataOnly( + local: SoundboardEntry, + remote: RemoteSound, + localIso: string, + ): Promise { + setBadge(local.id, 'uploading'); + try { + await upsertSound(supabase, { + id: local.id, + name: local.name, + mime: local.mime, + size: local.size, + category: local.category, + hotkey: local.hotkey, + gain: local.gain, + sortOrder: local.order, + storagePath: remote.storagePath, + updatedAtIso: localIso, + }); + setBadge(local.id, 'synced'); + } catch (err) { + console.error('soundboard metadata upload failed', err); + setBadge(local.id, 'error'); + } + } + + async function pullAndStore( + remote: RemoteSound, + priv: Uint8Array, + pub: Uint8Array, + ): Promise { + setBadge(remote.id, 'downloading'); + try { + const envelope = await downloadSoundCiphertext(supabase, remote.storagePath); + const plain = await decryptSoundEnvelope(envelope, pub, priv); + // Copy into a fresh ArrayBuffer so Blob accepts it across TS lib variants + // (mirrors the pattern in @chat-app/shared/chat/attachments.ts). + const copy = new Uint8Array(plain.byteLength); + copy.set(plain); + const blob = new Blob([copy.buffer], { type: remote.mime }); + const ms = Date.parse(remote.updatedAt); + await putRawStoredSound({ + id: remote.id, + name: remote.name, + mime: remote.mime, + size: remote.size, + category: remote.category, + hotkey: remote.hotkey, + gain: remote.gain, + order: remote.sortOrder, + createdAt: Date.parse(remote.createdAt), + updatedAt: ms, + blob, + }); + setBadge(remote.id, 'synced'); + } catch (err) { + console.error('soundboard pull failed', err); + setBadge(remote.id, 'error'); + } + } + + return { badges, initialPullDone }; +} diff --git a/apps/desktop/src/lib/soundboardStorage.ts b/apps/desktop/src/lib/soundboardStorage.ts index d4a028d..fae6d52 100644 --- a/apps/desktop/src/lib/soundboardStorage.ts +++ b/apps/desktop/src/lib/soundboardStorage.ts @@ -369,3 +369,52 @@ export async function isHotkeyTaken( } return false; } + +// --- Sync-engine bypass helpers ------------------------------------------ +// Used by useSoundboardSync to write/delete IndexedDB rows without firing +// notifyChange — pulls and remote-driven deletes are not "edits". If they +// triggered notifyChange the engine would loop: +// push debounce → upsert → realtime → pull → notifyChange → push debounce → ... + +export async function putRawStoredSound(stored: { + id: string; + name: string; + mime: string; + size: number; + category: string | null; + hotkey: string | null; + gain: number; + order: number; + createdAt: number; + updatedAt: number; + blob: Blob; +}): Promise { + await tx(SOUNDS_STORE, 'readwrite', (s) => s.put(stored)); +} + +export async function deleteRawStoredSound(id: string): Promise { + await tx(SOUNDS_STORE, 'readwrite', (s) => s.delete(id)); +} + +export async function getRawStoredSound(id: string): Promise<{ + id: string; + name: string; + mime: string; + size: number; + category: string | null; + hotkey: string | null; + gain: number; + order: number; + createdAt: number; + updatedAt: number; + blob: Blob; +} | null> { + const db = await openDb(); + return new Promise((resolve, reject) => { + const t = db.transaction(SOUNDS_STORE, 'readonly'); + const s = t.objectStore(SOUNDS_STORE); + const req = s.get(id); + req.onsuccess = () => resolve((req.result as any) ?? null); + req.onerror = () => reject(req.error); + }); +}