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 }; }