277 lines
8.0 KiB
TypeScript
277 lines
8.0 KiB
TypeScript
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 };
|
|
}
|