feat(P4C.T3): useSoundboardSync — initial diff + realtime + debounced push
This commit is contained in:
@@ -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<string, SyncBadge>;
|
||||||
|
initialPullDone: boolean;
|
||||||
|
} {
|
||||||
|
const { session } = useAuth();
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
const [badges, setBadges] = useState<Map<string, SyncBadge>>(new Map());
|
||||||
|
const [initialPullDone, setInitialPullDone] = useState(false);
|
||||||
|
const debounceRef = useRef<number | null>(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<void> {
|
||||||
|
const [localList, remoteList] = await Promise.all([
|
||||||
|
listSounds(),
|
||||||
|
listOwnSounds(supabase),
|
||||||
|
]);
|
||||||
|
const remoteById = new Map<string, RemoteSound>();
|
||||||
|
for (const r of remoteList) remoteById.set(r.id, r);
|
||||||
|
const localById = new Map<string, SoundboardEntry>();
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -369,3 +369,52 @@ export async function isHotkeyTaken(
|
|||||||
}
|
}
|
||||||
return false;
|
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<void> {
|
||||||
|
await tx(SOUNDS_STORE, 'readwrite', (s) => s.put(stored));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRawStoredSound(id: string): Promise<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user