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