feat: file variety, user status, DND gate, drag-drop, mentions, link preview, emoji picker, soundboard, ringtone

Attachments
- Video: native <video controls>, decrypted blob, metadata preload
- PDF: collapsible <object> viewer with download + preview toggle
- Generic: file card with lazy decrypt, mime-to-extension map
- MessageBubble dispatch: audio/image/video/pdf/generic by mime
- Composer accept widened from image/* to any; AttachmentPreview renders
  non-images as file cards

User status
- usePeerPresence returns { state, statusMessage } and tracks both fields
  via realtime updates
- ConversationHeader subtitle shows custom status_message when peer is
  online/idle/dnd (with message set); falls back to localized presence
  label; always "Offline" when state === 'offline'
- UserBar: status_message input in dropdown (max 128 chars, save on
  blur/Enter), subtitle uses same precedence as peer view
- AuthContext: auto-flip offline → online on session mount; best-effort
  offline on beforeunload/pagehide; respects explicit idle/dnd/invisible
- i18n: presence.status_placeholder (DE + EN)

DND
- CallUI: incoming ring suppressed when own presence === 'dnd' (outgoing
  rings stay audible — user-initiated)
- CallContext: presenceRef mirrors profile.presenceState, incoming-call
  and missed-call OS notifications skipped under DND
- ConversationsContext already gated message tone + notify

Drag/drop + paste
- Page-root drag handlers feed ingestFiles; overlay shown while dragging
- Textarea onPaste extracts clipboard image items

@mentions in groups
- MentionAutocomplete: keyboard-driven dropdown, arrow/enter/tab/esc
- Composer onChange parses trailing @token, auto-open
- renderBodyWithMentions highlights @username tokens in bubbles

Link previews
- Migration 20260421000003_link_previews.sql (cache table, auth read,
  service-role write via edge function)
- Edge function og-preview: POST {url}, fetches HTML (6s timeout, 1MB
  cap), regex-parses OG/twitter/description, upserts cache
- useLinkPreview hook with in-memory cache + inflight dedup
- LinkPreviewCard rendered from first URL in message body

Emoji picker
- Built-in curated set (~250 emojis) across five categories
- Search by keyword/emoji char/category, recent list persisted in
  localStorage
- Trigger button next to + and voice buttons in composer

Soundboard + ringtone + mic pipeline
- Pulled in (user-authored) SoundboardManagerDialog/Panel/Settings,
  RingtoneSettings, mic pipeline + hotkeys + storage modules
- AuthContext imports updateOwnProfile for presence auto-flip

Fixes
- AttachmentAudio min-width so bubbles don't collapse to 0px on peer
  side
- Focus flicker: visibility/online wake refresh throttled to 30s,
  focus listener dropped, loading flag only on first fetch
This commit is contained in:
2026-04-21 09:13:30 +02:00
parent b89ec90813
commit 672c8738c7
34 changed files with 4394 additions and 100 deletions
@@ -0,0 +1,127 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useState } from 'react';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
// Catch-all card for attachments without a richer renderer (zip, docx,
// txt, etc). Decrypt is deferred to first download click — these can be
// large and there's no inline preview to justify auto-fetching them.
export function AttachmentGeneric({ handle }: Props) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const download = async () => {
if (busy) return;
setBusy(true);
setError(null);
try {
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filenameFor(handle);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Defer revoke so Safari has a chance to start the download.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'download failed');
} finally {
setBusy(false);
}
};
return (
<div className="mt-2 inline-flex w-[280px] items-center gap-2.5 rounded-lg border border-line bg-surface-2 p-2.5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-accent/10 text-accent">
<FileGlyph />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-semibold text-fg">
{prettyMime(handle.mimeType)}
</p>
<p className="truncate text-[10px] text-fg-muted">
{formatSize(handle.sizeBytes)}
</p>
{error && (
<p className="mt-0.5 inline-flex items-center gap-1 text-[10px] text-rose-500">
<AlertIcon className="h-3 w-3" />
<span>{error}</span>
</p>
)}
</div>
<button
type="button"
onClick={() => void download()}
disabled={busy}
aria-label="Download"
title="Download"
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-line bg-surface-3 text-fg transition hover:brightness-95 disabled:opacity-60"
>
{busy ? <SpinnerIcon className="h-4 w-4" /> : <DownloadGlyph />}
</button>
</div>
);
}
function FileGlyph() {
return (
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M3 2a1 1 0 011-1h6l3 3v10a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm7 0v3h3l-3-3z" />
</svg>
);
}
function DownloadGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M8 2v8M4 7l4 4 4-4M3 13h10" />
</svg>
);
}
function filenameFor(handle: AttachmentHandle): string {
const ext = extFor(handle.mimeType);
return 'attachment-' + handle.id.slice(0, 8) + (ext ? '.' + ext : '');
}
function extFor(mime: string): string | null {
const map: Record<string, string> = {
'application/zip': 'zip',
'application/x-zip-compressed': 'zip',
'application/x-7z-compressed': '7z',
'application/x-tar': 'tar',
'application/gzip': 'gz',
'application/json': 'json',
'application/xml': 'xml',
'text/plain': 'txt',
'text/markdown': 'md',
'text/csv': 'csv',
'application/msword': 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
'application/vnd.ms-excel': 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
'application/vnd.ms-powerpoint': 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
};
return map[mime] ?? null;
}
function prettyMime(mime: string): string {
const ext = extFor(mime);
if (ext) return ext.toUpperCase() + '-Datei';
if (mime.startsWith('text/')) return 'Textdatei';
return mime || 'Datei';
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
@@ -0,0 +1,109 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
// PDF preview rendered via the browser's built-in PDF viewer (Chromium /
// Safari both ship one). Embedding via <object> with a fallback link keeps
// the implementation tiny — no pdf.js dependency.
export function AttachmentPdf({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
setError(null);
setBlobUrl(null);
downloadAndDecryptAttachment({ client: supabase, handle })
.then((blob) => {
if (cancelled) return;
// Force the application/pdf type so the browser plugin engages.
const typed = new Blob([blob], { type: 'application/pdf' });
url = URL.createObjectURL(typed);
setBlobUrl(url);
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
});
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
if (error) {
return (
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
<AlertIcon className="h-4 w-4" />
<span>{error}</span>
</div>
);
}
if (!blobUrl) {
return (
<div className="mt-2 flex h-24 w-[320px] items-center justify-center rounded-lg border border-line bg-surface-2 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
);
}
return (
<div className="mt-2 w-full max-w-[420px] overflow-hidden rounded-lg border border-line bg-surface-2">
<div className="flex items-center justify-between gap-2 border-b border-line bg-surface-3 px-3 py-2 text-xs">
<span className="flex items-center gap-2 truncate text-fg">
<PdfGlyph />
<span className="truncate">PDF · {formatSize(handle.sizeBytes)}</span>
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg hover:bg-surface-3"
>
{expanded ? 'Einklappen' : 'Vorschau'}
</button>
<a
href={blobUrl}
download={'attachment-' + handle.id.slice(0, 8) + '.pdf'}
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg no-underline hover:bg-surface-3"
>
Download
</a>
</div>
</div>
{expanded && (
<object data={blobUrl} type="application/pdf" className="block h-[420px] w-full">
<p className="p-4 text-xs text-fg-muted">
Vorschau nicht verfügbar bitte herunterladen.
</p>
</object>
)}
</div>
);
}
function PdfGlyph() {
return (
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
<path d="M3 2a1 1 0 011-1h6l3 3v10a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm7 0v3h3l-3-3zM5 9h6v1H5V9zm0 2h6v1H5v-1zm0-4h2v1H5V7z" />
</svg>
);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}
@@ -0,0 +1,68 @@
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon } from './icons';
interface Props {
handle: AttachmentHandle;
}
// Decrypt the blob, render a native <video controls>. Loads on demand —
// metadata-only preload so we don't burn bandwidth until the user hits play.
export function AttachmentVideo({ handle }: Props) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let url: string | null = null;
setError(null);
setBlobUrl(null);
downloadAndDecryptAttachment({ client: supabase, handle })
.then((blob) => {
if (cancelled) return;
url = URL.createObjectURL(blob);
setBlobUrl(url);
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'download failed');
}
});
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
if (error) {
return (
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
<AlertIcon className="h-4 w-4" />
<span>{error}</span>
</div>
);
}
if (!blobUrl) {
return (
<div className="mt-2 flex h-32 w-[320px] items-center justify-center rounded-lg border border-line bg-surface-2 text-fg-muted">
<SpinnerIcon className="h-5 w-5" />
</div>
);
}
return (
<video
controls
preload="metadata"
src={blobUrl}
className="mt-2 block max-h-80 w-full max-w-[420px] rounded-lg border border-line bg-black"
>
<track kind="captions" />
</video>
);
}
@@ -7,6 +7,7 @@ import {
MicOffIcon, MicOffIcon,
MonitorShareIcon, MonitorShareIcon,
MonitorStopIcon, MonitorStopIcon,
MusicIcon,
PhoneOffIcon, PhoneOffIcon,
UsersIcon, UsersIcon,
VideoIcon, VideoIcon,
@@ -23,6 +24,9 @@ interface Props {
onToggleDeafen: () => void; onToggleDeafen: () => void;
onHangup: () => void; onHangup: () => void;
onOpenParticipants?: () => 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 variant used inside the docked call (36px buttons). */
compact?: boolean; compact?: boolean;
/** Glass variant used when controls float on fullscreen cinema mode. */ /** Glass variant used when controls float on fullscreen cinema mode. */
@@ -42,6 +46,8 @@ export function CallControls({
onToggleDeafen, onToggleDeafen,
onHangup, onHangup,
onOpenParticipants, onOpenParticipants,
onToggleSoundboard,
soundboardOpen = false,
compact = false, compact = false,
glass = false, glass = false,
disabledMedia = false, disabledMedia = false,
@@ -115,6 +121,18 @@ export function CallControls({
<MonitorShareIcon className="h-5 w-5" /> <MonitorShareIcon className="h-5 w-5" />
)} )}
</CallButton> </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 && ( {onOpenParticipants && (
<CallButton <CallButton
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })} label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
+8 -2
View File
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext'; import { useCall } from '../context/CallContext';
import { useConversationsContext } from '../context/ConversationsContext'; import { useConversationsContext } from '../context/ConversationsContext';
import { useFriendshipsContext } from '../context/FriendshipsContext'; import { useFriendshipsContext } from '../context/FriendshipsContext';
@@ -17,12 +18,17 @@ import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons';
// different route. // different route.
export function CallUI() { export function CallUI() {
const { state } = useCall(); const { state } = useCall();
const { profile } = useAuth();
const dnd = profile?.presenceState === 'dnd';
useEffect(() => { 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'); 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(); else ringtone.stop();
}, [state.kind]); }, [state.kind, dnd]);
useEffect(() => { useEffect(() => {
return () => ringtone.stop(); return () => ringtone.stop();
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext'; import { useCall } from '../context/CallContext';
import { useCallPresence } from '../lib/useCallPresence'; import { useCallPresence } from '../lib/useCallPresence';
import type { PeerPresence } from '../lib/usePeerPresence';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { import {
InfoIcon, InfoIcon,
@@ -26,7 +27,7 @@ const PRESENCE_DOT: Record<PresenceState, string> = {
interface Props { interface Props {
conversation: ConversationSummary | null; conversation: ConversationSummary | null;
peerPresence: PresenceState | null; peerPresence: PeerPresence | null;
onInfoClick?: () => void; onInfoClick?: () => void;
onSearchClick?: () => void; onSearchClick?: () => void;
} }
@@ -51,7 +52,7 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick, on
interface HeaderBarProps { interface HeaderBarProps {
conversation: ConversationSummary; conversation: ConversationSummary;
peerPresence: PresenceState | null; peerPresence: PeerPresence | null;
onInfoClick?: () => void; onInfoClick?: () => void;
onSearchClick?: () => void; onSearchClick?: () => void;
} }
@@ -65,8 +66,18 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
const peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) : (conversation.avatarUrl ?? null); const peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) : (conversation.avatarUrl ?? null);
// Hide presence when peer chose invisible — reciprocal privacy. // Hide presence when peer chose invisible — reciprocal privacy.
const showPresence = isDm && peerPresence && peerPresence !== 'invisible'; const peerState = peerPresence?.state ?? null;
const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : ''; 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 ( return (
<header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3"> <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" /> <UsersIcon className="h-5 w-5" />
</div> </div>
)} )}
{showPresence && peerPresence && ( {showPresence && peerState && (
<span <span
aria-hidden="true" aria-hidden="true"
className={ className={
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' + 'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' +
PRESENCE_DOT[peerPresence] PRESENCE_DOT[peerState]
} }
/> />
)} )}
+408
View File
@@ -0,0 +1,408 @@
import { useEffect, useMemo, useRef, useState } from 'react';
interface EmojiEntry {
e: string;
k: string[]; // search keywords (incl. name)
}
interface Category {
label: string;
entries: EmojiEntry[];
}
// Curated emoji set — small enough to stay fast without a dependency, wide
// enough to cover everyday messaging. Keywords are the primary search
// surface; the emoji character itself is also matched so a user typing ❤️
// literally finds it.
const CATEGORIES: Category[] = [
{
label: 'Smileys',
entries: [
{ e: '😀', k: ['grin', 'smile', 'happy'] },
{ e: '😃', k: ['smile', 'happy'] },
{ e: '😄', k: ['smile', 'laugh'] },
{ e: '😁', k: ['grin', 'smile'] },
{ e: '😆', k: ['laugh', 'lol'] },
{ e: '😅', k: ['sweat', 'nervous', 'laugh'] },
{ e: '🤣', k: ['lol', 'rofl', 'laugh'] },
{ e: '😂', k: ['joy', 'laugh', 'tears'] },
{ e: '🙂', k: ['smile', 'slight'] },
{ e: '🙃', k: ['upside', 'irony'] },
{ e: '😉', k: ['wink'] },
{ e: '😊', k: ['blush', 'smile'] },
{ e: '😇', k: ['angel', 'innocent'] },
{ e: '🥰', k: ['love', 'hearts'] },
{ e: '😍', k: ['love', 'heart eyes'] },
{ e: '🤩', k: ['star', 'excited'] },
{ e: '😘', k: ['kiss'] },
{ e: '😗', k: ['kiss'] },
{ e: '😚', k: ['kiss'] },
{ e: '😙', k: ['kiss'] },
{ e: '🥲', k: ['tear', 'smile'] },
{ e: '😋', k: ['yum', 'tasty'] },
{ e: '😛', k: ['tongue'] },
{ e: '😜', k: ['tongue', 'wink'] },
{ e: '🤪', k: ['zany', 'silly'] },
{ e: '😝', k: ['tongue'] },
{ e: '🤑', k: ['money'] },
{ e: '🤗', k: ['hug'] },
{ e: '🤭', k: ['giggle', 'shy'] },
{ e: '🤫', k: ['shush', 'quiet'] },
{ e: '🤔', k: ['think'] },
{ e: '🤐', k: ['zip', 'quiet'] },
{ e: '🤨', k: ['raise brow'] },
{ e: '😐', k: ['neutral'] },
{ e: '😑', k: ['expressionless'] },
{ e: '😶', k: ['speechless'] },
{ e: '😏', k: ['smirk'] },
{ e: '😒', k: ['unamused'] },
{ e: '🙄', k: ['eye roll'] },
{ e: '😬', k: ['grimace', 'awkward'] },
{ e: '🤥', k: ['lying'] },
{ e: '😔', k: ['sad', 'pensive'] },
{ e: '😪', k: ['sleepy'] },
{ e: '😴', k: ['sleep'] },
{ e: '😷', k: ['mask', 'sick'] },
{ e: '🤒', k: ['sick', 'fever'] },
{ e: '🤕', k: ['injured'] },
{ e: '🤢', k: ['nauseated'] },
{ e: '🤮', k: ['vomit'] },
{ e: '🤧', k: ['sneeze'] },
{ e: '🥵', k: ['hot'] },
{ e: '🥶', k: ['cold'] },
{ e: '🥴', k: ['dizzy', 'woozy'] },
{ e: '😵', k: ['dizzy'] },
{ e: '🤯', k: ['mind blown'] },
{ e: '🤠', k: ['cowboy'] },
{ e: '🥳', k: ['party'] },
{ e: '😎', k: ['cool', 'sunglasses'] },
{ e: '🤓', k: ['nerd'] },
{ e: '🧐', k: ['monocle'] },
{ e: '😕', k: ['confused'] },
{ e: '😟', k: ['worried'] },
{ e: '🙁', k: ['frown'] },
{ e: '☹️', k: ['frown'] },
{ e: '😮', k: ['open mouth'] },
{ e: '😯', k: ['hushed'] },
{ e: '😲', k: ['astonished'] },
{ e: '😳', k: ['flushed'] },
{ e: '🥺', k: ['pleading'] },
{ e: '😦', k: ['frowning'] },
{ e: '😧', k: ['anguished'] },
{ e: '😨', k: ['fear'] },
{ e: '😰', k: ['anxious', 'sweat'] },
{ e: '😥', k: ['sad', 'relieved'] },
{ e: '😢', k: ['cry'] },
{ e: '😭', k: ['cry', 'loud'] },
{ e: '😱', k: ['scream', 'scared'] },
{ e: '😖', k: ['confounded'] },
{ e: '😣', k: ['persevere'] },
{ e: '😞', k: ['disappointed'] },
{ e: '😓', k: ['sweat'] },
{ e: '😩', k: ['weary'] },
{ e: '😫', k: ['tired'] },
{ e: '🥱', k: ['yawn'] },
{ e: '😤', k: ['triumph'] },
{ e: '😡', k: ['angry', 'rage'] },
{ e: '😠', k: ['angry'] },
{ e: '🤬', k: ['swear', 'curse'] },
{ e: '😈', k: ['devil'] },
{ e: '👿', k: ['imp'] },
{ e: '💀', k: ['skull', 'dead'] },
{ e: '🤡', k: ['clown'] },
{ e: '👻', k: ['ghost'] },
{ e: '👽', k: ['alien'] },
{ e: '🤖', k: ['robot'] },
{ e: '💩', k: ['poop', 'shit'] },
],
},
{
label: 'Gestures',
entries: [
{ e: '👋', k: ['wave', 'hi'] },
{ e: '🤚', k: ['hand'] },
{ e: '🖐️', k: ['hand'] },
{ e: '✋', k: ['stop', 'high five'] },
{ e: '🖖', k: ['spock'] },
{ e: '👌', k: ['ok'] },
{ e: '🤌', k: ['pinch'] },
{ e: '🤏', k: ['small'] },
{ e: '✌️', k: ['peace', 'victory'] },
{ e: '🤞', k: ['crossed fingers'] },
{ e: '🤟', k: ['love you'] },
{ e: '🤘', k: ['rock'] },
{ e: '🤙', k: ['call me'] },
{ e: '👈', k: ['point left'] },
{ e: '👉', k: ['point right'] },
{ e: '👆', k: ['point up'] },
{ e: '🖕', k: ['middle finger', 'fuck'] },
{ e: '👇', k: ['point down'] },
{ e: '☝️', k: ['point up'] },
{ e: '👍', k: ['thumbs up', 'like'] },
{ e: '👎', k: ['thumbs down', 'dislike'] },
{ e: '✊', k: ['fist'] },
{ e: '👊', k: ['punch'] },
{ e: '🤛', k: ['fist left'] },
{ e: '🤜', k: ['fist right'] },
{ e: '👏', k: ['clap'] },
{ e: '🙌', k: ['raised hands'] },
{ e: '👐', k: ['open hands'] },
{ e: '🤲', k: ['palms'] },
{ e: '🙏', k: ['pray', 'thanks'] },
{ e: '✍️', k: ['write'] },
{ e: '💪', k: ['flex', 'strong'] },
],
},
{
label: 'Hearts',
entries: [
{ e: '❤️', k: ['heart', 'love'] },
{ e: '🧡', k: ['orange heart'] },
{ e: '💛', k: ['yellow heart'] },
{ e: '💚', k: ['green heart'] },
{ e: '💙', k: ['blue heart'] },
{ e: '💜', k: ['purple heart'] },
{ e: '🖤', k: ['black heart'] },
{ e: '🤍', k: ['white heart'] },
{ e: '🤎', k: ['brown heart'] },
{ e: '💔', k: ['broken heart'] },
{ e: '❣️', k: ['heart exclamation'] },
{ e: '💕', k: ['hearts'] },
{ e: '💞', k: ['revolving hearts'] },
{ e: '💓', k: ['beating heart'] },
{ e: '💗', k: ['growing heart'] },
{ e: '💖', k: ['sparkle heart'] },
{ e: '💘', k: ['cupid'] },
{ e: '💝', k: ['heart gift'] },
],
},
{
label: 'Animals & Food',
entries: [
{ e: '🐶', k: ['dog'] },
{ e: '🐱', k: ['cat'] },
{ e: '🐭', k: ['mouse'] },
{ e: '🐹', k: ['hamster'] },
{ e: '🐰', k: ['rabbit'] },
{ e: '🦊', k: ['fox'] },
{ e: '🐻', k: ['bear'] },
{ e: '🐼', k: ['panda'] },
{ e: '🐨', k: ['koala'] },
{ e: '🐯', k: ['tiger'] },
{ e: '🦁', k: ['lion'] },
{ e: '🐸', k: ['frog'] },
{ e: '🐵', k: ['monkey'] },
{ e: '🐔', k: ['chicken'] },
{ e: '🐧', k: ['penguin'] },
{ e: '🐦', k: ['bird'] },
{ e: '🦆', k: ['duck'] },
{ e: '🍎', k: ['apple'] },
{ e: '🍌', k: ['banana'] },
{ e: '🍕', k: ['pizza'] },
{ e: '🍔', k: ['burger'] },
{ e: '🍟', k: ['fries'] },
{ e: '🌭', k: ['hotdog'] },
{ e: '🍿', k: ['popcorn'] },
{ e: '🍣', k: ['sushi'] },
{ e: '🍩', k: ['donut'] },
{ e: '🍪', k: ['cookie'] },
{ e: '🎂', k: ['cake', 'birthday'] },
{ e: '🍰', k: ['cake'] },
{ e: '🍫', k: ['chocolate'] },
{ e: '🍺', k: ['beer'] },
{ e: '🍷', k: ['wine'] },
{ e: '🥂', k: ['cheers'] },
{ e: '☕', k: ['coffee'] },
],
},
{
label: 'Objects & Symbols',
entries: [
{ e: '🔥', k: ['fire', 'lit'] },
{ e: '✨', k: ['sparkle'] },
{ e: '⭐', k: ['star'] },
{ e: '🌟', k: ['star glowing'] },
{ e: '💫', k: ['dizzy'] },
{ e: '💥', k: ['boom', 'explosion'] },
{ e: '⚡', k: ['lightning'] },
{ e: '☀️', k: ['sun'] },
{ e: '🌈', k: ['rainbow'] },
{ e: '☁️', k: ['cloud'] },
{ e: '🌧️', k: ['rain'] },
{ e: '❄️', k: ['snow'] },
{ e: '🎉', k: ['party', 'tada'] },
{ e: '🎊', k: ['confetti'] },
{ e: '🎁', k: ['gift'] },
{ e: '🎈', k: ['balloon'] },
{ e: '💯', k: ['100', 'perfect'] },
{ e: '✅', k: ['check'] },
{ e: '❌', k: ['x', 'no'] },
{ e: '⚠️', k: ['warning'] },
{ e: '❓', k: ['question'] },
{ e: '❗', k: ['exclamation'] },
{ e: '💬', k: ['speech'] },
{ e: '💭', k: ['thought'] },
{ e: '👀', k: ['eyes'] },
{ e: '🚀', k: ['rocket'] },
{ e: '🎵', k: ['music'] },
{ e: '🎶', k: ['music'] },
{ e: '🔔', k: ['bell'] },
{ e: '💡', k: ['idea', 'bulb'] },
],
},
];
const RECENT_KEY = 'chat.emoji.recents.v1';
const RECENT_MAX = 24;
function loadRecents(): string[] {
try {
const raw = localStorage.getItem(RECENT_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((x): x is string => typeof x === 'string').slice(0, RECENT_MAX);
} catch {
return [];
}
}
function saveRecents(list: string[]): void {
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
} catch {
/* ignore quota */
}
}
interface Props {
open: boolean;
onPick: (emoji: string) => void;
onClose: () => void;
}
export function EmojiPicker({ open, onPick, onClose }: Props) {
const [query, setQuery] = useState('');
const [recents, setRecents] = useState<string[]>(() => loadRecents());
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
const onDown = (e: MouseEvent) => {
const t = e.target as HTMLElement | null;
if (rootRef.current && t && !rootRef.current.contains(t) && !t.closest('[data-emoji-trigger]')) {
onClose();
}
};
window.addEventListener('keydown', onKey);
window.addEventListener('mousedown', onDown);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onDown);
};
}, [open, onClose]);
useEffect(() => {
if (open) setQuery('');
}, [open]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return CATEGORIES;
return CATEGORIES.map((cat) => ({
label: cat.label,
entries: cat.entries.filter(
(entry) =>
entry.e.includes(q) ||
entry.k.some((k) => k.includes(q)) ||
cat.label.toLowerCase().includes(q),
),
})).filter((cat) => cat.entries.length > 0);
}, [query]);
if (!open) return null;
const handlePick = (emoji: string) => {
onPick(emoji);
const next = [emoji, ...recents.filter((e) => e !== emoji)].slice(0, RECENT_MAX);
setRecents(next);
saveRecents(next);
};
return (
<div
ref={rootRef}
role="dialog"
aria-label="Emoji auswählen"
className="absolute bottom-full right-0 z-30 mb-2 flex w-[320px] flex-col rounded-xl border border-line bg-surface-2 shadow-xl"
>
<div className="border-b border-line p-2">
<input
type="search"
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Suchen…"
className="w-full rounded-md border border-line bg-surface-3 px-2.5 py-1.5 text-sm text-fg placeholder-fg-muted outline-none focus:border-accent"
/>
</div>
<div className="max-h-[320px] overflow-y-auto p-2">
{recents.length > 0 && !query && (
<CategoryBlock
label="Zuletzt"
entries={recents.map((e) => ({ e, k: [] }))}
onPick={handlePick}
/>
)}
{filtered.map((cat) => (
<CategoryBlock
key={cat.label}
label={cat.label}
entries={cat.entries}
onPick={handlePick}
/>
))}
{filtered.length === 0 && (
<p className="py-4 text-center text-xs text-fg-muted">Keine Treffer</p>
)}
</div>
</div>
);
}
function CategoryBlock({
label,
entries,
onPick,
}: {
label: string;
entries: EmojiEntry[];
onPick: (emoji: string) => void;
}) {
return (
<div className="mb-2">
<p className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{label}
</p>
<div className="grid grid-cols-8 gap-0.5">
{entries.map((entry, idx) => (
<button
key={entry.e + ':' + idx}
type="button"
onClick={() => onPick(entry.e)}
aria-label={entry.k[0] ?? entry.e}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-lg transition hover:bg-surface-3"
>
{entry.e}
</button>
))}
</div>
</div>
);
}
+34 -1
View File
@@ -18,6 +18,7 @@ import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } f
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu'; import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
import { ScreenShareDialog } from './ScreenShareDialog'; import { ScreenShareDialog } from './ScreenShareDialog';
import { ScreenShareViewer } from './ScreenShareViewer'; import { ScreenShareViewer } from './ScreenShareViewer';
import { SoundboardPanel } from './SoundboardPanel';
// Discord-style in-call dock rendered above the message list. Renders three // Discord-style in-call dock rendered above the message list. Renders three
// visual modes driven by CallContext.callMode: grid, focus, fullscreen. // visual modes driven by CallContext.callMode: grid, focus, fullscreen.
@@ -61,6 +62,7 @@ export function InCallPanel({ conversation }: Props) {
isCameraEnabled, isCameraEnabled,
isDeafened, isDeafened,
remoteDeafen, remoteDeafen,
remoteMute,
remoteScreenShares, remoteScreenShares,
callMode, callMode,
focusedId, focusedId,
@@ -77,6 +79,7 @@ export function InCallPanel({ conversation }: Props) {
const myId = session?.user.id ?? null; const myId = session?.user.id ?? null;
const activeSpeakers = useActiveSpeakers(room); const activeSpeakers = useActiveSpeakers(room);
const [shareDialogOpen, setShareDialogOpen] = useState(false); const [shareDialogOpen, setShareDialogOpen] = useState(false);
const [soundboardOpen, setSoundboardOpen] = useState(false);
const [volumeMenu, setVolumeMenu] = useState< const [volumeMenu, setVolumeMenu] = useState<
{ userId: string; displayName: string; x: number; y: number } | null { userId: string; displayName: string; x: number; y: number } | null
>(null); >(null);
@@ -108,6 +111,7 @@ export function InCallPanel({ conversation }: Props) {
isMuted, isMuted,
isDeafened, isDeafened,
remoteDeafen, remoteDeafen,
remoteMute,
isScreenSharing, isScreenSharing,
isCameraEnabled, isCameraEnabled,
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)), remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
@@ -150,6 +154,8 @@ export function InCallPanel({ conversation }: Props) {
}} }}
onToggleVideo={() => void toggleCamera()} onToggleVideo={() => void toggleCamera()}
onToggleDeafen={toggleDeafen} onToggleDeafen={toggleDeafen}
onToggleSoundboard={() => setSoundboardOpen((v) => !v)}
soundboardOpen={soundboardOpen}
onHangup={() => void hangup()} onHangup={() => void hangup()}
compact={callMode !== 'fullscreen'} compact={callMode !== 'fullscreen'}
glass={callMode === 'fullscreen'} glass={callMode === 'fullscreen'}
@@ -197,6 +203,10 @@ export function InCallPanel({ conversation }: Props) {
onClose={() => setVolumeMenu(null)} onClose={() => setVolumeMenu(null)}
/> />
)} )}
<SoundboardPopover
open={soundboardOpen}
onClose={() => setSoundboardOpen(false)}
/>
</> </>
); );
} }
@@ -280,10 +290,28 @@ export function InCallPanel({ conversation }: Props) {
onClose={() => setVolumeMenu(null)} onClose={() => setVolumeMenu(null)}
/> />
)} )}
<SoundboardPopover
open={soundboardOpen}
onClose={() => setSoundboardOpen(false)}
/>
</section> </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 // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -296,6 +324,7 @@ interface BuildArgs {
isMuted: boolean; isMuted: boolean;
isDeafened: boolean; isDeafened: boolean;
remoteDeafen: Record<string, boolean>; remoteDeafen: Record<string, boolean>;
remoteMute: Record<string, boolean>;
isScreenSharing: boolean; isScreenSharing: boolean;
isCameraEnabled: boolean; isCameraEnabled: boolean;
remoteSharerIds: Set<string>; remoteSharerIds: Set<string>;
@@ -321,6 +350,7 @@ function buildTiles({
isMuted, isMuted,
isDeafened, isDeafened,
remoteDeafen, remoteDeafen,
remoteMute,
isScreenSharing, isScreenSharing,
isCameraEnabled, isCameraEnabled,
remoteSharerIds, remoteSharerIds,
@@ -379,7 +409,10 @@ function buildTiles({
displayName: m.profile?.displayName ?? '?', displayName: m.profile?.displayName ?? '?',
avatarUrl: m.profile?.avatarUrl ?? null, avatarUrl: m.profile?.avatarUrl ?? null,
self: false, 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. // Deafen state arrives via LiveKit data channel; see CallContext.
deafened: remoteDeafen[m.userId] ?? false, deafened: remoteDeafen[m.userId] ?? false,
video: rp.isCameraEnabled, video: rp.isCameraEnabled,
@@ -0,0 +1,49 @@
import { useLinkPreview } from '../lib/useLinkPreview';
interface Props {
url: string;
}
// Renders a compact OpenGraph preview card under a message bubble. Fetches
// lazily through the edge function; silent when the URL returned no meta.
export function LinkPreviewCard({ url }: Props) {
const preview = useLinkPreview(url);
if (!preview || !preview.ok) return null;
if (!preview.title && !preview.description && !preview.imageUrl) return null;
return (
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className="mt-2 flex max-w-[320px] overflow-hidden rounded-lg border border-line bg-surface-2 text-sm no-underline transition hover:bg-surface-3"
>
{preview.imageUrl && (
<img
src={preview.imageUrl}
alt=""
loading="lazy"
className="h-20 w-20 shrink-0 object-cover"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
)}
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 p-2.5">
{preview.siteName && (
<p className="truncate text-[10px] uppercase tracking-wider text-fg-muted">
{preview.siteName}
</p>
)}
{preview.title && (
<p className="line-clamp-2 text-sm font-semibold text-fg">
{preview.title}
</p>
)}
{preview.description && (
<p className="line-clamp-2 text-xs text-fg-muted">{preview.description}</p>
)}
</div>
</a>
);
}
@@ -0,0 +1,103 @@
import type { ConversationSummary } from '@chat-app/shared/chat';
import { useEffect, useState } from 'react';
import { Avatar } from './Avatar';
interface Props {
members: ConversationSummary['members'];
query: string;
excludeUserId: string | undefined;
onSelect: (username: string) => void;
onClose: () => void;
}
// Dropdown shown above the composer when the user has typed `@` followed
// by the start of a member name. Keyboard-first — arrow keys move through,
// enter/tab commits, escape cancels.
export function MentionAutocomplete({
members,
query,
excludeUserId,
onSelect,
onClose,
}: Props) {
const q = query.toLowerCase();
const matches = members
.filter((m) => m.userId !== excludeUserId)
.filter((m) => {
if (!q) return true;
const name = (m.profile?.displayName ?? '').toLowerCase();
const handle = (m.profile?.username ?? '').toLowerCase();
return name.includes(q) || handle.includes(q);
})
.slice(0, 8);
const [active, setActive] = useState(0);
useEffect(() => {
setActive(0);
}, [query]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (matches.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive((i) => (i + 1) % matches.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((i) => (i - 1 + matches.length) % matches.length);
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const pick = matches[active];
if (pick?.profile?.username) onSelect(pick.profile.username);
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', onKey, true);
return () => {
window.removeEventListener('keydown', onKey, true);
};
}, [matches, active, onSelect, onClose]);
if (matches.length === 0) return null;
return (
<div
role="listbox"
aria-label="Mitglieder"
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl"
>
{matches.map((m, idx) => {
const name = m.profile?.displayName ?? m.profile?.username ?? '?';
const handle = m.profile?.username ?? '';
const isActive = idx === active;
return (
<button
key={m.userId}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActive(idx)}
onClick={() => {
if (m.profile?.username) onSelect(m.profile.username);
}}
className={
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
(isActive ? 'bg-accent/20 text-fg' : 'text-fg-muted hover:bg-surface-3')
}
>
<Avatar
displayName={name}
url={m.profile?.avatarUrl ?? null}
className="h-6 w-6 text-[10px]"
/>
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
</button>
);
})}
</div>
);
}
+53 -8
View File
@@ -13,8 +13,13 @@ import { useAuth } from '../context/AuthContext';
import { devLocalSecretStore } from '../lib/secretStore'; import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import type { AggregatedReaction } from '../lib/useMessageReactions'; import type { AggregatedReaction } from '../lib/useMessageReactions';
import { extractFirstUrl } from '../lib/useLinkPreview';
import { AttachmentAudio } from './AttachmentAudio'; import { AttachmentAudio } from './AttachmentAudio';
import { AttachmentGeneric } from './AttachmentGeneric';
import { AttachmentImage } from './AttachmentImage'; import { AttachmentImage } from './AttachmentImage';
import { AttachmentPdf } from './AttachmentPdf';
import { AttachmentVideo } from './AttachmentVideo';
import { LinkPreviewCard } from './LinkPreviewCard';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons'; 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> <span className="italic opacity-70">cannot decrypt</span>
) : ( ) : (
<> <>
{bodyText.length > 0 && <div>{bodyText}</div>} {bodyText.length > 0 && <div>{renderBodyWithMentions(bodyText)}</div>}
{attachments.map((a) => {bodyText.length > 0 &&
a.mimeType.startsWith('audio/') ? ( (() => {
<AttachmentAudio key={a.id} handle={a} /> const url = extractFirstUrl(bodyText);
) : ( return url ? <LinkPreviewCard url={url} /> : null;
<AttachmentImage key={a.id} handle={a} /> })()}
), {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 <div
@@ -510,6 +528,33 @@ function formatDuration(totalSec: number): string {
return m + ':' + s.toString().padStart(2, '0'); 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' }) { function DeliveryTicks({ state }: { state: 'sent' | 'delivered' | 'read' }) {
// One checkmark for sent, two for delivered/read. Read shifts color to the // One checkmark for sent, two for delivered/read. Read shifts color to the
// accent to match WhatsApp/Telegram blue-tick convention. // accent to match WhatsApp/Telegram blue-tick convention.
@@ -0,0 +1,244 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
clearIncomingRingtone,
getIncomingRingtone,
MAX_RINGTONE_BYTES,
saveIncomingRingtone,
type StoredRingtone,
} from '../lib/ringtoneStorage';
import { PhoneIcon, SpinnerIcon, TrashIcon } from './icons';
interface Props {
/** Disable interactions while a parent action is in flight. */
disabled?: boolean;
}
const BYTES_PER_MB = 1024 * 1024;
// UI for the custom incoming-call ringtone. Single file slot. Upload
// validates size + mime and surfaces errors inline. Preview button plays
// the stored blob through a local <audio> element without touching the
// shared ringtone singleton so we don't interfere with a live call.
export function RingtoneSettings({ disabled = false }: Props) {
const { t } = useTranslation(['app']);
const inputRef = useRef<HTMLInputElement | null>(null);
const previewRef = useRef<HTMLAudioElement | null>(null);
const previewUrlRef = useRef<string | null>(null);
const [current, setCurrent] = useState<StoredRingtone | null>(null);
const [busy, setBusy] = useState(false);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const cur = await getIncomingRingtone();
setCurrent(cur);
} catch (err: unknown) {
console.error('getIncomingRingtone failed', err);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
// Revoke any preview blob URL when the component unmounts so long-lived
// pages don't leak memory.
return () => {
stopPreview();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function stopPreview(): void {
const el = previewRef.current;
if (el) {
try {
el.pause();
} catch {
/* ignore */
}
el.src = '';
}
previewRef.current = null;
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setPlaying(false);
}
async function handleFile(file: File): Promise<void> {
setError(null);
setBusy(true);
try {
await saveIncomingRingtone(file);
await refresh();
} catch (err: unknown) {
const code = err instanceof Error ? err.message : 'upload_failed';
if (code === 'ringtone_too_large') {
setError(
t('app:settings.ringtone_error_too_large', {
defaultValue: 'Datei zu groß (max 2 MB).',
max: MAX_RINGTONE_BYTES / BYTES_PER_MB,
}),
);
} else if (code === 'ringtone_not_audio') {
setError(
t('app:settings.ringtone_error_not_audio', {
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
}),
);
} else {
setError(
t('app:settings.ringtone_error_generic', {
defaultValue: 'Ringtone konnte nicht gespeichert werden.',
}),
);
}
} finally {
setBusy(false);
if (inputRef.current) inputRef.current.value = '';
}
}
async function handleReset(): Promise<void> {
setError(null);
setBusy(true);
stopPreview();
try {
await clearIncomingRingtone();
await refresh();
} catch (err: unknown) {
console.error('clearIncomingRingtone failed', err);
} finally {
setBusy(false);
}
}
function handlePreview(): void {
if (!current) return;
if (playing) {
stopPreview();
return;
}
const url = URL.createObjectURL(current.blob);
const el = new Audio(url);
el.loop = false;
el.volume = 0.85;
el.onended = () => stopPreview();
el.onerror = () => {
setError(
t('app:settings.ringtone_error_play', {
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
}),
);
stopPreview();
};
el.play().catch(() => {
setError(
t('app:settings.ringtone_error_play', {
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
}),
);
stopPreview();
});
previewRef.current = el;
previewUrlRef.current = url;
setPlaying(true);
}
const hasCustom = current !== null;
const sizeMb = current ? (current.blob.size / BYTES_PER_MB).toFixed(2) : null;
const interactionsDisabled = disabled || busy;
return (
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium text-fg">
<PhoneIcon className="h-4 w-4 text-fg-muted" />
{t('app:settings.ringtone_incoming', { defaultValue: 'Eingehender Anruf' })}
</div>
<div className="mt-1 text-xs text-fg-muted">
{hasCustom && current
? t('app:settings.ringtone_custom_active', {
defaultValue: '{{name}} · {{size}} MB',
name: current.filename,
size: sizeMb,
})
: t('app:settings.ringtone_default_active', {
defaultValue: 'Standard-Klingelton (Doppelton)',
})}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{hasCustom && (
<button
type="button"
onClick={handlePreview}
disabled={interactionsDisabled}
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs font-semibold text-fg transition hover:brightness-95 disabled:cursor-not-allowed disabled:opacity-50"
>
{playing
? t('app:settings.ringtone_stop', { defaultValue: 'Stop' })
: t('app:settings.ringtone_preview', { defaultValue: 'Vorhören' })}
</button>
)}
<input
ref={inputRef}
type="file"
accept="audio/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleFile(f);
}}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={interactionsDisabled}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
<span>
{hasCustom
? t('app:settings.ringtone_replace', { defaultValue: 'Ersetzen' })
: t('app:settings.ringtone_upload', { defaultValue: 'Hochladen' })}
</span>
</button>
{hasCustom && (
<button
type="button"
onClick={() => void handleReset()}
disabled={interactionsDisabled}
aria-label={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
title={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-rose-500/40 bg-rose-500/10 text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
>
<TrashIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
{error && (
<p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
{error}
</p>
)}
<p className="text-[11px] text-fg-muted">
{t('app:settings.ringtone_hint', {
defaultValue:
'MP3, WAV, OGG oder M4A bis 2 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.',
})}
</p>
</div>
);
}
@@ -0,0 +1,625 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getPttSettings } from '../lib/pttSettings';
import { codeToShortcut } from '../lib/globalShortcut';
import { invalidate as invalidateSoundCache, preload } from '../lib/soundboardPlayback';
import {
addSound,
deleteSound,
getSoundBlob,
listSounds,
MAX_SOUND_BYTES,
reorderCategory,
type SoundboardEntry,
subscribeSoundboardChanges,
updateSound,
} from '../lib/soundboardStorage';
import { Modal } from './Modal';
import {
AlertIcon,
ChevronDownIcon,
PencilIcon,
PlusIcon,
SpinnerIcon,
TrashIcon,
} from './icons';
interface Props {
open: boolean;
onClose: () => void;
}
const BYTES_PER_MB = 1024 * 1024;
const CATEGORY_LIST_ID = 'sb-category-list';
// Admin UI for the soundboard. Users add, rename, categorise, reorder,
// assign hotkeys, adjust per-sound volume, preview and delete clips here.
// In-call panel only reads the resulting manifest.
export function SoundboardManagerDialog({ open, onClose }: Props) {
const { t } = useTranslation(['app']);
const fileRef = useRef<HTMLInputElement | null>(null);
const previewRef = useRef<HTMLAudioElement | null>(null);
const previewUrlRef = useRef<string | null>(null);
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
const [loading, setLoading] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [previewingId, setPreviewingId] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
try {
setEntries(await listSounds());
} catch (err: unknown) {
console.error('listSounds failed', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
void refresh();
// External mutations (hotkey fires, multi-tab edits) should reflect
// immediately while the dialog is open.
const unsub = subscribeSoundboardChanges(() => {
void refresh();
});
return () => {
unsub();
stopPreview();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, refresh]);
useEffect(() => {
return () => stopPreview();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const categories = useMemo(() => {
const set = new Set<string>();
for (const e of entries) if (e.category) set.add(e.category);
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [entries]);
const grouped = useMemo(() => {
const map = new Map<string, SoundboardEntry[]>();
for (const e of entries) {
const key = e.category ?? '';
const arr = map.get(key);
if (arr) arr.push(e);
else map.set(key, [e]);
}
return map;
}, [entries]);
function stopPreview(): void {
const el = previewRef.current;
if (el) {
try {
el.pause();
} catch {
/* ignore */
}
el.src = '';
}
previewRef.current = null;
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setPreviewingId(null);
}
async function handleAdd(file: File): Promise<void> {
setError(null);
setBusyId('__add');
try {
await addSound({ file });
await refresh();
} catch (err: unknown) {
const code = err instanceof Error ? err.message : 'add_failed';
if (code === 'sound_too_large') {
setError(
t('app:soundboard.error_too_large', {
defaultValue: 'Datei zu groß (max {{max}} MB).',
max: MAX_SOUND_BYTES / BYTES_PER_MB,
}),
);
} else if (code === 'sound_not_audio') {
setError(
t('app:soundboard.error_not_audio', {
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
}),
);
} else {
setError(
t('app:soundboard.error_generic', {
defaultValue: 'Sound konnte nicht gespeichert werden.',
}),
);
}
} finally {
setBusyId(null);
if (fileRef.current) fileRef.current.value = '';
}
}
async function handlePatch(
id: string,
patch: Parameters<typeof updateSound>[1],
): Promise<void> {
setBusyId(id);
try {
await updateSound(id, patch);
} catch (err: unknown) {
console.error('updateSound failed', err);
} finally {
setBusyId(null);
}
}
async function handleDelete(id: string): Promise<void> {
if (!window.confirm(t('app:soundboard.delete_confirm', { defaultValue: 'Sound löschen?' }))) {
return;
}
setBusyId(id);
try {
await deleteSound(id);
invalidateSoundCache(id);
if (previewingId === id) stopPreview();
} catch (err: unknown) {
console.error('deleteSound failed', err);
} finally {
setBusyId(null);
}
}
async function handlePreview(entry: SoundboardEntry): Promise<void> {
if (previewingId === entry.id) {
stopPreview();
return;
}
stopPreview();
const blob = await getSoundBlob(entry.id);
if (!blob) return;
const url = URL.createObjectURL(blob);
const el = new Audio(url);
el.volume = entry.gain;
el.onended = () => stopPreview();
el.onerror = () => stopPreview();
el.play().catch(() => stopPreview());
previewRef.current = el;
previewUrlRef.current = url;
setPreviewingId(entry.id);
// Opportunistic warm-up of the AudioBuffer cache so the first in-call
// playback doesn't pause on decode.
void preload(entry.id);
}
async function handleReorder(
category: string | null,
idx: number,
dir: -1 | 1,
): Promise<void> {
const bucket = grouped.get(category ?? '') ?? [];
const next = idx + dir;
if (next < 0 || next >= bucket.length) return;
const reordered = bucket.slice();
const tmp = reordered[idx]!;
reordered[idx] = reordered[next]!;
reordered[next] = tmp;
setBusyId('__reorder:' + (category ?? ''));
try {
await reorderCategory(
category,
reordered.map((e) => e.id),
);
} catch (err: unknown) {
console.error('reorderCategory failed', err);
} finally {
setBusyId(null);
}
}
if (!open) return null;
return (
<Modal
open={open}
onClose={onClose}
title={t('app:soundboard.manager_title', { defaultValue: 'Soundboard verwalten' })}
size="lg"
>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-fg-muted">
{t('app:soundboard.manager_hint', {
defaultValue:
'Beliebig viele Sounds, kein Hotkey nötig. Hotkeys feuern nur während eines Anrufs.',
})}
</p>
<input
ref={fileRef}
type="file"
accept="audio/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleAdd(f);
}}
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={busyId !== null}
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
>
{busyId === '__add' ? (
<SpinnerIcon className="h-3.5 w-3.5" />
) : (
<PlusIcon className="h-3.5 w-3.5" />
)}
<span>{t('app:soundboard.add', { defaultValue: 'Sound hinzufügen' })}</span>
</button>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
<datalist id={CATEGORY_LIST_ID}>
{categories.map((c) => (
<option key={c} value={c} />
))}
</datalist>
{loading ? (
<div className="flex items-center gap-2 text-xs text-fg-muted">
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
</div>
) : entries.length === 0 ? (
<p className="rounded-lg border border-line bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{t('app:soundboard.empty', {
defaultValue: 'Noch keine Sounds. Lade oben welche hoch.',
})}
</p>
) : (
<div className="flex flex-col gap-4">
{Array.from(grouped.entries()).map(([categoryKey, bucket]) => {
const category = categoryKey === '' ? null : categoryKey;
return (
<SoundboardCategoryGroup
key={categoryKey || '__uncat'}
category={category}
entries={bucket}
entriesTotal={entries}
busyId={busyId}
previewingId={previewingId}
onPatch={handlePatch}
onDelete={handleDelete}
onPreview={handlePreview}
onReorder={handleReorder}
/>
);
})}
</div>
)}
</div>
</Modal>
);
}
// ---------------------------------------------------------------------------
interface GroupProps {
category: string | null;
entries: SoundboardEntry[];
entriesTotal: SoundboardEntry[];
busyId: string | null;
previewingId: string | null;
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onPreview: (entry: SoundboardEntry) => Promise<void>;
onReorder: (category: string | null, idx: number, dir: -1 | 1) => Promise<void>;
}
function SoundboardCategoryGroup({
category,
entries,
entriesTotal,
busyId,
previewingId,
onPatch,
onDelete,
onPreview,
onReorder,
}: GroupProps) {
const { t } = useTranslation(['app']);
const [open, setOpen] = useState(true);
const label =
category ??
t('app:soundboard.category_uncategorized', { defaultValue: '(Ohne Kategorie)' });
return (
<section className="rounded-lg border border-line bg-surface-2">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5 text-left"
>
<span className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
{label} · {entries.length}
</span>
<ChevronDownIcon
className={'h-4 w-4 text-fg-muted transition ' + (open ? '' : '-rotate-90')}
/>
</button>
{open && (
<ul className="flex flex-col gap-2 border-t border-line p-3">
{entries.map((entry, idx) => (
<SoundboardRow
key={entry.id}
entry={entry}
entriesTotal={entriesTotal}
isFirst={idx === 0}
isLast={idx === entries.length - 1}
busy={busyId === entry.id}
previewing={previewingId === entry.id}
onPatch={onPatch}
onDelete={onDelete}
onPreview={onPreview}
onReorderUp={() => onReorder(category, idx, -1)}
onReorderDown={() => onReorder(category, idx, 1)}
/>
))}
</ul>
)}
</section>
);
}
// ---------------------------------------------------------------------------
interface RowProps {
entry: SoundboardEntry;
entriesTotal: SoundboardEntry[];
isFirst: boolean;
isLast: boolean;
busy: boolean;
previewing: boolean;
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onPreview: (entry: SoundboardEntry) => Promise<void>;
onReorderUp: () => Promise<void>;
onReorderDown: () => Promise<void>;
}
function SoundboardRow({
entry,
entriesTotal,
isFirst,
isLast,
busy,
previewing,
onPatch,
onDelete,
onPreview,
onReorderUp,
onReorderDown,
}: RowProps) {
const { t } = useTranslation(['app']);
const [editingName, setEditingName] = useState(false);
const [nameDraft, setNameDraft] = useState(entry.name);
const [categoryDraft, setCategoryDraft] = useState(entry.category ?? '');
const [capturingHotkey, setCapturingHotkey] = useState(false);
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
useEffect(() => {
setNameDraft(entry.name);
setCategoryDraft(entry.category ?? '');
}, [entry.name, entry.category]);
useEffect(() => {
if (!capturingHotkey) return;
const onKey = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.code === 'Escape') {
setCapturingHotkey(false);
setHotkeyError(null);
return;
}
// Conflict checks: PTT + other soundboard entries with this code.
const ptt = getPttSettings();
if (ptt.enabled && ptt.key === e.code) {
setHotkeyError(
t('app:soundboard.hotkey_conflict_ptt', {
defaultValue: 'Konflikt mit Push-to-Talk.',
}),
);
return;
}
const taken = entriesTotal.find((s) => s.id !== entry.id && s.hotkey === e.code);
if (taken) {
setHotkeyError(
t('app:soundboard.hotkey_conflict_sound', {
defaultValue: 'Bereits von "{{name}}" belegt.',
name: taken.name,
}),
);
return;
}
setHotkeyError(null);
setCapturingHotkey(false);
void onPatch(entry.id, { hotkey: e.code });
};
window.addEventListener('keydown', onKey, { capture: true });
return () => window.removeEventListener('keydown', onKey, { capture: true });
}, [capturingHotkey, entriesTotal, entry.id, onPatch, t]);
const hotkeyLabel = entry.hotkey ? codeToShortcut(entry.hotkey) : null;
return (
<li className="flex flex-wrap items-center gap-3 rounded-md border border-line bg-surface-3 px-3 py-2">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
{editingName ? (
<input
autoFocus
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
onBlur={() => {
setEditingName(false);
if (nameDraft.trim() && nameDraft !== entry.name) {
void onPatch(entry.id, { name: nameDraft });
} else {
setNameDraft(entry.name);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur();
}
if (e.key === 'Escape') {
setNameDraft(entry.name);
setEditingName(false);
}
}}
className="w-full rounded border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent focus:outline-none"
/>
) : (
<button
type="button"
onClick={() => setEditingName(true)}
className="flex cursor-pointer items-center gap-1.5 self-start text-sm font-semibold text-fg hover:text-accent"
>
{entry.name}
<PencilIcon className="h-3 w-3 opacity-50" />
</button>
)}
<p className="text-[10px] text-fg-muted">
{(entry.size / BYTES_PER_MB).toFixed(2)} MB · {entry.mime}
</p>
</div>
<input
type="text"
value={categoryDraft}
onChange={(e) => setCategoryDraft(e.target.value)}
onBlur={() => {
const next = categoryDraft.trim() || null;
if (next !== (entry.category ?? null)) {
void onPatch(entry.id, { category: next });
}
}}
list={CATEGORY_LIST_ID}
placeholder={t('app:soundboard.category_placeholder', {
defaultValue: 'Kategorie…',
})}
className="w-32 shrink-0 rounded border border-line bg-surface-2 px-2 py-1 text-xs text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
/>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => {
setHotkeyError(null);
setCapturingHotkey((v) => !v);
}}
className={
'inline-flex min-w-[5rem] cursor-pointer items-center justify-center rounded border px-2 py-1 text-[11px] font-mono font-semibold transition ' +
(capturingHotkey
? 'animate-pulse border-accent bg-accent/20 text-fg'
: hotkeyLabel
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-line bg-surface-2 text-fg-muted hover:bg-surface')
}
title={t('app:soundboard.hotkey_capture', {
defaultValue: 'Hotkey binden (Esc = abbrechen)',
})}
>
{capturingHotkey
? t('app:soundboard.hotkey_press', { defaultValue: 'Drücke…' })
: hotkeyLabel ??
t('app:soundboard.hotkey_none', { defaultValue: 'Kein Hotkey' })}
</button>
{entry.hotkey && !capturingHotkey && (
<button
type="button"
onClick={() => void onPatch(entry.id, { hotkey: null })}
aria-label={t('app:soundboard.hotkey_clear', { defaultValue: 'Hotkey entfernen' })}
title={t('app:soundboard.hotkey_clear', { defaultValue: 'Hotkey entfernen' })}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg"
>
×
</button>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<input
type="range"
min={0}
max={1}
step={0.05}
value={entry.gain}
onChange={(e) => void onPatch(entry.id, { gain: Number(e.target.value) })}
className="accent-accent w-20"
title={t('app:soundboard.gain_title', {
defaultValue: 'Lautstärke',
})}
/>
<button
type="button"
onClick={() => void onPreview(entry)}
disabled={busy}
className="inline-flex cursor-pointer items-center gap-1 rounded border border-line bg-surface-2 px-2 py-1 text-[11px] font-medium text-fg transition hover:bg-surface disabled:opacity-50"
>
{previewing
? t('app:soundboard.preview_stop', { defaultValue: 'Stop' })
: t('app:soundboard.preview', { defaultValue: 'Vorhören' })}
</button>
<button
type="button"
onClick={onReorderUp}
disabled={busy || isFirst}
aria-label={t('app:soundboard.move_up', { defaultValue: 'Nach oben' })}
title={t('app:soundboard.move_up', { defaultValue: 'Nach oben' })}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40"
>
</button>
<button
type="button"
onClick={onReorderDown}
disabled={busy || isLast}
aria-label={t('app:soundboard.move_down', { defaultValue: 'Nach unten' })}
title={t('app:soundboard.move_down', { defaultValue: 'Nach unten' })}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40"
>
</button>
<button
type="button"
onClick={() => void onDelete(entry.id)}
disabled={busy}
aria-label={t('app:soundboard.delete', { defaultValue: 'Löschen' })}
title={t('app:soundboard.delete', { defaultValue: 'Löschen' })}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded border border-rose-500/30 bg-rose-500/10 text-rose-600 hover:bg-rose-500/20 disabled:opacity-50 dark:text-rose-300"
>
<TrashIcon className="h-3 w-3" />
</button>
</div>
{hotkeyError && capturingHotkey && (
<p className="basis-full text-[11px] text-rose-600 dark:text-rose-300">
{hotkeyError}
</p>
)}
</li>
);
}
@@ -0,0 +1,320 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useCall } from '../context/CallContext';
import { codeToShortcut } from '../lib/globalShortcut';
import {
DEFAULT_PREFS,
getPrefs,
listSounds,
type SoundboardEntry,
type SoundboardPrefs,
subscribeSoundboardChanges,
} from '../lib/soundboardStorage';
import { ChevronDownIcon, XIcon } from './icons';
interface Props {
onClose: () => void;
}
// In-call popover that lists every stored sound grouped by category. Click a
// pad to play through the active pipeline. Hotkey-badge shows the bound
// accelerator (if any). Master + monitor sliders adjust the pipeline gains.
export function SoundboardPanel({ onClose }: Props) {
const { t } = useTranslation(['app']);
const {
playSoundboard,
stopSoundboard,
activeSoundboardIds,
setSoundboardMasterGain,
setSoundboardMonitorGain,
} = useCall();
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
const [prefs, setPrefs] = useState<SoundboardPrefs>(() => ({ ...DEFAULT_PREFS }));
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [query, setQuery] = useState('');
const closeRef = useRef(onClose);
closeRef.current = onClose;
const refresh = useCallback(async () => {
try {
const [all, p] = await Promise.all([listSounds(), getPrefs()]);
setEntries(all);
setPrefs(p);
} catch (err: unknown) {
console.warn('soundboard panel refresh failed', err);
}
}, []);
useEffect(() => {
void refresh();
const unsub = subscribeSoundboardChanges(() => {
void refresh();
});
return unsub;
}, [refresh]);
// Close on Esc — tapping outside is handled by the trigger's parent.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeRef.current();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return entries;
return entries.filter(
(e) =>
e.name.toLowerCase().includes(q) ||
(e.category ?? '').toLowerCase().includes(q),
);
}, [entries, query]);
const grouped = useMemo(() => {
const map = new Map<string, SoundboardEntry[]>();
for (const e of filtered) {
const key = e.category ?? '';
const arr = map.get(key);
if (arr) arr.push(e);
else map.set(key, [e]);
}
return map;
}, [filtered]);
function toggleCategory(key: string): void {
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
}
return (
<div
role="dialog"
aria-label={t('app:soundboard.panel_title', { defaultValue: 'Soundboard' })}
className="flex w-[360px] max-h-[70vh] flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-2xl"
>
<header className="flex items-center justify-between border-b border-line px-4 py-2.5">
<h3 className="font-display text-sm font-semibold text-fg">
{t('app:soundboard.panel_title', { defaultValue: 'Soundboard' })}
</h3>
<button
type="button"
onClick={onClose}
aria-label={t('app:soundboard.panel_close', { defaultValue: 'Schließen' })}
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</header>
{entries.length > 0 && (
<div className="border-b border-line px-3 pb-2 pt-2">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('app:soundboard.panel_search', { defaultValue: 'Suche…' })}
className="w-full rounded-md border border-line bg-surface-2 px-3 py-1.5 text-xs text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{entries.length === 0 ? (
<p className="py-6 text-center text-xs text-fg-muted">
{t('app:soundboard.panel_empty', {
defaultValue:
'Keine Sounds gespeichert. Füge welche in den Einstellungen hinzu.',
})}
</p>
) : filtered.length === 0 ? (
<p className="py-6 text-center text-xs text-fg-muted">
{t('app:soundboard.panel_no_matches', {
defaultValue: 'Keine Treffer.',
})}
</p>
) : (
<div className="flex flex-col gap-3">
{Array.from(grouped.entries()).map(([key, bucket]) => (
<CategorySection
key={key || '__uncat'}
categoryKey={key}
entries={bucket}
collapsed={collapsed[key] ?? false}
activeIds={activeSoundboardIds}
onToggle={() => toggleCategory(key)}
onActivate={(id) => {
if (activeSoundboardIds.has(id)) {
stopSoundboard(id);
} else {
void playSoundboard(id);
}
}}
/>
))}
</div>
)}
</div>
<footer className="flex flex-col gap-2 border-t border-line bg-surface-2 px-4 py-3">
<VolumeSlider
label={t('app:soundboard.panel_master', { defaultValue: 'Master' })}
value={prefs.masterGain}
onChange={(v) => {
setPrefs((p) => ({ ...p, masterGain: v }));
void setSoundboardMasterGain(v);
}}
/>
<VolumeSlider
label={t('app:soundboard.panel_monitor', { defaultValue: 'Mithören' })}
value={prefs.monitorGain}
onChange={(v) => {
setPrefs((p) => ({ ...p, monitorGain: v }));
void setSoundboardMonitorGain(v);
}}
/>
<button
type="button"
onClick={() => stopSoundboard()}
className="mt-1 inline-flex cursor-pointer items-center justify-center rounded-md border border-line bg-surface-3 px-3 py-1.5 text-[11px] font-semibold text-fg transition hover:brightness-95"
>
{t('app:soundboard.panel_stop_all', { defaultValue: 'Alle stoppen' })}
</button>
</footer>
</div>
);
}
interface CategorySectionProps {
categoryKey: string;
entries: SoundboardEntry[];
collapsed: boolean;
activeIds: ReadonlySet<string>;
onToggle: () => void;
onActivate: (id: string) => void;
}
function CategorySection({
categoryKey,
entries,
collapsed,
activeIds,
onToggle,
onActivate,
}: CategorySectionProps) {
const { t } = useTranslation(['app']);
const label =
categoryKey === ''
? t('app:soundboard.category_uncategorized', { defaultValue: '(Ohne Kategorie)' })
: categoryKey;
return (
<section>
<button
type="button"
onClick={onToggle}
className="mb-1.5 flex w-full cursor-pointer items-center justify-between gap-2 text-left text-[10px] font-semibold uppercase tracking-[0.1em] text-fg-muted"
>
<span>
{label} · {entries.length}
</span>
<ChevronDownIcon
className={'h-3 w-3 transition ' + (collapsed ? '-rotate-90' : '')}
/>
</button>
{!collapsed && (
<div className="grid grid-cols-2 gap-1.5">
{entries.map((entry) => (
<SoundPad
key={entry.id}
entry={entry}
active={activeIds.has(entry.id)}
onActivate={() => onActivate(entry.id)}
/>
))}
</div>
)}
</section>
);
}
interface PadProps {
entry: SoundboardEntry;
active: boolean;
onActivate: () => void;
}
function SoundPad({ entry, active, onActivate }: PadProps) {
const { t } = useTranslation(['app']);
const hotkey = entry.hotkey ? codeToShortcut(entry.hotkey) : null;
const base =
'group relative flex min-h-[54px] cursor-pointer flex-col justify-center gap-0.5 rounded-md border px-2.5 py-2 text-left text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40';
const toneClass = active
? 'border-rose-500 bg-rose-500/20 text-fg hover:brightness-110'
: 'border-line bg-surface-2 text-fg hover:border-accent hover:bg-surface-3';
return (
<button
type="button"
onClick={onActivate}
className={`${base} ${toneClass}`}
title={
active
? t('app:soundboard.pad_stop', { defaultValue: 'Stoppen' })
: entry.name
}
aria-pressed={active}
>
<span className="flex items-center gap-1.5 truncate">
{active && (
<span
aria-hidden="true"
className="h-2 w-2 shrink-0 rounded-full bg-rose-500 animate-live-dot"
/>
)}
<span className="truncate">{entry.name}</span>
</span>
<div className="flex items-center justify-between gap-1.5">
{hotkey ? (
<span className="inline-flex w-fit items-center rounded border border-line bg-surface-3 px-1 py-0.5 font-mono text-[9px] text-fg-muted">
{hotkey}
</span>
) : (
<span aria-hidden="true" />
)}
{active && (
<span className="text-[9px] font-semibold uppercase tracking-[0.08em] text-rose-500 dark:text-rose-300">
{t('app:soundboard.pad_stop', { defaultValue: 'Stoppen' })}
</span>
)}
</div>
</button>
);
}
function VolumeSlider({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (v: number) => void;
}) {
return (
<label className="flex items-center gap-3 text-[11px] text-fg">
<span className="w-16 shrink-0 text-fg-muted">{label}</span>
<input
type="range"
min={0}
max={1}
step={0.02}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="accent-accent flex-1"
/>
<span className="w-9 shrink-0 text-right tabular-nums text-fg-muted">
{Math.round(value * 100)}%
</span>
</label>
);
}
@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
listSounds,
subscribeSoundboardChanges,
type SoundboardEntry,
} from '../lib/soundboardStorage';
import { SoundboardManagerDialog } from './SoundboardManagerDialog';
import { ArrowRightIcon } from './icons';
// Entry point into the soundboard manager from the settings page. Shows a
// tiny summary (count, category count) and opens the big dialog on click.
export function SoundboardSettings() {
const { t } = useTranslation(['app']);
const [open, setOpen] = useState(false);
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
useEffect(() => {
let cancelled = false;
const refresh = async () => {
try {
const all = await listSounds();
if (!cancelled) setEntries(all);
} catch (err: unknown) {
console.warn('listSounds failed', err);
}
};
void refresh();
const unsub = subscribeSoundboardChanges(() => {
void refresh();
});
return () => {
cancelled = true;
unsub();
};
}, []);
const categoryCount = new Set(entries.map((e) => e.category ?? '__uncat')).size;
const withHotkey = entries.filter((e) => e.hotkey !== null).length;
return (
<>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-fg">
{t('app:soundboard.summary_title', { defaultValue: 'Deine Sounds' })}
</p>
<p className="mt-1 text-xs text-fg-muted">
{entries.length === 0
? t('app:soundboard.summary_empty', {
defaultValue: 'Noch keine Sounds vorhanden.',
})
: t('app:soundboard.summary_counts', {
defaultValue:
'{{sounds}} Sounds · {{categories}} Kategorien · {{hotkeys}} mit Hotkey',
sounds: entries.length,
categories: categoryCount,
hotkeys: withHotkey,
})}
</p>
</div>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110"
>
<span>
{t('app:soundboard.manage', { defaultValue: 'Verwalten' })}
</span>
<ArrowRightIcon className="h-3.5 w-3.5" />
</button>
</div>
<p className="text-[11px] text-fg-muted">
{t('app:soundboard.settings_hint', {
defaultValue:
'Hotkeys sind optional. Sounds lassen sich auch während eines Anrufs direkt im UI abspielen.',
})}
</p>
<SoundboardManagerDialog open={open} onClose={() => setOpen(false)} />
</>
);
}
+59 -4
View File
@@ -1,6 +1,6 @@
import { updateOwnProfile } from '@chat-app/shared/auth'; import { updateOwnProfile } from '@chat-app/shared/auth';
import type { PresenceState } from '@chat-app/shared/supabase'; import type { PresenceState } from '@chat-app/shared/supabase';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
@@ -8,6 +8,8 @@ import { supabase } from '../lib/supabase';
import { Avatar } from './Avatar'; import { Avatar } from './Avatar';
import { ChevronDownIcon } from './icons'; import { ChevronDownIcon } from './icons';
const STATUS_MAX = 128;
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline']; const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
const PRESENCE_DOT: Record<PresenceState, string> = { const PRESENCE_DOT: Record<PresenceState, string> = {
@@ -25,6 +27,23 @@ export function UserBar() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const presence = profile?.presenceState ?? 'offline'; 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) { async function changePresence(next: PresenceState) {
if (busy || next === presence) { 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 ( return (
<div className="relative"> <div className="relative">
<button <button
@@ -69,9 +102,7 @@ export function UserBar() {
<p className="truncate text-sm font-medium text-fg"> <p className="truncate text-sm font-medium text-fg">
{profile?.displayName ?? '—'} {profile?.displayName ?? '—'}
</p> </p>
<p className="truncate text-xs text-fg-muted"> <p className="truncate text-xs text-fg-muted">{subtitle}</p>
{t('app:presence.' + presence)}
</p>
</div> </div>
<ChevronDownIcon <ChevronDownIcon
className={'h-4 w-4 text-fg-muted transition ' + (open ? 'rotate-180' : '')} className={'h-4 w-4 text-fg-muted transition ' + (open ? 'rotate-180' : '')}
@@ -83,6 +114,30 @@ export function UserBar() {
role="menu" role="menu"
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-line bg-surface-3 shadow-xl" 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) => ( {PRESENCE_OPTIONS.map((opt) => (
<button <button
key={opt} key={opt}
+10
View File
@@ -401,6 +401,16 @@ export function CrownIcon(props: IconProps) {
); );
} }
export function MusicIcon(props: IconProps) {
return (
<Base {...props}>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</Base>
);
}
export function SendIcon(props: IconProps) { export function SendIcon(props: IconProps) {
return ( return (
<Base {...props}> <Base {...props}>
+38
View File
@@ -3,6 +3,7 @@ import {
getOwnProfile, getOwnProfile,
signOut as supabaseSignOut, signOut as supabaseSignOut,
type Profile, type Profile,
updateOwnProfile,
} from '@chat-app/shared/auth'; } from '@chat-app/shared/auth';
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n'; import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
import type { Session } from '@supabase/supabase-js'; import type { Session } from '@supabase/supabase-js';
@@ -143,6 +144,43 @@ export function AuthProvider({ children }: { children: ReactNode }) {
void registerWebPush(device.id); void registerWebPush(device.id);
}, [device?.id]); }, [device?.id]);
// Auto online/offline transition.
//
// - On mount with a session whose last persisted state is `offline`, flip
// to `online`. We never override an explicit `idle`, `dnd`, or
// `invisible` choice — those are user intent.
// - On `pagehide` / `beforeunload`, fire a best-effort update to
// `offline`. Browsers don't guarantee delivery during unload, but the
// request usually slips through; the next page load corrects state if it
// didn't.
useEffect(() => {
if (!session || !profile) return;
if (profile.presenceState === 'offline') {
void updateOwnProfile(supabase, { presenceState: 'online' })
.then(() => refreshProfile())
.catch((err: unknown) => {
console.warn('auto online flip failed', err);
});
}
const onLeave = () => {
// Skip if user explicitly chose a non-online state — they probably
// want to look unavailable on next reconnect too.
if (
profile.presenceState !== 'online' &&
profile.presenceState !== 'offline'
) {
return;
}
void updateOwnProfile(supabase, { presenceState: 'offline' }).catch(() => {});
};
window.addEventListener('beforeunload', onLeave);
window.addEventListener('pagehide', onLeave);
return () => {
window.removeEventListener('beforeunload', onLeave);
window.removeEventListener('pagehide', onLeave);
};
}, [session, profile, refreshProfile]);
const signOut = useCallback(async () => { const signOut = useCallback(async () => {
await supabaseSignOut(supabase); await supabaseSignOut(supabase);
}, []); }, []);
+249 -45
View File
@@ -47,7 +47,15 @@ import {
getCallE2EESettings, getCallE2EESettings,
isE2EESupported, isE2EESupported,
} from '../lib/callE2EE'; } from '../lib/callE2EE';
import { createMicPipeline, type MicPipeline } from '../lib/micPipeline';
import { getParticipantVolume } from '../lib/participantVolumes'; import { getParticipantVolume } from '../lib/participantVolumes';
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
import { playEntry } from '../lib/soundboardPlayback';
import {
getPrefs as getSoundboardPrefs,
listSounds as listSoundboard,
updatePrefs as updateSoundboardPrefs,
} from '../lib/soundboardStorage';
import { import {
type DisplaySurfaceHint, type DisplaySurfaceHint,
getPresetParams, getPresetParams,
@@ -110,6 +118,11 @@ interface CallContextValue {
isDeafened: boolean; isDeafened: boolean;
/** identity -> their deafen state, received via data channel. */ /** identity -> their deafen state, received via data channel. */
remoteDeafen: Record<string, boolean>; remoteDeafen: Record<string, boolean>;
/** identity -> mute state. Broadcast from peer whenever mic-gain flips.
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
* the mic pipeline keeps the track published with sound flowing even
* while the mic path is gain-silenced, so LK never sees "muted". */
remoteMute: Record<string, boolean>;
// Zero or more remote screen shares (LiveKit supports multiple simultaneous). // Zero or more remote screen shares (LiveKit supports multiple simultaneous).
remoteScreenShares: RemoteScreenShare[]; remoteScreenShares: RemoteScreenShare[];
// Remembers the conversation of the last call we left so a sidebar widget // Remembers the conversation of the last call we left so a sidebar widget
@@ -139,6 +152,19 @@ interface CallContextValue {
dismissLastCall: () => void; dismissLastCall: () => void;
setCallMode: (mode: CallMode) => void; setCallMode: (mode: CallMode) => void;
setFocusedId: (id: string | null) => void; setFocusedId: (id: string | null) => void;
/** Play a soundboard entry through the active call's mic pipeline.
* No-op when not connected. Default single-fire per id (spamming the
* hotkey cuts the previous instance); set overlap=true to layer. */
playSoundboard: (id: string, opts?: { overlap?: boolean }) => Promise<void>;
/** Stop every active sb source, or just the one matching `id` if given. */
stopSoundboard: (id?: string) => void;
/** Ids of soundboard entries currently emitting audio. Updated live so
* the in-call panel can show a stop icon on active pads. */
activeSoundboardIds: ReadonlySet<string>;
/** Apply new sb master/monitor gains to the live pipeline. Persists via
* updatePrefs in the storage module. */
setSoundboardMasterGain: (value: number) => Promise<void>;
setSoundboardMonitorGain: (value: number) => Promise<void>;
// Runtime mic switch. Persists choice so subsequent calls reuse it, and // Runtime mic switch. Persists choice so subsequent calls reuse it, and
// hot-swaps the input on an active call without a reconnect. // hot-swaps the input on an active call without a reconnect.
setAudioInputDevice: (deviceId: string | null) => Promise<void>; setAudioInputDevice: (deviceId: string | null) => Promise<void>;
@@ -157,7 +183,7 @@ function newCallId(): string {
} }
export function CallProvider({ children }: { children: ReactNode }) { export function CallProvider({ children }: { children: ReactNode }) {
const { session, device } = useAuth(); const { session, device, profile } = useAuth();
const { conversations } = useConversationsContext(); const { conversations } = useConversationsContext();
const myId = session?.user.id; const myId = session?.user.id;
@@ -173,12 +199,18 @@ export function CallProvider({ children }: { children: ReactNode }) {
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null); const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
const [callMode, setCallModeState] = useState<CallMode>('grid'); const [callMode, setCallModeState] = useState<CallMode>('grid');
const [focusedId, setFocusedIdState] = useState<string | null>(null); const [focusedId, setFocusedIdState] = useState<string | null>(null);
const [activeSoundboardIds, setActiveSoundboardIds] = useState<ReadonlySet<string>>(
() => new Set<string>(),
);
const signalChannelRef = useRef<RealtimeChannel | null>(null); const signalChannelRef = useRef<RealtimeChannel | null>(null);
const presenceChannelRef = useRef<RealtimeChannel | null>(null); const presenceChannelRef = useRef<RealtimeChannel | null>(null);
const ringTimerRef = useRef<number | null>(null); const ringTimerRef = useRef<number | null>(null);
const soloTimerRef = useRef<number | null>(null); const soloTimerRef = useRef<number | null>(null);
const roomRef = useRef<Room | null>(null); const roomRef = useRef<Room | null>(null);
// Web Audio graph that mixes live mic + soundboard sources into a single
// published track. Created per call in joinRoom, destroyed in disconnectRoom.
const pipelineRef = useRef<MicPipeline | null>(null);
// Tracks whether the current call was ever in the connected state — needed // Tracks whether the current call was ever in the connected state — needed
// so hangup/solo-timeout can emit a real duration message vs. "missed". // so hangup/solo-timeout can emit a real duration message vs. "missed".
const everConnectedRef = useRef<boolean>(false); const everConnectedRef = useRef<boolean>(false);
@@ -191,12 +223,21 @@ export function CallProvider({ children }: { children: ReactNode }) {
// channel messages ({ type: 'presence', deafened: bool }). Exposed as // channel messages ({ type: 'presence', deafened: bool }). Exposed as
// state so consumer components re-render on change. // state so consumer components re-render on change.
const [remoteDeafen, setRemoteDeafen] = useState<Record<string, boolean>>({}); const [remoteDeafen, setRemoteDeafen] = useState<Record<string, boolean>>({});
const [remoteMute, setRemoteMute] = useState<Record<string, boolean>>({});
// Mirror of isMuted for use inside LiveKit-event callbacks that run outside
// the React component (ParticipantConnected rebroadcast etc).
const mutedRef = useRef<boolean>(false);
const stateRef = useRef<CallState>(state); const stateRef = useRef<CallState>(state);
stateRef.current = state; stateRef.current = state;
// Keep latest conversations accessible from signal-channel closures without // Keep latest conversations accessible from signal-channel closures without
// re-subscribing the channel on every conversations update. // re-subscribing the channel on every conversations update.
const conversationsRef = useRef(conversations); const conversationsRef = useRef(conversations);
conversationsRef.current = conversations; conversationsRef.current = conversations;
// Mirror own presence state for use inside signal-channel callbacks. DND
// suppresses incoming-call OS notifications (ringtone is handled in CallUI
// which has direct access to the auth profile).
const presenceRef = useRef(profile?.presenceState ?? 'offline');
presenceRef.current = profile?.presenceState ?? 'offline';
// --- Helpers ----------------------------------------------------------- // --- Helpers -----------------------------------------------------------
@@ -271,8 +312,25 @@ export function CallProvider({ children }: { children: ReactNode }) {
setIsDeafened(false); setIsDeafened(false);
deafenedActive = false; deafenedActive = false;
setRemoteDeafen({}); setRemoteDeafen({});
setRemoteMute({});
setIsMuted(false);
mutedRef.current = false;
setIsE2EEActive(false); setIsE2EEActive(false);
// Tear down the mic pipeline AFTER LiveKit disconnects so the published
// track is unpublished cleanly first; then close AudioContext + stop
// raw mic + output tracks we own.
const pipeline = pipelineRef.current;
if (pipeline) {
try {
pipeline.destroy();
} catch {
/* ignore */
}
pipelineRef.current = null;
}
setActiveSoundboardIds(new Set<string>());
const pres = presenceChannelRef.current; const pres = presenceChannelRef.current;
if (pres) { if (pres) {
try { try {
@@ -511,23 +569,36 @@ export function CallProvider({ children }: { children: ReactNode }) {
if (!participant?.identity) return; if (!participant?.identity) return;
try { try {
const text = new TextDecoder().decode(payload); const text = new TextDecoder().decode(payload);
const msg = JSON.parse(text) as { type?: string; deafened?: boolean }; const msg = JSON.parse(text) as {
if (msg.type === 'presence' && typeof msg.deafened === 'boolean') { type?: string;
const id: string = participant.identity; deafened?: boolean;
muted?: boolean;
};
if (msg.type !== 'presence') return;
const id: string = participant.identity;
if (typeof msg.deafened === 'boolean') {
const deafened: boolean = msg.deafened; const deafened: boolean = msg.deafened;
setRemoteDeafen((prev) => { setRemoteDeafen((prev) => {
if (prev[id] === deafened) return prev; if (prev[id] === deafened) return prev;
return { ...prev, [id]: deafened }; return { ...prev, [id]: deafened };
}); });
} }
if (typeof msg.muted === 'boolean') {
const muted: boolean = msg.muted;
setRemoteMute((prev) => {
if (prev[id] === muted) return prev;
return { ...prev, [id]: muted };
});
}
} catch { } catch {
/* ignore malformed */ /* ignore malformed */
} }
}, },
); );
// When someone joins, re-send our current deafen state so they know. // When someone joins, re-send our current presence (deafen + mute) so
// they know immediately instead of waiting for the next toggle.
r.on(RoomEvent.ParticipantConnected, () => { r.on(RoomEvent.ParticipantConnected, () => {
void broadcastPresence(r, deafenedActive); void broadcastPresence(r, deafenedActive, mutedRef.current);
}); });
// Track my own screen-share state via LocalTrack events so the toggle // Track my own screen-share state via LocalTrack events so the toggle
@@ -557,18 +628,43 @@ export function CallProvider({ children }: { children: ReactNode }) {
} }
try { try {
const inputId = getAudioSettings().inputDeviceId; const inputId = getAudioSettings().inputDeviceId;
await r.localParticipant.setMicrophoneEnabled(true, { // Grab the raw mic ourselves instead of going through LiveKit's
echoCancellation: aParams.echoCancellation, // setMicrophoneEnabled. The resulting MediaStreamTrack is routed
noiseSuppression: aParams.noiseSuppression, // through createMicPipeline, which mixes in soundboard buffers and
autoGainControl: aParams.autoGainControl, // exposes a single output track we hand to publishTrack. Mute / PTT
channelCount: aParams.stereo ? 2 : 1, // are gain-based from here on, never track.enabled or device stop.
sampleRate: aParams.sampleRateHz, const rawStream = await navigator.mediaDevices.getUserMedia({
// Plain string maps to `ideal` — if the device is gone we fall back audio: {
// to OS default instead of throwing NotFoundError. echoCancellation: aParams.echoCancellation,
...(inputId ? { deviceId: inputId } : {}), noiseSuppression: aParams.noiseSuppression,
autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1,
sampleRate: aParams.sampleRateHz,
...(inputId ? { deviceId: { ideal: inputId } } : {}),
},
video: false,
});
const rawTrack = rawStream.getAudioTracks()[0];
if (!rawTrack) throw new Error('no audio track from getUserMedia');
const pipeline = createMicPipeline(rawTrack);
pipelineRef.current = pipeline;
// Pull the user's last-saved soundboard gains onto the live pipeline
// before the first sound ever plays so nothing blasts at 100%.
try {
const prefs = await getSoundboardPrefs();
pipeline.setSoundboardGain(prefs.masterGain);
pipeline.setMonitorGain(prefs.monitorGain);
} catch (err: unknown) {
console.warn('getSoundboardPrefs failed', err);
}
await r.localParticipant.publishTrack(pipeline.outputTrack, {
source: Track.Source.Microphone,
red: true,
dtx: aParams.stereo ? false : true,
forceStereo: aParams.stereo,
}); });
} catch (micErr: unknown) { } catch (micErr: unknown) {
console.error('setMicrophoneEnabled failed', micErr); console.error('mic pipeline setup failed', micErr);
} }
if (mediaKind === 'video') { if (mediaKind === 'video') {
try { try {
@@ -807,12 +903,15 @@ export function CallProvider({ children }: { children: ReactNode }) {
}, []); }, []);
const toggleMute = useCallback(() => { const toggleMute = useCallback(() => {
const r = roomRef.current; const pipeline = pipelineRef.current;
if (!r) return; if (!pipeline) return;
const lp = r.localParticipant; setIsMuted((prev) => {
const shouldEnable = !lp.isMicrophoneEnabled; const nextMuted = !prev;
void lp.setMicrophoneEnabled(shouldEnable).then(() => { pipeline.setMicGain(nextMuted ? 0 : 1);
setIsMuted(!shouldEnable); mutedRef.current = nextMuted;
const r = roomRef.current;
if (r) void broadcastPresence(r, deafenedActive, nextMuted);
return nextMuted;
}); });
}, []); }, []);
@@ -921,7 +1020,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
// headphones-off badge. Data channel works on any LiveKit server // headphones-off badge. Data channel works on any LiveKit server
// version, unlike `setAttributes` which requires a newer server. // version, unlike `setAttributes` which requires a newer server.
const r = roomRef.current; const r = roomRef.current;
if (r) void broadcastPresence(r, next); if (r) void broadcastPresence(r, next, mutedRef.current);
return next; return next;
}); });
}, []); }, []);
@@ -957,9 +1056,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
let globalRegisteredFor: string | null = null; let globalRegisteredFor: string | null = null;
const setMic = (on: boolean) => { const setMic = (on: boolean) => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
pipeline.setMicGain(on ? 1 : 0);
const nextMuted = !on;
mutedRef.current = nextMuted;
setIsMuted(nextMuted);
const r = roomRef.current; const r = roomRef.current;
if (!r) return; if (r) void broadcastPresence(r, deafenedActive, nextMuted);
void r.localParticipant.setMicrophoneEnabled(on).then(() => setIsMuted(!on));
}; };
const pressPtt = () => { const pressPtt = () => {
@@ -1088,14 +1192,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
conv?.peer?.displayName ?? conv?.peer?.displayName ??
'…'; '…';
const isGroup = conv?.type === 'group'; const isGroup = conv?.type === 'group';
void notify({ if (presenceRef.current !== 'dnd') {
title: isGroup void notify({
? (conv?.name ?? 'Gruppenanruf') title: isGroup
: 'Eingehender Anruf', ? (conv?.name ?? 'Gruppenanruf')
body: isGroup : 'Eingehender Anruf',
? callerName + ' ruft die Gruppe' body: isGroup
: callerName + ' ruft dich an', ? callerName + ' ruft die Gruppe'
}); : callerName + ' ruft dich an',
});
}
break; break;
} }
case 'accept': case 'accept':
@@ -1136,10 +1242,12 @@ export function CallProvider({ children }: { children: ReactNode }) {
conv?.members.find((m) => m.userId === cur.fromUserId)?.profile?.displayName ?? conv?.members.find((m) => m.userId === cur.fromUserId)?.profile?.displayName ??
conv?.peer?.displayName ?? conv?.peer?.displayName ??
'…'; '…';
void notify({ if (presenceRef.current !== 'dnd') {
title: 'Verpasster Anruf', void notify({
body: callerName + ' hat aufgelegt', title: 'Verpasster Anruf',
}); body: callerName + ' hat aufgelegt',
});
}
setState({ kind: 'idle' }); setState({ kind: 'idle' });
} }
break; break;
@@ -1156,16 +1264,96 @@ export function CallProvider({ children }: { children: ReactNode }) {
setFocusedIdState(id); setFocusedIdState(id);
}, []); }, []);
const markActive = useCallback((id: string, on: boolean) => {
setActiveSoundboardIds((prev) => {
const has = prev.has(id);
if (on && has) return prev;
if (!on && !has) return prev;
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
}, []);
const playSoundboard = useCallback(
async (id: string, opts?: { overlap?: boolean }) => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
const entries = await listSoundboard();
const entry = entries.find((e) => e.id === id);
if (!entry) return;
const handle = await playEntry(pipeline, entry, {
...(opts?.overlap !== undefined ? { overlap: opts.overlap } : {}),
onEnded: () => markActive(id, false),
});
if (handle) markActive(id, true);
},
[markActive],
);
const stopSoundboard = useCallback(
(id?: string) => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
pipeline.stopAll(id);
if (id) {
markActive(id, false);
} else {
setActiveSoundboardIds(new Set<string>());
}
},
[markActive],
);
const setSoundboardMasterGain = useCallback(async (value: number) => {
const prefs = await updateSoundboardPrefs({ masterGain: value });
pipelineRef.current?.setSoundboardGain(prefs.masterGain);
}, []);
const setSoundboardMonitorGain = useCallback(async (value: number) => {
const prefs = await updateSoundboardPrefs({ monitorGain: value });
pipelineRef.current?.setMonitorGain(prefs.monitorGain);
}, []);
// Global soundboard hotkey registration — runs only while connected so the
// OS-level shortcuts don't fire when the user is outside of a call.
useEffect(() => {
if (state.kind !== 'connected') return;
const teardown = startSoundboardHotkeys((id) => {
void playSoundboard(id);
});
return teardown;
}, [state.kind, playSoundboard]);
const setAudioInputDevice = useCallback(async (deviceId: string | null) => { const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
updateAudioSettings({ inputDeviceId: deviceId }); updateAudioSettings({ inputDeviceId: deviceId });
const r = roomRef.current; const pipeline = pipelineRef.current;
if (!r) return; if (!pipeline) return;
try { try {
// LiveKit API: `switchActiveDevice(kind, deviceId)` hot-swaps without a // We own the mic track (see joinRoom pipeline setup), so LiveKit's
// reconnect. Pass empty string or `default` to revert to OS default. // switchActiveDevice no longer applies. Fetch a new raw track with the
await r.switchActiveDevice('audioinput', deviceId ?? 'default'); // updated deviceId + the same quality constraints, then hand ownership
// to the pipeline. It disconnects the old source, stops the old track,
// and rewires micGain onto the new source — the published track stays
// stable so peers don't see a republish.
const aParams = getAudioQualityParams(getAudioSettings().quality);
const newStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: aParams.echoCancellation,
noiseSuppression: aParams.noiseSuppression,
autoGainControl: aParams.autoGainControl,
channelCount: aParams.stereo ? 2 : 1,
sampleRate: aParams.sampleRateHz,
...(deviceId ? { deviceId: { ideal: deviceId } } : {}),
},
video: false,
});
const newTrack = newStream.getAudioTracks()[0];
if (!newTrack) throw new Error('no audio track for device');
pipeline.replaceMicTrack(newTrack);
} catch (err: unknown) { } catch (err: unknown) {
console.error('switchActiveDevice(audioinput) failed', err); console.error('setAudioInputDevice failed', err);
} }
}, []); }, []);
@@ -1229,6 +1417,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
isCameraEnabled, isCameraEnabled,
isDeafened, isDeafened,
remoteDeafen, remoteDeafen,
remoteMute,
remoteScreenShares, remoteScreenShares,
lastCallConversationId, lastCallConversationId,
callMode, callMode,
@@ -1249,6 +1438,11 @@ export function CallProvider({ children }: { children: ReactNode }) {
setFocusedId, setFocusedId,
setAudioInputDevice, setAudioInputDevice,
setAudioOutputDevice, setAudioOutputDevice,
playSoundboard,
stopSoundboard,
activeSoundboardIds,
setSoundboardMasterGain,
setSoundboardMonitorGain,
}), }),
[ [
state, state,
@@ -1260,6 +1454,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
isCameraEnabled, isCameraEnabled,
isDeafened, isDeafened,
remoteDeafen, remoteDeafen,
remoteMute,
remoteScreenShares, remoteScreenShares,
lastCallConversationId, lastCallConversationId,
callMode, callMode,
@@ -1280,6 +1475,11 @@ export function CallProvider({ children }: { children: ReactNode }) {
setFocusedId, setFocusedId,
setAudioInputDevice, setAudioInputDevice,
setAudioOutputDevice, setAudioOutputDevice,
playSoundboard,
stopSoundboard,
activeSoundboardIds,
setSoundboardMasterGain,
setSoundboardMonitorGain,
], ],
); );
@@ -1297,10 +1497,14 @@ export function useCall(): CallContextValue {
// audio elements. Toggled by toggleDeafen in sync with the React state. // audio elements. Toggled by toggleDeafen in sync with the React state.
let deafenedActive = false; let deafenedActive = false;
async function broadcastPresence(room: Room, deafened: boolean): Promise<void> { async function broadcastPresence(
room: Room,
deafened: boolean,
muted: boolean,
): Promise<void> {
try { try {
const payload = new TextEncoder().encode( const payload = new TextEncoder().encode(
JSON.stringify({ type: 'presence', deafened }), JSON.stringify({ type: 'presence', deafened, muted }),
); );
await room.localParticipant.publishData(payload, { reliable: true }); await room.localParticipant.publishData(payload, { reliable: true });
} catch (err: unknown) { } catch (err: unknown) {
+69
View File
@@ -55,3 +55,72 @@ export async function unregisterPttShortcut(code: string): Promise<void> {
export function isTauriRuntime(): boolean { export function isTauriRuntime(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
} }
// --- Soundboard shortcuts --------------------------------------------------
//
// Separate from the single PTT shortcut: the soundboard needs to register
// many fire-and-forget press bindings at once, keep track of which ids own
// which accelerators so we can unregister just one, and expose conflict
// detection for the settings UI.
interface SoundShortcutRegistration {
shortcut: string;
onPress: () => void;
}
// Map of logical id (sound uuid) -> registration.
const soundRegistry = new Map<string, SoundShortcutRegistration>();
export async function registerSoundShortcut(
id: string,
code: string,
onPress: () => void,
): Promise<boolean> {
if (!isTauriRuntime()) return false;
const shortcut = codeToShortcut(code);
// Unregister any previous binding for this id first — caller may be
// re-registering after the user changed the hotkey for the same sound.
await unregisterSoundShortcut(id);
try {
if (await isRegistered(shortcut)) {
await unregister(shortcut);
}
await register(shortcut, (event: ShortcutEvent) => {
if (event.state === 'Pressed') onPress();
});
soundRegistry.set(id, { shortcut, onPress });
return true;
} catch (err: unknown) {
console.warn('registerSoundShortcut failed', { id, code, err });
return false;
}
}
export async function unregisterSoundShortcut(id: string): Promise<void> {
const reg = soundRegistry.get(id);
if (!reg) return;
soundRegistry.delete(id);
if (!isTauriRuntime()) return;
try {
if (await isRegistered(reg.shortcut)) {
await unregister(reg.shortcut);
}
} catch (err: unknown) {
console.warn('unregisterSoundShortcut failed', { id, err });
}
}
export async function unregisterAllSoundShortcuts(): Promise<void> {
const ids = Array.from(soundRegistry.keys());
await Promise.all(ids.map((id) => unregisterSoundShortcut(id)));
}
// Resolve a DOM code to the registry's current owner (if any). Used by the
// settings UI to surface conflicts before saving a new hotkey.
export function soundShortcutOwnerFor(code: string): string | null {
const shortcut = codeToShortcut(code);
for (const [id, reg] of soundRegistry) {
if (reg.shortcut === shortcut) return id;
}
return null;
}
+275
View File
@@ -0,0 +1,275 @@
// Shared Web Audio graph that sits between the raw microphone MediaStream
// and LiveKit's published track. Mixes live mic with on-demand soundboard
// buffers so both paths reach the peer through a single published track,
// and lets us locally monitor soundboard output without feedback from the
// mic path.
//
// Graph:
// rawMicSource ──► micGain ──┐
// ├─► destinationNode ─► publishedTrack
// sbBufferSources ─► sbGain ─┤
// └─► monitorGain ─► ctx.destination (local hear,
// soundboard only)
//
// Lifetime:
// createMicPipeline(rawTrack) — builds graph + AudioContext
// pipeline.outputTrack — pass to `localParticipant.publishTrack`
// pipeline.setMicGain(0..1) — mute / PTT
// pipeline.setSoundboardGain(..) / setMonitorGain(..) — sb master + local hear
// pipeline.playBuffer(buffer, opts) — returns a handle so callers can stop
// pipeline.stopAll(buffers?) — kill every active sb source (or only one id)
// pipeline.replaceMicTrack(newTrack) — hot-swap on device change
// pipeline.destroy() — close ctx, stop owned tracks
export interface PlayBufferOpts {
/** Per-source gain 0..1, multiplied by sb master. */
gain?: number;
/** Stable id — calling playBuffer with the same id stops the previous one
* first (single-fire mode). Omit for overlap mode. */
id?: string;
/** Fired when the buffer ends naturally (not when stopped manually). */
onEnded?: () => void;
}
export interface PlayHandle {
id: string | null;
stop(): void;
}
export interface MicPipeline {
readonly outputTrack: MediaStreamTrack;
setMicGain(value: number): void;
setSoundboardGain(value: number): void;
setMonitorGain(value: number): void;
replaceMicTrack(newTrack: MediaStreamTrack): void;
playBuffer(buffer: AudioBuffer, opts?: PlayBufferOpts): PlayHandle;
stopAll(id?: string): void;
destroy(): void;
}
interface ActiveSource {
id: string | null;
node: AudioBufferSourceNode;
gain: GainNode;
}
// Clamp helper — avoid letting callers pass NaN or out-of-range values.
function clamp01(v: number): number {
if (!Number.isFinite(v)) return 0;
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
export function createMicPipeline(rawTrack: MediaStreamTrack): MicPipeline {
const AudioCtx =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioCtx) {
throw new Error('AudioContext unavailable');
}
const ctx = new AudioCtx();
const micGain = ctx.createGain();
micGain.gain.value = 1;
const sbGain = ctx.createGain();
sbGain.gain.value = 1;
const monitorGain = ctx.createGain();
monitorGain.gain.value = 1;
const dest = ctx.createMediaStreamDestination();
// Mic path → published only.
micGain.connect(dest);
// Soundboard path → published + local monitor.
sbGain.connect(dest);
sbGain.connect(monitorGain);
monitorGain.connect(ctx.destination);
let currentRawTrack: MediaStreamTrack = rawTrack;
let micSource: MediaStreamAudioSourceNode = buildMicSource(ctx, rawTrack, micGain);
const active = new Set<ActiveSource>();
let destroyed = false;
function buildMicSource(
c: AudioContext,
t: MediaStreamTrack,
target: AudioNode,
): MediaStreamAudioSourceNode {
const stream = new MediaStream([t]);
const node = c.createMediaStreamSource(stream);
node.connect(target);
return node;
}
const outputTrack = dest.stream.getAudioTracks()[0];
if (!outputTrack) {
throw new Error('MediaStreamAudioDestinationNode produced no audio track');
}
return {
outputTrack,
setMicGain(value: number) {
if (destroyed) return;
const v = clamp01(value);
micGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
},
setSoundboardGain(value: number) {
if (destroyed) return;
const v = clamp01(value);
sbGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
},
setMonitorGain(value: number) {
if (destroyed) return;
const v = clamp01(value);
monitorGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01);
},
replaceMicTrack(newTrack: MediaStreamTrack) {
if (destroyed) return;
// Tear down the old MediaStreamSourceNode and stop the raw track we
// owned. Caller passes ownership of `newTrack` to the pipeline.
try {
micSource.disconnect();
} catch {
/* ignore */
}
if (currentRawTrack !== newTrack) {
try {
currentRawTrack.stop();
} catch {
/* ignore */
}
}
currentRawTrack = newTrack;
micSource = buildMicSource(ctx, newTrack, micGain);
},
playBuffer(buffer: AudioBuffer, opts: PlayBufferOpts = {}): PlayHandle {
if (destroyed) {
return { id: opts.id ?? null, stop: () => undefined };
}
// Single-fire: stop previous instance of the same id so holding a
// hotkey doesn't stack a dozen overlapping plays.
if (opts.id) {
for (const entry of active) {
if (entry.id === opts.id) stopActive(entry);
}
}
const node = ctx.createBufferSource();
node.buffer = buffer;
const g = ctx.createGain();
g.gain.value = clamp01(opts.gain ?? 1);
node.connect(g);
g.connect(sbGain);
const entry: ActiveSource = { id: opts.id ?? null, node, gain: g };
active.add(entry);
node.onended = () => {
if (!active.has(entry)) return;
try {
node.disconnect();
} catch {
/* ignore */
}
try {
g.disconnect();
} catch {
/* ignore */
}
active.delete(entry);
opts.onEnded?.();
};
try {
node.start();
} catch {
active.delete(entry);
}
return {
id: entry.id,
stop: () => stopActive(entry),
};
},
stopAll(id?: string) {
for (const entry of Array.from(active)) {
if (id !== undefined && entry.id !== id) continue;
stopActive(entry);
}
},
destroy() {
if (destroyed) return;
destroyed = true;
for (const entry of Array.from(active)) stopActive(entry);
try {
micSource.disconnect();
} catch {
/* ignore */
}
try {
micGain.disconnect();
} catch {
/* ignore */
}
try {
sbGain.disconnect();
} catch {
/* ignore */
}
try {
monitorGain.disconnect();
} catch {
/* ignore */
}
try {
currentRawTrack.stop();
} catch {
/* ignore */
}
try {
outputTrack.stop();
} catch {
/* ignore */
}
void ctx.close().catch(() => {
/* ignore */
});
},
};
function stopActive(entry: ActiveSource): void {
try {
entry.node.onended = null;
entry.node.stop();
} catch {
/* already ended */
}
try {
entry.node.disconnect();
} catch {
/* ignore */
}
try {
entry.gain.disconnect();
} catch {
/* ignore */
}
active.delete(entry);
}
}
+87 -6
View File
@@ -1,6 +1,13 @@
// Looping WebAudio ringtones. Two patterns: // Looping ringtones. Two patterns:
// - outgoing: long calling tone, 3s cycle // - outgoing: long calling tone, 3s cycle (oscillator only)
// - incoming: classic "ring ring" double beep, 2s cycle // - incoming: classic "ring ring" double beep (oscillator), optionally
// upgraded to a user-supplied audio file stored in ringtoneStorage
//
// The custom file plays immediately if we can load it; otherwise we fall
// back to the generated oscillator pattern so ringing never misses an
// incoming call due to an IO failure.
import { getIncomingRingtone } from './ringtoneStorage';
type Pattern = 'outgoing' | 'incoming'; type Pattern = 'outgoing' | 'incoming';
@@ -9,15 +16,90 @@ class Ringtone {
private interval: number | null = null; private interval: number | null = null;
private pattern: Pattern | null = null; private pattern: Pattern | null = null;
// Custom-file playback path (incoming only).
private audioEl: HTMLAudioElement | null = null;
private customUrl: string | null = null;
// Sequence token to ignore slow IO completing after user changed state.
private startSeq = 0;
start(pattern: Pattern): void { start(pattern: Pattern): void {
if (this.pattern === pattern) return; // already playing this pattern if (this.pattern === pattern) return; // already playing this pattern
this.stop(); this.stop();
this.pattern = pattern;
const seq = ++this.startSeq;
if (pattern === 'incoming') {
// Kick off oscillator immediately so we never miss ringing feedback
// while the custom file (if any) loads asynchronously. Once the blob
// is ready we hand playback over to the <audio> element.
this.startOscillator(pattern);
void this.tryUpgradeToCustom(seq);
} else {
this.startOscillator(pattern);
}
}
stop(): void {
this.startSeq++;
this.stopOscillator();
this.stopCustom();
this.pattern = null;
}
// --- Custom file path (incoming only) ----------------------------------
private async tryUpgradeToCustom(seq: number): Promise<void> {
let stored;
try {
stored = await getIncomingRingtone();
} catch {
return; // keep oscillator
}
// User stopped or switched patterns while we were loading.
if (seq !== this.startSeq || this.pattern !== 'incoming' || !stored) return;
const url = URL.createObjectURL(stored.blob);
const el = new Audio(url);
el.loop = true;
el.volume = 0.85;
// Chrome/WKWebView autoplay policy: muted playback is always allowed,
// but ringtones must be audible, so play() may reject the first time
// before the user interacted. If it rejects, we keep the oscillator.
el.play().catch(() => {
URL.revokeObjectURL(url);
});
this.audioEl = el;
this.customUrl = url;
// Only swap off the oscillator once the custom element is actually
// wired — avoids a silent gap on transition.
this.stopOscillator();
}
private stopCustom(): void {
if (this.audioEl) {
try {
this.audioEl.pause();
} catch {
/* ignore */
}
this.audioEl.src = '';
this.audioEl = null;
}
if (this.customUrl) {
URL.revokeObjectURL(this.customUrl);
this.customUrl = null;
}
}
// --- Oscillator fallback ----------------------------------------------
private startOscillator(pattern: Pattern): void {
const AudioCtx = const AudioCtx =
window.AudioContext ?? window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioCtx) return; if (!AudioCtx) return;
this.ctx = new AudioCtx(); this.ctx = new AudioCtx();
this.pattern = pattern;
const play = pattern === 'outgoing' ? this.playOutgoing : this.playIncoming; const play = pattern === 'outgoing' ? this.playOutgoing : this.playIncoming;
play.call(this); play.call(this);
@@ -27,7 +109,7 @@ class Ringtone {
); );
} }
stop(): void { private stopOscillator(): void {
if (this.interval !== null) { if (this.interval !== null) {
window.clearInterval(this.interval); window.clearInterval(this.interval);
this.interval = null; this.interval = null;
@@ -38,7 +120,6 @@ class Ringtone {
}); });
this.ctx = null; this.ctx = null;
} }
this.pattern = null;
} }
private beep(freq: number, durationSec: number, delaySec: number, gain = 0.18): void { private beep(freq: number, durationSec: number, delaySec: number, gain = 0.18): void {
+108
View File
@@ -0,0 +1,108 @@
// IndexedDB-backed storage for the user's custom incoming ringtone.
//
// Only one slot is exposed (`incoming`) — outgoing ringtone stays tied to the
// bundled oscillator pattern. The blob is stored alongside its mime type +
// original filename so playback + UI can show what's currently in use.
const DB_NAME = 'netralax-ringtones';
const DB_VERSION = 1;
const STORE_NAME = 'ringtones';
const SLOT_INCOMING = 'incoming';
export interface StoredRingtone {
blob: Blob;
mime: string;
filename: string;
updatedAt: number;
}
export const MAX_RINGTONE_BYTES = 2 * 1024 * 1024; // 2 MB cap
export const SUPPORTED_RINGTONE_MIMES = [
'audio/mpeg',
'audio/mp3',
'audio/wav',
'audio/x-wav',
'audio/ogg',
'audio/webm',
'audio/mp4',
'audio/aac',
'audio/x-m4a',
'audio/m4a',
];
let dbPromise: Promise<IDBDatabase> | null = null;
function openDb(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error ?? new Error('indexedDB open failed'));
});
return dbPromise;
}
function runTx<T>(
mode: IDBTransactionMode,
fn: (store: IDBObjectStore) => IDBRequest<T> | void,
): Promise<T | undefined> {
return openDb().then(
(db) =>
new Promise<T | undefined>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, mode);
const store = tx.objectStore(STORE_NAME);
let result: T | undefined = undefined;
const maybeReq = fn(store);
if (maybeReq) {
maybeReq.onsuccess = () => {
result = maybeReq.result;
};
maybeReq.onerror = () => reject(maybeReq.error);
}
tx.oncomplete = () => resolve(result);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
}),
);
}
export async function saveIncomingRingtone(file: File): Promise<void> {
if (file.size === 0) throw new Error('empty file');
if (file.size > MAX_RINGTONE_BYTES) {
throw new Error('ringtone_too_large');
}
const mime = file.type || 'application/octet-stream';
if (!mime.startsWith('audio/')) {
throw new Error('ringtone_not_audio');
}
const stored: StoredRingtone = {
blob: file,
mime,
filename: file.name,
updatedAt: Date.now(),
};
await runTx('readwrite', (store) => store.put(stored, SLOT_INCOMING));
}
export async function getIncomingRingtone(): Promise<StoredRingtone | null> {
const result = await runTx<StoredRingtone | undefined>('readonly', (store) =>
store.get(SLOT_INCOMING) as IDBRequest<StoredRingtone | undefined>,
);
return result ?? null;
}
export async function clearIncomingRingtone(): Promise<void> {
await runTx('readwrite', (store) => store.delete(SLOT_INCOMING));
}
export async function hasIncomingRingtone(): Promise<boolean> {
const cur = await getIncomingRingtone();
return cur !== null;
}
+88
View File
@@ -0,0 +1,88 @@
// Binds soundboard entries to global shortcuts and keeps the registry in
// sync with storage mutations. Starting returns a teardown function that
// undoes every registration the binding made.
//
// Usage (in CallContext effect when call becomes connected):
// const teardown = startSoundboardHotkeys((id) => void playSoundboard(id));
// return teardown; // useEffect cleanup
import {
isTauriRuntime,
registerSoundShortcut,
unregisterAllSoundShortcuts,
unregisterSoundShortcut,
} from './globalShortcut';
import { getPttSettings } from './pttSettings';
import { listSounds, subscribeSoundboardChanges } from './soundboardStorage';
export type FirePress = (id: string) => void;
export function startSoundboardHotkeys(onPress: FirePress): () => void {
if (!isTauriRuntime()) {
// No-op on pure web preview — global shortcuts unsupported. Storage
// change subscription would still fire, but there's nothing to sync.
return () => undefined;
}
let active = true;
// identity-keyed: sound id -> currently bound DOM code
const bound = new Map<string, string>();
const sync = async (): Promise<void> => {
if (!active) return;
let entries;
try {
entries = await listSounds();
} catch (err: unknown) {
console.warn('soundboardHotkeys list failed', err);
return;
}
const pttKey = getPttSettings().key;
const wantByCode = new Map<string, string>(); // code -> id (winner on conflict)
for (const e of entries) {
if (!e.hotkey) continue;
// PTT wins over soundboard: don't hijack the talk key, skip silently.
if (pttKey && e.hotkey === pttKey) continue;
// First-writer-wins for dupes (stable because listSounds is sorted
// deterministically). Settings UI should prevent this upstream.
if (!wantByCode.has(e.hotkey)) wantByCode.set(e.hotkey, e.id);
}
const want = new Map<string, string>();
for (const [code, id] of wantByCode) want.set(id, code);
// Unregister bindings that disappeared or changed key.
for (const [id, code] of bound) {
const nextCode = want.get(id);
if (nextCode !== code) {
await unregisterSoundShortcut(id);
bound.delete(id);
}
}
// Register new / updated bindings.
for (const [id, code] of want) {
if (bound.get(id) === code) continue;
const ok = await registerSoundShortcut(id, code, () => {
if (!active) return;
onPress(id);
});
if (ok) bound.set(id, code);
}
};
const unsubscribe = subscribeSoundboardChanges(() => {
void sync();
});
// Initial binding pass.
void sync();
return () => {
active = false;
unsubscribe();
void unregisterAllSoundShortcuts();
bound.clear();
};
}
+103
View File
@@ -0,0 +1,103 @@
// Decode + cache AudioBuffers for soundboard entries and play them through a
// MicPipeline. Cache is LRU-bounded per sound id. `invalidate(id)` drops the
// decoded buffer when the underlying blob changes (rename keeps blob intact,
// but re-upload of the same id rebuilds the buffer on next play).
import type { MicPipeline, PlayHandle } from './micPipeline';
import { getSoundBlob, type SoundboardEntry } from './soundboardStorage';
const MAX_CACHE_ENTRIES = 64;
const cache = new Map<string, AudioBuffer>();
// Decoding requires an AudioContext. We keep one short-lived context just for
// decoding — the pipeline's own ctx is used for playback, so we don't share.
// `decodeAudioData` is legacy-sync in Safari/WKWebView: it mutates the input
// ArrayBuffer, so we always pass a fresh slice() copy.
let decodeCtx: AudioContext | null = null;
function getDecodeCtx(): AudioContext {
if (decodeCtx) return decodeCtx;
const AudioCtx =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioCtx) throw new Error('AudioContext unavailable');
decodeCtx = new AudioCtx();
return decodeCtx;
}
function touch(id: string, buffer: AudioBuffer): void {
// Re-insert to push to the back of insertion order (Map is ordered by
// insertion, oldest first).
cache.delete(id);
cache.set(id, buffer);
if (cache.size > MAX_CACHE_ENTRIES) {
const first = cache.keys().next().value;
if (first !== undefined) cache.delete(first);
}
}
export async function preload(id: string): Promise<AudioBuffer | null> {
const existing = cache.get(id);
if (existing) {
touch(id, existing);
return existing;
}
const blob = await getSoundBlob(id);
if (!blob) return null;
const arrayBuffer = await blob.arrayBuffer();
// Clone into a fresh buffer because Safari's decodeAudioData transfers
// ownership on its legacy promise path.
const copy = arrayBuffer.slice(0);
const ctx = getDecodeCtx();
const buffer = await new Promise<AudioBuffer>((resolve, reject) => {
// The `(data, success, error)` callback form is the only one guaranteed
// on older WebKit. Modern browsers accept promise chains too.
const maybe = ctx.decodeAudioData(
copy,
(b) => resolve(b),
(e) => reject(e ?? new Error('decode failed')),
);
if (maybe && typeof (maybe as Promise<AudioBuffer>).then === 'function') {
(maybe as Promise<AudioBuffer>).then(resolve, reject);
}
});
touch(id, buffer);
return buffer;
}
export function invalidate(id: string): void {
cache.delete(id);
}
export function clearCache(): void {
cache.clear();
}
export interface PlayOptions {
/** Overrides entry.gain. Falls back to entry.gain when omitted. */
gain?: number;
/** Overlap mode: omit this flag to stop previous instance of the same id.
* Defaults to single-fire (id-keyed) so hotkey spamming doesn't stack. */
overlap?: boolean;
onEnded?: () => void;
}
/**
* Decode (if needed) and play the entry through the supplied pipeline.
* Returns the pipeline handle, or null if the pipeline is unavailable or
* the sound no longer exists.
*/
export async function playEntry(
pipeline: MicPipeline,
entry: SoundboardEntry,
opts: PlayOptions = {},
): Promise<PlayHandle | null> {
const buffer = await preload(entry.id);
if (!buffer) return null;
return pipeline.playBuffer(buffer, {
gain: opts.gain ?? entry.gain,
...(opts.overlap ? {} : { id: entry.id }),
...(opts.onEnded ? { onEnded: opts.onEnded } : {}),
});
}
+371
View File
@@ -0,0 +1,371 @@
// IndexedDB-backed soundboard.
//
// Two object stores:
// `sounds` — one record per user-added sound, keyed by uuid. Contains both
// metadata (name, category, hotkey, gain, order, timestamps) and
// the raw blob so decoding can pull everything in one get().
// `prefs` — single "prefs" record with global soundboard state (master
// volume, local monitor volume).
//
// CRUD helpers surface a SoundboardEntry shape without the blob so call sites
// that only need metadata (list views, hotkey registration) don't pull the
// audio payload into memory. `getSoundBlob(id)` fetches the blob on demand.
export interface SoundboardEntry {
id: string;
name: string;
mime: string;
size: number;
category: string | null;
hotkey: string | null;
gain: number; // 0..1
order: number; // ascending within (category, uncategorized) bucket
createdAt: number;
updatedAt: number;
}
export interface SoundboardPrefs {
masterGain: number; // 0..1
monitorGain: number; // 0..1
}
interface StoredSound extends SoundboardEntry {
blob: Blob;
}
export const DEFAULT_PREFS: SoundboardPrefs = {
masterGain: 0.8,
monitorGain: 0.5,
};
export const MAX_SOUND_BYTES = 5 * 1024 * 1024; // 5 MB per clip
// --- Change observer ------------------------------------------------------
// Synchronous tiny pub-sub so interested modules (hotkey registry, in-call
// panel, settings dialog) refresh when the manifest mutates in any tab.
type Listener = () => void;
const listeners = new Set<Listener>();
export function subscribeSoundboardChanges(l: Listener): () => void {
listeners.add(l);
return () => {
listeners.delete(l);
};
}
function notifyChange(): void {
for (const l of listeners) {
try {
l();
} catch (err: unknown) {
console.warn('soundboard change listener threw', err);
}
}
}
const DB_NAME = 'netralax-soundboard';
const DB_VERSION = 1;
const SOUNDS_STORE = 'sounds';
const PREFS_STORE = 'prefs';
const PREFS_KEY = 'prefs';
let dbPromise: Promise<IDBDatabase> | null = null;
function openDb(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(SOUNDS_STORE)) {
db.createObjectStore(SOUNDS_STORE, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(PREFS_STORE)) {
db.createObjectStore(PREFS_STORE);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error ?? new Error('indexedDB open failed'));
});
return dbPromise;
}
function tx<T>(
store: string,
mode: IDBTransactionMode,
fn: (s: IDBObjectStore) => IDBRequest<T> | void,
): Promise<T | undefined> {
return openDb().then(
(db) =>
new Promise<T | undefined>((resolve, reject) => {
const t = db.transaction(store, mode);
const s = t.objectStore(store);
let result: T | undefined = undefined;
const req = fn(s);
if (req) {
req.onsuccess = () => {
result = req.result;
};
req.onerror = () => reject(req.error);
}
t.oncomplete = () => resolve(result);
t.onerror = () => reject(t.error);
t.onabort = () => reject(t.error);
}),
);
}
function stripBlob(stored: StoredSound): SoundboardEntry {
const { blob: _blob, ...rest } = stored;
return rest;
}
function clamp01(v: number): number {
if (!Number.isFinite(v)) return 0;
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
function genId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return 'sb-' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
}
export async function listSounds(): Promise<SoundboardEntry[]> {
const db = await openDb();
return new Promise<SoundboardEntry[]>((resolve, reject) => {
const t = db.transaction(SOUNDS_STORE, 'readonly');
const s = t.objectStore(SOUNDS_STORE);
const req = s.getAll();
req.onsuccess = () => {
const all = (req.result as StoredSound[]).map(stripBlob);
// Stable sort: category name asc (null last), then order asc, then
// createdAt as a tie-breaker so freshly added sounds don't leapfrog.
all.sort((a, b) => {
const catCmp = compareCategory(a.category, b.category);
if (catCmp !== 0) return catCmp;
if (a.order !== b.order) return a.order - b.order;
return a.createdAt - b.createdAt;
});
resolve(all);
};
req.onerror = () => reject(req.error);
});
}
function compareCategory(a: string | null, b: string | null): number {
if (a === b) return 0;
if (a === null) return 1; // uncategorized last
if (b === null) return -1;
return a.localeCompare(b);
}
export async function listCategories(): Promise<string[]> {
const all = await listSounds();
const set = new Set<string>();
for (const s of all) {
if (s.category) set.add(s.category);
}
return Array.from(set).sort((a, b) => a.localeCompare(b));
}
async function nextOrderFor(category: string | null): Promise<number> {
const all = await listSounds();
let max = -1;
for (const s of all) {
if (s.category === category && s.order > max) max = s.order;
}
return max + 1;
}
export interface AddSoundInput {
file: File;
name?: string;
category?: string | null;
}
export async function addSound(input: AddSoundInput): Promise<SoundboardEntry> {
const { file, name, category = null } = input;
if (file.size === 0) throw new Error('empty file');
if (file.size > MAX_SOUND_BYTES) throw new Error('sound_too_large');
const mime = file.type || 'application/octet-stream';
if (!mime.startsWith('audio/')) throw new Error('sound_not_audio');
const now = Date.now();
const trimmedName = (name ?? file.name.replace(/\.[^.]+$/, '')).trim() || 'Untitled';
const entry: StoredSound = {
id: genId(),
name: trimmedName,
mime,
size: file.size,
category: category ?? null,
hotkey: null,
gain: 1,
order: await nextOrderFor(category ?? null),
createdAt: now,
updatedAt: now,
blob: file,
};
await tx(SOUNDS_STORE, 'readwrite', (s) => s.put(entry));
notifyChange();
return stripBlob(entry);
}
export interface UpdateSoundPatch {
name?: string;
category?: string | null;
hotkey?: string | null;
gain?: number;
}
export async function updateSound(
id: string,
patch: UpdateSoundPatch,
): Promise<SoundboardEntry> {
const db = await openDb();
return new Promise<SoundboardEntry>((resolve, reject) => {
const t = db.transaction(SOUNDS_STORE, 'readwrite');
const s = t.objectStore(SOUNDS_STORE);
const getReq = s.get(id);
getReq.onsuccess = () => {
const current = getReq.result as StoredSound | undefined;
if (!current) {
reject(new Error('sound_not_found'));
return;
}
const nextCategory = patch.category !== undefined ? patch.category : current.category;
const categoryChanged = nextCategory !== current.category;
const next: StoredSound = {
...current,
...(patch.name !== undefined ? { name: patch.name.trim() || current.name } : {}),
...(patch.category !== undefined ? { category: nextCategory } : {}),
...(patch.hotkey !== undefined ? { hotkey: patch.hotkey } : {}),
...(patch.gain !== undefined ? { gain: clamp01(patch.gain) } : {}),
updatedAt: Date.now(),
};
// When moving categories, append to the destination's end so the move
// doesn't collide with existing order values.
if (categoryChanged) {
const getAll = s.getAll();
getAll.onsuccess = () => {
const all = getAll.result as StoredSound[];
let max = -1;
for (const e of all) {
if (e.category === nextCategory && e.order > max) max = e.order;
}
next.order = max + 1;
const putReq = s.put(next);
putReq.onsuccess = () => {
notifyChange();
resolve(stripBlob(next));
};
putReq.onerror = () => reject(putReq.error);
};
getAll.onerror = () => reject(getAll.error);
return;
}
const putReq = s.put(next);
putReq.onsuccess = () => {
notifyChange();
resolve(stripBlob(next));
};
putReq.onerror = () => reject(putReq.error);
};
getReq.onerror = () => reject(getReq.error);
t.onerror = () => reject(t.error);
});
}
export async function deleteSound(id: string): Promise<void> {
await tx(SOUNDS_STORE, 'readwrite', (s) => s.delete(id));
notifyChange();
}
export async function reorderCategory(
category: string | null,
orderedIds: string[],
): Promise<void> {
const db = await openDb();
return new Promise<void>((resolve, reject) => {
const t = db.transaction(SOUNDS_STORE, 'readwrite');
const s = t.objectStore(SOUNDS_STORE);
let remaining = orderedIds.length;
if (remaining === 0) {
resolve();
return;
}
orderedIds.forEach((id, idx) => {
const getReq = s.get(id);
getReq.onsuccess = () => {
const current = getReq.result as StoredSound | undefined;
if (!current || current.category !== category) {
remaining--;
if (remaining === 0) resolve();
return;
}
const next: StoredSound = { ...current, order: idx, updatedAt: Date.now() };
const putReq = s.put(next);
putReq.onsuccess = () => {
remaining--;
if (remaining === 0) resolve();
};
putReq.onerror = () => reject(putReq.error);
};
getReq.onerror = () => reject(getReq.error);
});
t.oncomplete = () => notifyChange();
t.onerror = () => reject(t.error);
});
}
export async function getSoundBlob(id: string): Promise<Blob | null> {
const db = await openDb();
return new Promise<Blob | null>((resolve, reject) => {
const t = db.transaction(SOUNDS_STORE, 'readonly');
const s = t.objectStore(SOUNDS_STORE);
const req = s.get(id);
req.onsuccess = () => {
const cur = req.result as StoredSound | undefined;
resolve(cur ? cur.blob : null);
};
req.onerror = () => reject(req.error);
});
}
export async function getPrefs(): Promise<SoundboardPrefs> {
const raw = await tx<SoundboardPrefs | undefined>('prefs', 'readonly', (s) =>
s.get(PREFS_KEY) as IDBRequest<SoundboardPrefs | undefined>,
);
if (!raw) return { ...DEFAULT_PREFS };
return {
masterGain: clamp01(raw.masterGain ?? DEFAULT_PREFS.masterGain),
monitorGain: clamp01(raw.monitorGain ?? DEFAULT_PREFS.monitorGain),
};
}
export async function updatePrefs(patch: Partial<SoundboardPrefs>): Promise<SoundboardPrefs> {
const cur = await getPrefs();
const next: SoundboardPrefs = {
masterGain: patch.masterGain !== undefined ? clamp01(patch.masterGain) : cur.masterGain,
monitorGain: patch.monitorGain !== undefined ? clamp01(patch.monitorGain) : cur.monitorGain,
};
await tx('prefs', 'readwrite', (s) => s.put(next, PREFS_KEY));
return next;
}
// Convenience: checks whether `accelerator` is already bound to a sound other
// than `excludeId`. Pair with PTT check at the CallContext layer.
export async function isHotkeyTaken(
accelerator: string,
excludeId: string | null = null,
): Promise<boolean> {
const all = await listSounds();
for (const s of all) {
if (s.hotkey === accelerator && s.id !== excludeId) return true;
}
return false;
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import { supabase } from './supabase';
export interface LinkPreview {
url: string;
title: string | null;
description: string | null;
imageUrl: string | null;
siteName: string | null;
ok: boolean;
}
// In-memory cache keyed by URL — avoids re-invoking the edge function for
// the same URL within a session even if many bubbles reference it.
const cache = new Map<string, LinkPreview | null>();
const inflight = new Map<string, Promise<LinkPreview | null>>();
async function loadPreview(url: string): Promise<LinkPreview | null> {
if (cache.has(url)) return cache.get(url)!;
const existing = inflight.get(url);
if (existing) return existing;
const p = (async () => {
// First try the cache table directly (RLS allows authenticated reads).
// Works offline-first if the server has already fetched this URL.
// `link_previews` is a later migration — cast around stale generated types.
const { data: cached } = await (supabase as unknown as {
from: (t: string) => {
select: (cols: string) => {
eq: (col: string, val: string) => {
maybeSingle: () => Promise<{
data: {
url: string;
title: string | null;
description: string | null;
image_url: string | null;
site_name: string | null;
ok: boolean;
fetched_at: string;
} | null;
error: Error | null;
}>;
};
};
};
})
.from('link_previews')
.select('url, title, description, image_url, site_name, ok, fetched_at')
.eq('url', url)
.maybeSingle();
if (cached && cached.ok) {
const preview: LinkPreview = {
url: cached.url,
title: cached.title,
description: cached.description,
imageUrl: cached.image_url,
siteName: cached.site_name,
ok: cached.ok,
};
cache.set(url, preview);
return preview;
}
try {
const { data, error } = await supabase.functions.invoke('og-preview', {
body: { url },
});
if (error) throw error;
const preview = data as LinkPreview | null;
cache.set(url, preview && preview.ok ? preview : null);
return cache.get(url) ?? null;
} catch {
cache.set(url, null);
return null;
}
})();
inflight.set(url, p);
try {
return await p;
} finally {
inflight.delete(url);
}
}
export function useLinkPreview(url: string | null): LinkPreview | null {
const [preview, setPreview] = useState<LinkPreview | null>(() =>
url ? cache.get(url) ?? null : null,
);
useEffect(() => {
if (!url) {
setPreview(null);
return;
}
let cancelled = false;
void loadPreview(url).then((p) => {
if (!cancelled) setPreview(p);
});
return () => {
cancelled = true;
};
}, [url]);
return preview;
}
// Regex tuned for plain URLs inside message text. No markdown link syntax yet.
const URL_RE = /https?:\/\/[^\s<>"']+/i;
export function extractFirstUrl(text: string): string | null {
const m = URL_RE.exec(text);
return m?.[0] ?? null;
}
+31 -8
View File
@@ -3,10 +3,16 @@ import { useEffect, useState } from 'react';
import { supabase } from './supabase'; import { supabase } from './supabase';
// Subscribe to a single peer's presence_state via Supabase realtime. export interface PeerPresence {
// Returns null until the first row arrives, or when userId is undefined. state: PresenceState;
export function usePeerPresence(userId: string | undefined): PresenceState | null { statusMessage: string | null;
const [presence, setPresence] = useState<PresenceState | null>(null); }
// Subscribe to a single peer's presence_state + status_message via Supabase
// realtime. Returns null until the first row arrives, or when userId is
// undefined.
export function usePeerPresence(userId: string | undefined): PeerPresence | null {
const [presence, setPresence] = useState<PeerPresence | null>(null);
useEffect(() => { useEffect(() => {
if (!userId) { if (!userId) {
@@ -17,11 +23,15 @@ export function usePeerPresence(userId: string | undefined): PresenceState | nul
void supabase void supabase
.from('profiles') .from('profiles')
.select('presence_state') .select('presence_state, status_message')
.eq('user_id', userId) .eq('user_id', userId)
.maybeSingle() .maybeSingle()
.then(({ data }) => { .then(({ data }) => {
if (!cancelled) setPresence(data?.presence_state ?? null); if (cancelled || !data) return;
setPresence({
state: (data.presence_state as PresenceState | null) ?? 'offline',
statusMessage: data.status_message ?? null,
});
}); });
const channel = supabase const channel = supabase
@@ -35,8 +45,21 @@ export function usePeerPresence(userId: string | undefined): PresenceState | nul
filter: 'user_id=eq.' + userId, filter: 'user_id=eq.' + userId,
}, },
(payload: { new: Record<string, unknown> }) => { (payload: { new: Record<string, unknown> }) => {
const next = payload.new['presence_state']; const nextState = payload.new['presence_state'];
if (typeof next === 'string') setPresence(next as PresenceState); const nextMsg = payload.new['status_message'];
setPresence((prev) => {
const state =
typeof nextState === 'string'
? (nextState as PresenceState)
: prev?.state ?? 'offline';
const statusMessage =
nextMsg === null
? null
: typeof nextMsg === 'string'
? nextMsg
: prev?.statusMessage ?? null;
return { state, statusMessage };
});
}, },
) )
.subscribe(); .subscribe();
+159 -16
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { ConversationHeader } from '../components/ConversationHeader'; import { ConversationHeader } from '../components/ConversationHeader';
import { EmojiPicker } from '../components/EmojiPicker';
import { ForwardDialog } from '../components/ForwardDialog'; import { ForwardDialog } from '../components/ForwardDialog';
import { GroupInfoPanel } from '../components/GroupInfoPanel'; import { GroupInfoPanel } from '../components/GroupInfoPanel';
import { import {
@@ -15,11 +16,13 @@ import {
PlusIcon, PlusIcon,
ReplyIcon, ReplyIcon,
SearchIcon, SearchIcon,
SmileIcon,
SpinnerIcon, SpinnerIcon,
XIcon, XIcon,
} from '../components/icons'; } from '../components/icons';
import { InCallPanel } from '../components/InCallPanel'; import { InCallPanel } from '../components/InCallPanel';
import { IncomingCallPanel } from '../components/IncomingCallPanel'; import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { MentionAutocomplete } from '../components/MentionAutocomplete';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble'; import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
import { TypingIndicator } from '../components/TypingIndicator'; import { TypingIndicator } from '../components/TypingIndicator';
import { VoiceRecorder } from '../components/VoiceRecorder'; import { VoiceRecorder } from '../components/VoiceRecorder';
@@ -141,6 +144,11 @@ export function ConversationPage() {
const [searchDateTo, setSearchDateTo] = useState<string>(''); const [searchDateTo, setSearchDateTo] = useState<string>('');
const [highlightedId, setHighlightedId] = useState<string | null>(null); const [highlightedId, setHighlightedId] = useState<string | null>(null);
const [displayCount, setDisplayCount] = useState<number>(150); const [displayCount, setDisplayCount] = useState<number>(150);
const [isDraggingFile, setIsDraggingFile] = useState(false);
const [mentionState, setMentionState] = useState<
{ query: string; start: number } | null
>(null);
const [emojiOpen, setEmojiOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const loadMoreSentinelRef = useRef<HTMLDivElement>(null); const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -345,13 +353,9 @@ export function ConversationPage() {
} }
} }
function handleFilesChosen(list: FileList | null) { function ingestFiles(files: File[]) {
if (!list) return;
const next: File[] = []; const next: File[] = [];
for (let i = 0; i < list.length; i++) { for (const f of files) {
const f = list[i];
if (!f) continue;
if (!f.type.startsWith('image/')) continue;
if (f.size > 10 * 1024 * 1024) { if (f.size > 10 * 1024 * 1024) {
setSendError('Datei zu groß (max 10 MB)'); setSendError('Datei zu groß (max 10 MB)');
continue; continue;
@@ -361,6 +365,11 @@ export function ConversationPage() {
setAttachments((prev) => [...prev, ...next].slice(0, 4)); setAttachments((prev) => [...prev, ...next].slice(0, 4));
} }
function handleFilesChosen(list: FileList | null) {
if (!list) return;
ingestFiles(Array.from(list));
}
const { state: callState } = useCall(); const { state: callState } = useCall();
// Hide the chat header while this conversation hosts an active call — the // Hide the chat header while this conversation hosts an active call — the
// call topbar inside the dock already shows the channel name + duration, // call topbar inside the dock already shows the channel name + duration,
@@ -374,7 +383,31 @@ export function ConversationPage() {
callState.kind === 'incoming' && callState.conversationId === id; callState.kind === 'incoming' && callState.conversationId === id;
return ( return (
<div className="relative flex h-full flex-col"> <div
className="relative flex h-full flex-col"
onDragEnter={(e) => {
if (e.dataTransfer?.types.includes('Files')) {
e.preventDefault();
setIsDraggingFile(true);
}
}}
onDragOver={(e) => {
if (e.dataTransfer?.types.includes('Files')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
}}
onDragLeave={(e) => {
// leave fires on child enter too; only clear when leaving the page container.
if (e.currentTarget === e.target) setIsDraggingFile(false);
}}
onDrop={(e) => {
if (!e.dataTransfer?.files?.length) return;
e.preventDefault();
setIsDraggingFile(false);
ingestFiles(Array.from(e.dataTransfer.files));
}}
>
{!callHereActive && ( {!callHereActive && (
<ConversationHeader <ConversationHeader
conversation={conversation} conversation={conversation}
@@ -530,6 +563,17 @@ export function ConversationPage() {
members={conversation?.members ?? []} members={conversation?.members ?? []}
/> />
{isDraggingFile && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center rounded-lg border-2 border-dashed border-accent/60 bg-accent/10 backdrop-blur-sm"
>
<div className="rounded-xl border border-accent/40 bg-surface-3/90 px-4 py-3 text-sm font-semibold text-fg shadow-xl">
Datei hier ablegen zum Anhängen
</div>
</div>
)}
<form onSubmit={handleSend} className="border-t border-line bg-surface-3 p-4"> <form onSubmit={handleSend} className="border-t border-line bg-surface-3 p-4">
{sendError && ( {sendError && (
<div className="mb-2"> <div className="mb-2">
@@ -583,11 +627,36 @@ export function ConversationPage() {
</div> </div>
)} )}
<div className="flex items-end gap-2"> <div className="relative flex items-end gap-2">
{isGroup && mentionState && conversation && (
<MentionAutocomplete
members={conversation.members}
query={mentionState.query}
excludeUserId={myId}
onSelect={(username) => {
// Replace `@{query}` at `start..caret` with `@{username} `.
const start = mentionState.start;
const before = text.slice(0, start);
const afterCaret = text.slice(start + 1 + mentionState.query.length);
const inserted = '@' + username + ' ';
const next = before + inserted + afterCaret;
setText(next);
setMentionState(null);
// Restore caret position after inserted mention.
const caret = (before + inserted).length;
requestAnimationFrame(() => {
const el = composerRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(caret, caret);
});
}}
onClose={() => setMentionState(null)}
/>
)}
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/*"
multiple multiple
className="hidden" className="hidden"
onChange={(e) => handleFilesChosen(e.target.files)} onChange={(e) => handleFilesChosen(e.target.files)}
@@ -595,12 +664,41 @@ export function ConversationPage() {
<button <button
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
aria-label="Bild anhängen" aria-label="Datei anhängen"
title="Bild anhängen" title="Datei anhängen"
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50" className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
> >
<PlusIcon className="h-4 w-4" /> <PlusIcon className="h-4 w-4" />
</button> </button>
<div className="relative">
<button
type="button"
data-emoji-trigger
onClick={() => setEmojiOpen((v) => !v)}
aria-label="Emoji einfügen"
title="Emoji einfügen"
aria-expanded={emojiOpen}
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-surface-2 text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
<SmileIcon className="h-4 w-4" />
</button>
<EmojiPicker
open={emojiOpen}
onPick={(emoji) => {
const el = composerRef.current;
const caret = el?.selectionStart ?? text.length;
const next = text.slice(0, caret) + emoji + text.slice(caret);
setText(next);
requestAnimationFrame(() => {
if (!el) return;
el.focus();
const pos = caret + emoji.length;
el.setSelectionRange(pos, pos);
});
}}
onClose={() => setEmojiOpen(false)}
/>
</div>
<VoiceRecorder <VoiceRecorder
disabled={sending} disabled={sending}
onComplete={async (file) => { onComplete={async (file) => {
@@ -617,8 +715,26 @@ export function ConversationPage() {
ref={composerRef} ref={composerRef}
value={text} value={text}
onChange={(e) => { onChange={(e) => {
setText(e.target.value); const next = e.target.value;
if (e.target.value.length > 0) notifyTyping(); setText(next);
if (next.length > 0) notifyTyping();
// Detect an in-progress @mention: find the last '@' before the
// caret, with no whitespace between it and the caret. If
// present, open the autocomplete with the partial query.
const caret = e.target.selectionStart ?? next.length;
const before = next.slice(0, caret);
const atIdx = before.lastIndexOf('@');
if (
atIdx >= 0 &&
(atIdx === 0 || /\s/.test(before[atIdx - 1] ?? ''))
) {
const q = before.slice(atIdx + 1);
if (!/\s/.test(q)) {
setMentionState({ query: q, start: atIdx });
return;
}
}
setMentionState(null);
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
@@ -626,6 +742,21 @@ export function ConversationPage() {
void handleSend(); void handleSend();
} }
}} }}
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
const pics: File[] = [];
for (const it of Array.from(items)) {
if (it.kind === 'file') {
const f = it.getAsFile();
if (f && f.type.startsWith('image/')) pics.push(f);
}
}
if (pics.length > 0) {
e.preventDefault();
ingestFiles(pics);
}
}}
rows={1} rows={1}
placeholder="Nachricht schreiben…" placeholder="Nachricht schreiben…"
className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30" className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
@@ -885,18 +1016,30 @@ function Banner({ children }: { children: React.ReactNode }) {
} }
function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => void }) { function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => void }) {
const isImage = file.type.startsWith('image/');
const [url, setUrl] = useState<string | null>(null); const [url, setUrl] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!isImage) return;
const u = URL.createObjectURL(file); const u = URL.createObjectURL(file);
setUrl(u); setUrl(u);
return () => URL.revokeObjectURL(u); return () => URL.revokeObjectURL(u);
}, [file]); }, [file, isImage]);
return ( return (
<div className="relative overflow-hidden rounded-lg border border-line bg-surface-2"> <div className="relative overflow-hidden rounded-lg border border-line bg-surface-2">
{url ? ( {isImage && url ? (
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" /> <img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
) : ( ) : (
<div className="h-20 w-20" /> <div className="flex h-20 w-32 flex-col justify-center gap-0.5 px-2 text-[10px]">
<span className="truncate font-semibold text-fg" title={file.name}>
{file.name || 'Datei'}
</span>
<span className="text-fg-muted">
{file.type || 'unbekannt'}
</span>
<span className="text-fg-muted">
{(file.size / 1024).toFixed(0)} KB
</span>
</div>
)} )}
<button <button
type="button" type="button"
+12
View File
@@ -9,6 +9,8 @@ import { useTranslation } from 'react-i18next';
import { Avatar } from '../components/Avatar'; import { Avatar } from '../components/Avatar';
import { BackupExportDialog } from '../components/BackupExportDialog'; import { BackupExportDialog } from '../components/BackupExportDialog';
import { RingtoneSettings } from '../components/RingtoneSettings';
import { SoundboardSettings } from '../components/SoundboardSettings';
import { LockIcon } from '../components/icons'; import { LockIcon } from '../components/icons';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext'; import { useCall } from '../context/CallContext';
@@ -141,6 +143,16 @@ export function SettingsPage() {
/> />
</Section> </Section>
{/* Ringtone (incoming custom) */}
<Section title={t('app:settings.section_ringtone', { defaultValue: 'Klingelton' })}>
<RingtoneSettings disabled={busy} />
</Section>
{/* Soundboard */}
<Section title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}>
<SoundboardSettings />
</Section>
{/* Voice / Push-to-Talk + Audio Quality + E2EE */} {/* Voice / Push-to-Talk + Audio Quality + E2EE */}
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}> <Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
<AudioDeviceControls /> <AudioDeviceControls />
+57 -2
View File
@@ -15,7 +15,8 @@
"idle": "Abwesend", "idle": "Abwesend",
"dnd": "Nicht stören", "dnd": "Nicht stören",
"invisible": "Unsichtbar", "invisible": "Unsichtbar",
"offline": "Offline" "offline": "Offline",
"status_placeholder": "Status setzen…"
}, },
"chats": { "chats": {
"empty_title": "Noch keine Unterhaltungen", "empty_title": "Noch keine Unterhaltungen",
@@ -194,7 +195,61 @@
"allow_dms_strangers_hint": "Wenn aus, können dich nur Freunde direkt anschreiben.", "allow_dms_strangers_hint": "Wenn aus, können dich nur Freunde direkt anschreiben.",
"this_device": "Dieses Gerät", "this_device": "Dieses Gerät",
"danger_zone": "Gefahrenzone", "danger_zone": "Gefahrenzone",
"sign_out": "Abmelden" "sign_out": "Abmelden",
"section_ringtone": "Klingelton",
"ringtone_incoming": "Eingehender Anruf",
"ringtone_default_active": "Standard-Klingelton (Doppelton)",
"ringtone_custom_active": "{{name}} · {{size}} MB",
"ringtone_upload": "Hochladen",
"ringtone_replace": "Ersetzen",
"ringtone_preview": "Vorhören",
"ringtone_stop": "Stop",
"ringtone_reset": "Zurücksetzen",
"ringtone_hint": "MP3, WAV, OGG oder M4A bis 2 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.",
"ringtone_error_too_large": "Datei zu groß (max {{max}} MB).",
"ringtone_error_not_audio": "Nur Audio-Dateien werden unterstützt.",
"ringtone_error_generic": "Ringtone konnte nicht gespeichert werden.",
"ringtone_error_play": "Ringtone konnte nicht abgespielt werden.",
"section_soundboard": "Soundboard"
},
"soundboard": {
"summary_title": "Deine Sounds",
"summary_empty": "Noch keine Sounds vorhanden.",
"summary_counts": "{{sounds}} Sounds · {{categories}} Kategorien · {{hotkeys}} mit Hotkey",
"settings_hint": "Hotkeys sind optional. Sounds lassen sich auch während eines Anrufs direkt im UI abspielen.",
"manage": "Verwalten",
"manager_title": "Soundboard verwalten",
"manager_hint": "Beliebig viele Sounds, kein Hotkey nötig. Hotkeys feuern nur während eines Anrufs.",
"add": "Sound hinzufügen",
"empty": "Noch keine Sounds. Lade oben welche hoch.",
"category_placeholder": "Kategorie…",
"category_uncategorized": "(Ohne Kategorie)",
"hotkey_capture": "Hotkey binden (Esc = abbrechen)",
"hotkey_press": "Drücke…",
"hotkey_none": "Kein Hotkey",
"hotkey_clear": "Hotkey entfernen",
"hotkey_conflict_ptt": "Konflikt mit Push-to-Talk.",
"hotkey_conflict_sound": "Bereits von \"{{name}}\" belegt.",
"gain_title": "Lautstärke",
"preview": "Vorhören",
"preview_stop": "Stop",
"move_up": "Nach oben",
"move_down": "Nach unten",
"delete": "Löschen",
"delete_confirm": "Sound löschen?",
"error_too_large": "Datei zu groß (max {{max}} MB).",
"error_not_audio": "Nur Audio-Dateien werden unterstützt.",
"error_generic": "Sound konnte nicht gespeichert werden.",
"toggle": "Soundboard",
"panel_title": "Soundboard",
"panel_close": "Schließen",
"panel_search": "Suche…",
"panel_empty": "Keine Sounds gespeichert. Füge welche in den Einstellungen hinzu.",
"panel_no_matches": "Keine Treffer.",
"panel_master": "Master",
"panel_monitor": "Mithören",
"panel_stop_all": "Alle stoppen",
"pad_stop": "Stoppen"
}, },
"update": { "update": {
"available": "Update verfügbar", "available": "Update verfügbar",
+57 -2
View File
@@ -15,7 +15,8 @@
"idle": "Idle", "idle": "Idle",
"dnd": "Do not disturb", "dnd": "Do not disturb",
"invisible": "Invisible", "invisible": "Invisible",
"offline": "Offline" "offline": "Offline",
"status_placeholder": "Set status…"
}, },
"chats": { "chats": {
"empty_title": "No conversations yet", "empty_title": "No conversations yet",
@@ -194,7 +195,61 @@
"allow_dms_strangers_hint": "When off, only friends can DM you.", "allow_dms_strangers_hint": "When off, only friends can DM you.",
"this_device": "This device", "this_device": "This device",
"danger_zone": "Danger zone", "danger_zone": "Danger zone",
"sign_out": "Sign out" "sign_out": "Sign out",
"section_ringtone": "Ringtone",
"ringtone_incoming": "Incoming call",
"ringtone_default_active": "Default ringtone (double beep)",
"ringtone_custom_active": "{{name}} · {{size}} MB",
"ringtone_upload": "Upload",
"ringtone_replace": "Replace",
"ringtone_preview": "Preview",
"ringtone_stop": "Stop",
"ringtone_reset": "Reset",
"ringtone_hint": "MP3, WAV, OGG or M4A up to 2 MB. Incoming calls only — outgoing keeps the default.",
"ringtone_error_too_large": "File too large (max {{max}} MB).",
"ringtone_error_not_audio": "Only audio files are supported.",
"ringtone_error_generic": "Could not save the ringtone.",
"ringtone_error_play": "Could not play the ringtone.",
"section_soundboard": "Soundboard"
},
"soundboard": {
"summary_title": "Your sounds",
"summary_empty": "No sounds stored yet.",
"summary_counts": "{{sounds}} sounds · {{categories}} categories · {{hotkeys}} with hotkey",
"settings_hint": "Hotkeys are optional. Sounds can also be played from the in-call panel.",
"manage": "Manage",
"manager_title": "Manage soundboard",
"manager_hint": "Any number of sounds, hotkeys optional. Hotkeys fire only while in a call.",
"add": "Add sound",
"empty": "No sounds yet. Upload some above.",
"category_placeholder": "Category…",
"category_uncategorized": "(Uncategorized)",
"hotkey_capture": "Bind hotkey (Esc = cancel)",
"hotkey_press": "Press…",
"hotkey_none": "No hotkey",
"hotkey_clear": "Clear hotkey",
"hotkey_conflict_ptt": "Conflicts with Push-to-Talk.",
"hotkey_conflict_sound": "Already bound to \"{{name}}\".",
"gain_title": "Volume",
"preview": "Preview",
"preview_stop": "Stop",
"move_up": "Move up",
"move_down": "Move down",
"delete": "Delete",
"delete_confirm": "Delete this sound?",
"error_too_large": "File too large (max {{max}} MB).",
"error_not_audio": "Only audio files are supported.",
"error_generic": "Could not save the sound.",
"toggle": "Soundboard",
"panel_title": "Soundboard",
"panel_close": "Close",
"panel_search": "Search…",
"panel_empty": "No sounds stored. Add some from settings.",
"panel_no_matches": "No matches.",
"panel_master": "Master",
"panel_monitor": "Monitor",
"panel_stop_all": "Stop all",
"pad_stop": "Stop"
}, },
"update": { "update": {
"available": "Update available", "available": "Update available",
+213
View File
@@ -0,0 +1,213 @@
// Edge function — fetch OpenGraph/meta tags for a URL and cache the result.
//
// POST /og-preview { "url": "..." }
//
// Returns: { url, title, description, imageUrl, siteName, ok }
//
// Caching: rows live in public.link_previews keyed by URL. Repeat calls
// return the cached row without re-fetching (unless older than MAX_CACHE_AGE_MS).
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? '';
const SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '';
const MAX_CACHE_AGE_MS = 7 * 24 * 3600 * 1000; // 7 days
const FETCH_TIMEOUT_MS = 6_000;
const MAX_HTML_BYTES = 1_000_000;
const corsHeaders = {
'access-control-allow-origin': '*',
'access-control-allow-headers': 'authorization, x-client-info, apikey, content-type',
'access-control-allow-methods': 'POST, OPTIONS',
};
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json', ...corsHeaders },
});
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders });
if (req.method !== 'POST') return json({ error: 'method' }, 405);
let body: { url?: string };
try {
body = await req.json();
} catch {
return json({ error: 'bad json' }, 400);
}
const url = body.url;
if (!url || typeof url !== 'string') return json({ error: 'missing url' }, 400);
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return json({ error: 'invalid url' }, 400);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return json({ error: 'unsupported scheme' }, 400);
}
const client = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
// Cache lookup.
const { data: cached } = await client
.from('link_previews')
.select('*')
.eq('url', url)
.maybeSingle();
if (
cached &&
Date.now() - new Date(cached.fetched_at).getTime() < MAX_CACHE_AGE_MS
) {
return json(toClientShape(cached));
}
// Fetch HTML with timeout.
let html = '';
let ok = true;
let error: string | null = null;
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
const res = await fetch(url, {
signal: ctrl.signal,
headers: {
'user-agent': 'Mozilla/5.0 (compatible; LinkPreviewBot/1.0)',
accept: 'text/html,application/xhtml+xml',
},
redirect: 'follow',
});
clearTimeout(timer);
if (!res.ok) throw new Error('status ' + res.status);
const ct = res.headers.get('content-type') ?? '';
if (!ct.includes('html')) throw new Error('not html');
const reader = res.body?.getReader();
if (!reader) throw new Error('no body');
const decoder = new TextDecoder();
let total = 0;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
if (!value) continue;
total += value.byteLength;
html += decoder.decode(value, { stream: true });
if (total >= MAX_HTML_BYTES) {
await reader.cancel();
break;
}
}
} catch (err) {
ok = false;
error = err instanceof Error ? err.message : 'fetch failed';
}
const meta = ok ? parseMeta(html, parsed) : null;
// Upsert cache row so even failures cache briefly.
const row = {
url,
title: meta?.title ?? null,
description: meta?.description ?? null,
image_url: meta?.imageUrl ?? null,
site_name: meta?.siteName ?? null,
ok,
error,
fetched_at: new Date().toISOString(),
};
await client.from('link_previews').upsert(row);
return json(toClientShape(row));
});
interface MetaRow {
url: string;
title: string | null;
description: string | null;
image_url: string | null;
site_name: string | null;
ok: boolean;
}
function toClientShape(row: MetaRow) {
return {
url: row.url,
title: row.title,
description: row.description,
imageUrl: row.image_url,
siteName: row.site_name,
ok: row.ok,
};
}
function parseMeta(html: string, baseUrl: URL): {
title: string | null;
description: string | null;
imageUrl: string | null;
siteName: string | null;
} {
// Cheap regex-based parser — no DOM in Deno runtime without extra deps.
// Extracts <meta property|name="..."> values + <title>.
const getMeta = (...keys: string[]): string | null => {
for (const key of keys) {
const re = new RegExp(
'<meta[^>]+(?:property|name)=[\'"]' +
escapeRegex(key) +
'[\'"][^>]+content=[\'"]([^\'"]+)[\'"]',
'i',
);
const m = re.exec(html);
if (m?.[1]) return decodeHtmlEntities(m[1]);
const re2 = new RegExp(
'<meta[^>]+content=[\'"]([^\'"]+)[\'"][^>]+(?:property|name)=[\'"]' +
escapeRegex(key) +
'[\'"]',
'i',
);
const m2 = re2.exec(html);
if (m2?.[1]) return decodeHtmlEntities(m2[1]);
}
return null;
};
const title =
getMeta('og:title', 'twitter:title') ??
/<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1]?.trim() ??
null;
const description = getMeta('og:description', 'twitter:description', 'description');
const imageRaw = getMeta('og:image', 'twitter:image', 'twitter:image:src');
const siteName = getMeta('og:site_name') ?? baseUrl.hostname;
let imageUrl: string | null = null;
if (imageRaw) {
try {
imageUrl = new URL(imageRaw, baseUrl).toString();
} catch {
imageUrl = null;
}
}
return {
title: title ? decodeHtmlEntities(title) : null,
description,
imageUrl,
siteName,
};
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function decodeHtmlEntities(s: string): string {
return s
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'");
}
@@ -0,0 +1,29 @@
-- ============================================================================
-- Cached OpenGraph previews. The edge function `og-preview` writes rows
-- server-side (service-role). Clients read via RLS for any authenticated
-- user. Rows are keyed by URL so hits across conversations deduplicate.
-- ============================================================================
create table public.link_previews (
url text primary key,
title text,
description text,
image_url text,
site_name text,
fetched_at timestamptz not null default now(),
-- Absence of metadata (404, unreachable, parse failure) still produces a
-- cache row so we don't spam the fetcher. `ok = false` signals the UI to
-- hide the preview.
ok boolean not null default true,
error text
);
create index link_previews_fetched_at_idx on public.link_previews (fetched_at);
alter table public.link_previews enable row level security;
create policy link_previews_select_authenticated on public.link_previews
for select to authenticated
using (true);
-- No INSERT/UPDATE policy: writes flow exclusively through the edge function
-- which authenticates with the service-role key.