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 };
|
||||
}
|
||||
Reference in New Issue
Block a user