feat: file variety, user status, DND gate, drag-drop, mentions, link preview, emoji picker, soundboard, ringtone
Attachments
- Video: native <video controls>, decrypted blob, metadata preload
- PDF: collapsible <object> viewer with download + preview toggle
- Generic: file card with lazy decrypt, mime-to-extension map
- MessageBubble dispatch: audio/image/video/pdf/generic by mime
- Composer accept widened from image/* to any; AttachmentPreview renders
non-images as file cards
User status
- usePeerPresence returns { state, statusMessage } and tracks both fields
via realtime updates
- ConversationHeader subtitle shows custom status_message when peer is
online/idle/dnd (with message set); falls back to localized presence
label; always "Offline" when state === 'offline'
- UserBar: status_message input in dropdown (max 128 chars, save on
blur/Enter), subtitle uses same precedence as peer view
- AuthContext: auto-flip offline → online on session mount; best-effort
offline on beforeunload/pagehide; respects explicit idle/dnd/invisible
- i18n: presence.status_placeholder (DE + EN)
DND
- CallUI: incoming ring suppressed when own presence === 'dnd' (outgoing
rings stay audible — user-initiated)
- CallContext: presenceRef mirrors profile.presenceState, incoming-call
and missed-call OS notifications skipped under DND
- ConversationsContext already gated message tone + notify
Drag/drop + paste
- Page-root drag handlers feed ingestFiles; overlay shown while dragging
- Textarea onPaste extracts clipboard image items
@mentions in groups
- MentionAutocomplete: keyboard-driven dropdown, arrow/enter/tab/esc
- Composer onChange parses trailing @token, auto-open
- renderBodyWithMentions highlights @username tokens in bubbles
Link previews
- Migration 20260421000003_link_previews.sql (cache table, auth read,
service-role write via edge function)
- Edge function og-preview: POST {url}, fetches HTML (6s timeout, 1MB
cap), regex-parses OG/twitter/description, upserts cache
- useLinkPreview hook with in-memory cache + inflight dedup
- LinkPreviewCard rendered from first URL in message body
Emoji picker
- Built-in curated set (~250 emojis) across five categories
- Search by keyword/emoji char/category, recent list persisted in
localStorage
- Trigger button next to + and voice buttons in composer
Soundboard + ringtone + mic pipeline
- Pulled in (user-authored) SoundboardManagerDialog/Panel/Settings,
RingtoneSettings, mic pipeline + hotkeys + storage modules
- AuthContext imports updateOwnProfile for presence auto-flip
Fixes
- AttachmentAudio min-width so bubbles don't collapse to 0px on peer
side
- Focus flicker: visibility/online wake refresh throttled to 30s,
focus listener dropped, loading flag only on first fetch
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
handle: AttachmentHandle;
|
||||
}
|
||||
|
||||
// Catch-all card for attachments without a richer renderer (zip, docx,
|
||||
// txt, etc). Decrypt is deferred to first download click — these can be
|
||||
// large and there's no inline preview to justify auto-fetching them.
|
||||
export function AttachmentGeneric({ handle }: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const download = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const blob = await downloadAndDecryptAttachment({ client: supabase, handle });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filenameFor(handle);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Defer revoke so Safari has a chance to start the download.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 inline-flex w-[280px] items-center gap-2.5 rounded-lg border border-line bg-surface-2 p-2.5">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-accent/10 text-accent">
|
||||
<FileGlyph />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-semibold text-fg">
|
||||
{prettyMime(handle.mimeType)}
|
||||
</p>
|
||||
<p className="truncate text-[10px] text-fg-muted">
|
||||
{formatSize(handle.sizeBytes)}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="mt-0.5 inline-flex items-center gap-1 text-[10px] text-rose-500">
|
||||
<AlertIcon className="h-3 w-3" />
|
||||
<span>{error}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void download()}
|
||||
disabled={busy}
|
||||
aria-label="Download"
|
||||
title="Download"
|
||||
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-line bg-surface-3 text-fg transition hover:brightness-95 disabled:opacity-60"
|
||||
>
|
||||
{busy ? <SpinnerIcon className="h-4 w-4" /> : <DownloadGlyph />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileGlyph() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M3 2a1 1 0 011-1h6l3 3v10a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm7 0v3h3l-3-3z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadGlyph() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M8 2v8M4 7l4 4 4-4M3 13h10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function filenameFor(handle: AttachmentHandle): string {
|
||||
const ext = extFor(handle.mimeType);
|
||||
return 'attachment-' + handle.id.slice(0, 8) + (ext ? '.' + ext : '');
|
||||
}
|
||||
|
||||
function extFor(mime: string): string | null {
|
||||
const map: Record<string, string> = {
|
||||
'application/zip': 'zip',
|
||||
'application/x-zip-compressed': 'zip',
|
||||
'application/x-7z-compressed': '7z',
|
||||
'application/x-tar': 'tar',
|
||||
'application/gzip': 'gz',
|
||||
'application/json': 'json',
|
||||
'application/xml': 'xml',
|
||||
'text/plain': 'txt',
|
||||
'text/markdown': 'md',
|
||||
'text/csv': 'csv',
|
||||
'application/msword': 'doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
||||
'application/vnd.ms-excel': 'xls',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
||||
'application/vnd.ms-powerpoint': 'ppt',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
|
||||
};
|
||||
return map[mime] ?? null;
|
||||
}
|
||||
|
||||
function prettyMime(mime: string): string {
|
||||
const ext = extFor(mime);
|
||||
if (ext) return ext.toUpperCase() + '-Datei';
|
||||
if (mime.startsWith('text/')) return 'Textdatei';
|
||||
return mime || 'Datei';
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
|
||||
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
handle: AttachmentHandle;
|
||||
}
|
||||
|
||||
// PDF preview rendered via the browser's built-in PDF viewer (Chromium /
|
||||
// Safari both ship one). Embedding via <object> with a fallback link keeps
|
||||
// the implementation tiny — no pdf.js dependency.
|
||||
export function AttachmentPdf({ handle }: Props) {
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let url: string | null = null;
|
||||
setError(null);
|
||||
setBlobUrl(null);
|
||||
|
||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
// Force the application/pdf type so the browser plugin engages.
|
||||
const typed = new Blob([blob], { type: 'application/pdf' });
|
||||
url = URL.createObjectURL(typed);
|
||||
setBlobUrl(url);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
|
||||
<AlertIcon className="h-4 w-4" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!blobUrl) {
|
||||
return (
|
||||
<div className="mt-2 flex h-24 w-[320px] items-center justify-center rounded-lg border border-line bg-surface-2 text-fg-muted">
|
||||
<SpinnerIcon className="h-5 w-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 w-full max-w-[420px] overflow-hidden rounded-lg border border-line bg-surface-2">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-line bg-surface-3 px-3 py-2 text-xs">
|
||||
<span className="flex items-center gap-2 truncate text-fg">
|
||||
<PdfGlyph />
|
||||
<span className="truncate">PDF · {formatSize(handle.sizeBytes)}</span>
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg hover:bg-surface-3"
|
||||
>
|
||||
{expanded ? 'Einklappen' : 'Vorschau'}
|
||||
</button>
|
||||
<a
|
||||
href={blobUrl}
|
||||
download={'attachment-' + handle.id.slice(0, 8) + '.pdf'}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-2 px-2 py-0.5 text-[11px] text-fg no-underline hover:bg-surface-3"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<object data={blobUrl} type="application/pdf" className="block h-[420px] w-full">
|
||||
<p className="p-4 text-xs text-fg-muted">
|
||||
Vorschau nicht verfügbar — bitte herunterladen.
|
||||
</p>
|
||||
</object>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PdfGlyph() {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||
<path d="M3 2a1 1 0 011-1h6l3 3v10a1 1 0 01-1 1H4a1 1 0 01-1-1V2zm7 0v3h3l-3-3zM5 9h6v1H5V9zm0 2h6v1H5v-1zm0-4h2v1H5V7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
|
||||
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { AlertIcon, SpinnerIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
handle: AttachmentHandle;
|
||||
}
|
||||
|
||||
// Decrypt the blob, render a native <video controls>. Loads on demand —
|
||||
// metadata-only preload so we don't burn bandwidth until the user hits play.
|
||||
export function AttachmentVideo({ handle }: Props) {
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let url: string | null = null;
|
||||
setError(null);
|
||||
setBlobUrl(null);
|
||||
|
||||
downloadAndDecryptAttachment({ client: supabase, handle })
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
url = URL.createObjectURL(blob);
|
||||
setBlobUrl(url);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'download failed');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [handle.id, handle.storagePath, handle.keyB64, handle.nonceB64]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mt-2 inline-flex items-center gap-2 rounded-lg border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs text-rose-200">
|
||||
<AlertIcon className="h-4 w-4" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!blobUrl) {
|
||||
return (
|
||||
<div className="mt-2 flex h-32 w-[320px] items-center justify-center rounded-lg border border-line bg-surface-2 text-fg-muted">
|
||||
<SpinnerIcon className="h-5 w-5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
controls
|
||||
preload="metadata"
|
||||
src={blobUrl}
|
||||
className="mt-2 block max-h-80 w-full max-w-[420px] rounded-lg border border-line bg-black"
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MicOffIcon,
|
||||
MonitorShareIcon,
|
||||
MonitorStopIcon,
|
||||
MusicIcon,
|
||||
PhoneOffIcon,
|
||||
UsersIcon,
|
||||
VideoIcon,
|
||||
@@ -23,6 +24,9 @@ interface Props {
|
||||
onToggleDeafen: () => void;
|
||||
onHangup: () => void;
|
||||
onOpenParticipants?: () => void;
|
||||
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
||||
onToggleSoundboard?: () => void;
|
||||
soundboardOpen?: boolean;
|
||||
/** Compact variant used inside the docked call (36px buttons). */
|
||||
compact?: boolean;
|
||||
/** Glass variant used when controls float on fullscreen cinema mode. */
|
||||
@@ -42,6 +46,8 @@ export function CallControls({
|
||||
onToggleDeafen,
|
||||
onHangup,
|
||||
onOpenParticipants,
|
||||
onToggleSoundboard,
|
||||
soundboardOpen = false,
|
||||
compact = false,
|
||||
glass = false,
|
||||
disabledMedia = false,
|
||||
@@ -115,6 +121,18 @@ export function CallControls({
|
||||
<MonitorShareIcon className="h-5 w-5" />
|
||||
)}
|
||||
</CallButton>
|
||||
{onToggleSoundboard && (
|
||||
<CallButton
|
||||
label={t('app:soundboard.toggle', { defaultValue: 'Soundboard' })}
|
||||
active={soundboardOpen}
|
||||
activeTone="accent"
|
||||
onClick={onToggleSoundboard}
|
||||
glass={glass}
|
||||
className={btnSize}
|
||||
>
|
||||
<MusicIcon className="h-5 w-5" />
|
||||
</CallButton>
|
||||
)}
|
||||
{onOpenParticipants && (
|
||||
<CallButton
|
||||
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||
@@ -17,12 +18,17 @@ import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons';
|
||||
// different route.
|
||||
export function CallUI() {
|
||||
const { state } = useCall();
|
||||
const { profile } = useAuth();
|
||||
const dnd = profile?.presenceState === 'dnd';
|
||||
|
||||
useEffect(() => {
|
||||
// DND silences only the *incoming* ring — outgoing stays audible because
|
||||
// the user initiated that call themselves. The incoming-call panel still
|
||||
// appears visually; only the audible ring is suppressed.
|
||||
if (state.kind === 'outgoing') ringtone.start('outgoing');
|
||||
else if (state.kind === 'incoming') ringtone.start('incoming');
|
||||
else if (state.kind === 'incoming' && !dnd) ringtone.start('incoming');
|
||||
else ringtone.stop();
|
||||
}, [state.kind]);
|
||||
}, [state.kind, dnd]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => ringtone.stop();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useCallPresence } from '../lib/useCallPresence';
|
||||
import type { PeerPresence } from '../lib/usePeerPresence';
|
||||
import { Avatar } from './Avatar';
|
||||
import {
|
||||
InfoIcon,
|
||||
@@ -26,7 +27,7 @@ const PRESENCE_DOT: Record<PresenceState, string> = {
|
||||
|
||||
interface Props {
|
||||
conversation: ConversationSummary | null;
|
||||
peerPresence: PresenceState | null;
|
||||
peerPresence: PeerPresence | null;
|
||||
onInfoClick?: () => void;
|
||||
onSearchClick?: () => void;
|
||||
}
|
||||
@@ -51,7 +52,7 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick, on
|
||||
|
||||
interface HeaderBarProps {
|
||||
conversation: ConversationSummary;
|
||||
peerPresence: PresenceState | null;
|
||||
peerPresence: PeerPresence | null;
|
||||
onInfoClick?: () => void;
|
||||
onSearchClick?: () => void;
|
||||
}
|
||||
@@ -65,8 +66,18 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
|
||||
const peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) : (conversation.avatarUrl ?? null);
|
||||
|
||||
// Hide presence when peer chose invisible — reciprocal privacy.
|
||||
const showPresence = isDm && peerPresence && peerPresence !== 'invisible';
|
||||
const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : '';
|
||||
const peerState = peerPresence?.state ?? null;
|
||||
const showPresence = isDm && peerState && peerState !== 'invisible';
|
||||
// Subtitle priority: custom status_message when online (or idle/dnd), else
|
||||
// the localized presence label. Offline always wins → just "Offline".
|
||||
const presenceLabel = (() => {
|
||||
if (!peerState) return '';
|
||||
if (peerState === 'offline') return t('app:presence.offline');
|
||||
if (peerPresence?.statusMessage && peerPresence.statusMessage.trim().length > 0) {
|
||||
return peerPresence.statusMessage.trim();
|
||||
}
|
||||
return t('app:presence.' + peerState);
|
||||
})();
|
||||
|
||||
return (
|
||||
<header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3">
|
||||
@@ -80,12 +91,12 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
|
||||
<UsersIcon className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
{showPresence && peerPresence && (
|
||||
{showPresence && peerState && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={
|
||||
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' +
|
||||
PRESENCE_DOT[peerPresence]
|
||||
PRESENCE_DOT[peerState]
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
interface EmojiEntry {
|
||||
e: string;
|
||||
k: string[]; // search keywords (incl. name)
|
||||
}
|
||||
|
||||
interface Category {
|
||||
label: string;
|
||||
entries: EmojiEntry[];
|
||||
}
|
||||
|
||||
// Curated emoji set — small enough to stay fast without a dependency, wide
|
||||
// enough to cover everyday messaging. Keywords are the primary search
|
||||
// surface; the emoji character itself is also matched so a user typing ❤️
|
||||
// literally finds it.
|
||||
const CATEGORIES: Category[] = [
|
||||
{
|
||||
label: 'Smileys',
|
||||
entries: [
|
||||
{ e: '😀', k: ['grin', 'smile', 'happy'] },
|
||||
{ e: '😃', k: ['smile', 'happy'] },
|
||||
{ e: '😄', k: ['smile', 'laugh'] },
|
||||
{ e: '😁', k: ['grin', 'smile'] },
|
||||
{ e: '😆', k: ['laugh', 'lol'] },
|
||||
{ e: '😅', k: ['sweat', 'nervous', 'laugh'] },
|
||||
{ e: '🤣', k: ['lol', 'rofl', 'laugh'] },
|
||||
{ e: '😂', k: ['joy', 'laugh', 'tears'] },
|
||||
{ e: '🙂', k: ['smile', 'slight'] },
|
||||
{ e: '🙃', k: ['upside', 'irony'] },
|
||||
{ e: '😉', k: ['wink'] },
|
||||
{ e: '😊', k: ['blush', 'smile'] },
|
||||
{ e: '😇', k: ['angel', 'innocent'] },
|
||||
{ e: '🥰', k: ['love', 'hearts'] },
|
||||
{ e: '😍', k: ['love', 'heart eyes'] },
|
||||
{ e: '🤩', k: ['star', 'excited'] },
|
||||
{ e: '😘', k: ['kiss'] },
|
||||
{ e: '😗', k: ['kiss'] },
|
||||
{ e: '😚', k: ['kiss'] },
|
||||
{ e: '😙', k: ['kiss'] },
|
||||
{ e: '🥲', k: ['tear', 'smile'] },
|
||||
{ e: '😋', k: ['yum', 'tasty'] },
|
||||
{ e: '😛', k: ['tongue'] },
|
||||
{ e: '😜', k: ['tongue', 'wink'] },
|
||||
{ e: '🤪', k: ['zany', 'silly'] },
|
||||
{ e: '😝', k: ['tongue'] },
|
||||
{ e: '🤑', k: ['money'] },
|
||||
{ e: '🤗', k: ['hug'] },
|
||||
{ e: '🤭', k: ['giggle', 'shy'] },
|
||||
{ e: '🤫', k: ['shush', 'quiet'] },
|
||||
{ e: '🤔', k: ['think'] },
|
||||
{ e: '🤐', k: ['zip', 'quiet'] },
|
||||
{ e: '🤨', k: ['raise brow'] },
|
||||
{ e: '😐', k: ['neutral'] },
|
||||
{ e: '😑', k: ['expressionless'] },
|
||||
{ e: '😶', k: ['speechless'] },
|
||||
{ e: '😏', k: ['smirk'] },
|
||||
{ e: '😒', k: ['unamused'] },
|
||||
{ e: '🙄', k: ['eye roll'] },
|
||||
{ e: '😬', k: ['grimace', 'awkward'] },
|
||||
{ e: '🤥', k: ['lying'] },
|
||||
{ e: '😔', k: ['sad', 'pensive'] },
|
||||
{ e: '😪', k: ['sleepy'] },
|
||||
{ e: '😴', k: ['sleep'] },
|
||||
{ e: '😷', k: ['mask', 'sick'] },
|
||||
{ e: '🤒', k: ['sick', 'fever'] },
|
||||
{ e: '🤕', k: ['injured'] },
|
||||
{ e: '🤢', k: ['nauseated'] },
|
||||
{ e: '🤮', k: ['vomit'] },
|
||||
{ e: '🤧', k: ['sneeze'] },
|
||||
{ e: '🥵', k: ['hot'] },
|
||||
{ e: '🥶', k: ['cold'] },
|
||||
{ e: '🥴', k: ['dizzy', 'woozy'] },
|
||||
{ e: '😵', k: ['dizzy'] },
|
||||
{ e: '🤯', k: ['mind blown'] },
|
||||
{ e: '🤠', k: ['cowboy'] },
|
||||
{ e: '🥳', k: ['party'] },
|
||||
{ e: '😎', k: ['cool', 'sunglasses'] },
|
||||
{ e: '🤓', k: ['nerd'] },
|
||||
{ e: '🧐', k: ['monocle'] },
|
||||
{ e: '😕', k: ['confused'] },
|
||||
{ e: '😟', k: ['worried'] },
|
||||
{ e: '🙁', k: ['frown'] },
|
||||
{ e: '☹️', k: ['frown'] },
|
||||
{ e: '😮', k: ['open mouth'] },
|
||||
{ e: '😯', k: ['hushed'] },
|
||||
{ e: '😲', k: ['astonished'] },
|
||||
{ e: '😳', k: ['flushed'] },
|
||||
{ e: '🥺', k: ['pleading'] },
|
||||
{ e: '😦', k: ['frowning'] },
|
||||
{ e: '😧', k: ['anguished'] },
|
||||
{ e: '😨', k: ['fear'] },
|
||||
{ e: '😰', k: ['anxious', 'sweat'] },
|
||||
{ e: '😥', k: ['sad', 'relieved'] },
|
||||
{ e: '😢', k: ['cry'] },
|
||||
{ e: '😭', k: ['cry', 'loud'] },
|
||||
{ e: '😱', k: ['scream', 'scared'] },
|
||||
{ e: '😖', k: ['confounded'] },
|
||||
{ e: '😣', k: ['persevere'] },
|
||||
{ e: '😞', k: ['disappointed'] },
|
||||
{ e: '😓', k: ['sweat'] },
|
||||
{ e: '😩', k: ['weary'] },
|
||||
{ e: '😫', k: ['tired'] },
|
||||
{ e: '🥱', k: ['yawn'] },
|
||||
{ e: '😤', k: ['triumph'] },
|
||||
{ e: '😡', k: ['angry', 'rage'] },
|
||||
{ e: '😠', k: ['angry'] },
|
||||
{ e: '🤬', k: ['swear', 'curse'] },
|
||||
{ e: '😈', k: ['devil'] },
|
||||
{ e: '👿', k: ['imp'] },
|
||||
{ e: '💀', k: ['skull', 'dead'] },
|
||||
{ e: '🤡', k: ['clown'] },
|
||||
{ e: '👻', k: ['ghost'] },
|
||||
{ e: '👽', k: ['alien'] },
|
||||
{ e: '🤖', k: ['robot'] },
|
||||
{ e: '💩', k: ['poop', 'shit'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Gestures',
|
||||
entries: [
|
||||
{ e: '👋', k: ['wave', 'hi'] },
|
||||
{ e: '🤚', k: ['hand'] },
|
||||
{ e: '🖐️', k: ['hand'] },
|
||||
{ e: '✋', k: ['stop', 'high five'] },
|
||||
{ e: '🖖', k: ['spock'] },
|
||||
{ e: '👌', k: ['ok'] },
|
||||
{ e: '🤌', k: ['pinch'] },
|
||||
{ e: '🤏', k: ['small'] },
|
||||
{ e: '✌️', k: ['peace', 'victory'] },
|
||||
{ e: '🤞', k: ['crossed fingers'] },
|
||||
{ e: '🤟', k: ['love you'] },
|
||||
{ e: '🤘', k: ['rock'] },
|
||||
{ e: '🤙', k: ['call me'] },
|
||||
{ e: '👈', k: ['point left'] },
|
||||
{ e: '👉', k: ['point right'] },
|
||||
{ e: '👆', k: ['point up'] },
|
||||
{ e: '🖕', k: ['middle finger', 'fuck'] },
|
||||
{ e: '👇', k: ['point down'] },
|
||||
{ e: '☝️', k: ['point up'] },
|
||||
{ e: '👍', k: ['thumbs up', 'like'] },
|
||||
{ e: '👎', k: ['thumbs down', 'dislike'] },
|
||||
{ e: '✊', k: ['fist'] },
|
||||
{ e: '👊', k: ['punch'] },
|
||||
{ e: '🤛', k: ['fist left'] },
|
||||
{ e: '🤜', k: ['fist right'] },
|
||||
{ e: '👏', k: ['clap'] },
|
||||
{ e: '🙌', k: ['raised hands'] },
|
||||
{ e: '👐', k: ['open hands'] },
|
||||
{ e: '🤲', k: ['palms'] },
|
||||
{ e: '🙏', k: ['pray', 'thanks'] },
|
||||
{ e: '✍️', k: ['write'] },
|
||||
{ e: '💪', k: ['flex', 'strong'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Hearts',
|
||||
entries: [
|
||||
{ e: '❤️', k: ['heart', 'love'] },
|
||||
{ e: '🧡', k: ['orange heart'] },
|
||||
{ e: '💛', k: ['yellow heart'] },
|
||||
{ e: '💚', k: ['green heart'] },
|
||||
{ e: '💙', k: ['blue heart'] },
|
||||
{ e: '💜', k: ['purple heart'] },
|
||||
{ e: '🖤', k: ['black heart'] },
|
||||
{ e: '🤍', k: ['white heart'] },
|
||||
{ e: '🤎', k: ['brown heart'] },
|
||||
{ e: '💔', k: ['broken heart'] },
|
||||
{ e: '❣️', k: ['heart exclamation'] },
|
||||
{ e: '💕', k: ['hearts'] },
|
||||
{ e: '💞', k: ['revolving hearts'] },
|
||||
{ e: '💓', k: ['beating heart'] },
|
||||
{ e: '💗', k: ['growing heart'] },
|
||||
{ e: '💖', k: ['sparkle heart'] },
|
||||
{ e: '💘', k: ['cupid'] },
|
||||
{ e: '💝', k: ['heart gift'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Animals & Food',
|
||||
entries: [
|
||||
{ e: '🐶', k: ['dog'] },
|
||||
{ e: '🐱', k: ['cat'] },
|
||||
{ e: '🐭', k: ['mouse'] },
|
||||
{ e: '🐹', k: ['hamster'] },
|
||||
{ e: '🐰', k: ['rabbit'] },
|
||||
{ e: '🦊', k: ['fox'] },
|
||||
{ e: '🐻', k: ['bear'] },
|
||||
{ e: '🐼', k: ['panda'] },
|
||||
{ e: '🐨', k: ['koala'] },
|
||||
{ e: '🐯', k: ['tiger'] },
|
||||
{ e: '🦁', k: ['lion'] },
|
||||
{ e: '🐸', k: ['frog'] },
|
||||
{ e: '🐵', k: ['monkey'] },
|
||||
{ e: '🐔', k: ['chicken'] },
|
||||
{ e: '🐧', k: ['penguin'] },
|
||||
{ e: '🐦', k: ['bird'] },
|
||||
{ e: '🦆', k: ['duck'] },
|
||||
{ e: '🍎', k: ['apple'] },
|
||||
{ e: '🍌', k: ['banana'] },
|
||||
{ e: '🍕', k: ['pizza'] },
|
||||
{ e: '🍔', k: ['burger'] },
|
||||
{ e: '🍟', k: ['fries'] },
|
||||
{ e: '🌭', k: ['hotdog'] },
|
||||
{ e: '🍿', k: ['popcorn'] },
|
||||
{ e: '🍣', k: ['sushi'] },
|
||||
{ e: '🍩', k: ['donut'] },
|
||||
{ e: '🍪', k: ['cookie'] },
|
||||
{ e: '🎂', k: ['cake', 'birthday'] },
|
||||
{ e: '🍰', k: ['cake'] },
|
||||
{ e: '🍫', k: ['chocolate'] },
|
||||
{ e: '🍺', k: ['beer'] },
|
||||
{ e: '🍷', k: ['wine'] },
|
||||
{ e: '🥂', k: ['cheers'] },
|
||||
{ e: '☕', k: ['coffee'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Objects & Symbols',
|
||||
entries: [
|
||||
{ e: '🔥', k: ['fire', 'lit'] },
|
||||
{ e: '✨', k: ['sparkle'] },
|
||||
{ e: '⭐', k: ['star'] },
|
||||
{ e: '🌟', k: ['star glowing'] },
|
||||
{ e: '💫', k: ['dizzy'] },
|
||||
{ e: '💥', k: ['boom', 'explosion'] },
|
||||
{ e: '⚡', k: ['lightning'] },
|
||||
{ e: '☀️', k: ['sun'] },
|
||||
{ e: '🌈', k: ['rainbow'] },
|
||||
{ e: '☁️', k: ['cloud'] },
|
||||
{ e: '🌧️', k: ['rain'] },
|
||||
{ e: '❄️', k: ['snow'] },
|
||||
{ e: '🎉', k: ['party', 'tada'] },
|
||||
{ e: '🎊', k: ['confetti'] },
|
||||
{ e: '🎁', k: ['gift'] },
|
||||
{ e: '🎈', k: ['balloon'] },
|
||||
{ e: '💯', k: ['100', 'perfect'] },
|
||||
{ e: '✅', k: ['check'] },
|
||||
{ e: '❌', k: ['x', 'no'] },
|
||||
{ e: '⚠️', k: ['warning'] },
|
||||
{ e: '❓', k: ['question'] },
|
||||
{ e: '❗', k: ['exclamation'] },
|
||||
{ e: '💬', k: ['speech'] },
|
||||
{ e: '💭', k: ['thought'] },
|
||||
{ e: '👀', k: ['eyes'] },
|
||||
{ e: '🚀', k: ['rocket'] },
|
||||
{ e: '🎵', k: ['music'] },
|
||||
{ e: '🎶', k: ['music'] },
|
||||
{ e: '🔔', k: ['bell'] },
|
||||
{ e: '💡', k: ['idea', 'bulb'] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const RECENT_KEY = 'chat.emoji.recents.v1';
|
||||
const RECENT_MAX = 24;
|
||||
|
||||
function loadRecents(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((x): x is string => typeof x === 'string').slice(0, RECENT_MAX);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveRecents(list: string[]): void {
|
||||
try {
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
|
||||
} catch {
|
||||
/* ignore quota */
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onPick: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function EmojiPicker({ open, onPick, onClose }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [recents, setRecents] = useState<string[]>(() => loadRecents());
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (rootRef.current && t && !rootRef.current.contains(t) && !t.closest('[data-emoji-trigger]')) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
window.addEventListener('mousedown', onDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('mousedown', onDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setQuery('');
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return CATEGORIES;
|
||||
return CATEGORIES.map((cat) => ({
|
||||
label: cat.label,
|
||||
entries: cat.entries.filter(
|
||||
(entry) =>
|
||||
entry.e.includes(q) ||
|
||||
entry.k.some((k) => k.includes(q)) ||
|
||||
cat.label.toLowerCase().includes(q),
|
||||
),
|
||||
})).filter((cat) => cat.entries.length > 0);
|
||||
}, [query]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handlePick = (emoji: string) => {
|
||||
onPick(emoji);
|
||||
const next = [emoji, ...recents.filter((e) => e !== emoji)].slice(0, RECENT_MAX);
|
||||
setRecents(next);
|
||||
saveRecents(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
role="dialog"
|
||||
aria-label="Emoji auswählen"
|
||||
className="absolute bottom-full right-0 z-30 mb-2 flex w-[320px] flex-col rounded-xl border border-line bg-surface-2 shadow-xl"
|
||||
>
|
||||
<div className="border-b border-line p-2">
|
||||
<input
|
||||
type="search"
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Suchen…"
|
||||
className="w-full rounded-md border border-line bg-surface-3 px-2.5 py-1.5 text-sm text-fg placeholder-fg-muted outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[320px] overflow-y-auto p-2">
|
||||
{recents.length > 0 && !query && (
|
||||
<CategoryBlock
|
||||
label="Zuletzt"
|
||||
entries={recents.map((e) => ({ e, k: [] }))}
|
||||
onPick={handlePick}
|
||||
/>
|
||||
)}
|
||||
{filtered.map((cat) => (
|
||||
<CategoryBlock
|
||||
key={cat.label}
|
||||
label={cat.label}
|
||||
entries={cat.entries}
|
||||
onPick={handlePick}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className="py-4 text-center text-xs text-fg-muted">Keine Treffer</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryBlock({
|
||||
label,
|
||||
entries,
|
||||
onPick,
|
||||
}: {
|
||||
label: string;
|
||||
entries: EmojiEntry[];
|
||||
onPick: (emoji: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<p className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{label}
|
||||
</p>
|
||||
<div className="grid grid-cols-8 gap-0.5">
|
||||
{entries.map((entry, idx) => (
|
||||
<button
|
||||
key={entry.e + ':' + idx}
|
||||
type="button"
|
||||
onClick={() => onPick(entry.e)}
|
||||
aria-label={entry.k[0] ?? entry.e}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-lg transition hover:bg-surface-3"
|
||||
>
|
||||
{entry.e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } f
|
||||
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
||||
import { ScreenShareDialog } from './ScreenShareDialog';
|
||||
import { ScreenShareViewer } from './ScreenShareViewer';
|
||||
import { SoundboardPanel } from './SoundboardPanel';
|
||||
|
||||
// Discord-style in-call dock rendered above the message list. Renders three
|
||||
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
|
||||
@@ -61,6 +62,7 @@ export function InCallPanel({ conversation }: Props) {
|
||||
isCameraEnabled,
|
||||
isDeafened,
|
||||
remoteDeafen,
|
||||
remoteMute,
|
||||
remoteScreenShares,
|
||||
callMode,
|
||||
focusedId,
|
||||
@@ -77,6 +79,7 @@ export function InCallPanel({ conversation }: Props) {
|
||||
const myId = session?.user.id ?? null;
|
||||
const activeSpeakers = useActiveSpeakers(room);
|
||||
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
||||
const [soundboardOpen, setSoundboardOpen] = useState(false);
|
||||
const [volumeMenu, setVolumeMenu] = useState<
|
||||
{ userId: string; displayName: string; x: number; y: number } | null
|
||||
>(null);
|
||||
@@ -108,6 +111,7 @@ export function InCallPanel({ conversation }: Props) {
|
||||
isMuted,
|
||||
isDeafened,
|
||||
remoteDeafen,
|
||||
remoteMute,
|
||||
isScreenSharing,
|
||||
isCameraEnabled,
|
||||
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
|
||||
@@ -150,6 +154,8 @@ export function InCallPanel({ conversation }: Props) {
|
||||
}}
|
||||
onToggleVideo={() => void toggleCamera()}
|
||||
onToggleDeafen={toggleDeafen}
|
||||
onToggleSoundboard={() => setSoundboardOpen((v) => !v)}
|
||||
soundboardOpen={soundboardOpen}
|
||||
onHangup={() => void hangup()}
|
||||
compact={callMode !== 'fullscreen'}
|
||||
glass={callMode === 'fullscreen'}
|
||||
@@ -197,6 +203,10 @@ export function InCallPanel({ conversation }: Props) {
|
||||
onClose={() => setVolumeMenu(null)}
|
||||
/>
|
||||
)}
|
||||
<SoundboardPopover
|
||||
open={soundboardOpen}
|
||||
onClose={() => setSoundboardOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -280,10 +290,28 @@ export function InCallPanel({ conversation }: Props) {
|
||||
onClose={() => setVolumeMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SoundboardPopover
|
||||
open={soundboardOpen}
|
||||
onClose={() => setSoundboardOpen(false)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Fixed-position overlay so the popover sits above both docked + fullscreen
|
||||
// call modes without needing a portal or parent-relative anchoring.
|
||||
function SoundboardPopover({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-24 z-50 flex justify-center px-4">
|
||||
<div className="pointer-events-auto">
|
||||
<SoundboardPanel onClose={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -296,6 +324,7 @@ interface BuildArgs {
|
||||
isMuted: boolean;
|
||||
isDeafened: boolean;
|
||||
remoteDeafen: Record<string, boolean>;
|
||||
remoteMute: Record<string, boolean>;
|
||||
isScreenSharing: boolean;
|
||||
isCameraEnabled: boolean;
|
||||
remoteSharerIds: Set<string>;
|
||||
@@ -321,6 +350,7 @@ function buildTiles({
|
||||
isMuted,
|
||||
isDeafened,
|
||||
remoteDeafen,
|
||||
remoteMute,
|
||||
isScreenSharing,
|
||||
isCameraEnabled,
|
||||
remoteSharerIds,
|
||||
@@ -379,7 +409,10 @@ function buildTiles({
|
||||
displayName: m.profile?.displayName ?? '?',
|
||||
avatarUrl: m.profile?.avatarUrl ?? null,
|
||||
self: false,
|
||||
muted: !rp.isMicrophoneEnabled,
|
||||
// Peer's self-reported mute state via data channel. LiveKit's own
|
||||
// `isMicrophoneEnabled` no longer flips on mute since the pipeline
|
||||
// output track stays published. See remoteMute broadcast in CallContext.
|
||||
muted: remoteMute[m.userId] ?? false,
|
||||
// Deafen state arrives via LiveKit data channel; see CallContext.
|
||||
deafened: remoteDeafen[m.userId] ?? false,
|
||||
video: rp.isCameraEnabled,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useLinkPreview } from '../lib/useLinkPreview';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
}
|
||||
|
||||
// Renders a compact OpenGraph preview card under a message bubble. Fetches
|
||||
// lazily through the edge function; silent when the URL returned no meta.
|
||||
export function LinkPreviewCard({ url }: Props) {
|
||||
const preview = useLinkPreview(url);
|
||||
if (!preview || !preview.ok) return null;
|
||||
if (!preview.title && !preview.description && !preview.imageUrl) return null;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="mt-2 flex max-w-[320px] overflow-hidden rounded-lg border border-line bg-surface-2 text-sm no-underline transition hover:bg-surface-3"
|
||||
>
|
||||
{preview.imageUrl && (
|
||||
<img
|
||||
src={preview.imageUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-20 w-20 shrink-0 object-cover"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 p-2.5">
|
||||
{preview.siteName && (
|
||||
<p className="truncate text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
{preview.siteName}
|
||||
</p>
|
||||
)}
|
||||
{preview.title && (
|
||||
<p className="line-clamp-2 text-sm font-semibold text-fg">
|
||||
{preview.title}
|
||||
</p>
|
||||
)}
|
||||
{preview.description && (
|
||||
<p className="line-clamp-2 text-xs text-fg-muted">{preview.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
interface Props {
|
||||
members: ConversationSummary['members'];
|
||||
query: string;
|
||||
excludeUserId: string | undefined;
|
||||
onSelect: (username: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Dropdown shown above the composer when the user has typed `@` followed
|
||||
// by the start of a member name. Keyboard-first — arrow keys move through,
|
||||
// enter/tab commits, escape cancels.
|
||||
export function MentionAutocomplete({
|
||||
members,
|
||||
query,
|
||||
excludeUserId,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const q = query.toLowerCase();
|
||||
const matches = members
|
||||
.filter((m) => m.userId !== excludeUserId)
|
||||
.filter((m) => {
|
||||
if (!q) return true;
|
||||
const name = (m.profile?.displayName ?? '').toLowerCase();
|
||||
const handle = (m.profile?.username ?? '').toLowerCase();
|
||||
return name.includes(q) || handle.includes(q);
|
||||
})
|
||||
.slice(0, 8);
|
||||
|
||||
const [active, setActive] = useState(0);
|
||||
useEffect(() => {
|
||||
setActive(0);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (matches.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActive((i) => (i + 1) % matches.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActive((i) => (i - 1 + matches.length) % matches.length);
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const pick = matches[active];
|
||||
if (pick?.profile?.username) onSelect(pick.profile.username);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
};
|
||||
}, [matches, active, onSelect, onClose]);
|
||||
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Mitglieder"
|
||||
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl"
|
||||
>
|
||||
{matches.map((m, idx) => {
|
||||
const name = m.profile?.displayName ?? m.profile?.username ?? '?';
|
||||
const handle = m.profile?.username ?? '';
|
||||
const isActive = idx === active;
|
||||
return (
|
||||
<button
|
||||
key={m.userId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
onClick={() => {
|
||||
if (m.profile?.username) onSelect(m.profile.username);
|
||||
}}
|
||||
className={
|
||||
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
|
||||
(isActive ? 'bg-accent/20 text-fg' : 'text-fg-muted hover:bg-surface-3')
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
displayName={name}
|
||||
url={m.profile?.avatarUrl ?? null}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
|
||||
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,13 @@ import { useAuth } from '../context/AuthContext';
|
||||
import { devLocalSecretStore } from '../lib/secretStore';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||
import { extractFirstUrl } from '../lib/useLinkPreview';
|
||||
import { AttachmentAudio } from './AttachmentAudio';
|
||||
import { AttachmentGeneric } from './AttachmentGeneric';
|
||||
import { AttachmentImage } from './AttachmentImage';
|
||||
import { AttachmentPdf } from './AttachmentPdf';
|
||||
import { AttachmentVideo } from './AttachmentVideo';
|
||||
import { LinkPreviewCard } from './LinkPreviewCard';
|
||||
import { Avatar } from './Avatar';
|
||||
import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
|
||||
|
||||
@@ -299,14 +304,27 @@ export function MessageBubble({
|
||||
<span className="italic opacity-70">…cannot decrypt</span>
|
||||
) : (
|
||||
<>
|
||||
{bodyText.length > 0 && <div>{bodyText}</div>}
|
||||
{attachments.map((a) =>
|
||||
a.mimeType.startsWith('audio/') ? (
|
||||
<AttachmentAudio key={a.id} handle={a} />
|
||||
) : (
|
||||
<AttachmentImage key={a.id} handle={a} />
|
||||
),
|
||||
)}
|
||||
{bodyText.length > 0 && <div>{renderBodyWithMentions(bodyText)}</div>}
|
||||
{bodyText.length > 0 &&
|
||||
(() => {
|
||||
const url = extractFirstUrl(bodyText);
|
||||
return url ? <LinkPreviewCard url={url} /> : null;
|
||||
})()}
|
||||
{attachments.map((a) => {
|
||||
if (a.mimeType.startsWith('audio/')) {
|
||||
return <AttachmentAudio key={a.id} handle={a} />;
|
||||
}
|
||||
if (a.mimeType.startsWith('image/')) {
|
||||
return <AttachmentImage key={a.id} handle={a} />;
|
||||
}
|
||||
if (a.mimeType.startsWith('video/')) {
|
||||
return <AttachmentVideo key={a.id} handle={a} />;
|
||||
}
|
||||
if (a.mimeType === 'application/pdf') {
|
||||
return <AttachmentPdf key={a.id} handle={a} />;
|
||||
}
|
||||
return <AttachmentGeneric key={a.id} handle={a} />;
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
@@ -510,6 +528,33 @@ function formatDuration(totalSec: number): string {
|
||||
return m + ':' + s.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
// Splits body text on `@username` tokens, rendering matches as highlighted
|
||||
// pills. Username alphabet matches Supabase citext usernames: alphanumerics
|
||||
// + underscores, length 1..32 (we don't bound here — regex is permissive
|
||||
// and keys off a leading `@` with an alnum/underscore follow).
|
||||
const MENTION_RE = /@([A-Za-z0-9_]{1,32})/g;
|
||||
|
||||
function renderBodyWithMentions(text: string): React.ReactNode[] {
|
||||
const out: React.ReactNode[] = [];
|
||||
let lastIdx = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
MENTION_RE.lastIndex = 0;
|
||||
while ((m = MENTION_RE.exec(text)) !== null) {
|
||||
if (m.index > lastIdx) out.push(text.slice(lastIdx, m.index));
|
||||
out.push(
|
||||
<span
|
||||
key={m.index + ':' + m[1]}
|
||||
className="rounded bg-accent/20 px-1 text-accent"
|
||||
>
|
||||
{m[0]}
|
||||
</span>,
|
||||
);
|
||||
lastIdx = m.index + m[0].length;
|
||||
}
|
||||
if (lastIdx < text.length) out.push(text.slice(lastIdx));
|
||||
return out;
|
||||
}
|
||||
|
||||
function DeliveryTicks({ state }: { state: 'sent' | 'delivered' | 'read' }) {
|
||||
// One checkmark for sent, two for delivered/read. Read shifts color to the
|
||||
// accent to match WhatsApp/Telegram blue-tick convention.
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
clearIncomingRingtone,
|
||||
getIncomingRingtone,
|
||||
MAX_RINGTONE_BYTES,
|
||||
saveIncomingRingtone,
|
||||
type StoredRingtone,
|
||||
} from '../lib/ringtoneStorage';
|
||||
import { PhoneIcon, SpinnerIcon, TrashIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
/** Disable interactions while a parent action is in flight. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const BYTES_PER_MB = 1024 * 1024;
|
||||
|
||||
// UI for the custom incoming-call ringtone. Single file slot. Upload
|
||||
// validates size + mime and surfaces errors inline. Preview button plays
|
||||
// the stored blob through a local <audio> element without touching the
|
||||
// shared ringtone singleton so we don't interfere with a live call.
|
||||
export function RingtoneSettings({ disabled = false }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const previewRef = useRef<HTMLAudioElement | null>(null);
|
||||
const previewUrlRef = useRef<string | null>(null);
|
||||
const [current, setCurrent] = useState<StoredRingtone | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const cur = await getIncomingRingtone();
|
||||
setCurrent(cur);
|
||||
} catch (err: unknown) {
|
||||
console.error('getIncomingRingtone failed', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
// Revoke any preview blob URL when the component unmounts so long-lived
|
||||
// pages don't leak memory.
|
||||
return () => {
|
||||
stopPreview();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function stopPreview(): void {
|
||||
const el = previewRef.current;
|
||||
if (el) {
|
||||
try {
|
||||
el.pause();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
el.src = '';
|
||||
}
|
||||
previewRef.current = null;
|
||||
if (previewUrlRef.current) {
|
||||
URL.revokeObjectURL(previewUrlRef.current);
|
||||
previewUrlRef.current = null;
|
||||
}
|
||||
setPlaying(false);
|
||||
}
|
||||
|
||||
async function handleFile(file: File): Promise<void> {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await saveIncomingRingtone(file);
|
||||
await refresh();
|
||||
} catch (err: unknown) {
|
||||
const code = err instanceof Error ? err.message : 'upload_failed';
|
||||
if (code === 'ringtone_too_large') {
|
||||
setError(
|
||||
t('app:settings.ringtone_error_too_large', {
|
||||
defaultValue: 'Datei zu groß (max 2 MB).',
|
||||
max: MAX_RINGTONE_BYTES / BYTES_PER_MB,
|
||||
}),
|
||||
);
|
||||
} else if (code === 'ringtone_not_audio') {
|
||||
setError(
|
||||
t('app:settings.ringtone_error_not_audio', {
|
||||
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setError(
|
||||
t('app:settings.ringtone_error_generic', {
|
||||
defaultValue: 'Ringtone konnte nicht gespeichert werden.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset(): Promise<void> {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
stopPreview();
|
||||
try {
|
||||
await clearIncomingRingtone();
|
||||
await refresh();
|
||||
} catch (err: unknown) {
|
||||
console.error('clearIncomingRingtone failed', err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreview(): void {
|
||||
if (!current) return;
|
||||
if (playing) {
|
||||
stopPreview();
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(current.blob);
|
||||
const el = new Audio(url);
|
||||
el.loop = false;
|
||||
el.volume = 0.85;
|
||||
el.onended = () => stopPreview();
|
||||
el.onerror = () => {
|
||||
setError(
|
||||
t('app:settings.ringtone_error_play', {
|
||||
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
|
||||
}),
|
||||
);
|
||||
stopPreview();
|
||||
};
|
||||
el.play().catch(() => {
|
||||
setError(
|
||||
t('app:settings.ringtone_error_play', {
|
||||
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
|
||||
}),
|
||||
);
|
||||
stopPreview();
|
||||
});
|
||||
previewRef.current = el;
|
||||
previewUrlRef.current = url;
|
||||
setPlaying(true);
|
||||
}
|
||||
|
||||
const hasCustom = current !== null;
|
||||
const sizeMb = current ? (current.blob.size / BYTES_PER_MB).toFixed(2) : null;
|
||||
const interactionsDisabled = disabled || busy;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-fg">
|
||||
<PhoneIcon className="h-4 w-4 text-fg-muted" />
|
||||
{t('app:settings.ringtone_incoming', { defaultValue: 'Eingehender Anruf' })}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-fg-muted">
|
||||
{hasCustom && current
|
||||
? t('app:settings.ringtone_custom_active', {
|
||||
defaultValue: '{{name}} · {{size}} MB',
|
||||
name: current.filename,
|
||||
size: sizeMb,
|
||||
})
|
||||
: t('app:settings.ringtone_default_active', {
|
||||
defaultValue: 'Standard-Klingelton (Doppelton)',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{hasCustom && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreview}
|
||||
disabled={interactionsDisabled}
|
||||
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs font-semibold text-fg transition hover:brightness-95 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{playing
|
||||
? t('app:settings.ringtone_stop', { defaultValue: 'Stop' })
|
||||
: t('app:settings.ringtone_preview', { defaultValue: 'Vorhören' })}
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void handleFile(f);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={interactionsDisabled}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
||||
<span>
|
||||
{hasCustom
|
||||
? t('app:settings.ringtone_replace', { defaultValue: 'Ersetzen' })
|
||||
: t('app:settings.ringtone_upload', { defaultValue: 'Hochladen' })}
|
||||
</span>
|
||||
</button>
|
||||
{hasCustom && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleReset()}
|
||||
disabled={interactionsDisabled}
|
||||
aria-label={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
|
||||
title={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-rose-500/40 bg-rose-500/10 text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
{t('app:settings.ringtone_hint', {
|
||||
defaultValue:
|
||||
'MP3, WAV, OGG oder M4A bis 2 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { getPttSettings } from '../lib/pttSettings';
|
||||
import { codeToShortcut } from '../lib/globalShortcut';
|
||||
import { invalidate as invalidateSoundCache, preload } from '../lib/soundboardPlayback';
|
||||
import {
|
||||
addSound,
|
||||
deleteSound,
|
||||
getSoundBlob,
|
||||
listSounds,
|
||||
MAX_SOUND_BYTES,
|
||||
reorderCategory,
|
||||
type SoundboardEntry,
|
||||
subscribeSoundboardChanges,
|
||||
updateSound,
|
||||
} from '../lib/soundboardStorage';
|
||||
import { Modal } from './Modal';
|
||||
import {
|
||||
AlertIcon,
|
||||
ChevronDownIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
} from './icons';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const BYTES_PER_MB = 1024 * 1024;
|
||||
const CATEGORY_LIST_ID = 'sb-category-list';
|
||||
|
||||
// Admin UI for the soundboard. Users add, rename, categorise, reorder,
|
||||
// assign hotkeys, adjust per-sound volume, preview and delete clips here.
|
||||
// In-call panel only reads the resulting manifest.
|
||||
export function SoundboardManagerDialog({ open, onClose }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
const previewRef = useRef<HTMLAudioElement | null>(null);
|
||||
const previewUrlRef = useRef<string | null>(null);
|
||||
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setEntries(await listSounds());
|
||||
} catch (err: unknown) {
|
||||
console.error('listSounds failed', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void refresh();
|
||||
// External mutations (hotkey fires, multi-tab edits) should reflect
|
||||
// immediately while the dialog is open.
|
||||
const unsub = subscribeSoundboardChanges(() => {
|
||||
void refresh();
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
stopPreview();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => stopPreview();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const e of entries) if (e.category) set.add(e.category);
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
}, [entries]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, SoundboardEntry[]>();
|
||||
for (const e of entries) {
|
||||
const key = e.category ?? '';
|
||||
const arr = map.get(key);
|
||||
if (arr) arr.push(e);
|
||||
else map.set(key, [e]);
|
||||
}
|
||||
return map;
|
||||
}, [entries]);
|
||||
|
||||
function stopPreview(): void {
|
||||
const el = previewRef.current;
|
||||
if (el) {
|
||||
try {
|
||||
el.pause();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
el.src = '';
|
||||
}
|
||||
previewRef.current = null;
|
||||
if (previewUrlRef.current) {
|
||||
URL.revokeObjectURL(previewUrlRef.current);
|
||||
previewUrlRef.current = null;
|
||||
}
|
||||
setPreviewingId(null);
|
||||
}
|
||||
|
||||
async function handleAdd(file: File): Promise<void> {
|
||||
setError(null);
|
||||
setBusyId('__add');
|
||||
try {
|
||||
await addSound({ file });
|
||||
await refresh();
|
||||
} catch (err: unknown) {
|
||||
const code = err instanceof Error ? err.message : 'add_failed';
|
||||
if (code === 'sound_too_large') {
|
||||
setError(
|
||||
t('app:soundboard.error_too_large', {
|
||||
defaultValue: 'Datei zu groß (max {{max}} MB).',
|
||||
max: MAX_SOUND_BYTES / BYTES_PER_MB,
|
||||
}),
|
||||
);
|
||||
} else if (code === 'sound_not_audio') {
|
||||
setError(
|
||||
t('app:soundboard.error_not_audio', {
|
||||
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setError(
|
||||
t('app:soundboard.error_generic', {
|
||||
defaultValue: 'Sound konnte nicht gespeichert werden.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePatch(
|
||||
id: string,
|
||||
patch: Parameters<typeof updateSound>[1],
|
||||
): Promise<void> {
|
||||
setBusyId(id);
|
||||
try {
|
||||
await updateSound(id, patch);
|
||||
} catch (err: unknown) {
|
||||
console.error('updateSound failed', err);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string): Promise<void> {
|
||||
if (!window.confirm(t('app:soundboard.delete_confirm', { defaultValue: 'Sound löschen?' }))) {
|
||||
return;
|
||||
}
|
||||
setBusyId(id);
|
||||
try {
|
||||
await deleteSound(id);
|
||||
invalidateSoundCache(id);
|
||||
if (previewingId === id) stopPreview();
|
||||
} catch (err: unknown) {
|
||||
console.error('deleteSound failed', err);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreview(entry: SoundboardEntry): Promise<void> {
|
||||
if (previewingId === entry.id) {
|
||||
stopPreview();
|
||||
return;
|
||||
}
|
||||
stopPreview();
|
||||
const blob = await getSoundBlob(entry.id);
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const el = new Audio(url);
|
||||
el.volume = entry.gain;
|
||||
el.onended = () => stopPreview();
|
||||
el.onerror = () => stopPreview();
|
||||
el.play().catch(() => stopPreview());
|
||||
previewRef.current = el;
|
||||
previewUrlRef.current = url;
|
||||
setPreviewingId(entry.id);
|
||||
// Opportunistic warm-up of the AudioBuffer cache so the first in-call
|
||||
// playback doesn't pause on decode.
|
||||
void preload(entry.id);
|
||||
}
|
||||
|
||||
async function handleReorder(
|
||||
category: string | null,
|
||||
idx: number,
|
||||
dir: -1 | 1,
|
||||
): Promise<void> {
|
||||
const bucket = grouped.get(category ?? '') ?? [];
|
||||
const next = idx + dir;
|
||||
if (next < 0 || next >= bucket.length) return;
|
||||
const reordered = bucket.slice();
|
||||
const tmp = reordered[idx]!;
|
||||
reordered[idx] = reordered[next]!;
|
||||
reordered[next] = tmp;
|
||||
setBusyId('__reorder:' + (category ?? ''));
|
||||
try {
|
||||
await reorderCategory(
|
||||
category,
|
||||
reordered.map((e) => e.id),
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
console.error('reorderCategory failed', err);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t('app:soundboard.manager_title', { defaultValue: 'Soundboard verwalten' })}
|
||||
size="lg"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('app:soundboard.manager_hint', {
|
||||
defaultValue:
|
||||
'Beliebig viele Sounds, kein Hotkey nötig. Hotkeys feuern nur während eines Anrufs.',
|
||||
})}
|
||||
</p>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void handleAdd(f);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busyId !== null}
|
||||
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{busyId === '__add' ? (
|
||||
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<PlusIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{t('app:soundboard.add', { defaultValue: 'Sound hinzufügen' })}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
|
||||
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<p className="min-w-0 flex-1 break-words">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<datalist id={CATEGORY_LIST_ID}>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
||||
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="rounded-lg border border-line bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
||||
{t('app:soundboard.empty', {
|
||||
defaultValue: 'Noch keine Sounds. Lade oben welche hoch.',
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{Array.from(grouped.entries()).map(([categoryKey, bucket]) => {
|
||||
const category = categoryKey === '' ? null : categoryKey;
|
||||
return (
|
||||
<SoundboardCategoryGroup
|
||||
key={categoryKey || '__uncat'}
|
||||
category={category}
|
||||
entries={bucket}
|
||||
entriesTotal={entries}
|
||||
busyId={busyId}
|
||||
previewingId={previewingId}
|
||||
onPatch={handlePatch}
|
||||
onDelete={handleDelete}
|
||||
onPreview={handlePreview}
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GroupProps {
|
||||
category: string | null;
|
||||
entries: SoundboardEntry[];
|
||||
entriesTotal: SoundboardEntry[];
|
||||
busyId: string | null;
|
||||
previewingId: string | null;
|
||||
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||||
onReorder: (category: string | null, idx: number, dir: -1 | 1) => Promise<void>;
|
||||
}
|
||||
|
||||
function SoundboardCategoryGroup({
|
||||
category,
|
||||
entries,
|
||||
entriesTotal,
|
||||
busyId,
|
||||
previewingId,
|
||||
onPatch,
|
||||
onDelete,
|
||||
onPreview,
|
||||
onReorder,
|
||||
}: GroupProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [open, setOpen] = useState(true);
|
||||
const label =
|
||||
category ??
|
||||
t('app:soundboard.category_uncategorized', { defaultValue: '(Ohne Kategorie)' });
|
||||
return (
|
||||
<section className="rounded-lg border border-line bg-surface-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5 text-left"
|
||||
>
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
||||
{label} · {entries.length}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={'h-4 w-4 text-fg-muted transition ' + (open ? '' : '-rotate-90')}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="flex flex-col gap-2 border-t border-line p-3">
|
||||
{entries.map((entry, idx) => (
|
||||
<SoundboardRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
entriesTotal={entriesTotal}
|
||||
isFirst={idx === 0}
|
||||
isLast={idx === entries.length - 1}
|
||||
busy={busyId === entry.id}
|
||||
previewing={previewingId === entry.id}
|
||||
onPatch={onPatch}
|
||||
onDelete={onDelete}
|
||||
onPreview={onPreview}
|
||||
onReorderUp={() => onReorder(category, idx, -1)}
|
||||
onReorderDown={() => onReorder(category, idx, 1)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RowProps {
|
||||
entry: SoundboardEntry;
|
||||
entriesTotal: SoundboardEntry[];
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
busy: boolean;
|
||||
previewing: boolean;
|
||||
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||||
onReorderUp: () => Promise<void>;
|
||||
onReorderDown: () => Promise<void>;
|
||||
}
|
||||
|
||||
function SoundboardRow({
|
||||
entry,
|
||||
entriesTotal,
|
||||
isFirst,
|
||||
isLast,
|
||||
busy,
|
||||
previewing,
|
||||
onPatch,
|
||||
onDelete,
|
||||
onPreview,
|
||||
onReorderUp,
|
||||
onReorderDown,
|
||||
}: RowProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameDraft, setNameDraft] = useState(entry.name);
|
||||
const [categoryDraft, setCategoryDraft] = useState(entry.category ?? '');
|
||||
const [capturingHotkey, setCapturingHotkey] = useState(false);
|
||||
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setNameDraft(entry.name);
|
||||
setCategoryDraft(entry.category ?? '');
|
||||
}, [entry.name, entry.category]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!capturingHotkey) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.code === 'Escape') {
|
||||
setCapturingHotkey(false);
|
||||
setHotkeyError(null);
|
||||
return;
|
||||
}
|
||||
// Conflict checks: PTT + other soundboard entries with this code.
|
||||
const ptt = getPttSettings();
|
||||
if (ptt.enabled && ptt.key === e.code) {
|
||||
setHotkeyError(
|
||||
t('app:soundboard.hotkey_conflict_ptt', {
|
||||
defaultValue: 'Konflikt mit Push-to-Talk.',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const taken = entriesTotal.find((s) => s.id !== entry.id && s.hotkey === e.code);
|
||||
if (taken) {
|
||||
setHotkeyError(
|
||||
t('app:soundboard.hotkey_conflict_sound', {
|
||||
defaultValue: 'Bereits von "{{name}}" belegt.',
|
||||
name: taken.name,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setHotkeyError(null);
|
||||
setCapturingHotkey(false);
|
||||
void onPatch(entry.id, { hotkey: e.code });
|
||||
};
|
||||
window.addEventListener('keydown', onKey, { capture: true });
|
||||
return () => window.removeEventListener('keydown', onKey, { capture: true });
|
||||
}, [capturingHotkey, entriesTotal, entry.id, onPatch, t]);
|
||||
|
||||
const hotkeyLabel = entry.hotkey ? codeToShortcut(entry.hotkey) : null;
|
||||
|
||||
return (
|
||||
<li className="flex flex-wrap items-center gap-3 rounded-md border border-line bg-surface-3 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
{editingName ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={nameDraft}
|
||||
onChange={(e) => setNameDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
setEditingName(false);
|
||||
if (nameDraft.trim() && nameDraft !== entry.name) {
|
||||
void onPatch(entry.id, { name: nameDraft });
|
||||
} else {
|
||||
setNameDraft(entry.name);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setNameDraft(entry.name);
|
||||
setEditingName(false);
|
||||
}
|
||||
}}
|
||||
className="w-full rounded border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingName(true)}
|
||||
className="flex cursor-pointer items-center gap-1.5 self-start text-sm font-semibold text-fg hover:text-accent"
|
||||
>
|
||||
{entry.name}
|
||||
<PencilIcon className="h-3 w-3 opacity-50" />
|
||||
</button>
|
||||
)}
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
{(entry.size / BYTES_PER_MB).toFixed(2)} MB · {entry.mime}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={categoryDraft}
|
||||
onChange={(e) => setCategoryDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
const next = categoryDraft.trim() || null;
|
||||
if (next !== (entry.category ?? null)) {
|
||||
void onPatch(entry.id, { category: next });
|
||||
}
|
||||
}}
|
||||
list={CATEGORY_LIST_ID}
|
||||
placeholder={t('app:soundboard.category_placeholder', {
|
||||
defaultValue: 'Kategorie…',
|
||||
})}
|
||||
className="w-32 shrink-0 rounded border border-line bg-surface-2 px-2 py-1 text-xs text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHotkeyError(null);
|
||||
setCapturingHotkey((v) => !v);
|
||||
}}
|
||||
className={
|
||||
'inline-flex min-w-[5rem] cursor-pointer items-center justify-center rounded border px-2 py-1 text-[11px] font-mono font-semibold transition ' +
|
||||
(capturingHotkey
|
||||
? 'animate-pulse border-accent bg-accent/20 text-fg'
|
||||
: hotkeyLabel
|
||||
? 'border-accent/40 bg-accent/10 text-accent'
|
||||
: 'border-line bg-surface-2 text-fg-muted hover:bg-surface')
|
||||
}
|
||||
title={t('app:soundboard.hotkey_capture', {
|
||||
defaultValue: 'Hotkey binden (Esc = abbrechen)',
|
||||
})}
|
||||
>
|
||||
{capturingHotkey
|
||||
? t('app:soundboard.hotkey_press', { defaultValue: 'Drücke…' })
|
||||
: hotkeyLabel ??
|
||||
t('app:soundboard.hotkey_none', { defaultValue: 'Kein Hotkey' })}
|
||||
</button>
|
||||
{entry.hotkey && !capturingHotkey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onPatch(entry.id, { hotkey: null })}
|
||||
aria-label={t('app:soundboard.hotkey_clear', { defaultValue: 'Hotkey entfernen' })}
|
||||
title={t('app:soundboard.hotkey_clear', { defaultValue: 'Hotkey entfernen' })}
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={entry.gain}
|
||||
onChange={(e) => void onPatch(entry.id, { gain: Number(e.target.value) })}
|
||||
className="accent-accent w-20"
|
||||
title={t('app:soundboard.gain_title', {
|
||||
defaultValue: 'Lautstärke',
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onPreview(entry)}
|
||||
disabled={busy}
|
||||
className="inline-flex cursor-pointer items-center gap-1 rounded border border-line bg-surface-2 px-2 py-1 text-[11px] font-medium text-fg transition hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
{previewing
|
||||
? t('app:soundboard.preview_stop', { defaultValue: 'Stop' })
|
||||
: t('app:soundboard.preview', { defaultValue: 'Vorhören' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReorderUp}
|
||||
disabled={busy || isFirst}
|
||||
aria-label={t('app:soundboard.move_up', { defaultValue: 'Nach oben' })}
|
||||
title={t('app:soundboard.move_up', { defaultValue: 'Nach oben' })}
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReorderDown}
|
||||
disabled={busy || isLast}
|
||||
aria-label={t('app:soundboard.move_down', { defaultValue: 'Nach unten' })}
|
||||
title={t('app:soundboard.move_down', { defaultValue: 'Nach unten' })}
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDelete(entry.id)}
|
||||
disabled={busy}
|
||||
aria-label={t('app:soundboard.delete', { defaultValue: 'Löschen' })}
|
||||
title={t('app:soundboard.delete', { defaultValue: 'Löschen' })}
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded border border-rose-500/30 bg-rose-500/10 text-rose-600 hover:bg-rose-500/20 disabled:opacity-50 dark:text-rose-300"
|
||||
>
|
||||
<TrashIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hotkeyError && capturingHotkey && (
|
||||
<p className="basis-full text-[11px] text-rose-600 dark:text-rose-300">
|
||||
{hotkeyError}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { codeToShortcut } from '../lib/globalShortcut';
|
||||
import {
|
||||
DEFAULT_PREFS,
|
||||
getPrefs,
|
||||
listSounds,
|
||||
type SoundboardEntry,
|
||||
type SoundboardPrefs,
|
||||
subscribeSoundboardChanges,
|
||||
} from '../lib/soundboardStorage';
|
||||
import { ChevronDownIcon, XIcon } from './icons';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// In-call popover that lists every stored sound grouped by category. Click a
|
||||
// pad to play through the active pipeline. Hotkey-badge shows the bound
|
||||
// accelerator (if any). Master + monitor sliders adjust the pipeline gains.
|
||||
export function SoundboardPanel({ onClose }: Props) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const {
|
||||
playSoundboard,
|
||||
stopSoundboard,
|
||||
activeSoundboardIds,
|
||||
setSoundboardMasterGain,
|
||||
setSoundboardMonitorGain,
|
||||
} = useCall();
|
||||
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
|
||||
const [prefs, setPrefs] = useState<SoundboardPrefs>(() => ({ ...DEFAULT_PREFS }));
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
const [query, setQuery] = useState('');
|
||||
const closeRef = useRef(onClose);
|
||||
closeRef.current = onClose;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [all, p] = await Promise.all([listSounds(), getPrefs()]);
|
||||
setEntries(all);
|
||||
setPrefs(p);
|
||||
} catch (err: unknown) {
|
||||
console.warn('soundboard panel refresh failed', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const unsub = subscribeSoundboardChanges(() => {
|
||||
void refresh();
|
||||
});
|
||||
return unsub;
|
||||
}, [refresh]);
|
||||
|
||||
// Close on Esc — tapping outside is handled by the trigger's parent.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeRef.current();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return entries;
|
||||
return entries.filter(
|
||||
(e) =>
|
||||
e.name.toLowerCase().includes(q) ||
|
||||
(e.category ?? '').toLowerCase().includes(q),
|
||||
);
|
||||
}, [entries, query]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, SoundboardEntry[]>();
|
||||
for (const e of filtered) {
|
||||
const key = e.category ?? '';
|
||||
const arr = map.get(key);
|
||||
if (arr) arr.push(e);
|
||||
else map.set(key, [e]);
|
||||
}
|
||||
return map;
|
||||
}, [filtered]);
|
||||
|
||||
function toggleCategory(key: string): void {
|
||||
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={t('app:soundboard.panel_title', { defaultValue: 'Soundboard' })}
|
||||
className="flex w-[360px] max-h-[70vh] flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-2xl"
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-line px-4 py-2.5">
|
||||
<h3 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:soundboard.panel_title', { defaultValue: 'Soundboard' })}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('app:soundboard.panel_close', { defaultValue: 'Schließen' })}
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{entries.length > 0 && (
|
||||
<div className="border-b border-line px-3 pb-2 pt-2">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('app:soundboard.panel_search', { defaultValue: 'Suche…' })}
|
||||
className="w-full rounded-md border border-line bg-surface-2 px-3 py-1.5 text-xs text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{entries.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-fg-muted">
|
||||
{t('app:soundboard.panel_empty', {
|
||||
defaultValue:
|
||||
'Keine Sounds gespeichert. Füge welche in den Einstellungen hinzu.',
|
||||
})}
|
||||
</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-fg-muted">
|
||||
{t('app:soundboard.panel_no_matches', {
|
||||
defaultValue: 'Keine Treffer.',
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{Array.from(grouped.entries()).map(([key, bucket]) => (
|
||||
<CategorySection
|
||||
key={key || '__uncat'}
|
||||
categoryKey={key}
|
||||
entries={bucket}
|
||||
collapsed={collapsed[key] ?? false}
|
||||
activeIds={activeSoundboardIds}
|
||||
onToggle={() => toggleCategory(key)}
|
||||
onActivate={(id) => {
|
||||
if (activeSoundboardIds.has(id)) {
|
||||
stopSoundboard(id);
|
||||
} else {
|
||||
void playSoundboard(id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex flex-col gap-2 border-t border-line bg-surface-2 px-4 py-3">
|
||||
<VolumeSlider
|
||||
label={t('app:soundboard.panel_master', { defaultValue: 'Master' })}
|
||||
value={prefs.masterGain}
|
||||
onChange={(v) => {
|
||||
setPrefs((p) => ({ ...p, masterGain: v }));
|
||||
void setSoundboardMasterGain(v);
|
||||
}}
|
||||
/>
|
||||
<VolumeSlider
|
||||
label={t('app:soundboard.panel_monitor', { defaultValue: 'Mithören' })}
|
||||
value={prefs.monitorGain}
|
||||
onChange={(v) => {
|
||||
setPrefs((p) => ({ ...p, monitorGain: v }));
|
||||
void setSoundboardMonitorGain(v);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stopSoundboard()}
|
||||
className="mt-1 inline-flex cursor-pointer items-center justify-center rounded-md border border-line bg-surface-3 px-3 py-1.5 text-[11px] font-semibold text-fg transition hover:brightness-95"
|
||||
>
|
||||
{t('app:soundboard.panel_stop_all', { defaultValue: 'Alle stoppen' })}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CategorySectionProps {
|
||||
categoryKey: string;
|
||||
entries: SoundboardEntry[];
|
||||
collapsed: boolean;
|
||||
activeIds: ReadonlySet<string>;
|
||||
onToggle: () => void;
|
||||
onActivate: (id: string) => void;
|
||||
}
|
||||
|
||||
function CategorySection({
|
||||
categoryKey,
|
||||
entries,
|
||||
collapsed,
|
||||
activeIds,
|
||||
onToggle,
|
||||
onActivate,
|
||||
}: CategorySectionProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const label =
|
||||
categoryKey === ''
|
||||
? t('app:soundboard.category_uncategorized', { defaultValue: '(Ohne Kategorie)' })
|
||||
: categoryKey;
|
||||
return (
|
||||
<section>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="mb-1.5 flex w-full cursor-pointer items-center justify-between gap-2 text-left text-[10px] font-semibold uppercase tracking-[0.1em] text-fg-muted"
|
||||
>
|
||||
<span>
|
||||
{label} · {entries.length}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={'h-3 w-3 transition ' + (collapsed ? '-rotate-90' : '')}
|
||||
/>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{entries.map((entry) => (
|
||||
<SoundPad
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
active={activeIds.has(entry.id)}
|
||||
onActivate={() => onActivate(entry.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface PadProps {
|
||||
entry: SoundboardEntry;
|
||||
active: boolean;
|
||||
onActivate: () => void;
|
||||
}
|
||||
|
||||
function SoundPad({ entry, active, onActivate }: PadProps) {
|
||||
const { t } = useTranslation(['app']);
|
||||
const hotkey = entry.hotkey ? codeToShortcut(entry.hotkey) : null;
|
||||
const base =
|
||||
'group relative flex min-h-[54px] cursor-pointer flex-col justify-center gap-0.5 rounded-md border px-2.5 py-2 text-left text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40';
|
||||
const toneClass = active
|
||||
? 'border-rose-500 bg-rose-500/20 text-fg hover:brightness-110'
|
||||
: 'border-line bg-surface-2 text-fg hover:border-accent hover:bg-surface-3';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onActivate}
|
||||
className={`${base} ${toneClass}`}
|
||||
title={
|
||||
active
|
||||
? t('app:soundboard.pad_stop', { defaultValue: 'Stoppen' })
|
||||
: entry.name
|
||||
}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
{active && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-2 w-2 shrink-0 rounded-full bg-rose-500 animate-live-dot"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</span>
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
{hotkey ? (
|
||||
<span className="inline-flex w-fit items-center rounded border border-line bg-surface-3 px-1 py-0.5 font-mono text-[9px] text-fg-muted">
|
||||
{hotkey}
|
||||
</span>
|
||||
) : (
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
{active && (
|
||||
<span className="text-[9px] font-semibold uppercase tracking-[0.08em] text-rose-500 dark:text-rose-300">
|
||||
{t('app:soundboard.pad_stop', { defaultValue: 'Stoppen' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function VolumeSlider({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-3 text-[11px] text-fg">
|
||||
<span className="w-16 shrink-0 text-fg-muted">{label}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.02}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="accent-accent flex-1"
|
||||
/>
|
||||
<span className="w-9 shrink-0 text-right tabular-nums text-fg-muted">
|
||||
{Math.round(value * 100)}%
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
listSounds,
|
||||
subscribeSoundboardChanges,
|
||||
type SoundboardEntry,
|
||||
} from '../lib/soundboardStorage';
|
||||
import { SoundboardManagerDialog } from './SoundboardManagerDialog';
|
||||
import { ArrowRightIcon } from './icons';
|
||||
|
||||
// Entry point into the soundboard manager from the settings page. Shows a
|
||||
// tiny summary (count, category count) and opens the big dialog on click.
|
||||
export function SoundboardSettings() {
|
||||
const { t } = useTranslation(['app']);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const all = await listSounds();
|
||||
if (!cancelled) setEntries(all);
|
||||
} catch (err: unknown) {
|
||||
console.warn('listSounds failed', err);
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
const unsub = subscribeSoundboardChanges(() => {
|
||||
void refresh();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsub();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const categoryCount = new Set(entries.map((e) => e.category ?? '__uncat')).size;
|
||||
const withHotkey = entries.filter((e) => e.hotkey !== null).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-fg">
|
||||
{t('app:soundboard.summary_title', { defaultValue: 'Deine Sounds' })}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{entries.length === 0
|
||||
? t('app:soundboard.summary_empty', {
|
||||
defaultValue: 'Noch keine Sounds vorhanden.',
|
||||
})
|
||||
: t('app:soundboard.summary_counts', {
|
||||
defaultValue:
|
||||
'{{sounds}} Sounds · {{categories}} Kategorien · {{hotkeys}} mit Hotkey',
|
||||
sounds: entries.length,
|
||||
categories: categoryCount,
|
||||
hotkeys: withHotkey,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110"
|
||||
>
|
||||
<span>
|
||||
{t('app:soundboard.manage', { defaultValue: 'Verwalten' })}
|
||||
</span>
|
||||
<ArrowRightIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
{t('app:soundboard.settings_hint', {
|
||||
defaultValue:
|
||||
'Hotkeys sind optional. Sounds lassen sich auch während eines Anrufs direkt im UI abspielen.',
|
||||
})}
|
||||
</p>
|
||||
<SoundboardManagerDialog open={open} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { updateOwnProfile } from '@chat-app/shared/auth';
|
||||
import type { PresenceState } from '@chat-app/shared/supabase';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
@@ -8,6 +8,8 @@ import { supabase } from '../lib/supabase';
|
||||
import { Avatar } from './Avatar';
|
||||
import { ChevronDownIcon } from './icons';
|
||||
|
||||
const STATUS_MAX = 128;
|
||||
|
||||
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
|
||||
|
||||
const PRESENCE_DOT: Record<PresenceState, string> = {
|
||||
@@ -25,6 +27,23 @@ export function UserBar() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const presence = profile?.presenceState ?? 'offline';
|
||||
const persistedStatus = profile?.statusMessage ?? '';
|
||||
const [statusDraft, setStatusDraft] = useState(persistedStatus);
|
||||
|
||||
// Re-sync local draft with the server when the profile refreshes (e.g. after
|
||||
// a successful save) — without this the input would forget any incoming
|
||||
// updates from another device.
|
||||
useEffect(() => {
|
||||
setStatusDraft(persistedStatus);
|
||||
}, [persistedStatus]);
|
||||
|
||||
// Subtitle priority: custom status when online and set, else label, else "Offline".
|
||||
const subtitle =
|
||||
presence === 'offline'
|
||||
? t('app:presence.offline')
|
||||
: persistedStatus.trim().length > 0
|
||||
? persistedStatus.trim()
|
||||
: t('app:presence.' + presence);
|
||||
|
||||
async function changePresence(next: PresenceState) {
|
||||
if (busy || next === presence) {
|
||||
@@ -43,6 +62,20 @@ export function UserBar() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStatus() {
|
||||
const next = statusDraft.trim().slice(0, STATUS_MAX);
|
||||
if (next === persistedStatus) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await updateOwnProfile(supabase, { statusMessage: next.length === 0 ? null : next });
|
||||
await refreshProfile();
|
||||
} catch (err: unknown) {
|
||||
console.error('updateStatusMessage failed', err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
@@ -69,9 +102,7 @@ export function UserBar() {
|
||||
<p className="truncate text-sm font-medium text-fg">
|
||||
{profile?.displayName ?? '—'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-fg-muted">
|
||||
{t('app:presence.' + presence)}
|
||||
</p>
|
||||
<p className="truncate text-xs text-fg-muted">{subtitle}</p>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={'h-4 w-4 text-fg-muted transition ' + (open ? 'rotate-180' : '')}
|
||||
@@ -83,6 +114,30 @@ export function UserBar() {
|
||||
role="menu"
|
||||
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-line bg-surface-3 shadow-xl"
|
||||
>
|
||||
<div className="border-b border-line p-2">
|
||||
<input
|
||||
type="text"
|
||||
value={statusDraft}
|
||||
onChange={(e) => setStatusDraft(e.target.value.slice(0, STATUS_MAX))}
|
||||
onBlur={() => void saveStatus()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void saveStatus();
|
||||
setOpen(false);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setStatusDraft(persistedStatus);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
placeholder={t('app:presence.status_placeholder', {
|
||||
defaultValue: 'Status setzen…',
|
||||
})}
|
||||
maxLength={STATUS_MAX}
|
||||
className="w-full rounded-md border border-line bg-surface-2 px-2 py-1.5 text-sm text-fg placeholder-fg-muted outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
{PRESENCE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
|
||||
@@ -401,6 +401,16 @@ export function CrownIcon(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function MusicIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
export function SendIcon(props: IconProps) {
|
||||
return (
|
||||
<Base {...props}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getOwnProfile,
|
||||
signOut as supabaseSignOut,
|
||||
type Profile,
|
||||
updateOwnProfile,
|
||||
} from '@chat-app/shared/auth';
|
||||
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
|
||||
import type { Session } from '@supabase/supabase-js';
|
||||
@@ -143,6 +144,43 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
void registerWebPush(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 () => {
|
||||
await supabaseSignOut(supabase);
|
||||
}, []);
|
||||
|
||||
@@ -47,7 +47,15 @@ import {
|
||||
getCallE2EESettings,
|
||||
isE2EESupported,
|
||||
} from '../lib/callE2EE';
|
||||
import { createMicPipeline, type MicPipeline } from '../lib/micPipeline';
|
||||
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 {
|
||||
type DisplaySurfaceHint,
|
||||
getPresetParams,
|
||||
@@ -110,6 +118,11 @@ interface CallContextValue {
|
||||
isDeafened: boolean;
|
||||
/** identity -> their deafen state, received via data channel. */
|
||||
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).
|
||||
remoteScreenShares: RemoteScreenShare[];
|
||||
// Remembers the conversation of the last call we left so a sidebar widget
|
||||
@@ -139,6 +152,19 @@ interface CallContextValue {
|
||||
dismissLastCall: () => void;
|
||||
setCallMode: (mode: CallMode) => 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
|
||||
// hot-swaps the input on an active call without a reconnect.
|
||||
setAudioInputDevice: (deviceId: string | null) => Promise<void>;
|
||||
@@ -157,7 +183,7 @@ function newCallId(): string {
|
||||
}
|
||||
|
||||
export function CallProvider({ children }: { children: ReactNode }) {
|
||||
const { session, device } = useAuth();
|
||||
const { session, device, profile } = useAuth();
|
||||
const { conversations } = useConversationsContext();
|
||||
const myId = session?.user.id;
|
||||
|
||||
@@ -173,12 +199,18 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
|
||||
const [callMode, setCallModeState] = useState<CallMode>('grid');
|
||||
const [focusedId, setFocusedIdState] = useState<string | null>(null);
|
||||
const [activeSoundboardIds, setActiveSoundboardIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set<string>(),
|
||||
);
|
||||
|
||||
const signalChannelRef = useRef<RealtimeChannel | null>(null);
|
||||
const presenceChannelRef = useRef<RealtimeChannel | null>(null);
|
||||
const ringTimerRef = useRef<number | null>(null);
|
||||
const soloTimerRef = useRef<number | 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
|
||||
// so hangup/solo-timeout can emit a real duration message vs. "missed".
|
||||
const everConnectedRef = useRef<boolean>(false);
|
||||
@@ -191,12 +223,21 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
// channel messages ({ type: 'presence', deafened: bool }). Exposed as
|
||||
// state so consumer components re-render on change.
|
||||
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);
|
||||
stateRef.current = state;
|
||||
// Keep latest conversations accessible from signal-channel closures without
|
||||
// re-subscribing the channel on every conversations update.
|
||||
const conversationsRef = useRef(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 -----------------------------------------------------------
|
||||
|
||||
@@ -271,8 +312,25 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
setIsDeafened(false);
|
||||
deafenedActive = false;
|
||||
setRemoteDeafen({});
|
||||
setRemoteMute({});
|
||||
setIsMuted(false);
|
||||
mutedRef.current = 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;
|
||||
if (pres) {
|
||||
try {
|
||||
@@ -511,23 +569,36 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
if (!participant?.identity) return;
|
||||
try {
|
||||
const text = new TextDecoder().decode(payload);
|
||||
const msg = JSON.parse(text) as { type?: string; deafened?: boolean };
|
||||
if (msg.type === 'presence' && typeof msg.deafened === 'boolean') {
|
||||
const id: string = participant.identity;
|
||||
const msg = JSON.parse(text) as {
|
||||
type?: string;
|
||||
deafened?: boolean;
|
||||
muted?: boolean;
|
||||
};
|
||||
if (msg.type !== 'presence') return;
|
||||
const id: string = participant.identity;
|
||||
if (typeof msg.deafened === 'boolean') {
|
||||
const deafened: boolean = msg.deafened;
|
||||
setRemoteDeafen((prev) => {
|
||||
if (prev[id] === deafened) return prev;
|
||||
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 {
|
||||
/* 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, () => {
|
||||
void broadcastPresence(r, deafenedActive);
|
||||
void broadcastPresence(r, deafenedActive, mutedRef.current);
|
||||
});
|
||||
|
||||
// Track my own screen-share state via LocalTrack events so the toggle
|
||||
@@ -557,18 +628,43 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
try {
|
||||
const inputId = getAudioSettings().inputDeviceId;
|
||||
await r.localParticipant.setMicrophoneEnabled(true, {
|
||||
echoCancellation: aParams.echoCancellation,
|
||||
noiseSuppression: aParams.noiseSuppression,
|
||||
autoGainControl: aParams.autoGainControl,
|
||||
channelCount: aParams.stereo ? 2 : 1,
|
||||
sampleRate: aParams.sampleRateHz,
|
||||
// Plain string maps to `ideal` — if the device is gone we fall back
|
||||
// to OS default instead of throwing NotFoundError.
|
||||
...(inputId ? { deviceId: inputId } : {}),
|
||||
// Grab the raw mic ourselves instead of going through LiveKit's
|
||||
// setMicrophoneEnabled. The resulting MediaStreamTrack is routed
|
||||
// through createMicPipeline, which mixes in soundboard buffers and
|
||||
// exposes a single output track we hand to publishTrack. Mute / PTT
|
||||
// are gain-based from here on, never track.enabled or device stop.
|
||||
const rawStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: aParams.echoCancellation,
|
||||
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) {
|
||||
console.error('setMicrophoneEnabled failed', micErr);
|
||||
console.error('mic pipeline setup failed', micErr);
|
||||
}
|
||||
if (mediaKind === 'video') {
|
||||
try {
|
||||
@@ -807,12 +903,15 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const r = roomRef.current;
|
||||
if (!r) return;
|
||||
const lp = r.localParticipant;
|
||||
const shouldEnable = !lp.isMicrophoneEnabled;
|
||||
void lp.setMicrophoneEnabled(shouldEnable).then(() => {
|
||||
setIsMuted(!shouldEnable);
|
||||
const pipeline = pipelineRef.current;
|
||||
if (!pipeline) return;
|
||||
setIsMuted((prev) => {
|
||||
const nextMuted = !prev;
|
||||
pipeline.setMicGain(nextMuted ? 0 : 1);
|
||||
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
|
||||
// version, unlike `setAttributes` which requires a newer server.
|
||||
const r = roomRef.current;
|
||||
if (r) void broadcastPresence(r, next);
|
||||
if (r) void broadcastPresence(r, next, mutedRef.current);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
@@ -957,9 +1056,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
let globalRegisteredFor: string | null = null;
|
||||
|
||||
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;
|
||||
if (!r) return;
|
||||
void r.localParticipant.setMicrophoneEnabled(on).then(() => setIsMuted(!on));
|
||||
if (r) void broadcastPresence(r, deafenedActive, nextMuted);
|
||||
};
|
||||
|
||||
const pressPtt = () => {
|
||||
@@ -1088,14 +1192,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
conv?.peer?.displayName ??
|
||||
'…';
|
||||
const isGroup = conv?.type === 'group';
|
||||
void notify({
|
||||
title: isGroup
|
||||
? (conv?.name ?? 'Gruppenanruf')
|
||||
: 'Eingehender Anruf',
|
||||
body: isGroup
|
||||
? callerName + ' ruft die Gruppe'
|
||||
: callerName + ' ruft dich an',
|
||||
});
|
||||
if (presenceRef.current !== 'dnd') {
|
||||
void notify({
|
||||
title: isGroup
|
||||
? (conv?.name ?? 'Gruppenanruf')
|
||||
: 'Eingehender Anruf',
|
||||
body: isGroup
|
||||
? callerName + ' ruft die Gruppe'
|
||||
: callerName + ' ruft dich an',
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'accept':
|
||||
@@ -1136,10 +1242,12 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
conv?.members.find((m) => m.userId === cur.fromUserId)?.profile?.displayName ??
|
||||
conv?.peer?.displayName ??
|
||||
'…';
|
||||
void notify({
|
||||
title: 'Verpasster Anruf',
|
||||
body: callerName + ' hat aufgelegt',
|
||||
});
|
||||
if (presenceRef.current !== 'dnd') {
|
||||
void notify({
|
||||
title: 'Verpasster Anruf',
|
||||
body: callerName + ' hat aufgelegt',
|
||||
});
|
||||
}
|
||||
setState({ kind: 'idle' });
|
||||
}
|
||||
break;
|
||||
@@ -1156,16 +1264,96 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
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) => {
|
||||
updateAudioSettings({ inputDeviceId: deviceId });
|
||||
const r = roomRef.current;
|
||||
if (!r) return;
|
||||
const pipeline = pipelineRef.current;
|
||||
if (!pipeline) return;
|
||||
try {
|
||||
// LiveKit API: `switchActiveDevice(kind, deviceId)` hot-swaps without a
|
||||
// reconnect. Pass empty string or `default` to revert to OS default.
|
||||
await r.switchActiveDevice('audioinput', deviceId ?? 'default');
|
||||
// We own the mic track (see joinRoom pipeline setup), so LiveKit's
|
||||
// switchActiveDevice no longer applies. Fetch a new raw track with the
|
||||
// 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) {
|
||||
console.error('switchActiveDevice(audioinput) failed', err);
|
||||
console.error('setAudioInputDevice failed', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -1229,6 +1417,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
isCameraEnabled,
|
||||
isDeafened,
|
||||
remoteDeafen,
|
||||
remoteMute,
|
||||
remoteScreenShares,
|
||||
lastCallConversationId,
|
||||
callMode,
|
||||
@@ -1249,6 +1438,11 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
setFocusedId,
|
||||
setAudioInputDevice,
|
||||
setAudioOutputDevice,
|
||||
playSoundboard,
|
||||
stopSoundboard,
|
||||
activeSoundboardIds,
|
||||
setSoundboardMasterGain,
|
||||
setSoundboardMonitorGain,
|
||||
}),
|
||||
[
|
||||
state,
|
||||
@@ -1260,6 +1454,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
isCameraEnabled,
|
||||
isDeafened,
|
||||
remoteDeafen,
|
||||
remoteMute,
|
||||
remoteScreenShares,
|
||||
lastCallConversationId,
|
||||
callMode,
|
||||
@@ -1280,6 +1475,11 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
||||
setFocusedId,
|
||||
setAudioInputDevice,
|
||||
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.
|
||||
let deafenedActive = false;
|
||||
|
||||
async function broadcastPresence(room: Room, deafened: boolean): Promise<void> {
|
||||
async function broadcastPresence(
|
||||
room: Room,
|
||||
deafened: boolean,
|
||||
muted: boolean,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const payload = new TextEncoder().encode(
|
||||
JSON.stringify({ type: 'presence', deafened }),
|
||||
JSON.stringify({ type: 'presence', deafened, muted }),
|
||||
);
|
||||
await room.localParticipant.publishData(payload, { reliable: true });
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -55,3 +55,72 @@ export async function unregisterPttShortcut(code: string): Promise<void> {
|
||||
export function isTauriRuntime(): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
// Looping WebAudio ringtones. Two patterns:
|
||||
// - outgoing: long calling tone, 3s cycle
|
||||
// - incoming: classic "ring ring" double beep, 2s cycle
|
||||
// Looping ringtones. Two patterns:
|
||||
// - outgoing: long calling tone, 3s cycle (oscillator only)
|
||||
// - 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';
|
||||
|
||||
@@ -9,15 +16,90 @@ class Ringtone {
|
||||
private interval: number | 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 {
|
||||
if (this.pattern === pattern) return; // already playing this pattern
|
||||
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 =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioCtx) return;
|
||||
this.ctx = new AudioCtx();
|
||||
this.pattern = pattern;
|
||||
|
||||
const play = pattern === 'outgoing' ? this.playOutgoing : this.playIncoming;
|
||||
play.call(this);
|
||||
@@ -27,7 +109,7 @@ class Ringtone {
|
||||
);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
private stopOscillator(): void {
|
||||
if (this.interval !== null) {
|
||||
window.clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
@@ -38,7 +120,6 @@ class Ringtone {
|
||||
});
|
||||
this.ctx = null;
|
||||
}
|
||||
this.pattern = null;
|
||||
}
|
||||
|
||||
private beep(freq: number, durationSec: number, delaySec: number, gain = 0.18): void {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,10 +3,16 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// Subscribe to a single peer's presence_state via Supabase realtime.
|
||||
// Returns null until the first row arrives, or when userId is undefined.
|
||||
export function usePeerPresence(userId: string | undefined): PresenceState | null {
|
||||
const [presence, setPresence] = useState<PresenceState | null>(null);
|
||||
export interface PeerPresence {
|
||||
state: PresenceState;
|
||||
statusMessage: string | 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(() => {
|
||||
if (!userId) {
|
||||
@@ -17,11 +23,15 @@ export function usePeerPresence(userId: string | undefined): PresenceState | nul
|
||||
|
||||
void supabase
|
||||
.from('profiles')
|
||||
.select('presence_state')
|
||||
.select('presence_state, status_message')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
.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
|
||||
@@ -35,8 +45,21 @@ export function usePeerPresence(userId: string | undefined): PresenceState | nul
|
||||
filter: 'user_id=eq.' + userId,
|
||||
},
|
||||
(payload: { new: Record<string, unknown> }) => {
|
||||
const next = payload.new['presence_state'];
|
||||
if (typeof next === 'string') setPresence(next as PresenceState);
|
||||
const nextState = payload.new['presence_state'];
|
||||
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();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { ConversationHeader } from '../components/ConversationHeader';
|
||||
import { EmojiPicker } from '../components/EmojiPicker';
|
||||
import { ForwardDialog } from '../components/ForwardDialog';
|
||||
import { GroupInfoPanel } from '../components/GroupInfoPanel';
|
||||
import {
|
||||
@@ -15,11 +16,13 @@ import {
|
||||
PlusIcon,
|
||||
ReplyIcon,
|
||||
SearchIcon,
|
||||
SmileIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '../components/icons';
|
||||
import { InCallPanel } from '../components/InCallPanel';
|
||||
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||
import { MentionAutocomplete } from '../components/MentionAutocomplete';
|
||||
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
||||
import { TypingIndicator } from '../components/TypingIndicator';
|
||||
import { VoiceRecorder } from '../components/VoiceRecorder';
|
||||
@@ -141,6 +144,11 @@ export function ConversationPage() {
|
||||
const [searchDateTo, setSearchDateTo] = useState<string>('');
|
||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||
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 loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -345,13 +353,9 @@ export function ConversationPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilesChosen(list: FileList | null) {
|
||||
if (!list) return;
|
||||
function ingestFiles(files: File[]) {
|
||||
const next: File[] = [];
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const f = list[i];
|
||||
if (!f) continue;
|
||||
if (!f.type.startsWith('image/')) continue;
|
||||
for (const f of files) {
|
||||
if (f.size > 10 * 1024 * 1024) {
|
||||
setSendError('Datei zu groß (max 10 MB)');
|
||||
continue;
|
||||
@@ -361,6 +365,11 @@ export function ConversationPage() {
|
||||
setAttachments((prev) => [...prev, ...next].slice(0, 4));
|
||||
}
|
||||
|
||||
function handleFilesChosen(list: FileList | null) {
|
||||
if (!list) return;
|
||||
ingestFiles(Array.from(list));
|
||||
}
|
||||
|
||||
const { state: callState } = useCall();
|
||||
// Hide the chat header while this conversation hosts an active call — the
|
||||
// call topbar inside the dock already shows the channel name + duration,
|
||||
@@ -374,7 +383,31 @@ export function ConversationPage() {
|
||||
callState.kind === 'incoming' && callState.conversationId === id;
|
||||
|
||||
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 && (
|
||||
<ConversationHeader
|
||||
conversation={conversation}
|
||||
@@ -530,6 +563,17 @@ export function ConversationPage() {
|
||||
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">
|
||||
{sendError && (
|
||||
<div className="mb-2">
|
||||
@@ -583,11 +627,36 @@ export function ConversationPage() {
|
||||
</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
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFilesChosen(e.target.files)}
|
||||
@@ -595,12 +664,41 @@ export function ConversationPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label="Bild anhängen"
|
||||
title="Bild anhängen"
|
||||
aria-label="Datei 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"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</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
|
||||
disabled={sending}
|
||||
onComplete={async (file) => {
|
||||
@@ -617,8 +715,26 @@ export function ConversationPage() {
|
||||
ref={composerRef}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
if (e.target.value.length > 0) notifyTyping();
|
||||
const next = e.target.value;
|
||||
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) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
@@ -626,6 +742,21 @@ export function ConversationPage() {
|
||||
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}
|
||||
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"
|
||||
@@ -885,18 +1016,30 @@ function Banner({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => void }) {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!isImage) return;
|
||||
const u = URL.createObjectURL(file);
|
||||
setUrl(u);
|
||||
return () => URL.revokeObjectURL(u);
|
||||
}, [file]);
|
||||
}, [file, isImage]);
|
||||
return (
|
||||
<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" />
|
||||
) : (
|
||||
<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
|
||||
type="button"
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Avatar } from '../components/Avatar';
|
||||
import { BackupExportDialog } from '../components/BackupExportDialog';
|
||||
import { RingtoneSettings } from '../components/RingtoneSettings';
|
||||
import { SoundboardSettings } from '../components/SoundboardSettings';
|
||||
import { LockIcon } from '../components/icons';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
@@ -141,6 +143,16 @@ export function SettingsPage() {
|
||||
/>
|
||||
</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 */}
|
||||
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
|
||||
<AudioDeviceControls />
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"idle": "Abwesend",
|
||||
"dnd": "Nicht stören",
|
||||
"invisible": "Unsichtbar",
|
||||
"offline": "Offline"
|
||||
"offline": "Offline",
|
||||
"status_placeholder": "Status setzen…"
|
||||
},
|
||||
"chats": {
|
||||
"empty_title": "Noch keine Unterhaltungen",
|
||||
@@ -194,7 +195,61 @@
|
||||
"allow_dms_strangers_hint": "Wenn aus, können dich nur Freunde direkt anschreiben.",
|
||||
"this_device": "Dieses Gerät",
|
||||
"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": {
|
||||
"available": "Update verfügbar",
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"idle": "Idle",
|
||||
"dnd": "Do not disturb",
|
||||
"invisible": "Invisible",
|
||||
"offline": "Offline"
|
||||
"offline": "Offline",
|
||||
"status_placeholder": "Set status…"
|
||||
},
|
||||
"chats": {
|
||||
"empty_title": "No conversations yet",
|
||||
@@ -194,7 +195,61 @@
|
||||
"allow_dms_strangers_hint": "When off, only friends can DM you.",
|
||||
"this_device": "This device",
|
||||
"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": {
|
||||
"available": "Update available",
|
||||
|
||||
@@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/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.
|
||||
Reference in New Issue
Block a user