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:
@@ -55,3 +55,72 @@ export async function unregisterPttShortcut(code: string): Promise<void> {
|
||||
export function isTauriRuntime(): boolean {
|
||||
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
}
|
||||
|
||||
// --- Soundboard shortcuts --------------------------------------------------
|
||||
//
|
||||
// Separate from the single PTT shortcut: the soundboard needs to register
|
||||
// many fire-and-forget press bindings at once, keep track of which ids own
|
||||
// which accelerators so we can unregister just one, and expose conflict
|
||||
// detection for the settings UI.
|
||||
|
||||
interface SoundShortcutRegistration {
|
||||
shortcut: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
// Map of logical id (sound uuid) -> registration.
|
||||
const soundRegistry = new Map<string, SoundShortcutRegistration>();
|
||||
|
||||
export async function registerSoundShortcut(
|
||||
id: string,
|
||||
code: string,
|
||||
onPress: () => void,
|
||||
): Promise<boolean> {
|
||||
if (!isTauriRuntime()) return false;
|
||||
const shortcut = codeToShortcut(code);
|
||||
// Unregister any previous binding for this id first — caller may be
|
||||
// re-registering after the user changed the hotkey for the same sound.
|
||||
await unregisterSoundShortcut(id);
|
||||
try {
|
||||
if (await isRegistered(shortcut)) {
|
||||
await unregister(shortcut);
|
||||
}
|
||||
await register(shortcut, (event: ShortcutEvent) => {
|
||||
if (event.state === 'Pressed') onPress();
|
||||
});
|
||||
soundRegistry.set(id, { shortcut, onPress });
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
console.warn('registerSoundShortcut failed', { id, code, err });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterSoundShortcut(id: string): Promise<void> {
|
||||
const reg = soundRegistry.get(id);
|
||||
if (!reg) return;
|
||||
soundRegistry.delete(id);
|
||||
if (!isTauriRuntime()) return;
|
||||
try {
|
||||
if (await isRegistered(reg.shortcut)) {
|
||||
await unregister(reg.shortcut);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.warn('unregisterSoundShortcut failed', { id, err });
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterAllSoundShortcuts(): Promise<void> {
|
||||
const ids = Array.from(soundRegistry.keys());
|
||||
await Promise.all(ids.map((id) => unregisterSoundShortcut(id)));
|
||||
}
|
||||
|
||||
// Resolve a DOM code to the registry's current owner (if any). Used by the
|
||||
// settings UI to surface conflicts before saving a new hotkey.
|
||||
export function soundShortcutOwnerFor(code: string): string | null {
|
||||
const shortcut = codeToShortcut(code);
|
||||
for (const [id, reg] of soundRegistry) {
|
||||
if (reg.shortcut === shortcut) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
// Shared Web Audio graph that sits between the raw microphone MediaStream
|
||||
// and LiveKit's published track. Mixes live mic with on-demand soundboard
|
||||
// buffers so both paths reach the peer through a single published track,
|
||||
// and lets us locally monitor soundboard output without feedback from the
|
||||
// mic path.
|
||||
//
|
||||
// Graph:
|
||||
// rawMicSource ──► micGain ──┐
|
||||
// ├─► destinationNode ─► publishedTrack
|
||||
// sbBufferSources ─► sbGain ─┤
|
||||
// └─► monitorGain ─► ctx.destination (local hear,
|
||||
// soundboard only)
|
||||
//
|
||||
// Lifetime:
|
||||
// createMicPipeline(rawTrack) — builds graph + AudioContext
|
||||
// pipeline.outputTrack — pass to `localParticipant.publishTrack`
|
||||
// pipeline.setMicGain(0..1) — mute / PTT
|
||||
// pipeline.setSoundboardGain(..) / setMonitorGain(..) — sb master + local hear
|
||||
// pipeline.playBuffer(buffer, opts) — returns a handle so callers can stop
|
||||
// pipeline.stopAll(buffers?) — kill every active sb source (or only one id)
|
||||
// pipeline.replaceMicTrack(newTrack) — hot-swap on device change
|
||||
// pipeline.destroy() — close ctx, stop owned tracks
|
||||
|
||||
export interface PlayBufferOpts {
|
||||
/** Per-source gain 0..1, multiplied by sb master. */
|
||||
gain?: number;
|
||||
/** Stable id — calling playBuffer with the same id stops the previous one
|
||||
* first (single-fire mode). Omit for overlap mode. */
|
||||
id?: string;
|
||||
/** Fired when the buffer ends naturally (not when stopped manually). */
|
||||
onEnded?: () => void;
|
||||
}
|
||||
|
||||
export interface PlayHandle {
|
||||
id: string | null;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export interface MicPipeline {
|
||||
readonly outputTrack: MediaStreamTrack;
|
||||
setMicGain(value: number): void;
|
||||
setSoundboardGain(value: number): void;
|
||||
setMonitorGain(value: number): void;
|
||||
replaceMicTrack(newTrack: MediaStreamTrack): void;
|
||||
playBuffer(buffer: AudioBuffer, opts?: PlayBufferOpts): PlayHandle;
|
||||
stopAll(id?: string): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
interface ActiveSource {
|
||||
id: string | null;
|
||||
node: AudioBufferSourceNode;
|
||||
gain: GainNode;
|
||||
}
|
||||
|
||||
// Clamp helper — avoid letting callers pass NaN or out-of-range values.
|
||||
function clamp01(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
if (v < 0) return 0;
|
||||
if (v > 1) return 1;
|
||||
return v;
|
||||
}
|
||||
|
||||
export function createMicPipeline(rawTrack: MediaStreamTrack): MicPipeline {
|
||||
const AudioCtx =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioCtx) {
|
||||
throw new Error('AudioContext unavailable');
|
||||
}
|
||||
|
||||
const ctx = new AudioCtx();
|
||||
|
||||
const micGain = ctx.createGain();
|
||||
micGain.gain.value = 1;
|
||||
|
||||
const sbGain = ctx.createGain();
|
||||
sbGain.gain.value = 1;
|
||||
|
||||
const monitorGain = ctx.createGain();
|
||||
monitorGain.gain.value = 1;
|
||||
|
||||
const dest = ctx.createMediaStreamDestination();
|
||||
|
||||
// Mic path → published only.
|
||||
micGain.connect(dest);
|
||||
|
||||
// Soundboard path → published + local monitor.
|
||||
sbGain.connect(dest);
|
||||
sbGain.connect(monitorGain);
|
||||
monitorGain.connect(ctx.destination);
|
||||
|
||||
let currentRawTrack: MediaStreamTrack = rawTrack;
|
||||
let micSource: MediaStreamAudioSourceNode = buildMicSource(ctx, rawTrack, micGain);
|
||||
|
||||
const active = new Set<ActiveSource>();
|
||||
let destroyed = false;
|
||||
|
||||
function buildMicSource(
|
||||
c: AudioContext,
|
||||
t: MediaStreamTrack,
|
||||
target: AudioNode,
|
||||
): MediaStreamAudioSourceNode {
|
||||
const stream = new MediaStream([t]);
|
||||
const node = c.createMediaStreamSource(stream);
|
||||
node.connect(target);
|
||||
return node;
|
||||
}
|
||||
|
||||
const outputTrack = dest.stream.getAudioTracks()[0];
|
||||
if (!outputTrack) {
|
||||
throw new Error('MediaStreamAudioDestinationNode produced no audio track');
|
||||
}
|
||||
|
||||
return {
|
||||
outputTrack,
|
||||
|
||||
setMicGain(value: number) {
|
||||
if (destroyed) return;
|
||||
const v = clamp01(value);
|
||||
micGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
|
||||
},
|
||||
|
||||
setSoundboardGain(value: number) {
|
||||
if (destroyed) return;
|
||||
const v = clamp01(value);
|
||||
sbGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
|
||||
},
|
||||
|
||||
setMonitorGain(value: number) {
|
||||
if (destroyed) return;
|
||||
const v = clamp01(value);
|
||||
monitorGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
|
||||
},
|
||||
|
||||
replaceMicTrack(newTrack: MediaStreamTrack) {
|
||||
if (destroyed) return;
|
||||
// Tear down the old MediaStreamSourceNode and stop the raw track we
|
||||
// owned. Caller passes ownership of `newTrack` to the pipeline.
|
||||
try {
|
||||
micSource.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (currentRawTrack !== newTrack) {
|
||||
try {
|
||||
currentRawTrack.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
currentRawTrack = newTrack;
|
||||
micSource = buildMicSource(ctx, newTrack, micGain);
|
||||
},
|
||||
|
||||
playBuffer(buffer: AudioBuffer, opts: PlayBufferOpts = {}): PlayHandle {
|
||||
if (destroyed) {
|
||||
return { id: opts.id ?? null, stop: () => undefined };
|
||||
}
|
||||
|
||||
// Single-fire: stop previous instance of the same id so holding a
|
||||
// hotkey doesn't stack a dozen overlapping plays.
|
||||
if (opts.id) {
|
||||
for (const entry of active) {
|
||||
if (entry.id === opts.id) stopActive(entry);
|
||||
}
|
||||
}
|
||||
|
||||
const node = ctx.createBufferSource();
|
||||
node.buffer = buffer;
|
||||
|
||||
const g = ctx.createGain();
|
||||
g.gain.value = clamp01(opts.gain ?? 1);
|
||||
|
||||
node.connect(g);
|
||||
g.connect(sbGain);
|
||||
|
||||
const entry: ActiveSource = { id: opts.id ?? null, node, gain: g };
|
||||
active.add(entry);
|
||||
|
||||
node.onended = () => {
|
||||
if (!active.has(entry)) return;
|
||||
try {
|
||||
node.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
g.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
active.delete(entry);
|
||||
opts.onEnded?.();
|
||||
};
|
||||
|
||||
try {
|
||||
node.start();
|
||||
} catch {
|
||||
active.delete(entry);
|
||||
}
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
stop: () => stopActive(entry),
|
||||
};
|
||||
},
|
||||
|
||||
stopAll(id?: string) {
|
||||
for (const entry of Array.from(active)) {
|
||||
if (id !== undefined && entry.id !== id) continue;
|
||||
stopActive(entry);
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
for (const entry of Array.from(active)) stopActive(entry);
|
||||
try {
|
||||
micSource.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
micGain.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
sbGain.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
monitorGain.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
currentRawTrack.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
outputTrack.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
void ctx.close().catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function stopActive(entry: ActiveSource): void {
|
||||
try {
|
||||
entry.node.onended = null;
|
||||
entry.node.stop();
|
||||
} catch {
|
||||
/* already ended */
|
||||
}
|
||||
try {
|
||||
entry.node.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
entry.gain.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
active.delete(entry);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
// Looping WebAudio ringtones. Two patterns:
|
||||
// - outgoing: long calling tone, 3s cycle
|
||||
// - incoming: classic "ring ring" double beep, 2s cycle
|
||||
// Looping ringtones. Two patterns:
|
||||
// - outgoing: long calling tone, 3s cycle (oscillator only)
|
||||
// - incoming: classic "ring ring" double beep (oscillator), optionally
|
||||
// upgraded to a user-supplied audio file stored in ringtoneStorage
|
||||
//
|
||||
// The custom file plays immediately if we can load it; otherwise we fall
|
||||
// back to the generated oscillator pattern so ringing never misses an
|
||||
// incoming call due to an IO failure.
|
||||
|
||||
import { getIncomingRingtone } from './ringtoneStorage';
|
||||
|
||||
type Pattern = 'outgoing' | 'incoming';
|
||||
|
||||
@@ -9,15 +16,90 @@ class Ringtone {
|
||||
private interval: number | null = null;
|
||||
private pattern: Pattern | null = null;
|
||||
|
||||
// Custom-file playback path (incoming only).
|
||||
private audioEl: HTMLAudioElement | null = null;
|
||||
private customUrl: string | null = null;
|
||||
// Sequence token to ignore slow IO completing after user changed state.
|
||||
private startSeq = 0;
|
||||
|
||||
start(pattern: Pattern): void {
|
||||
if (this.pattern === pattern) return; // already playing this pattern
|
||||
this.stop();
|
||||
this.pattern = pattern;
|
||||
const seq = ++this.startSeq;
|
||||
|
||||
if (pattern === 'incoming') {
|
||||
// Kick off oscillator immediately so we never miss ringing feedback
|
||||
// while the custom file (if any) loads asynchronously. Once the blob
|
||||
// is ready we hand playback over to the <audio> element.
|
||||
this.startOscillator(pattern);
|
||||
void this.tryUpgradeToCustom(seq);
|
||||
} else {
|
||||
this.startOscillator(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.startSeq++;
|
||||
this.stopOscillator();
|
||||
this.stopCustom();
|
||||
this.pattern = null;
|
||||
}
|
||||
|
||||
// --- Custom file path (incoming only) ----------------------------------
|
||||
|
||||
private async tryUpgradeToCustom(seq: number): Promise<void> {
|
||||
let stored;
|
||||
try {
|
||||
stored = await getIncomingRingtone();
|
||||
} catch {
|
||||
return; // keep oscillator
|
||||
}
|
||||
// User stopped or switched patterns while we were loading.
|
||||
if (seq !== this.startSeq || this.pattern !== 'incoming' || !stored) return;
|
||||
|
||||
const url = URL.createObjectURL(stored.blob);
|
||||
const el = new Audio(url);
|
||||
el.loop = true;
|
||||
el.volume = 0.85;
|
||||
// Chrome/WKWebView autoplay policy: muted playback is always allowed,
|
||||
// but ringtones must be audible, so play() may reject the first time
|
||||
// before the user interacted. If it rejects, we keep the oscillator.
|
||||
el.play().catch(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
this.audioEl = el;
|
||||
this.customUrl = url;
|
||||
// Only swap off the oscillator once the custom element is actually
|
||||
// wired — avoids a silent gap on transition.
|
||||
this.stopOscillator();
|
||||
}
|
||||
|
||||
private stopCustom(): void {
|
||||
if (this.audioEl) {
|
||||
try {
|
||||
this.audioEl.pause();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.audioEl.src = '';
|
||||
this.audioEl = null;
|
||||
}
|
||||
if (this.customUrl) {
|
||||
URL.revokeObjectURL(this.customUrl);
|
||||
this.customUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Oscillator fallback ----------------------------------------------
|
||||
|
||||
private startOscillator(pattern: Pattern): void {
|
||||
const AudioCtx =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioCtx) return;
|
||||
this.ctx = new AudioCtx();
|
||||
this.pattern = pattern;
|
||||
|
||||
const play = pattern === 'outgoing' ? this.playOutgoing : this.playIncoming;
|
||||
play.call(this);
|
||||
@@ -27,7 +109,7 @@ class Ringtone {
|
||||
);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
private stopOscillator(): void {
|
||||
if (this.interval !== null) {
|
||||
window.clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
@@ -38,7 +120,6 @@ class Ringtone {
|
||||
});
|
||||
this.ctx = null;
|
||||
}
|
||||
this.pattern = null;
|
||||
}
|
||||
|
||||
private beep(freq: number, durationSec: number, delaySec: number, gain = 0.18): void {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// IndexedDB-backed storage for the user's custom incoming ringtone.
|
||||
//
|
||||
// Only one slot is exposed (`incoming`) — outgoing ringtone stays tied to the
|
||||
// bundled oscillator pattern. The blob is stored alongside its mime type +
|
||||
// original filename so playback + UI can show what's currently in use.
|
||||
|
||||
const DB_NAME = 'netralax-ringtones';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'ringtones';
|
||||
const SLOT_INCOMING = 'incoming';
|
||||
|
||||
export interface StoredRingtone {
|
||||
blob: Blob;
|
||||
mime: string;
|
||||
filename: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export const MAX_RINGTONE_BYTES = 2 * 1024 * 1024; // 2 MB cap
|
||||
|
||||
export const SUPPORTED_RINGTONE_MIMES = [
|
||||
'audio/mpeg',
|
||||
'audio/mp3',
|
||||
'audio/wav',
|
||||
'audio/x-wav',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/aac',
|
||||
'audio/x-m4a',
|
||||
'audio/m4a',
|
||||
];
|
||||
|
||||
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(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error ?? new Error('indexedDB open failed'));
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
function runTx<T>(
|
||||
mode: IDBTransactionMode,
|
||||
fn: (store: IDBObjectStore) => IDBRequest<T> | void,
|
||||
): Promise<T | undefined> {
|
||||
return openDb().then(
|
||||
(db) =>
|
||||
new Promise<T | undefined>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, mode);
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
let result: T | undefined = undefined;
|
||||
const maybeReq = fn(store);
|
||||
if (maybeReq) {
|
||||
maybeReq.onsuccess = () => {
|
||||
result = maybeReq.result;
|
||||
};
|
||||
maybeReq.onerror = () => reject(maybeReq.error);
|
||||
}
|
||||
tx.oncomplete = () => resolve(result);
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveIncomingRingtone(file: File): Promise<void> {
|
||||
if (file.size === 0) throw new Error('empty file');
|
||||
if (file.size > MAX_RINGTONE_BYTES) {
|
||||
throw new Error('ringtone_too_large');
|
||||
}
|
||||
const mime = file.type || 'application/octet-stream';
|
||||
if (!mime.startsWith('audio/')) {
|
||||
throw new Error('ringtone_not_audio');
|
||||
}
|
||||
const stored: StoredRingtone = {
|
||||
blob: file,
|
||||
mime,
|
||||
filename: file.name,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await runTx('readwrite', (store) => store.put(stored, SLOT_INCOMING));
|
||||
}
|
||||
|
||||
export async function getIncomingRingtone(): Promise<StoredRingtone | null> {
|
||||
const result = await runTx<StoredRingtone | undefined>('readonly', (store) =>
|
||||
store.get(SLOT_INCOMING) as IDBRequest<StoredRingtone | undefined>,
|
||||
);
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
export async function clearIncomingRingtone(): Promise<void> {
|
||||
await runTx('readwrite', (store) => store.delete(SLOT_INCOMING));
|
||||
}
|
||||
|
||||
export async function hasIncomingRingtone(): Promise<boolean> {
|
||||
const cur = await getIncomingRingtone();
|
||||
return cur !== null;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Binds soundboard entries to global shortcuts and keeps the registry in
|
||||
// sync with storage mutations. Starting returns a teardown function that
|
||||
// undoes every registration the binding made.
|
||||
//
|
||||
// Usage (in CallContext effect when call becomes connected):
|
||||
// const teardown = startSoundboardHotkeys((id) => void playSoundboard(id));
|
||||
// return teardown; // useEffect cleanup
|
||||
|
||||
import {
|
||||
isTauriRuntime,
|
||||
registerSoundShortcut,
|
||||
unregisterAllSoundShortcuts,
|
||||
unregisterSoundShortcut,
|
||||
} from './globalShortcut';
|
||||
import { getPttSettings } from './pttSettings';
|
||||
import { listSounds, subscribeSoundboardChanges } from './soundboardStorage';
|
||||
|
||||
export type FirePress = (id: string) => void;
|
||||
|
||||
export function startSoundboardHotkeys(onPress: FirePress): () => void {
|
||||
if (!isTauriRuntime()) {
|
||||
// No-op on pure web preview — global shortcuts unsupported. Storage
|
||||
// change subscription would still fire, but there's nothing to sync.
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
// identity-keyed: sound id -> currently bound DOM code
|
||||
const bound = new Map<string, string>();
|
||||
|
||||
const sync = async (): Promise<void> => {
|
||||
if (!active) return;
|
||||
let entries;
|
||||
try {
|
||||
entries = await listSounds();
|
||||
} catch (err: unknown) {
|
||||
console.warn('soundboardHotkeys list failed', err);
|
||||
return;
|
||||
}
|
||||
const pttKey = getPttSettings().key;
|
||||
|
||||
const wantByCode = new Map<string, string>(); // code -> id (winner on conflict)
|
||||
for (const e of entries) {
|
||||
if (!e.hotkey) continue;
|
||||
// PTT wins over soundboard: don't hijack the talk key, skip silently.
|
||||
if (pttKey && e.hotkey === pttKey) continue;
|
||||
// First-writer-wins for dupes (stable because listSounds is sorted
|
||||
// deterministically). Settings UI should prevent this upstream.
|
||||
if (!wantByCode.has(e.hotkey)) wantByCode.set(e.hotkey, e.id);
|
||||
}
|
||||
|
||||
const want = new Map<string, string>();
|
||||
for (const [code, id] of wantByCode) want.set(id, code);
|
||||
|
||||
// Unregister bindings that disappeared or changed key.
|
||||
for (const [id, code] of bound) {
|
||||
const nextCode = want.get(id);
|
||||
if (nextCode !== code) {
|
||||
await unregisterSoundShortcut(id);
|
||||
bound.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Register new / updated bindings.
|
||||
for (const [id, code] of want) {
|
||||
if (bound.get(id) === code) continue;
|
||||
const ok = await registerSoundShortcut(id, code, () => {
|
||||
if (!active) return;
|
||||
onPress(id);
|
||||
});
|
||||
if (ok) bound.set(id, code);
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = subscribeSoundboardChanges(() => {
|
||||
void sync();
|
||||
});
|
||||
|
||||
// Initial binding pass.
|
||||
void sync();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
unsubscribe();
|
||||
void unregisterAllSoundShortcuts();
|
||||
bound.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Decode + cache AudioBuffers for soundboard entries and play them through a
|
||||
// MicPipeline. Cache is LRU-bounded per sound id. `invalidate(id)` drops the
|
||||
// decoded buffer when the underlying blob changes (rename keeps blob intact,
|
||||
// but re-upload of the same id rebuilds the buffer on next play).
|
||||
|
||||
import type { MicPipeline, PlayHandle } from './micPipeline';
|
||||
import { getSoundBlob, type SoundboardEntry } from './soundboardStorage';
|
||||
|
||||
const MAX_CACHE_ENTRIES = 64;
|
||||
|
||||
const cache = new Map<string, AudioBuffer>();
|
||||
|
||||
// Decoding requires an AudioContext. We keep one short-lived context just for
|
||||
// decoding — the pipeline's own ctx is used for playback, so we don't share.
|
||||
// `decodeAudioData` is legacy-sync in Safari/WKWebView: it mutates the input
|
||||
// ArrayBuffer, so we always pass a fresh slice() copy.
|
||||
let decodeCtx: AudioContext | null = null;
|
||||
|
||||
function getDecodeCtx(): AudioContext {
|
||||
if (decodeCtx) return decodeCtx;
|
||||
const AudioCtx =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioCtx) throw new Error('AudioContext unavailable');
|
||||
decodeCtx = new AudioCtx();
|
||||
return decodeCtx;
|
||||
}
|
||||
|
||||
function touch(id: string, buffer: AudioBuffer): void {
|
||||
// Re-insert to push to the back of insertion order (Map is ordered by
|
||||
// insertion, oldest first).
|
||||
cache.delete(id);
|
||||
cache.set(id, buffer);
|
||||
if (cache.size > MAX_CACHE_ENTRIES) {
|
||||
const first = cache.keys().next().value;
|
||||
if (first !== undefined) cache.delete(first);
|
||||
}
|
||||
}
|
||||
|
||||
export async function preload(id: string): Promise<AudioBuffer | null> {
|
||||
const existing = cache.get(id);
|
||||
if (existing) {
|
||||
touch(id, existing);
|
||||
return existing;
|
||||
}
|
||||
const blob = await getSoundBlob(id);
|
||||
if (!blob) return null;
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
// Clone into a fresh buffer because Safari's decodeAudioData transfers
|
||||
// ownership on its legacy promise path.
|
||||
const copy = arrayBuffer.slice(0);
|
||||
const ctx = getDecodeCtx();
|
||||
const buffer = await new Promise<AudioBuffer>((resolve, reject) => {
|
||||
// The `(data, success, error)` callback form is the only one guaranteed
|
||||
// on older WebKit. Modern browsers accept promise chains too.
|
||||
const maybe = ctx.decodeAudioData(
|
||||
copy,
|
||||
(b) => resolve(b),
|
||||
(e) => reject(e ?? new Error('decode failed')),
|
||||
);
|
||||
if (maybe && typeof (maybe as Promise<AudioBuffer>).then === 'function') {
|
||||
(maybe as Promise<AudioBuffer>).then(resolve, reject);
|
||||
}
|
||||
});
|
||||
touch(id, buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function invalidate(id: string): void {
|
||||
cache.delete(id);
|
||||
}
|
||||
|
||||
export function clearCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
export interface PlayOptions {
|
||||
/** Overrides entry.gain. Falls back to entry.gain when omitted. */
|
||||
gain?: number;
|
||||
/** Overlap mode: omit this flag to stop previous instance of the same id.
|
||||
* Defaults to single-fire (id-keyed) so hotkey spamming doesn't stack. */
|
||||
overlap?: boolean;
|
||||
onEnded?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode (if needed) and play the entry through the supplied pipeline.
|
||||
* Returns the pipeline handle, or null if the pipeline is unavailable or
|
||||
* the sound no longer exists.
|
||||
*/
|
||||
export async function playEntry(
|
||||
pipeline: MicPipeline,
|
||||
entry: SoundboardEntry,
|
||||
opts: PlayOptions = {},
|
||||
): Promise<PlayHandle | null> {
|
||||
const buffer = await preload(entry.id);
|
||||
if (!buffer) return null;
|
||||
return pipeline.playBuffer(buffer, {
|
||||
gain: opts.gain ?? entry.gain,
|
||||
...(opts.overlap ? {} : { id: entry.id }),
|
||||
...(opts.onEnded ? { onEnded: opts.onEnded } : {}),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { supabase } from './supabase';
|
||||
|
||||
export interface LinkPreview {
|
||||
url: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
imageUrl: string | null;
|
||||
siteName: string | null;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
// In-memory cache keyed by URL — avoids re-invoking the edge function for
|
||||
// the same URL within a session even if many bubbles reference it.
|
||||
const cache = new Map<string, LinkPreview | null>();
|
||||
const inflight = new Map<string, Promise<LinkPreview | null>>();
|
||||
|
||||
async function loadPreview(url: string): Promise<LinkPreview | null> {
|
||||
if (cache.has(url)) return cache.get(url)!;
|
||||
const existing = inflight.get(url);
|
||||
if (existing) return existing;
|
||||
|
||||
const p = (async () => {
|
||||
// First try the cache table directly (RLS allows authenticated reads).
|
||||
// Works offline-first if the server has already fetched this URL.
|
||||
// `link_previews` is a later migration — cast around stale generated types.
|
||||
const { data: cached } = await (supabase as unknown as {
|
||||
from: (t: string) => {
|
||||
select: (cols: string) => {
|
||||
eq: (col: string, val: string) => {
|
||||
maybeSingle: () => Promise<{
|
||||
data: {
|
||||
url: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
image_url: string | null;
|
||||
site_name: string | null;
|
||||
ok: boolean;
|
||||
fetched_at: string;
|
||||
} | null;
|
||||
error: Error | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
})
|
||||
.from('link_previews')
|
||||
.select('url, title, description, image_url, site_name, ok, fetched_at')
|
||||
.eq('url', url)
|
||||
.maybeSingle();
|
||||
if (cached && cached.ok) {
|
||||
const preview: LinkPreview = {
|
||||
url: cached.url,
|
||||
title: cached.title,
|
||||
description: cached.description,
|
||||
imageUrl: cached.image_url,
|
||||
siteName: cached.site_name,
|
||||
ok: cached.ok,
|
||||
};
|
||||
cache.set(url, preview);
|
||||
return preview;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('og-preview', {
|
||||
body: { url },
|
||||
});
|
||||
if (error) throw error;
|
||||
const preview = data as LinkPreview | null;
|
||||
cache.set(url, preview && preview.ok ? preview : null);
|
||||
return cache.get(url) ?? null;
|
||||
} catch {
|
||||
cache.set(url, null);
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
inflight.set(url, p);
|
||||
try {
|
||||
return await p;
|
||||
} finally {
|
||||
inflight.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
export function useLinkPreview(url: string | null): LinkPreview | null {
|
||||
const [preview, setPreview] = useState<LinkPreview | null>(() =>
|
||||
url ? cache.get(url) ?? null : null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void loadPreview(url).then((p) => {
|
||||
if (!cancelled) setPreview(p);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url]);
|
||||
return preview;
|
||||
}
|
||||
|
||||
// Regex tuned for plain URLs inside message text. No markdown link syntax yet.
|
||||
const URL_RE = /https?:\/\/[^\s<>"']+/i;
|
||||
|
||||
export function extractFirstUrl(text: string): string | null {
|
||||
const m = URL_RE.exec(text);
|
||||
return m?.[0] ?? null;
|
||||
}
|
||||
@@ -3,10 +3,16 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// Subscribe to a single peer's presence_state via Supabase realtime.
|
||||
// Returns null until the first row arrives, or when userId is undefined.
|
||||
export function usePeerPresence(userId: string | undefined): PresenceState | null {
|
||||
const [presence, setPresence] = useState<PresenceState | null>(null);
|
||||
export interface PeerPresence {
|
||||
state: PresenceState;
|
||||
statusMessage: string | null;
|
||||
}
|
||||
|
||||
// Subscribe to a single peer's presence_state + status_message via Supabase
|
||||
// realtime. Returns null until the first row arrives, or when userId is
|
||||
// undefined.
|
||||
export function usePeerPresence(userId: string | undefined): PeerPresence | null {
|
||||
const [presence, setPresence] = useState<PeerPresence | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
@@ -17,11 +23,15 @@ export function usePeerPresence(userId: string | undefined): PresenceState | nul
|
||||
|
||||
void supabase
|
||||
.from('profiles')
|
||||
.select('presence_state')
|
||||
.select('presence_state, status_message')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
.then(({ data }) => {
|
||||
if (!cancelled) setPresence(data?.presence_state ?? null);
|
||||
if (cancelled || !data) return;
|
||||
setPresence({
|
||||
state: (data.presence_state as PresenceState | null) ?? 'offline',
|
||||
statusMessage: data.status_message ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
const channel = supabase
|
||||
@@ -35,8 +45,21 @@ export function usePeerPresence(userId: string | undefined): PresenceState | nul
|
||||
filter: 'user_id=eq.' + userId,
|
||||
},
|
||||
(payload: { new: Record<string, unknown> }) => {
|
||||
const next = payload.new['presence_state'];
|
||||
if (typeof next === 'string') setPresence(next as PresenceState);
|
||||
const nextState = payload.new['presence_state'];
|
||||
const nextMsg = payload.new['status_message'];
|
||||
setPresence((prev) => {
|
||||
const state =
|
||||
typeof nextState === 'string'
|
||||
? (nextState as PresenceState)
|
||||
: prev?.state ?? 'offline';
|
||||
const statusMessage =
|
||||
nextMsg === null
|
||||
? null
|
||||
: typeof nextMsg === 'string'
|
||||
? nextMsg
|
||||
: prev?.statusMessage ?? null;
|
||||
return { state, statusMessage };
|
||||
});
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
Reference in New Issue
Block a user