672c8738c7
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
321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useCall } from '../context/CallContext';
|
|
import { codeToShortcut } from '../lib/globalShortcut';
|
|
import {
|
|
DEFAULT_PREFS,
|
|
getPrefs,
|
|
listSounds,
|
|
type SoundboardEntry,
|
|
type SoundboardPrefs,
|
|
subscribeSoundboardChanges,
|
|
} from '../lib/soundboardStorage';
|
|
import { ChevronDownIcon, XIcon } from './icons';
|
|
|
|
interface Props {
|
|
onClose: () => void;
|
|
}
|
|
|
|
// In-call popover that lists every stored sound grouped by category. Click a
|
|
// pad to play through the active pipeline. Hotkey-badge shows the bound
|
|
// accelerator (if any). Master + monitor sliders adjust the pipeline gains.
|
|
export function SoundboardPanel({ onClose }: Props) {
|
|
const { t } = useTranslation(['app']);
|
|
const {
|
|
playSoundboard,
|
|
stopSoundboard,
|
|
activeSoundboardIds,
|
|
setSoundboardMasterGain,
|
|
setSoundboardMonitorGain,
|
|
} = useCall();
|
|
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
|
|
const [prefs, setPrefs] = useState<SoundboardPrefs>(() => ({ ...DEFAULT_PREFS }));
|
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
|
const [query, setQuery] = useState('');
|
|
const closeRef = useRef(onClose);
|
|
closeRef.current = onClose;
|
|
|
|
const refresh = useCallback(async () => {
|
|
try {
|
|
const [all, p] = await Promise.all([listSounds(), getPrefs()]);
|
|
setEntries(all);
|
|
setPrefs(p);
|
|
} catch (err: unknown) {
|
|
console.warn('soundboard panel refresh failed', err);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void refresh();
|
|
const unsub = subscribeSoundboardChanges(() => {
|
|
void refresh();
|
|
});
|
|
return unsub;
|
|
}, [refresh]);
|
|
|
|
// Close on Esc — tapping outside is handled by the trigger's parent.
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') closeRef.current();
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, []);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return entries;
|
|
return entries.filter(
|
|
(e) =>
|
|
e.name.toLowerCase().includes(q) ||
|
|
(e.category ?? '').toLowerCase().includes(q),
|
|
);
|
|
}, [entries, query]);
|
|
|
|
const grouped = useMemo(() => {
|
|
const map = new Map<string, SoundboardEntry[]>();
|
|
for (const e of filtered) {
|
|
const key = e.category ?? '';
|
|
const arr = map.get(key);
|
|
if (arr) arr.push(e);
|
|
else map.set(key, [e]);
|
|
}
|
|
return map;
|
|
}, [filtered]);
|
|
|
|
function toggleCategory(key: string): void {
|
|
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
}
|
|
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-label={t('app:soundboard.panel_title', { defaultValue: 'Soundboard' })}
|
|
className="flex w-[360px] max-h-[70vh] flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-2xl"
|
|
>
|
|
<header className="flex items-center justify-between border-b border-line px-4 py-2.5">
|
|
<h3 className="font-display text-sm font-semibold text-fg">
|
|
{t('app:soundboard.panel_title', { defaultValue: 'Soundboard' })}
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label={t('app:soundboard.panel_close', { defaultValue: 'Schließen' })}
|
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
|
>
|
|
<XIcon className="h-4 w-4" />
|
|
</button>
|
|
</header>
|
|
|
|
{entries.length > 0 && (
|
|
<div className="border-b border-line px-3 pb-2 pt-2">
|
|
<input
|
|
type="search"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={t('app:soundboard.panel_search', { defaultValue: 'Suche…' })}
|
|
className="w-full rounded-md border border-line bg-surface-2 px-3 py-1.5 text-xs text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
|
{entries.length === 0 ? (
|
|
<p className="py-6 text-center text-xs text-fg-muted">
|
|
{t('app:soundboard.panel_empty', {
|
|
defaultValue:
|
|
'Keine Sounds gespeichert. Füge welche in den Einstellungen hinzu.',
|
|
})}
|
|
</p>
|
|
) : filtered.length === 0 ? (
|
|
<p className="py-6 text-center text-xs text-fg-muted">
|
|
{t('app:soundboard.panel_no_matches', {
|
|
defaultValue: 'Keine Treffer.',
|
|
})}
|
|
</p>
|
|
) : (
|
|
<div className="flex flex-col gap-3">
|
|
{Array.from(grouped.entries()).map(([key, bucket]) => (
|
|
<CategorySection
|
|
key={key || '__uncat'}
|
|
categoryKey={key}
|
|
entries={bucket}
|
|
collapsed={collapsed[key] ?? false}
|
|
activeIds={activeSoundboardIds}
|
|
onToggle={() => toggleCategory(key)}
|
|
onActivate={(id) => {
|
|
if (activeSoundboardIds.has(id)) {
|
|
stopSoundboard(id);
|
|
} else {
|
|
void playSoundboard(id);
|
|
}
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<footer className="flex flex-col gap-2 border-t border-line bg-surface-2 px-4 py-3">
|
|
<VolumeSlider
|
|
label={t('app:soundboard.panel_master', { defaultValue: 'Master' })}
|
|
value={prefs.masterGain}
|
|
onChange={(v) => {
|
|
setPrefs((p) => ({ ...p, masterGain: v }));
|
|
void setSoundboardMasterGain(v);
|
|
}}
|
|
/>
|
|
<VolumeSlider
|
|
label={t('app:soundboard.panel_monitor', { defaultValue: 'Mithören' })}
|
|
value={prefs.monitorGain}
|
|
onChange={(v) => {
|
|
setPrefs((p) => ({ ...p, monitorGain: v }));
|
|
void setSoundboardMonitorGain(v);
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => stopSoundboard()}
|
|
className="mt-1 inline-flex cursor-pointer items-center justify-center rounded-md border border-line bg-surface-3 px-3 py-1.5 text-[11px] font-semibold text-fg transition hover:brightness-95"
|
|
>
|
|
{t('app:soundboard.panel_stop_all', { defaultValue: 'Alle stoppen' })}
|
|
</button>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface CategorySectionProps {
|
|
categoryKey: string;
|
|
entries: SoundboardEntry[];
|
|
collapsed: boolean;
|
|
activeIds: ReadonlySet<string>;
|
|
onToggle: () => void;
|
|
onActivate: (id: string) => void;
|
|
}
|
|
|
|
function CategorySection({
|
|
categoryKey,
|
|
entries,
|
|
collapsed,
|
|
activeIds,
|
|
onToggle,
|
|
onActivate,
|
|
}: CategorySectionProps) {
|
|
const { t } = useTranslation(['app']);
|
|
const label =
|
|
categoryKey === ''
|
|
? t('app:soundboard.category_uncategorized', { defaultValue: '(Ohne Kategorie)' })
|
|
: categoryKey;
|
|
return (
|
|
<section>
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
className="mb-1.5 flex w-full cursor-pointer items-center justify-between gap-2 text-left text-[10px] font-semibold uppercase tracking-[0.1em] text-fg-muted"
|
|
>
|
|
<span>
|
|
{label} · {entries.length}
|
|
</span>
|
|
<ChevronDownIcon
|
|
className={'h-3 w-3 transition ' + (collapsed ? '-rotate-90' : '')}
|
|
/>
|
|
</button>
|
|
{!collapsed && (
|
|
<div className="grid grid-cols-2 gap-1.5">
|
|
{entries.map((entry) => (
|
|
<SoundPad
|
|
key={entry.id}
|
|
entry={entry}
|
|
active={activeIds.has(entry.id)}
|
|
onActivate={() => onActivate(entry.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
interface PadProps {
|
|
entry: SoundboardEntry;
|
|
active: boolean;
|
|
onActivate: () => void;
|
|
}
|
|
|
|
function SoundPad({ entry, active, onActivate }: PadProps) {
|
|
const { t } = useTranslation(['app']);
|
|
const hotkey = entry.hotkey ? codeToShortcut(entry.hotkey) : null;
|
|
const base =
|
|
'group relative flex min-h-[54px] cursor-pointer flex-col justify-center gap-0.5 rounded-md border px-2.5 py-2 text-left text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40';
|
|
const toneClass = active
|
|
? 'border-rose-500 bg-rose-500/20 text-fg hover:brightness-110'
|
|
: 'border-line bg-surface-2 text-fg hover:border-accent hover:bg-surface-3';
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onActivate}
|
|
className={`${base} ${toneClass}`}
|
|
title={
|
|
active
|
|
? t('app:soundboard.pad_stop', { defaultValue: 'Stoppen' })
|
|
: entry.name
|
|
}
|
|
aria-pressed={active}
|
|
>
|
|
<span className="flex items-center gap-1.5 truncate">
|
|
{active && (
|
|
<span
|
|
aria-hidden="true"
|
|
className="h-2 w-2 shrink-0 rounded-full bg-rose-500 animate-live-dot"
|
|
/>
|
|
)}
|
|
<span className="truncate">{entry.name}</span>
|
|
</span>
|
|
<div className="flex items-center justify-between gap-1.5">
|
|
{hotkey ? (
|
|
<span className="inline-flex w-fit items-center rounded border border-line bg-surface-3 px-1 py-0.5 font-mono text-[9px] text-fg-muted">
|
|
{hotkey}
|
|
</span>
|
|
) : (
|
|
<span aria-hidden="true" />
|
|
)}
|
|
{active && (
|
|
<span className="text-[9px] font-semibold uppercase tracking-[0.08em] text-rose-500 dark:text-rose-300">
|
|
{t('app:soundboard.pad_stop', { defaultValue: 'Stoppen' })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function VolumeSlider({
|
|
label,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
onChange: (v: number) => void;
|
|
}) {
|
|
return (
|
|
<label className="flex items-center gap-3 text-[11px] text-fg">
|
|
<span className="w-16 shrink-0 text-fg-muted">{label}</span>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={1}
|
|
step={0.02}
|
|
value={value}
|
|
onChange={(e) => onChange(Number(e.target.value))}
|
|
className="accent-accent flex-1"
|
|
/>
|
|
<span className="w-9 shrink-0 text-right tabular-nums text-fg-muted">
|
|
{Math.round(value * 100)}%
|
|
</span>
|
|
</label>
|
|
);
|
|
}
|