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:
2026-04-21 09:13:30 +02:00
parent b89ec90813
commit 672c8738c7
34 changed files with 4394 additions and 100 deletions
@@ -0,0 +1,127 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useState } from 'react';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
// Catch-all card for attachments without a richer renderer (zip, docx,
// txt, etc). Decrypt is deferred to first download click — these can be
// large and there's no inline preview to justify auto-fetching them.
export function AttachmentGeneric({ handle }: Props) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const download = async () => {
if (busy) return;
setBusy(true);
setError(null);
try {
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filenameFor(handle);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Defer revoke so Safari has a chance to start the download.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'download failed');
} finally {
setBusy(false);
}
};
return (
<div className="mt-2 inline-flex w-[280px] items-center gap-2.5 rounded-lg border border-line bg-surface-2 p-2.5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-accent/10 text-accent">
<FileGlyph />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-fg">
{prettyMime(handle.mimeType)}
</p>
<p className="truncate text-[10px] text-fg-muted">
{formatSize(handle.sizeBytes)}
</p>
{error && (
<p className="mt-0.5 inline-flex items-center gap-1 text-[10px] text-rose-500">
<AlertIcon className="h-3 w-3" />
<span>{error}</span>
</p>
)}
</div>
<button
type="button"
onClick={() => void download()}
disabled={busy}
aria-label="Download"
title="Download"
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-line bg-surface-3 text-fg transition hover:brightness-95 disabled:opacity-60"
>
{busy ? <SpinnerIcon className="h-4 w-4" /> : <DownloadGlyph />}
</button>
</div>
);
}
function FileGlyph() {
return (
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M3 2a1 1 0 011-1h6l3 3v10a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm7 0v3h3l-3-3z" />
</svg>
);
}
function DownloadGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M8 2v8M4 7l4 4 4-4M3 13h10" />
</svg>
);
}
function filenameFor(handle: AttachmentHandle): string {
const ext = extFor(handle.mimeType);
return 'attachment-' + handle.id.slice(0, 8) + (ext ? '.' + ext : '');
}
function extFor(mime: string): string | null {
const map: Record<string, string> = {
'application/zip': 'zip',
'application/x-zip-compressed': 'zip',
'application/x-7z-compressed': '7z',
'application/x-tar': 'tar',
'application/gzip': 'gz',
'application/json': 'json',
'application/xml': 'xml',
'text/plain': 'txt',
'text/markdown': 'md',
'text/csv': 'csv',
'application/msword': 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
'application/vnd.ms-excel': 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
'application/vnd.ms-powerpoint': 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
};
return map[mime] ?? null;
}
function prettyMime(mime: string): string {
const ext = extFor(mime);
if (ext) return ext.toUpperCase() + '-Datei';
if (mime.startsWith('text/')) return 'Textdatei';
return mime || 'Datei';
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
@@ -0,0 +1,109 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
// PDF preview rendered via the browser's built-in PDF viewer (Chromium /
// Safari both ship one). Embedding via <object> with a fallback link keeps
// the implementation tiny — no pdf.js dependency.
export function AttachmentPdf({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
setError(null);
setBlobUrl(null);
downloadAndDecryptAttachment({ client: supabase, handle })
.then((blob) => {
if (cancelled) return;
// Force the application/pdf type so the browser plugin engages.
const typed = new Blob([blob], { type: 'application/pdf' });
url = URL.createObjectURL(typed);
setBlobUrl(url);
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
});
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
if (error) {
return (
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
<AlertIcon className="h-4 w-4" />
<span>{error}</span>
</div>
);
}
if (!blobUrl) {
return (
<div className="mt-2 flex h-24 w-[320px] items-center justify-center rounded-lg border border-line bg-surface-2 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
);
}
return (
<div className="mt-2 w-full max-w-[420px] overflow-hidden rounded-lg border border-line bg-surface-2">
<div className="flex items-center justify-between gap-2 border-b border-line bg-surface-3 px-3 py-2 text-xs">
<span className="flex items-center gap-2 truncate text-fg">
<PdfGlyph />
<span className="truncate">PDF · {formatSize(handle.sizeBytes)}</span>
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg hover:bg-surface-3"
>
{expanded ? 'Einklappen' : 'Vorschau'}
</button>
<a
href={blobUrl}
download={'attachment-' + handle.id.slice(0, 8) + '.pdf'}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg no-underline hover:bg-surface-3"
>
Download
</a>
</div>
</div>
{expanded && (
<object data={blobUrl} type="application/pdf" className="block h-[420px] w-full">
<p className="p-4 text-xs text-fg-muted">
Vorschau nicht verfügbar bitte herunterladen.
</p>
</object>
)}
</div>
);
}
function PdfGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M3 2a1 1 0 011-1h6l3 3v10a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm7 0v3h3l-3-3zM5 9h6v1H5V9zm0 2h6v1H5v-1zm0-4h2v1H5V7z" />
</svg>
);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
@@ -0,0 +1,68 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
// Decrypt the blob, render a native <video controls>. Loads on demand —
// metadata-only preload so we don't burn bandwidth until the user hits play.
export function AttachmentVideo({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
setError(null);
setBlobUrl(null);
downloadAndDecryptAttachment({ client: supabase, handle })
.then((blob) => {
if (cancelled) return;
url = URL.createObjectURL(blob);
setBlobUrl(url);
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
});
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
if (error) {
return (
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
<AlertIcon className="h-4 w-4" />
<span>{error}</span>
</div>
);
}
if (!blobUrl) {
return (
<div className="mt-2 flex h-32 w-[320px] items-center justify-center rounded-lg border border-line bg-surface-2 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
);
}
return (
<video
controls
preload="metadata"
src={blobUrl}
className="mt-2 block max-h-80 w-full max-w-[420px] rounded-lg border border-line bg-black"
>
<track kind="captions" />
</video>
);
}
@@ -7,6 +7,7 @@ import {
MicOffIcon,
MonitorShareIcon,
MonitorStopIcon,
MusicIcon,
PhoneOffIcon,
UsersIcon,
VideoIcon,
@@ -23,6 +24,9 @@ interface Props {
onToggleDeafen: () => void;
onHangup: () => void;
onOpenParticipants?: () => void;
/** Toggle the in-call soundboard popover. Active = panel currently open. */
onToggleSoundboard?: () => void;
soundboardOpen?: boolean;
/** Compact variant used inside the docked call (36px buttons). */
compact?: boolean;
/** Glass variant used when controls float on fullscreen cinema mode. */
@@ -42,6 +46,8 @@ export function CallControls({
onToggleDeafen,
onHangup,
onOpenParticipants,
onToggleSoundboard,
soundboardOpen = false,
compact = false,
glass = false,
disabledMedia = false,
@@ -115,6 +121,18 @@ export function CallControls({
<MonitorShareIcon className="h-5 w-5" />
)}
</CallButton>
{onToggleSoundboard && (
<CallButton
label={t('app:soundboard.toggle', { defaultValue: 'Soundboard' })}
active={soundboardOpen}
activeTone="accent"
onClick={onToggleSoundboard}
glass={glass}
className={btnSize}
>
<MusicIcon className="h-5 w-5" />
</CallButton>
)}
{onOpenParticipants && (
<CallButton
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
+8 -2
View File
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext';
import { useFriendshipsContext } from '../context/FriendshipsContext';
@@ -17,12 +18,17 @@ import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons';
// different route.
export function CallUI() {
const { state } = useCall();
const { profile } = useAuth();
const dnd = profile?.presenceState === 'dnd';
useEffect(() => {
// DND silences only the *incoming* ring — outgoing stays audible because
// the user initiated that call themselves. The incoming-call panel still
// appears visually; only the audible ring is suppressed.
if (state.kind === 'outgoing') ringtone.start('outgoing');
else if (state.kind === 'incoming') ringtone.start('incoming');
else if (state.kind === 'incoming' && !dnd) ringtone.start('incoming');
else ringtone.stop();
}, [state.kind]);
}, [state.kind, dnd]);
useEffect(() => {
return () => ringtone.stop();
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
import { useCallPresence } from '../lib/useCallPresence';
import type { PeerPresence } from '../lib/usePeerPresence';
import { Avatar } from './Avatar';
import {
InfoIcon,
@@ -26,7 +27,7 @@ const PRESENCE_DOT: Record<PresenceState, string> = {
interface Props {
conversation: ConversationSummary | null;
peerPresence: PresenceState | null;
peerPresence: PeerPresence | null;
onInfoClick?: () => void;
onSearchClick?: () => void;
}
@@ -51,7 +52,7 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick, on
interface HeaderBarProps {
conversation: ConversationSummary;
peerPresence: PresenceState | null;
peerPresence: PeerPresence | null;
onInfoClick?: () => void;
onSearchClick?: () => void;
}
@@ -65,8 +66,18 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
const peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) : (conversation.avatarUrl ?? null);
// Hide presence when peer chose invisible — reciprocal privacy.
const showPresence = isDm && peerPresence && peerPresence !== 'invisible';
const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : '';
const peerState = peerPresence?.state ?? null;
const showPresence = isDm && peerState && peerState !== 'invisible';
// Subtitle priority: custom status_message when online (or idle/dnd), else
// the localized presence label. Offline always wins → just "Offline".
const presenceLabel = (() => {
if (!peerState) return '';
if (peerState === 'offline') return t('app:presence.offline');
if (peerPresence?.statusMessage && peerPresence.statusMessage.trim().length > 0) {
return peerPresence.statusMessage.trim();
}
return t('app:presence.' + peerState);
})();
return (
<header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3">
@@ -80,12 +91,12 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
<UsersIcon className="h-5 w-5" />
</div>
)}
{showPresence && peerPresence && (
{showPresence && peerState && (
<span
aria-hidden="true"
className={
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' +
PRESENCE_DOT[peerPresence]
PRESENCE_DOT[peerState]
}
/>
)}
+408
View File
@@ -0,0 +1,408 @@
import { useEffect, useMemo, useRef, useState } from 'react';
interface EmojiEntry {
e: string;
k: string[]; // search keywords (incl. name)
}
interface Category {
label: string;
entries: EmojiEntry[];
}
// Curated emoji set — small enough to stay fast without a dependency, wide
// enough to cover everyday messaging. Keywords are the primary search
// surface; the emoji character itself is also matched so a user typing ❤️
// literally finds it.
const CATEGORIES: Category[] = [
{
label: 'Smileys',
entries: [
{ e: '😀', k: ['grin', 'smile', 'happy'] },
{ e: '😃', k: ['smile', 'happy'] },
{ e: '😄', k: ['smile', 'laugh'] },
{ e: '😁', k: ['grin', 'smile'] },
{ e: '😆', k: ['laugh', 'lol'] },
{ e: '😅', k: ['sweat', 'nervous', 'laugh'] },
{ e: '🤣', k: ['lol', 'rofl', 'laugh'] },
{ e: '😂', k: ['joy', 'laugh', 'tears'] },
{ e: '🙂', k: ['smile', 'slight'] },
{ e: '🙃', k: ['upside', 'irony'] },
{ e: '😉', k: ['wink'] },
{ e: '😊', k: ['blush', 'smile'] },
{ e: '😇', k: ['angel', 'innocent'] },
{ e: '🥰', k: ['love', 'hearts'] },
{ e: '😍', k: ['love', 'heart eyes'] },
{ e: '🤩', k: ['star', 'excited'] },
{ e: '😘', k: ['kiss'] },
{ e: '😗', k: ['kiss'] },
{ e: '😚', k: ['kiss'] },
{ e: '😙', k: ['kiss'] },
{ e: '🥲', k: ['tear', 'smile'] },
{ e: '😋', k: ['yum', 'tasty'] },
{ e: '😛', k: ['tongue'] },
{ e: '😜', k: ['tongue', 'wink'] },
{ e: '🤪', k: ['zany', 'silly'] },
{ e: '😝', k: ['tongue'] },
{ e: '🤑', k: ['money'] },
{ e: '🤗', k: ['hug'] },
{ e: '🤭', k: ['giggle', 'shy'] },
{ e: '🤫', k: ['shush', 'quiet'] },
{ e: '🤔', k: ['think'] },
{ e: '🤐', k: ['zip', 'quiet'] },
{ e: '🤨', k: ['raise brow'] },
{ e: '😐', k: ['neutral'] },
{ e: '😑', k: ['expressionless'] },
{ e: '😶', k: ['speechless'] },
{ e: '😏', k: ['smirk'] },
{ e: '😒', k: ['unamused'] },
{ e: '🙄', k: ['eye roll'] },
{ e: '😬', k: ['grimace', 'awkward'] },
{ e: '🤥', k: ['lying'] },
{ e: '😔', k: ['sad', 'pensive'] },
{ e: '😪', k: ['sleepy'] },
{ e: '😴', k: ['sleep'] },
{ e: '😷', k: ['mask', 'sick'] },
{ e: '🤒', k: ['sick', 'fever'] },
{ e: '🤕', k: ['injured'] },
{ e: '🤢', k: ['nauseated'] },
{ e: '🤮', k: ['vomit'] },
{ e: '🤧', k: ['sneeze'] },
{ e: '🥵', k: ['hot'] },
{ e: '🥶', k: ['cold'] },
{ e: '🥴', k: ['dizzy', 'woozy'] },
{ e: '😵', k: ['dizzy'] },
{ e: '🤯', k: ['mind blown'] },
{ e: '🤠', k: ['cowboy'] },
{ e: '🥳', k: ['party'] },
{ e: '😎', k: ['cool', 'sunglasses'] },
{ e: '🤓', k: ['nerd'] },
{ e: '🧐', k: ['monocle'] },
{ e: '😕', k: ['confused'] },
{ e: '😟', k: ['worried'] },
{ e: '🙁', k: ['frown'] },
{ e: '☹️', k: ['frown'] },
{ e: '😮', k: ['open mouth'] },
{ e: '😯', k: ['hushed'] },
{ e: '😲', k: ['astonished'] },
{ e: '😳', k: ['flushed'] },
{ e: '🥺', k: ['pleading'] },
{ e: '😦', k: ['frowning'] },
{ e: '😧', k: ['anguished'] },
{ e: '😨', k: ['fear'] },
{ e: '😰', k: ['anxious', 'sweat'] },
{ e: '😥', k: ['sad', 'relieved'] },
{ e: '😢', k: ['cry'] },
{ e: '😭', k: ['cry', 'loud'] },
{ e: '😱', k: ['scream', 'scared'] },
{ e: '😖', k: ['confounded'] },
{ e: '😣', k: ['persevere'] },
{ e: '😞', k: ['disappointed'] },
{ e: '😓', k: ['sweat'] },
{ e: '😩', k: ['weary'] },
{ e: '😫', k: ['tired'] },
{ e: '🥱', k: ['yawn'] },
{ e: '😤', k: ['triumph'] },
{ e: '😡', k: ['angry', 'rage'] },
{ e: '😠', k: ['angry'] },
{ e: '🤬', k: ['swear', 'curse'] },
{ e: '😈', k: ['devil'] },
{ e: '👿', k: ['imp'] },
{ e: '💀', k: ['skull', 'dead'] },
{ e: '🤡', k: ['clown'] },
{ e: '👻', k: ['ghost'] },
{ e: '👽', k: ['alien'] },
{ e: '🤖', k: ['robot'] },
{ e: '💩', k: ['poop', 'shit'] },
],
},
{
label: 'Gestures',
entries: [
{ e: '👋', k: ['wave', 'hi'] },
{ e: '🤚', k: ['hand'] },
{ e: '🖐️', k: ['hand'] },
{ e: '✋', k: ['stop', 'high five'] },
{ e: '🖖', k: ['spock'] },
{ e: '👌', k: ['ok'] },
{ e: '🤌', k: ['pinch'] },
{ e: '🤏', k: ['small'] },
{ e: '✌️', k: ['peace', 'victory'] },
{ e: '🤞', k: ['crossed fingers'] },
{ e: '🤟', k: ['love you'] },
{ e: '🤘', k: ['rock'] },
{ e: '🤙', k: ['call me'] },
{ e: '👈', k: ['point left'] },
{ e: '👉', k: ['point right'] },
{ e: '👆', k: ['point up'] },
{ e: '🖕', k: ['middle finger', 'fuck'] },
{ e: '👇', k: ['point down'] },
{ e: '☝️', k: ['point up'] },
{ e: '👍', k: ['thumbs up', 'like'] },
{ e: '👎', k: ['thumbs down', 'dislike'] },
{ e: '✊', k: ['fist'] },
{ e: '👊', k: ['punch'] },
{ e: '🤛', k: ['fist left'] },
{ e: '🤜', k: ['fist right'] },
{ e: '👏', k: ['clap'] },
{ e: '🙌', k: ['raised hands'] },
{ e: '👐', k: ['open hands'] },
{ e: '🤲', k: ['palms'] },
{ e: '🙏', k: ['pray', 'thanks'] },
{ e: '✍️', k: ['write'] },
{ e: '💪', k: ['flex', 'strong'] },
],
},
{
label: 'Hearts',
entries: [
{ e: '❤️', k: ['heart', 'love'] },
{ e: '🧡', k: ['orange heart'] },
{ e: '💛', k: ['yellow heart'] },
{ e: '💚', k: ['green heart'] },
{ e: '💙', k: ['blue heart'] },
{ e: '💜', k: ['purple heart'] },
{ e: '🖤', k: ['black heart'] },
{ e: '🤍', k: ['white heart'] },
{ e: '🤎', k: ['brown heart'] },
{ e: '💔', k: ['broken heart'] },
{ e: '❣️', k: ['heart exclamation'] },
{ e: '💕', k: ['hearts'] },
{ e: '💞', k: ['revolving hearts'] },
{ e: '💓', k: ['beating heart'] },
{ e: '💗', k: ['growing heart'] },
{ e: '💖', k: ['sparkle heart'] },
{ e: '💘', k: ['cupid'] },
{ e: '💝', k: ['heart gift'] },
],
},
{
label: 'Animals & Food',
entries: [
{ e: '🐶', k: ['dog'] },
{ e: '🐱', k: ['cat'] },
{ e: '🐭', k: ['mouse'] },
{ e: '🐹', k: ['hamster'] },
{ e: '🐰', k: ['rabbit'] },
{ e: '🦊', k: ['fox'] },
{ e: '🐻', k: ['bear'] },
{ e: '🐼', k: ['panda'] },
{ e: '🐨', k: ['koala'] },
{ e: '🐯', k: ['tiger'] },
{ e: '🦁', k: ['lion'] },
{ e: '🐸', k: ['frog'] },
{ e: '🐵', k: ['monkey'] },
{ e: '🐔', k: ['chicken'] },
{ e: '🐧', k: ['penguin'] },
{ e: '🐦', k: ['bird'] },
{ e: '🦆', k: ['duck'] },
{ e: '🍎', k: ['apple'] },
{ e: '🍌', k: ['banana'] },
{ e: '🍕', k: ['pizza'] },
{ e: '🍔', k: ['burger'] },
{ e: '🍟', k: ['fries'] },
{ e: '🌭', k: ['hotdog'] },
{ e: '🍿', k: ['popcorn'] },
{ e: '🍣', k: ['sushi'] },
{ e: '🍩', k: ['donut'] },
{ e: '🍪', k: ['cookie'] },
{ e: '🎂', k: ['cake', 'birthday'] },
{ e: '🍰', k: ['cake'] },
{ e: '🍫', k: ['chocolate'] },
{ e: '🍺', k: ['beer'] },
{ e: '🍷', k: ['wine'] },
{ e: '🥂', k: ['cheers'] },
{ e: '☕', k: ['coffee'] },
],
},
{
label: 'Objects & Symbols',
entries: [
{ e: '🔥', k: ['fire', 'lit'] },
{ e: '✨', k: ['sparkle'] },
{ e: '⭐', k: ['star'] },
{ e: '🌟', k: ['star glowing'] },
{ e: '💫', k: ['dizzy'] },
{ e: '💥', k: ['boom', 'explosion'] },
{ e: '⚡', k: ['lightning'] },
{ e: '☀️', k: ['sun'] },
{ e: '🌈', k: ['rainbow'] },
{ e: '☁️', k: ['cloud'] },
{ e: '🌧️', k: ['rain'] },
{ e: '❄️', k: ['snow'] },
{ e: '🎉', k: ['party', 'tada'] },
{ e: '🎊', k: ['confetti'] },
{ e: '🎁', k: ['gift'] },
{ e: '🎈', k: ['balloon'] },
{ e: '💯', k: ['100', 'perfect'] },
{ e: '✅', k: ['check'] },
{ e: '❌', k: ['x', 'no'] },
{ e: '⚠️', k: ['warning'] },
{ e: '❓', k: ['question'] },
{ e: '❗', k: ['exclamation'] },
{ e: '💬', k: ['speech'] },
{ e: '💭', k: ['thought'] },
{ e: '👀', k: ['eyes'] },
{ e: '🚀', k: ['rocket'] },
{ e: '🎵', k: ['music'] },
{ e: '🎶', k: ['music'] },
{ e: '🔔', k: ['bell'] },
{ e: '💡', k: ['idea', 'bulb'] },
],
},
];
const RECENT_KEY = 'chat.emoji.recents.v1';
const RECENT_MAX = 24;
function loadRecents(): string[] {
try {
const raw = localStorage.getItem(RECENT_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((x): x is string => typeof x === 'string').slice(0, RECENT_MAX);
} catch {
return [];
}
}
function saveRecents(list: string[]): void {
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
} catch {
/* ignore quota */
}
}
interface Props {
open: boolean;
onPick: (emoji: string) => void;
onClose: () => void;
}
export function EmojiPicker({ open, onPick, onClose }: Props) {
const [query, setQuery] = useState('');
const [recents, setRecents] = useState<string[]>(() => loadRecents());
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (rootRef.current && t && !rootRef.current.contains(t) && !t.closest('[data-emoji-trigger]')) {
onClose();
}
};
window.addEventListener('keydown', onKey);
window.addEventListener('mousedown', onDown);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onDown);
};
}, [open, onClose]);
useEffect(() => {
if (open) setQuery('');
}, [open]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return CATEGORIES;
return CATEGORIES.map((cat) => ({
label: cat.label,
entries: cat.entries.filter(
(entry) =>
entry.e.includes(q) ||
entry.k.some((k) => k.includes(q)) ||
cat.label.toLowerCase().includes(q),
),
})).filter((cat) => cat.entries.length > 0);
}, [query]);
if (!open) return null;
const handlePick = (emoji: string) => {
onPick(emoji);
const next = [emoji, ...recents.filter((e) => e !== emoji)].slice(0, RECENT_MAX);
setRecents(next);
saveRecents(next);
};
return (
<div
ref={rootRef}
role="dialog"
aria-label="Emoji auswählen"
className="absolute bottom-full right-0 z-30 mb-2 flex w-[320px] flex-col rounded-xl border border-line bg-surface-2 shadow-xl"
>
<div className="border-b border-line p-2">
<input
type="search"
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Suchen…"
className="w-full rounded-md border border-line bg-surface-3 px-2.5 py-1.5 text-sm text-fg placeholder-fg-muted outline-none focus:border-accent"
/>
</div>
<div className="max-h-[320px] overflow-y-auto p-2">
{recents.length > 0 && !query && (
<CategoryBlock
label="Zuletzt"
entries={recents.map((e) => ({ e, k: [] }))}
onPick={handlePick}
/>
)}
{filtered.map((cat) => (
<CategoryBlock
key={cat.label}
label={cat.label}
entries={cat.entries}
onPick={handlePick}
/>
))}
{filtered.length === 0 && (
<p className="py-4 text-center text-xs text-fg-muted">Keine Treffer</p>
)}
</div>
</div>
);
}
function CategoryBlock({
label,
entries,
onPick,
}: {
label: string;
entries: EmojiEntry[];
onPick: (emoji: string) => void;
}) {
return (
<div className="mb-2">
<p className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{label}
</p>
<div className="grid grid-cols-8 gap-0.5">
{entries.map((entry, idx) => (
<button
key={entry.e + ':' + idx}
type="button"
onClick={() => onPick(entry.e)}
aria-label={entry.k[0] ?? entry.e}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-lg transition hover:bg-surface-3"
>
{entry.e}
</button>
))}
</div>
</div>
);
}
+34 -1
View File
@@ -18,6 +18,7 @@ import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } f
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
import { ScreenShareDialog } from './ScreenShareDialog';
import { ScreenShareViewer } from './ScreenShareViewer';
import { SoundboardPanel } from './SoundboardPanel';
// Discord-style in-call dock rendered above the message list. Renders three
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
@@ -61,6 +62,7 @@ export function InCallPanel({ conversation }: Props) {
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteMute,
remoteScreenShares,
callMode,
focusedId,
@@ -77,6 +79,7 @@ export function InCallPanel({ conversation }: Props) {
const myId = session?.user.id ?? null;
const activeSpeakers = useActiveSpeakers(room);
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const [soundboardOpen, setSoundboardOpen] = useState(false);
const [volumeMenu, setVolumeMenu] = useState<
{ userId: string; displayName: string; x: number; y: number } | null
>(null);
@@ -108,6 +111,7 @@ export function InCallPanel({ conversation }: Props) {
isMuted,
isDeafened,
remoteDeafen,
remoteMute,
isScreenSharing,
isCameraEnabled,
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
@@ -150,6 +154,8 @@ export function InCallPanel({ conversation }: Props) {
}}
onToggleVideo={() => void toggleCamera()}
onToggleDeafen={toggleDeafen}
onToggleSoundboard={() => setSoundboardOpen((v) => !v)}
soundboardOpen={soundboardOpen}
onHangup={() => void hangup()}
compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'}
@@ -197,6 +203,10 @@ export function InCallPanel({ conversation }: Props) {
onClose={() => setVolumeMenu(null)}
/>
)}
<SoundboardPopover
open={soundboardOpen}
onClose={() => setSoundboardOpen(false)}
/>
</>
);
}
@@ -280,10 +290,28 @@ export function InCallPanel({ conversation }: Props) {
onClose={() => setVolumeMenu(null)}
/>
)}
<SoundboardPopover
open={soundboardOpen}
onClose={() => setSoundboardOpen(false)}
/>
</section>
);
}
// Fixed-position overlay so the popover sits above both docked + fullscreen
// call modes without needing a portal or parent-relative anchoring.
function SoundboardPopover({ open, onClose }: { open: boolean; onClose: () => void }) {
if (!open) return null;
return (
<div className="pointer-events-none fixed inset-x-0 bottom-24 z-50 flex justify-center px-4">
<div className="pointer-events-auto">
<SoundboardPanel onClose={onClose} />
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -296,6 +324,7 @@ interface BuildArgs {
isMuted: boolean;
isDeafened: boolean;
remoteDeafen: Record<string, boolean>;
remoteMute: Record<string, boolean>;
isScreenSharing: boolean;
isCameraEnabled: boolean;
remoteSharerIds: Set<string>;
@@ -321,6 +350,7 @@ function buildTiles({
isMuted,
isDeafened,
remoteDeafen,
remoteMute,
isScreenSharing,
isCameraEnabled,
remoteSharerIds,
@@ -379,7 +409,10 @@ function buildTiles({
displayName: m.profile?.displayName ?? '?',
avatarUrl: m.profile?.avatarUrl ?? null,
self: false,
muted: !rp.isMicrophoneEnabled,
// Peer's self-reported mute state via data channel. LiveKit's own
// `isMicrophoneEnabled` no longer flips on mute since the pipeline
// output track stays published. See remoteMute broadcast in CallContext.
muted: remoteMute[m.userId] ?? false,
// Deafen state arrives via LiveKit data channel; see CallContext.
deafened: remoteDeafen[m.userId] ?? false,
video: rp.isCameraEnabled,
@@ -0,0 +1,49 @@
import { useLinkPreview } from '../lib/useLinkPreview';
interface Props {
url: string;
}
// Renders a compact OpenGraph preview card under a message bubble. Fetches
// lazily through the edge function; silent when the URL returned no meta.
export function LinkPreviewCard({ url }: Props) {
const preview = useLinkPreview(url);
if (!preview || !preview.ok) return null;
if (!preview.title && !preview.description && !preview.imageUrl) return null;
return (
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className="mt-2 flex max-w-[320px] overflow-hidden rounded-lg border border-line bg-surface-2 text-sm no-underline transition hover:bg-surface-3"
>
{preview.imageUrl && (
<img
src={preview.imageUrl}
alt=""
loading="lazy"
className="h-20 w-20 shrink-0 object-cover"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
)}
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 p-2.5">
{preview.siteName && (
<p className="truncate text-[10px] uppercase tracking-wider text-fg-muted">
{preview.siteName}
</p>
)}
{preview.title && (
<p className="line-clamp-2 text-sm font-semibold text-fg">
{preview.title}
</p>
)}
{preview.description && (
<p className="line-clamp-2 text-xs text-fg-muted">{preview.description}</p>
)}
</div>
</a>
);
}
@@ -0,0 +1,103 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { Avatar } from './Avatar';
interface Props {
members: ConversationSummary['members'];
query: string;
excludeUserId: string | undefined;
onSelect: (username: string) => void;
onClose: () => void;
}
// Dropdown shown above the composer when the user has typed `@` followed
// by the start of a member name. Keyboard-first — arrow keys move through,
// enter/tab commits, escape cancels.
export function MentionAutocomplete({
members,
query,
excludeUserId,
onSelect,
onClose,
}: Props) {
const q = query.toLowerCase();
const matches = members
.filter((m) => m.userId !== excludeUserId)
.filter((m) => {
if (!q) return true;
const name = (m.profile?.displayName ?? '').toLowerCase();
const handle = (m.profile?.username ?? '').toLowerCase();
return name.includes(q) || handle.includes(q);
})
.slice(0, 8);
const [active, setActive] = useState(0);
useEffect(() => {
setActive(0);
}, [query]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (matches.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive((i) => (i + 1) % matches.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((i) => (i - 1 + matches.length) % matches.length);
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const pick = matches[active];
if (pick?.profile?.username) onSelect(pick.profile.username);
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', onKey, true);
return () => {
window.removeEventListener('keydown', onKey, true);
};
}, [matches, active, onSelect, onClose]);
if (matches.length === 0) return null;
return (
<div
role="listbox"
aria-label="Mitglieder"
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl"
>
{matches.map((m, idx) => {
const name = m.profile?.displayName ?? m.profile?.username ?? '?';
const handle = m.profile?.username ?? '';
const isActive = idx === active;
return (
<button
key={m.userId}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActive(idx)}
onClick={() => {
if (m.profile?.username) onSelect(m.profile.username);
}}
className={
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
(isActive ? 'bg-accent/20 text-fg' : 'text-fg-muted hover:bg-surface-3')
}
>
<Avatar
displayName={name}
url={m.profile?.avatarUrl ?? null}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
</button>
);
})}
</div>
);
}
+53 -8
View File
@@ -13,8 +13,13 @@ import { useAuth } from '../context/AuthContext';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import type { AggregatedReaction } from '../lib/useMessageReactions';
import { extractFirstUrl } from '../lib/useLinkPreview';
import { AttachmentAudio } from './AttachmentAudio';
import { AttachmentGeneric } from './AttachmentGeneric';
import { AttachmentImage } from './AttachmentImage';
import { AttachmentPdf } from './AttachmentPdf';
import { AttachmentVideo } from './AttachmentVideo';
import { LinkPreviewCard } from './LinkPreviewCard';
import { Avatar } from './Avatar';
import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
@@ -299,14 +304,27 @@ export function MessageBubble({
<span className="italic opacity-70">cannot decrypt</span>
) : (
<>
{bodyText.length > 0 && <div>{bodyText}</div>}
{attachments.map((a) =>
a.mimeType.startsWith('audio/') ? (
<AttachmentAudio key={a.id} handle={a} />
) : (
<AttachmentImage key={a.id} handle={a} />
),
)}
{bodyText.length > 0 && <div>{renderBodyWithMentions(bodyText)}</div>}
{bodyText.length > 0 &&
(() => {
const url = extractFirstUrl(bodyText);
return url ? <LinkPreviewCard url={url} /> : null;
})()}
{attachments.map((a) => {
if (a.mimeType.startsWith('audio/')) {
return <AttachmentAudio key={a.id} handle={a} />;
}
if (a.mimeType.startsWith('image/')) {
return <AttachmentImage key={a.id} handle={a} />;
}
if (a.mimeType.startsWith('video/')) {
return <AttachmentVideo key={a.id} handle={a} />;
}
if (a.mimeType === 'application/pdf') {
return <AttachmentPdf key={a.id} handle={a} />;
}
return <AttachmentGeneric key={a.id} handle={a} />;
})}
</>
)}
<div
@@ -510,6 +528,33 @@ function formatDuration(totalSec: number): string {
return m + ':' + s.toString().padStart(2, '0');
}
// Splits body text on `@username` tokens, rendering matches as highlighted
// pills. Username alphabet matches Supabase citext usernames: alphanumerics
// + underscores, length 1..32 (we don't bound here — regex is permissive
// and keys off a leading `@` with an alnum/underscore follow).
const MENTION_RE = /@([A-Za-z0-9_]{1,32})/g;
function renderBodyWithMentions(text: string): React.ReactNode[] {
const out: React.ReactNode[] = [];
let lastIdx = 0;
let m: RegExpExecArray | null;
MENTION_RE.lastIndex = 0;
while ((m = MENTION_RE.exec(text)) !== null) {
if (m.index > lastIdx) out.push(text.slice(lastIdx, m.index));
out.push(
<span
key={m.index + ':' + m[1]}
className="rounded bg-accent/20 px-1 text-accent"
>
{m[0]}
</span>,
);
lastIdx = m.index + m[0].length;
}
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out;
}
function DeliveryTicks({ state }: { state: 'sent' | 'delivered' | 'read' }) {
// One checkmark for sent, two for delivered/read. Read shifts color to the
// accent to match WhatsApp/Telegram blue-tick convention.
@@ -0,0 +1,244 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
clearIncomingRingtone,
getIncomingRingtone,
MAX_RINGTONE_BYTES,
saveIncomingRingtone,
type StoredRingtone,
} from '../lib/ringtoneStorage';
import { PhoneIcon, SpinnerIcon, TrashIcon } from './icons';
interface Props {
/** Disable interactions while a parent action is in flight. */
disabled?: boolean;
}
const BYTES_PER_MB = 1024 * 1024;
// UI for the custom incoming-call ringtone. Single file slot. Upload
// validates size + mime and surfaces errors inline. Preview button plays
// the stored blob through a local <audio> element without touching the
// shared ringtone singleton so we don't interfere with a live call.
export function RingtoneSettings({ disabled = false }: Props) {
const { t } = useTranslation(['app']);
const inputRef = useRef<HTMLInputElement | null>(null);
const previewRef = useRef<HTMLAudioElement | null>(null);
const previewUrlRef = useRef<string | null>(null);
const [current, setCurrent] = useState<StoredRingtone | null>(null);
const [busy, setBusy] = useState(false);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const cur = await getIncomingRingtone();
setCurrent(cur);
} catch (err: unknown) {
console.error('getIncomingRingtone failed', err);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
// Revoke any preview blob URL when the component unmounts so long-lived
// pages don't leak memory.
return () => {
stopPreview();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function stopPreview(): void {
const el = previewRef.current;
if (el) {
try {
el.pause();
} catch {
/* ignore */
}
el.src = '';
}
previewRef.current = null;
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setPlaying(false);
}
async function handleFile(file: File): Promise<void> {
setError(null);
setBusy(true);
try {
await saveIncomingRingtone(file);
await refresh();
} catch (err: unknown) {
const code = err instanceof Error ? err.message : 'upload_failed';
if (code === 'ringtone_too_large') {
setError(
t('app:settings.ringtone_error_too_large', {
defaultValue: 'Datei zu groß (max 2 MB).',
max: MAX_RINGTONE_BYTES / BYTES_PER_MB,
}),
);
} else if (code === 'ringtone_not_audio') {
setError(
t('app:settings.ringtone_error_not_audio', {
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
}),
);
} else {
setError(
t('app:settings.ringtone_error_generic', {
defaultValue: 'Ringtone konnte nicht gespeichert werden.',
}),
);
}
} finally {
setBusy(false);
if (inputRef.current) inputRef.current.value = '';
}
}
async function handleReset(): Promise<void> {
setError(null);
setBusy(true);
stopPreview();
try {
await clearIncomingRingtone();
await refresh();
} catch (err: unknown) {
console.error('clearIncomingRingtone failed', err);
} finally {
setBusy(false);
}
}
function handlePreview(): void {
if (!current) return;
if (playing) {
stopPreview();
return;
}
const url = URL.createObjectURL(current.blob);
const el = new Audio(url);
el.loop = false;
el.volume = 0.85;
el.onended = () => stopPreview();
el.onerror = () => {
setError(
t('app:settings.ringtone_error_play', {
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
}),
);
stopPreview();
};
el.play().catch(() => {
setError(
t('app:settings.ringtone_error_play', {
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
}),
);
stopPreview();
});
previewRef.current = el;
previewUrlRef.current = url;
setPlaying(true);
}
const hasCustom = current !== null;
const sizeMb = current ? (current.blob.size / BYTES_PER_MB).toFixed(2) : null;
const interactionsDisabled = disabled || busy;
return (
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-fg">
<PhoneIcon className="h-4 w-4 text-fg-muted" />
{t('app:settings.ringtone_incoming', { defaultValue: 'Eingehender Anruf' })}
</div>
<div className="mt-1 text-xs text-fg-muted">
{hasCustom && current
? t('app:settings.ringtone_custom_active', {
defaultValue: '{{name}} · {{size}} MB',
name: current.filename,
size: sizeMb,
})
: t('app:settings.ringtone_default_active', {
defaultValue: 'Standard-Klingelton (Doppelton)',
})}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{hasCustom && (
<button
type="button"
onClick={handlePreview}
disabled={interactionsDisabled}
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs font-semibold text-fg transition hover:brightness-95 disabled:cursor-not-allowed disabled:opacity-50"
>
{playing
? t('app:settings.ringtone_stop', { defaultValue: 'Stop' })
: t('app:settings.ringtone_preview', { defaultValue: 'Vorhören' })}
</button>
)}
<input
ref={inputRef}
type="file"
accept="audio/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
}}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={interactionsDisabled}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>
{hasCustom
? t('app:settings.ringtone_replace', { defaultValue: 'Ersetzen' })
: t('app:settings.ringtone_upload', { defaultValue: 'Hochladen' })}
</span>
</button>
{hasCustom && (
<button
type="button"
onClick={() => void handleReset()}
disabled={interactionsDisabled}
aria-label={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
title={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-rose-500/40 bg-rose-500/10 text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
>
<TrashIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
{error && (
<p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
{error}
</p>
)}
<p className="text-[11px] text-fg-muted">
{t('app:settings.ringtone_hint', {
defaultValue:
'MP3, WAV, OGG oder M4A bis 2 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.',
})}
</p>
</div>
);
}
@@ -0,0 +1,625 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getPttSettings } from '../lib/pttSettings';
import { codeToShortcut } from '../lib/globalShortcut';
import { invalidate as invalidateSoundCache, preload } from '../lib/soundboardPlayback';
import {
addSound,
deleteSound,
getSoundBlob,
listSounds,
MAX_SOUND_BYTES,
reorderCategory,
type SoundboardEntry,
subscribeSoundboardChanges,
updateSound,
} from '../lib/soundboardStorage';
import { Modal } from './Modal';
import {
AlertIcon,
ChevronDownIcon,
PencilIcon,
PlusIcon,
SpinnerIcon,
TrashIcon,
} from './icons';
interface Props {
open: boolean;
onClose: () => void;
}
const BYTES_PER_MB = 1024 * 1024;
const CATEGORY_LIST_ID = 'sb-category-list';
// Admin UI for the soundboard. Users add, rename, categorise, reorder,
// assign hotkeys, adjust per-sound volume, preview and delete clips here.
// In-call panel only reads the resulting manifest.
export function SoundboardManagerDialog({ open, onClose }: Props) {
const { t } = useTranslation(['app']);
const fileRef = useRef<HTMLInputElement | null>(null);
const previewRef = useRef<HTMLAudioElement | null>(null);
const previewUrlRef = useRef<string | null>(null);
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
const [loading, setLoading] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [previewingId, setPreviewingId] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
try {
setEntries(await listSounds());
} catch (err: unknown) {
console.error('listSounds failed', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
void refresh();
// External mutations (hotkey fires, multi-tab edits) should reflect
// immediately while the dialog is open.
const unsub = subscribeSoundboardChanges(() => {
void refresh();
});
return () => {
unsub();
stopPreview();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, refresh]);
useEffect(() => {
return () => stopPreview();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const categories = useMemo(() => {
const set = new Set<string>();
for (const e of entries) if (e.category) set.add(e.category);
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [entries]);
const grouped = useMemo(() => {
const map = new Map<string, SoundboardEntry[]>();
for (const e of entries) {
const key = e.category ?? '';
const arr = map.get(key);
if (arr) arr.push(e);
else map.set(key, [e]);
}
return map;
}, [entries]);
function stopPreview(): void {
const el = previewRef.current;
if (el) {
try {
el.pause();
} catch {
/* ignore */
}
el.src = '';
}
previewRef.current = null;
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setPreviewingId(null);
}
async function handleAdd(file: File): Promise<void> {
setError(null);
setBusyId('__add');
try {
await addSound({ file });
await refresh();
} catch (err: unknown) {
const code = err instanceof Error ? err.message : 'add_failed';
if (code === 'sound_too_large') {
setError(
t('app:soundboard.error_too_large', {
defaultValue: 'Datei zu groß (max {{max}} MB).',
max: MAX_SOUND_BYTES / BYTES_PER_MB,
}),
);
} else if (code === 'sound_not_audio') {
setError(
t('app:soundboard.error_not_audio', {
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
}),
);
} else {
setError(
t('app:soundboard.error_generic', {
defaultValue: 'Sound konnte nicht gespeichert werden.',
}),
);
}
} finally {
setBusyId(null);
if (fileRef.current) fileRef.current.value = '';
}
}
async function handlePatch(
id: string,
patch: Parameters<typeof updateSound>[1],
): Promise<void> {
setBusyId(id);
try {
await updateSound(id, patch);
} catch (err: unknown) {
console.error('updateSound failed', err);
} finally {
setBusyId(null);
}
}
async function handleDelete(id: string): Promise<void> {
if (!window.confirm(t('app:soundboard.delete_confirm', { defaultValue: 'Sound löschen?' }))) {
return;
}
setBusyId(id);
try {
await deleteSound(id);
invalidateSoundCache(id);
if (previewingId === id) stopPreview();
} catch (err: unknown) {
console.error('deleteSound failed', err);
} finally {
setBusyId(null);
}
}
async function handlePreview(entry: SoundboardEntry): Promise<void> {
if (previewingId === entry.id) {
stopPreview();
return;
}
stopPreview();
const blob = await getSoundBlob(entry.id);
if (!blob) return;
const url = URL.createObjectURL(blob);
const el = new Audio(url);
el.volume = entry.gain;
el.onended = () => stopPreview();
el.onerror = () => stopPreview();
el.play().catch(() => stopPreview());
previewRef.current = el;
previewUrlRef.current = url;
setPreviewingId(entry.id);
// Opportunistic warm-up of the AudioBuffer cache so the first in-call
// playback doesn't pause on decode.
void preload(entry.id);
}
async function handleReorder(
category: string | null,
idx: number,
dir: -1 | 1,
): Promise<void> {
const bucket = grouped.get(category ?? '') ?? [];
const next = idx + dir;
if (next < 0 || next >= bucket.length) return;
const reordered = bucket.slice();
const tmp = reordered[idx]!;
reordered[idx] = reordered[next]!;
reordered[next] = tmp;
setBusyId('__reorder:' + (category ?? ''));
try {
await reorderCategory(
category,
reordered.map((e) => e.id),
);
} catch (err: unknown) {
console.error('reorderCategory failed', err);
} finally {
setBusyId(null);
}
}
if (!open) return null;
return (
<Modal
open={open}
onClose={onClose}
title={t('app:soundboard.manager_title', { defaultValue: 'Soundboard verwalten' })}
size="lg"
>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-fg-muted">
{t('app:soundboard.manager_hint', {
defaultValue:
'Beliebig viele Sounds, kein Hotkey nötig. Hotkeys feuern nur während eines Anrufs.',
})}
</p>
<input
ref={fileRef}
type="file"
accept="audio/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleAdd(f);
}}
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={busyId !== null}
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{busyId === '__add' ? (
<SpinnerIcon className="h-3.5 w-3.5" />
) : (
<PlusIcon className="h-3.5 w-3.5" />
)}
<span>{t('app:soundboard.add', { defaultValue: 'Sound hinzufügen' })}</span>
</button>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
<datalist id={CATEGORY_LIST_ID}>
{categories.map((c) => (
<option key={c} value={c} />
))}
</datalist>
{loading ? (
<div className="flex items-center gap-2 text-xs text-fg-muted">
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
</div>
) : entries.length === 0 ? (
<p className="rounded-lg border border-line bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{t('app:soundboard.empty', {
defaultValue: 'Noch keine Sounds. Lade oben welche hoch.',
})}
</p>
) : (
<div className="flex flex-col gap-4">
{Array.from(grouped.entries()).map(([categoryKey, bucket]) => {
const category = categoryKey === '' ? null : categoryKey;
return (
<SoundboardCategoryGroup
key={categoryKey || '__uncat'}
category={category}
entries={bucket}
entriesTotal={entries}
busyId={busyId}
previewingId={previewingId}
onPatch={handlePatch}
onDelete={handleDelete}
onPreview={handlePreview}
onReorder={handleReorder}
/>
);
})}
</div>
)}
</div>
</Modal>
);
}
// ---------------------------------------------------------------------------
interface GroupProps {
category: string | null;
entries: SoundboardEntry[];
entriesTotal: SoundboardEntry[];
busyId: string | null;
previewingId: string | null;
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onPreview: (entry: SoundboardEntry) => Promise<void>;
onReorder: (category: string | null, idx: number, dir: -1 | 1) => Promise<void>;
}
function SoundboardCategoryGroup({
category,
entries,
entriesTotal,
busyId,
previewingId,
onPatch,
onDelete,
onPreview,
onReorder,
}: GroupProps) {
const { t } = useTranslation(['app']);
const [open, setOpen] = useState(true);
const label =
category ??
t('app:soundboard.category_uncategorized', { defaultValue: '(Ohne Kategorie)' });
return (
<section className="rounded-lg border border-line bg-surface-2">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5 text-left"
>
<span className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
{label} · {entries.length}
</span>
<ChevronDownIcon
className={'h-4 w-4 text-fg-muted transition ' + (open ? '' : '-rotate-90')}
/>
</button>
{open && (
<ul className="flex flex-col gap-2 border-t border-line p-3">
{entries.map((entry, idx) => (
<SoundboardRow
key={entry.id}
entry={entry}
entriesTotal={entriesTotal}
isFirst={idx === 0}
isLast={idx === entries.length - 1}
busy={busyId === entry.id}
previewing={previewingId === entry.id}
onPatch={onPatch}
onDelete={onDelete}
onPreview={onPreview}
onReorderUp={() => onReorder(category, idx, -1)}
onReorderDown={() => onReorder(category, idx, 1)}
/>
))}
</ul>
)}
</section>
);
}
// ---------------------------------------------------------------------------
interface RowProps {
entry: SoundboardEntry;
entriesTotal: SoundboardEntry[];
isFirst: boolean;
isLast: boolean;
busy: boolean;
previewing: boolean;
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onPreview: (entry: SoundboardEntry) => Promise<void>;
onReorderUp: () => Promise<void>;
onReorderDown: () => Promise<void>;
}
function SoundboardRow({
entry,
entriesTotal,
isFirst,
isLast,
busy,
previewing,
onPatch,
onDelete,
onPreview,
onReorderUp,
onReorderDown,
}: RowProps) {
const { t } = useTranslation(['app']);
const [editingName, setEditingName] = useState(false);
const [nameDraft, setNameDraft] = useState(entry.name);
const [categoryDraft, setCategoryDraft] = useState(entry.category ?? '');
const [capturingHotkey, setCapturingHotkey] = useState(false);
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
useEffect(() => {
setNameDraft(entry.name);
setCategoryDraft(entry.category ?? '');
}, [entry.name, entry.category]);
useEffect(() => {
if (!capturingHotkey) return;
const onKey = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.code === 'Escape') {
setCapturingHotkey(false);
setHotkeyError(null);
return;
}
// Conflict checks: PTT + other soundboard entries with this code.
const ptt = getPttSettings();
if (ptt.enabled && ptt.key === e.code) {
setHotkeyError(
t('app:soundboard.hotkey_conflict_ptt', {
defaultValue: 'Konflikt mit Push-to-Talk.',
}),
);
return;
}
const taken = entriesTotal.find((s) => s.id !== entry.id && s.hotkey === e.code);
if (taken) {
setHotkeyError(
t('app:soundboard.hotkey_conflict_sound', {
defaultValue: 'Bereits von "{{name}}" belegt.',
name: taken.name,
}),
);
return;
}
setHotkeyError(null);
setCapturingHotkey(false);
void onPatch(entry.id, { hotkey: e.code });
};
window.addEventListener('keydown', onKey, { capture: true });
return () => window.removeEventListener('keydown', onKey, { capture: true });
}, [capturingHotkey, entriesTotal, entry.id, onPatch, t]);
const hotkeyLabel = entry.hotkey ? codeToShortcut(entry.hotkey) : null;
return (
<li className="flex flex-wrap items-center gap-3 rounded-md border border-line bg-surface-3 px-3 py-2">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
{editingName ? (
<input
autoFocus
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
onBlur={() => {
setEditingName(false);
if (nameDraft.trim() && nameDraft !== entry.name) {
void onPatch(entry.id, { name: nameDraft });
} else {
setNameDraft(entry.name);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur();
}
if (e.key === 'Escape') {
setNameDraft(entry.name);
setEditingName(false);
}
}}
className="w-full rounded border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent focus:outline-none"
/>
) : (
<button
type="button"
onClick={() => setEditingName(true)}
className="flex cursor-pointer items-center gap-1.5 self-start text-sm font-semibold text-fg hover:text-accent"
>
{entry.name}
<PencilIcon className="h-3 w-3 opacity-50" />
</button>
)}
<p className="text-[10px] text-fg-muted">
{(entry.size / BYTES_PER_MB).toFixed(2)} MB · {entry.mime}
</p>
</div>
<input
type="text"
value={categoryDraft}
onChange={(e) => setCategoryDraft(e.target.value)}
onBlur={() => {
const next = categoryDraft.trim() || null;
if (next !== (entry.category ?? null)) {
void onPatch(entry.id, { category: next });
}
}}
list={CATEGORY_LIST_ID}
placeholder={t('app:soundboard.category_placeholder', {
defaultValue: 'Kategorie…',
})}
className="w-32 shrink-0 rounded border border-line bg-surface-2 px-2 py-1 text-xs text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
/>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => {
setHotkeyError(null);
setCapturingHotkey((v) => !v);
}}
className={
'inline-flex min-w-[5rem] cursor-pointer items-center justify-center rounded border px-2 py-1 text-[11px] font-mono font-semibold transition ' +
(capturingHotkey
? 'animate-pulse border-accent bg-accent/20 text-fg'
: hotkeyLabel
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-line bg-surface-2 text-fg-muted hover:bg-surface')
}
title={t('app:soundboard.hotkey_capture', {
defaultValue: 'Hotkey binden (Esc = abbrechen)',
})}
>
{capturingHotkey
? t('app:soundboard.hotkey_press', { defaultValue: 'Drücke…' })
: hotkeyLabel ??
t('app:soundboard.hotkey_none', { defaultValue: 'Kein Hotkey' })}
</button>
{entry.hotkey && !capturingHotkey && (
<button
type="button"
onClick={() => void onPatch(entry.id, { hotkey: null })}
aria-label={t('app:soundboard.hotkey_clear', { defaultValue: 'Hotkey entfernen' })}
title={t('app:soundboard.hotkey_clear', { defaultValue: 'Hotkey entfernen' })}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg"
>
×
</button>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<input
type="range"
min={0}
max={1}
step={0.05}
value={entry.gain}
onChange={(e) => void onPatch(entry.id, { gain: Number(e.target.value) })}
className="accent-accent w-20"
title={t('app:soundboard.gain_title', {
defaultValue: 'Lautstärke',
})}
/>
<button
type="button"
onClick={() => void onPreview(entry)}
disabled={busy}
className="inline-flex cursor-pointer items-center gap-1 rounded border border-line bg-surface-2 px-2 py-1 text-[11px] font-medium text-fg transition hover:bg-surface disabled:opacity-50"
>
{previewing
? t('app:soundboard.preview_stop', { defaultValue: 'Stop' })
: t('app:soundboard.preview', { defaultValue: 'Vorhören' })}
</button>
<button
type="button"
onClick={onReorderUp}
disabled={busy || isFirst}
aria-label={t('app:soundboard.move_up', { defaultValue: 'Nach oben' })}
title={t('app:soundboard.move_up', { defaultValue: 'Nach oben' })}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40"
>
</button>
<button
type="button"
onClick={onReorderDown}
disabled={busy || isLast}
aria-label={t('app:soundboard.move_down', { defaultValue: 'Nach unten' })}
title={t('app:soundboard.move_down', { defaultValue: 'Nach unten' })}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40"
>
</button>
<button
type="button"
onClick={() => void onDelete(entry.id)}
disabled={busy}
aria-label={t('app:soundboard.delete', { defaultValue: 'Löschen' })}
title={t('app:soundboard.delete', { defaultValue: 'Löschen' })}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded border border-rose-500/30 bg-rose-500/10 text-rose-600 hover:bg-rose-500/20 disabled:opacity-50 dark:text-rose-300"
>
<TrashIcon className="h-3 w-3" />
</button>
</div>
{hotkeyError && capturingHotkey && (
<p className="basis-full text-[11px] text-rose-600 dark:text-rose-300">
{hotkeyError}
</p>
)}
</li>
);
}
@@ -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>
);
}
@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
listSounds,
subscribeSoundboardChanges,
type SoundboardEntry,
} from '../lib/soundboardStorage';
import { SoundboardManagerDialog } from './SoundboardManagerDialog';
import { ArrowRightIcon } from './icons';
// Entry point into the soundboard manager from the settings page. Shows a
// tiny summary (count, category count) and opens the big dialog on click.
export function SoundboardSettings() {
const { t } = useTranslation(['app']);
const [open, setOpen] = useState(false);
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
useEffect(() => {
let cancelled = false;
const refresh = async () => {
try {
const all = await listSounds();
if (!cancelled) setEntries(all);
} catch (err: unknown) {
console.warn('listSounds failed', err);
}
};
void refresh();
const unsub = subscribeSoundboardChanges(() => {
void refresh();
});
return () => {
cancelled = true;
unsub();
};
}, []);
const categoryCount = new Set(entries.map((e) => e.category ?? '__uncat')).size;
const withHotkey = entries.filter((e) => e.hotkey !== null).length;
return (
<>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-fg">
{t('app:soundboard.summary_title', { defaultValue: 'Deine Sounds' })}
</p>
<p className="mt-1 text-xs text-fg-muted">
{entries.length === 0
? t('app:soundboard.summary_empty', {
defaultValue: 'Noch keine Sounds vorhanden.',
})
: t('app:soundboard.summary_counts', {
defaultValue:
'{{sounds}} Sounds · {{categories}} Kategorien · {{hotkeys}} mit Hotkey',
sounds: entries.length,
categories: categoryCount,
hotkeys: withHotkey,
})}
</p>
</div>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110"
>
<span>
{t('app:soundboard.manage', { defaultValue: 'Verwalten' })}
</span>
<ArrowRightIcon className="h-3.5 w-3.5" />
</button>
</div>
<p className="text-[11px] text-fg-muted">
{t('app:soundboard.settings_hint', {
defaultValue:
'Hotkeys sind optional. Sounds lassen sich auch während eines Anrufs direkt im UI abspielen.',
})}
</p>
<SoundboardManagerDialog open={open} onClose={() => setOpen(false)} />
</>
);
}
+59 -4
View File
@@ -1,6 +1,6 @@
import { updateOwnProfile } from '@chat-app/shared/auth';
import type { PresenceState } from '@chat-app/shared/supabase';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
@@ -8,6 +8,8 @@ import { supabase } from '../lib/supabase';
import { Avatar } from './Avatar';
import { ChevronDownIcon } from './icons';
const STATUS_MAX = 128;
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
const PRESENCE_DOT: Record<PresenceState, string> = {
@@ -25,6 +27,23 @@ export function UserBar() {
const [busy, setBusy] = useState(false);
const presence = profile?.presenceState ?? 'offline';
const persistedStatus = profile?.statusMessage ?? '';
const [statusDraft, setStatusDraft] = useState(persistedStatus);
// Re-sync local draft with the server when the profile refreshes (e.g. after
// a successful save) — without this the input would forget any incoming
// updates from another device.
useEffect(() => {
setStatusDraft(persistedStatus);
}, [persistedStatus]);
// Subtitle priority: custom status when online and set, else label, else "Offline".
const subtitle =
presence === 'offline'
? t('app:presence.offline')
: persistedStatus.trim().length > 0
? persistedStatus.trim()
: t('app:presence.' + presence);
async function changePresence(next: PresenceState) {
if (busy || next === presence) {
@@ -43,6 +62,20 @@ export function UserBar() {
}
}
async function saveStatus() {
const next = statusDraft.trim().slice(0, STATUS_MAX);
if (next === persistedStatus) return;
setBusy(true);
try {
await updateOwnProfile(supabase, { statusMessage: next.length === 0 ? null : next });
await refreshProfile();
} catch (err: unknown) {
console.error('updateStatusMessage failed', err);
} finally {
setBusy(false);
}
}
return (
<div className="relative">
<button
@@ -69,9 +102,7 @@ export function UserBar() {
<p className="truncate text-sm font-medium text-fg">
{profile?.displayName ?? '—'}
</p>
<p className="truncate text-xs text-fg-muted">
{t('app:presence.' + presence)}
</p>
<p className="truncate text-xs text-fg-muted">{subtitle}</p>
</div>
<ChevronDownIcon
className={'h-4 w-4 text-fg-muted transition ' + (open ? 'rotate-180' : '')}
@@ -83,6 +114,30 @@ export function UserBar() {
role="menu"
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-line bg-surface-3 shadow-xl"
>
<div className="border-b border-line p-2">
<input
type="text"
value={statusDraft}
onChange={(e) => setStatusDraft(e.target.value.slice(0, STATUS_MAX))}
onBlur={() => void saveStatus()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void saveStatus();
setOpen(false);
}
if (e.key === 'Escape') {
setStatusDraft(persistedStatus);
setOpen(false);
}
}}
placeholder={t('app:presence.status_placeholder', {
defaultValue: 'Status setzen…',
})}
maxLength={STATUS_MAX}
className="w-full rounded-md border border-line bg-surface-2 px-2 py-1.5 text-sm text-fg placeholder-fg-muted outline-none focus:border-accent"
/>
</div>
{PRESENCE_OPTIONS.map((opt) => (
<button
key={opt}
+10
View File
@@ -401,6 +401,16 @@ export function CrownIcon(props: IconProps) {
);
}
export function MusicIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</Base>
);
}
export function SendIcon(props: IconProps) {
return (
<Base {...props}>