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