feat: file variety, user status, DND gate, drag-drop, mentions, link preview, emoji picker, soundboard, ringtone
Attachments
- Video: native <video controls>, decrypted blob, metadata preload
- PDF: collapsible <object> viewer with download + preview toggle
- Generic: file card with lazy decrypt, mime-to-extension map
- MessageBubble dispatch: audio/image/video/pdf/generic by mime
- Composer accept widened from image/* to any; AttachmentPreview renders
non-images as file cards
User status
- usePeerPresence returns { state, statusMessage } and tracks both fields
via realtime updates
- ConversationHeader subtitle shows custom status_message when peer is
online/idle/dnd (with message set); falls back to localized presence
label; always "Offline" when state === 'offline'
- UserBar: status_message input in dropdown (max 128 chars, save on
blur/Enter), subtitle uses same precedence as peer view
- AuthContext: auto-flip offline → online on session mount; best-effort
offline on beforeunload/pagehide; respects explicit idle/dnd/invisible
- i18n: presence.status_placeholder (DE + EN)
DND
- CallUI: incoming ring suppressed when own presence === 'dnd' (outgoing
rings stay audible — user-initiated)
- CallContext: presenceRef mirrors profile.presenceState, incoming-call
and missed-call OS notifications skipped under DND
- ConversationsContext already gated message tone + notify
Drag/drop + paste
- Page-root drag handlers feed ingestFiles; overlay shown while dragging
- Textarea onPaste extracts clipboard image items
@mentions in groups
- MentionAutocomplete: keyboard-driven dropdown, arrow/enter/tab/esc
- Composer onChange parses trailing @token, auto-open
- renderBodyWithMentions highlights @username tokens in bubbles
Link previews
- Migration 20260421000003_link_previews.sql (cache table, auth read,
service-role write via edge function)
- Edge function og-preview: POST {url}, fetches HTML (6s timeout, 1MB
cap), regex-parses OG/twitter/description, upserts cache
- useLinkPreview hook with in-memory cache + inflight dedup
- LinkPreviewCard rendered from first URL in message body
Emoji picker
- Built-in curated set (~250 emojis) across five categories
- Search by keyword/emoji char/category, recent list persisted in
localStorage
- Trigger button next to + and voice buttons in composer
Soundboard + ringtone + mic pipeline
- Pulled in (user-authored) SoundboardManagerDialog/Panel/Settings,
RingtoneSettings, mic pipeline + hotkeys + storage modules
- AuthContext imports updateOwnProfile for presence auto-flip
Fixes
- AttachmentAudio min-width so bubbles don't collapse to 0px on peer
side
- Focus flicker: visibility/online wake refresh throttled to 30s,
focus listener dropped, loading flag only on first fetch
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
// IndexedDB-backed soundboard.
|
||||
//
|
||||
// Two object stores:
|
||||
// `sounds` — one record per user-added sound, keyed by uuid. Contains both
|
||||
// metadata (name, category, hotkey, gain, order, timestamps) and
|
||||
// the raw blob so decoding can pull everything in one get().
|
||||
// `prefs` — single "prefs" record with global soundboard state (master
|
||||
// volume, local monitor volume).
|
||||
//
|
||||
// CRUD helpers surface a SoundboardEntry shape without the blob so call sites
|
||||
// that only need metadata (list views, hotkey registration) don't pull the
|
||||
// audio payload into memory. `getSoundBlob(id)` fetches the blob on demand.
|
||||
|
||||
export interface SoundboardEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
category: string | null;
|
||||
hotkey: string | null;
|
||||
gain: number; // 0..1
|
||||
order: number; // ascending within (category, uncategorized) bucket
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface SoundboardPrefs {
|
||||
masterGain: number; // 0..1
|
||||
monitorGain: number; // 0..1
|
||||
}
|
||||
|
||||
interface StoredSound extends SoundboardEntry {
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFS: SoundboardPrefs = {
|
||||
masterGain: 0.8,
|
||||
monitorGain: 0.5,
|
||||
};
|
||||
|
||||
export const MAX_SOUND_BYTES = 5 * 1024 * 1024; // 5 MB per clip
|
||||
|
||||
// --- Change observer ------------------------------------------------------
|
||||
// Synchronous tiny pub-sub so interested modules (hotkey registry, in-call
|
||||
// panel, settings dialog) refresh when the manifest mutates in any tab.
|
||||
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export function subscribeSoundboardChanges(l: Listener): () => void {
|
||||
listeners.add(l);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
}
|
||||
|
||||
function notifyChange(): void {
|
||||
for (const l of listeners) {
|
||||
try {
|
||||
l();
|
||||
} catch (err: unknown) {
|
||||
console.warn('soundboard change listener threw', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DB_NAME = 'netralax-soundboard';
|
||||
const DB_VERSION = 1;
|
||||
const SOUNDS_STORE = 'sounds';
|
||||
const PREFS_STORE = 'prefs';
|
||||
const PREFS_KEY = 'prefs';
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(SOUNDS_STORE)) {
|
||||
db.createObjectStore(SOUNDS_STORE, { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(PREFS_STORE)) {
|
||||
db.createObjectStore(PREFS_STORE);
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error ?? new Error('indexedDB open failed'));
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
function tx<T>(
|
||||
store: string,
|
||||
mode: IDBTransactionMode,
|
||||
fn: (s: IDBObjectStore) => IDBRequest<T> | void,
|
||||
): Promise<T | undefined> {
|
||||
return openDb().then(
|
||||
(db) =>
|
||||
new Promise<T | undefined>((resolve, reject) => {
|
||||
const t = db.transaction(store, mode);
|
||||
const s = t.objectStore(store);
|
||||
let result: T | undefined = undefined;
|
||||
const req = fn(s);
|
||||
if (req) {
|
||||
req.onsuccess = () => {
|
||||
result = req.result;
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
}
|
||||
t.oncomplete = () => resolve(result);
|
||||
t.onerror = () => reject(t.error);
|
||||
t.onabort = () => reject(t.error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function stripBlob(stored: StoredSound): SoundboardEntry {
|
||||
const { blob: _blob, ...rest } = stored;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function clamp01(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
if (v < 0) return 0;
|
||||
if (v > 1) return 1;
|
||||
return v;
|
||||
}
|
||||
|
||||
function genId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return 'sb-' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
export async function listSounds(): Promise<SoundboardEntry[]> {
|
||||
const db = await openDb();
|
||||
return new Promise<SoundboardEntry[]>((resolve, reject) => {
|
||||
const t = db.transaction(SOUNDS_STORE, 'readonly');
|
||||
const s = t.objectStore(SOUNDS_STORE);
|
||||
const req = s.getAll();
|
||||
req.onsuccess = () => {
|
||||
const all = (req.result as StoredSound[]).map(stripBlob);
|
||||
// Stable sort: category name asc (null last), then order asc, then
|
||||
// createdAt as a tie-breaker so freshly added sounds don't leapfrog.
|
||||
all.sort((a, b) => {
|
||||
const catCmp = compareCategory(a.category, b.category);
|
||||
if (catCmp !== 0) return catCmp;
|
||||
if (a.order !== b.order) return a.order - b.order;
|
||||
return a.createdAt - b.createdAt;
|
||||
});
|
||||
resolve(all);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
function compareCategory(a: string | null, b: string | null): number {
|
||||
if (a === b) return 0;
|
||||
if (a === null) return 1; // uncategorized last
|
||||
if (b === null) return -1;
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
export async function listCategories(): Promise<string[]> {
|
||||
const all = await listSounds();
|
||||
const set = new Set<string>();
|
||||
for (const s of all) {
|
||||
if (s.category) set.add(s.category);
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
async function nextOrderFor(category: string | null): Promise<number> {
|
||||
const all = await listSounds();
|
||||
let max = -1;
|
||||
for (const s of all) {
|
||||
if (s.category === category && s.order > max) max = s.order;
|
||||
}
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
export interface AddSoundInput {
|
||||
file: File;
|
||||
name?: string;
|
||||
category?: string | null;
|
||||
}
|
||||
|
||||
export async function addSound(input: AddSoundInput): Promise<SoundboardEntry> {
|
||||
const { file, name, category = null } = input;
|
||||
if (file.size === 0) throw new Error('empty file');
|
||||
if (file.size > MAX_SOUND_BYTES) throw new Error('sound_too_large');
|
||||
const mime = file.type || 'application/octet-stream';
|
||||
if (!mime.startsWith('audio/')) throw new Error('sound_not_audio');
|
||||
|
||||
const now = Date.now();
|
||||
const trimmedName = (name ?? file.name.replace(/\.[^.]+$/, '')).trim() || 'Untitled';
|
||||
const entry: StoredSound = {
|
||||
id: genId(),
|
||||
name: trimmedName,
|
||||
mime,
|
||||
size: file.size,
|
||||
category: category ?? null,
|
||||
hotkey: null,
|
||||
gain: 1,
|
||||
order: await nextOrderFor(category ?? null),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
blob: file,
|
||||
};
|
||||
await tx(SOUNDS_STORE, 'readwrite', (s) => s.put(entry));
|
||||
notifyChange();
|
||||
return stripBlob(entry);
|
||||
}
|
||||
|
||||
export interface UpdateSoundPatch {
|
||||
name?: string;
|
||||
category?: string | null;
|
||||
hotkey?: string | null;
|
||||
gain?: number;
|
||||
}
|
||||
|
||||
export async function updateSound(
|
||||
id: string,
|
||||
patch: UpdateSoundPatch,
|
||||
): Promise<SoundboardEntry> {
|
||||
const db = await openDb();
|
||||
return new Promise<SoundboardEntry>((resolve, reject) => {
|
||||
const t = db.transaction(SOUNDS_STORE, 'readwrite');
|
||||
const s = t.objectStore(SOUNDS_STORE);
|
||||
const getReq = s.get(id);
|
||||
getReq.onsuccess = () => {
|
||||
const current = getReq.result as StoredSound | undefined;
|
||||
if (!current) {
|
||||
reject(new Error('sound_not_found'));
|
||||
return;
|
||||
}
|
||||
const nextCategory = patch.category !== undefined ? patch.category : current.category;
|
||||
const categoryChanged = nextCategory !== current.category;
|
||||
const next: StoredSound = {
|
||||
...current,
|
||||
...(patch.name !== undefined ? { name: patch.name.trim() || current.name } : {}),
|
||||
...(patch.category !== undefined ? { category: nextCategory } : {}),
|
||||
...(patch.hotkey !== undefined ? { hotkey: patch.hotkey } : {}),
|
||||
...(patch.gain !== undefined ? { gain: clamp01(patch.gain) } : {}),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
// When moving categories, append to the destination's end so the move
|
||||
// doesn't collide with existing order values.
|
||||
if (categoryChanged) {
|
||||
const getAll = s.getAll();
|
||||
getAll.onsuccess = () => {
|
||||
const all = getAll.result as StoredSound[];
|
||||
let max = -1;
|
||||
for (const e of all) {
|
||||
if (e.category === nextCategory && e.order > max) max = e.order;
|
||||
}
|
||||
next.order = max + 1;
|
||||
const putReq = s.put(next);
|
||||
putReq.onsuccess = () => {
|
||||
notifyChange();
|
||||
resolve(stripBlob(next));
|
||||
};
|
||||
putReq.onerror = () => reject(putReq.error);
|
||||
};
|
||||
getAll.onerror = () => reject(getAll.error);
|
||||
return;
|
||||
}
|
||||
const putReq = s.put(next);
|
||||
putReq.onsuccess = () => {
|
||||
notifyChange();
|
||||
resolve(stripBlob(next));
|
||||
};
|
||||
putReq.onerror = () => reject(putReq.error);
|
||||
};
|
||||
getReq.onerror = () => reject(getReq.error);
|
||||
t.onerror = () => reject(t.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSound(id: string): Promise<void> {
|
||||
await tx(SOUNDS_STORE, 'readwrite', (s) => s.delete(id));
|
||||
notifyChange();
|
||||
}
|
||||
|
||||
export async function reorderCategory(
|
||||
category: string | null,
|
||||
orderedIds: string[],
|
||||
): Promise<void> {
|
||||
const db = await openDb();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const t = db.transaction(SOUNDS_STORE, 'readwrite');
|
||||
const s = t.objectStore(SOUNDS_STORE);
|
||||
let remaining = orderedIds.length;
|
||||
if (remaining === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
orderedIds.forEach((id, idx) => {
|
||||
const getReq = s.get(id);
|
||||
getReq.onsuccess = () => {
|
||||
const current = getReq.result as StoredSound | undefined;
|
||||
if (!current || current.category !== category) {
|
||||
remaining--;
|
||||
if (remaining === 0) resolve();
|
||||
return;
|
||||
}
|
||||
const next: StoredSound = { ...current, order: idx, updatedAt: Date.now() };
|
||||
const putReq = s.put(next);
|
||||
putReq.onsuccess = () => {
|
||||
remaining--;
|
||||
if (remaining === 0) resolve();
|
||||
};
|
||||
putReq.onerror = () => reject(putReq.error);
|
||||
};
|
||||
getReq.onerror = () => reject(getReq.error);
|
||||
});
|
||||
t.oncomplete = () => notifyChange();
|
||||
t.onerror = () => reject(t.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSoundBlob(id: string): Promise<Blob | null> {
|
||||
const db = await openDb();
|
||||
return new Promise<Blob | null>((resolve, reject) => {
|
||||
const t = db.transaction(SOUNDS_STORE, 'readonly');
|
||||
const s = t.objectStore(SOUNDS_STORE);
|
||||
const req = s.get(id);
|
||||
req.onsuccess = () => {
|
||||
const cur = req.result as StoredSound | undefined;
|
||||
resolve(cur ? cur.blob : null);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPrefs(): Promise<SoundboardPrefs> {
|
||||
const raw = await tx<SoundboardPrefs | undefined>('prefs', 'readonly', (s) =>
|
||||
s.get(PREFS_KEY) as IDBRequest<SoundboardPrefs | undefined>,
|
||||
);
|
||||
if (!raw) return { ...DEFAULT_PREFS };
|
||||
return {
|
||||
masterGain: clamp01(raw.masterGain ?? DEFAULT_PREFS.masterGain),
|
||||
monitorGain: clamp01(raw.monitorGain ?? DEFAULT_PREFS.monitorGain),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updatePrefs(patch: Partial<SoundboardPrefs>): Promise<SoundboardPrefs> {
|
||||
const cur = await getPrefs();
|
||||
const next: SoundboardPrefs = {
|
||||
masterGain: patch.masterGain !== undefined ? clamp01(patch.masterGain) : cur.masterGain,
|
||||
monitorGain: patch.monitorGain !== undefined ? clamp01(patch.monitorGain) : cur.monitorGain,
|
||||
};
|
||||
await tx('prefs', 'readwrite', (s) => s.put(next, PREFS_KEY));
|
||||
return next;
|
||||
}
|
||||
|
||||
// Convenience: checks whether `accelerator` is already bound to a sound other
|
||||
// than `excludeId`. Pair with PTT check at the CallContext layer.
|
||||
export async function isHotkeyTaken(
|
||||
accelerator: string,
|
||||
excludeId: string | null = null,
|
||||
): Promise<boolean> {
|
||||
const all = await listSounds();
|
||||
for (const s of all) {
|
||||
if (s.hotkey === accelerator && s.id !== excludeId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user