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,
|
MicOffIcon,
|
||||||
MonitorShareIcon,
|
MonitorShareIcon,
|
||||||
MonitorStopIcon,
|
MonitorStopIcon,
|
||||||
|
MusicIcon,
|
||||||
PhoneOffIcon,
|
PhoneOffIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
VideoIcon,
|
VideoIcon,
|
||||||
@@ -23,6 +24,9 @@ interface Props {
|
|||||||
onToggleDeafen: () => void;
|
onToggleDeafen: () => void;
|
||||||
onHangup: () => void;
|
onHangup: () => void;
|
||||||
onOpenParticipants?: () => void;
|
onOpenParticipants?: () => void;
|
||||||
|
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
||||||
|
onToggleSoundboard?: () => void;
|
||||||
|
soundboardOpen?: boolean;
|
||||||
/** Compact variant used inside the docked call (36px buttons). */
|
/** Compact variant used inside the docked call (36px buttons). */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
/** Glass variant used when controls float on fullscreen cinema mode. */
|
/** Glass variant used when controls float on fullscreen cinema mode. */
|
||||||
@@ -42,6 +46,8 @@ export function CallControls({
|
|||||||
onToggleDeafen,
|
onToggleDeafen,
|
||||||
onHangup,
|
onHangup,
|
||||||
onOpenParticipants,
|
onOpenParticipants,
|
||||||
|
onToggleSoundboard,
|
||||||
|
soundboardOpen = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
glass = false,
|
glass = false,
|
||||||
disabledMedia = false,
|
disabledMedia = false,
|
||||||
@@ -115,6 +121,18 @@ export function CallControls({
|
|||||||
<MonitorShareIcon className="h-5 w-5" />
|
<MonitorShareIcon className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
</CallButton>
|
</CallButton>
|
||||||
|
{onToggleSoundboard && (
|
||||||
|
<CallButton
|
||||||
|
label={t('app:soundboard.toggle', { defaultValue: 'Soundboard' })}
|
||||||
|
active={soundboardOpen}
|
||||||
|
activeTone="accent"
|
||||||
|
onClick={onToggleSoundboard}
|
||||||
|
glass={glass}
|
||||||
|
className={btnSize}
|
||||||
|
>
|
||||||
|
<MusicIcon className="h-5 w-5" />
|
||||||
|
</CallButton>
|
||||||
|
)}
|
||||||
{onOpenParticipants && (
|
{onOpenParticipants && (
|
||||||
<CallButton
|
<CallButton
|
||||||
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useCall } from '../context/CallContext';
|
import { useCall } from '../context/CallContext';
|
||||||
import { useConversationsContext } from '../context/ConversationsContext';
|
import { useConversationsContext } from '../context/ConversationsContext';
|
||||||
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
import { useFriendshipsContext } from '../context/FriendshipsContext';
|
||||||
@@ -17,12 +18,17 @@ import { LockIcon, MonitorShareIcon, PhoneIcon, PhoneOffIcon } from './icons';
|
|||||||
// different route.
|
// different route.
|
||||||
export function CallUI() {
|
export function CallUI() {
|
||||||
const { state } = useCall();
|
const { state } = useCall();
|
||||||
|
const { profile } = useAuth();
|
||||||
|
const dnd = profile?.presenceState === 'dnd';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// DND silences only the *incoming* ring — outgoing stays audible because
|
||||||
|
// the user initiated that call themselves. The incoming-call panel still
|
||||||
|
// appears visually; only the audible ring is suppressed.
|
||||||
if (state.kind === 'outgoing') ringtone.start('outgoing');
|
if (state.kind === 'outgoing') ringtone.start('outgoing');
|
||||||
else if (state.kind === 'incoming') ringtone.start('incoming');
|
else if (state.kind === 'incoming' && !dnd) ringtone.start('incoming');
|
||||||
else ringtone.stop();
|
else ringtone.stop();
|
||||||
}, [state.kind]);
|
}, [state.kind, dnd]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => ringtone.stop();
|
return () => ringtone.stop();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useCall } from '../context/CallContext';
|
import { useCall } from '../context/CallContext';
|
||||||
import { useCallPresence } from '../lib/useCallPresence';
|
import { useCallPresence } from '../lib/useCallPresence';
|
||||||
|
import type { PeerPresence } from '../lib/usePeerPresence';
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
import {
|
import {
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
@@ -26,7 +27,7 @@ const PRESENCE_DOT: Record<PresenceState, string> = {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conversation: ConversationSummary | null;
|
conversation: ConversationSummary | null;
|
||||||
peerPresence: PresenceState | null;
|
peerPresence: PeerPresence | null;
|
||||||
onInfoClick?: () => void;
|
onInfoClick?: () => void;
|
||||||
onSearchClick?: () => void;
|
onSearchClick?: () => void;
|
||||||
}
|
}
|
||||||
@@ -51,7 +52,7 @@ export function ConversationHeader({ conversation, peerPresence, onInfoClick, on
|
|||||||
|
|
||||||
interface HeaderBarProps {
|
interface HeaderBarProps {
|
||||||
conversation: ConversationSummary;
|
conversation: ConversationSummary;
|
||||||
peerPresence: PresenceState | null;
|
peerPresence: PeerPresence | null;
|
||||||
onInfoClick?: () => void;
|
onInfoClick?: () => void;
|
||||||
onSearchClick?: () => void;
|
onSearchClick?: () => void;
|
||||||
}
|
}
|
||||||
@@ -65,8 +66,18 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
|
|||||||
const peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) : (conversation.avatarUrl ?? null);
|
const peerAvatar = isDm ? (conversation.peer?.avatarUrl ?? null) : (conversation.avatarUrl ?? null);
|
||||||
|
|
||||||
// Hide presence when peer chose invisible — reciprocal privacy.
|
// Hide presence when peer chose invisible — reciprocal privacy.
|
||||||
const showPresence = isDm && peerPresence && peerPresence !== 'invisible';
|
const peerState = peerPresence?.state ?? null;
|
||||||
const presenceLabel = peerPresence ? t('app:presence.' + peerPresence) : '';
|
const showPresence = isDm && peerState && peerState !== 'invisible';
|
||||||
|
// Subtitle priority: custom status_message when online (or idle/dnd), else
|
||||||
|
// the localized presence label. Offline always wins → just "Offline".
|
||||||
|
const presenceLabel = (() => {
|
||||||
|
if (!peerState) return '';
|
||||||
|
if (peerState === 'offline') return t('app:presence.offline');
|
||||||
|
if (peerPresence?.statusMessage && peerPresence.statusMessage.trim().length > 0) {
|
||||||
|
return peerPresence.statusMessage.trim();
|
||||||
|
}
|
||||||
|
return t('app:presence.' + peerState);
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3">
|
<header className="flex items-center gap-3 border-b border-line bg-surface-3 px-6 py-3">
|
||||||
@@ -80,12 +91,12 @@ function HeaderBar({ conversation, peerPresence, onInfoClick, onSearchClick }: H
|
|||||||
<UsersIcon className="h-5 w-5" />
|
<UsersIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showPresence && peerPresence && (
|
{showPresence && peerState && (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={
|
className={
|
||||||
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' +
|
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-surface-3 ' +
|
||||||
PRESENCE_DOT[peerPresence]
|
PRESENCE_DOT[peerState]
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
|
||||||
import { ScreenShareDialog } from './ScreenShareDialog';
|
import { ScreenShareDialog } from './ScreenShareDialog';
|
||||||
import { ScreenShareViewer } from './ScreenShareViewer';
|
import { ScreenShareViewer } from './ScreenShareViewer';
|
||||||
|
import { SoundboardPanel } from './SoundboardPanel';
|
||||||
|
|
||||||
// Discord-style in-call dock rendered above the message list. Renders three
|
// Discord-style in-call dock rendered above the message list. Renders three
|
||||||
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
|
// visual modes driven by CallContext.callMode: grid, focus, fullscreen.
|
||||||
@@ -61,6 +62,7 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
isCameraEnabled,
|
isCameraEnabled,
|
||||||
isDeafened,
|
isDeafened,
|
||||||
remoteDeafen,
|
remoteDeafen,
|
||||||
|
remoteMute,
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
callMode,
|
callMode,
|
||||||
focusedId,
|
focusedId,
|
||||||
@@ -77,6 +79,7 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
const myId = session?.user.id ?? null;
|
const myId = session?.user.id ?? null;
|
||||||
const activeSpeakers = useActiveSpeakers(room);
|
const activeSpeakers = useActiveSpeakers(room);
|
||||||
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
const [shareDialogOpen, setShareDialogOpen] = useState(false);
|
||||||
|
const [soundboardOpen, setSoundboardOpen] = useState(false);
|
||||||
const [volumeMenu, setVolumeMenu] = useState<
|
const [volumeMenu, setVolumeMenu] = useState<
|
||||||
{ userId: string; displayName: string; x: number; y: number } | null
|
{ userId: string; displayName: string; x: number; y: number } | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -108,6 +111,7 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
isMuted,
|
isMuted,
|
||||||
isDeafened,
|
isDeafened,
|
||||||
remoteDeafen,
|
remoteDeafen,
|
||||||
|
remoteMute,
|
||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
isCameraEnabled,
|
isCameraEnabled,
|
||||||
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
|
remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)),
|
||||||
@@ -150,6 +154,8 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
}}
|
}}
|
||||||
onToggleVideo={() => void toggleCamera()}
|
onToggleVideo={() => void toggleCamera()}
|
||||||
onToggleDeafen={toggleDeafen}
|
onToggleDeafen={toggleDeafen}
|
||||||
|
onToggleSoundboard={() => setSoundboardOpen((v) => !v)}
|
||||||
|
soundboardOpen={soundboardOpen}
|
||||||
onHangup={() => void hangup()}
|
onHangup={() => void hangup()}
|
||||||
compact={callMode !== 'fullscreen'}
|
compact={callMode !== 'fullscreen'}
|
||||||
glass={callMode === 'fullscreen'}
|
glass={callMode === 'fullscreen'}
|
||||||
@@ -197,6 +203,10 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
onClose={() => setVolumeMenu(null)}
|
onClose={() => setVolumeMenu(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<SoundboardPopover
|
||||||
|
open={soundboardOpen}
|
||||||
|
onClose={() => setSoundboardOpen(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -280,10 +290,28 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
onClose={() => setVolumeMenu(null)}
|
onClose={() => setVolumeMenu(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SoundboardPopover
|
||||||
|
open={soundboardOpen}
|
||||||
|
onClose={() => setSoundboardOpen(false)}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fixed-position overlay so the popover sits above both docked + fullscreen
|
||||||
|
// call modes without needing a portal or parent-relative anchoring.
|
||||||
|
function SoundboardPopover({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 bottom-24 z-50 flex justify-center px-4">
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<SoundboardPanel onClose={onClose} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -296,6 +324,7 @@ interface BuildArgs {
|
|||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
isDeafened: boolean;
|
isDeafened: boolean;
|
||||||
remoteDeafen: Record<string, boolean>;
|
remoteDeafen: Record<string, boolean>;
|
||||||
|
remoteMute: Record<string, boolean>;
|
||||||
isScreenSharing: boolean;
|
isScreenSharing: boolean;
|
||||||
isCameraEnabled: boolean;
|
isCameraEnabled: boolean;
|
||||||
remoteSharerIds: Set<string>;
|
remoteSharerIds: Set<string>;
|
||||||
@@ -321,6 +350,7 @@ function buildTiles({
|
|||||||
isMuted,
|
isMuted,
|
||||||
isDeafened,
|
isDeafened,
|
||||||
remoteDeafen,
|
remoteDeafen,
|
||||||
|
remoteMute,
|
||||||
isScreenSharing,
|
isScreenSharing,
|
||||||
isCameraEnabled,
|
isCameraEnabled,
|
||||||
remoteSharerIds,
|
remoteSharerIds,
|
||||||
@@ -379,7 +409,10 @@ function buildTiles({
|
|||||||
displayName: m.profile?.displayName ?? '?',
|
displayName: m.profile?.displayName ?? '?',
|
||||||
avatarUrl: m.profile?.avatarUrl ?? null,
|
avatarUrl: m.profile?.avatarUrl ?? null,
|
||||||
self: false,
|
self: false,
|
||||||
muted: !rp.isMicrophoneEnabled,
|
// Peer's self-reported mute state via data channel. LiveKit's own
|
||||||
|
// `isMicrophoneEnabled` no longer flips on mute since the pipeline
|
||||||
|
// output track stays published. See remoteMute broadcast in CallContext.
|
||||||
|
muted: remoteMute[m.userId] ?? false,
|
||||||
// Deafen state arrives via LiveKit data channel; see CallContext.
|
// Deafen state arrives via LiveKit data channel; see CallContext.
|
||||||
deafened: remoteDeafen[m.userId] ?? false,
|
deafened: remoteDeafen[m.userId] ?? false,
|
||||||
video: rp.isCameraEnabled,
|
video: rp.isCameraEnabled,
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useLinkPreview } from '../lib/useLinkPreview';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders a compact OpenGraph preview card under a message bubble. Fetches
|
||||||
|
// lazily through the edge function; silent when the URL returned no meta.
|
||||||
|
export function LinkPreviewCard({ url }: Props) {
|
||||||
|
const preview = useLinkPreview(url);
|
||||||
|
if (!preview || !preview.ok) return null;
|
||||||
|
if (!preview.title && !preview.description && !preview.imageUrl) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="mt-2 flex max-w-[320px] overflow-hidden rounded-lg border border-line bg-surface-2 text-sm no-underline transition hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{preview.imageUrl && (
|
||||||
|
<img
|
||||||
|
src={preview.imageUrl}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="h-20 w-20 shrink-0 object-cover"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5 p-2.5">
|
||||||
|
{preview.siteName && (
|
||||||
|
<p className="truncate text-[10px] uppercase tracking-wider text-fg-muted">
|
||||||
|
{preview.siteName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{preview.title && (
|
||||||
|
<p className="line-clamp-2 text-sm font-semibold text-fg">
|
||||||
|
{preview.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{preview.description && (
|
||||||
|
<p className="line-clamp-2 text-xs text-fg-muted">{preview.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { ConversationSummary } from '@chat-app/shared/chat';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Avatar } from './Avatar';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
members: ConversationSummary['members'];
|
||||||
|
query: string;
|
||||||
|
excludeUserId: string | undefined;
|
||||||
|
onSelect: (username: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropdown shown above the composer when the user has typed `@` followed
|
||||||
|
// by the start of a member name. Keyboard-first — arrow keys move through,
|
||||||
|
// enter/tab commits, escape cancels.
|
||||||
|
export function MentionAutocomplete({
|
||||||
|
members,
|
||||||
|
query,
|
||||||
|
excludeUserId,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const matches = members
|
||||||
|
.filter((m) => m.userId !== excludeUserId)
|
||||||
|
.filter((m) => {
|
||||||
|
if (!q) return true;
|
||||||
|
const name = (m.profile?.displayName ?? '').toLowerCase();
|
||||||
|
const handle = (m.profile?.username ?? '').toLowerCase();
|
||||||
|
return name.includes(q) || handle.includes(q);
|
||||||
|
})
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
setActive(0);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (matches.length === 0) return;
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActive((i) => (i + 1) % matches.length);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActive((i) => (i - 1 + matches.length) % matches.length);
|
||||||
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
const pick = matches[active];
|
||||||
|
if (pick?.profile?.username) onSelect(pick.profile.username);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey, true);
|
||||||
|
};
|
||||||
|
}, [matches, active, onSelect, onClose]);
|
||||||
|
|
||||||
|
if (matches.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Mitglieder"
|
||||||
|
className="absolute bottom-full left-0 right-0 z-20 mb-2 max-h-64 overflow-y-auto rounded-xl border border-line bg-surface-2 p-1 shadow-xl"
|
||||||
|
>
|
||||||
|
{matches.map((m, idx) => {
|
||||||
|
const name = m.profile?.displayName ?? m.profile?.username ?? '?';
|
||||||
|
const handle = m.profile?.username ?? '';
|
||||||
|
const isActive = idx === active;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.userId}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onMouseEnter={() => setActive(idx)}
|
||||||
|
onClick={() => {
|
||||||
|
if (m.profile?.username) onSelect(m.profile.username);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
'flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ' +
|
||||||
|
(isActive ? 'bg-accent/20 text-fg' : 'text-fg-muted hover:bg-surface-3')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
displayName={name}
|
||||||
|
url={m.profile?.avatarUrl ?? null}
|
||||||
|
className="h-6 w-6 text-[10px]"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-fg">{name}</span>
|
||||||
|
<span className="shrink-0 text-[10px] text-fg-muted">@{handle}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,8 +13,13 @@ import { useAuth } from '../context/AuthContext';
|
|||||||
import { devLocalSecretStore } from '../lib/secretStore';
|
import { devLocalSecretStore } from '../lib/secretStore';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
import type { AggregatedReaction } from '../lib/useMessageReactions';
|
||||||
|
import { extractFirstUrl } from '../lib/useLinkPreview';
|
||||||
import { AttachmentAudio } from './AttachmentAudio';
|
import { AttachmentAudio } from './AttachmentAudio';
|
||||||
|
import { AttachmentGeneric } from './AttachmentGeneric';
|
||||||
import { AttachmentImage } from './AttachmentImage';
|
import { AttachmentImage } from './AttachmentImage';
|
||||||
|
import { AttachmentPdf } from './AttachmentPdf';
|
||||||
|
import { AttachmentVideo } from './AttachmentVideo';
|
||||||
|
import { LinkPreviewCard } from './LinkPreviewCard';
|
||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
|
import { ForwardIcon, PencilIcon, PhoneIcon, PhoneOffIcon, ReplyIcon, SmileIcon, SpinnerIcon, TrashIcon, XIcon } from './icons';
|
||||||
|
|
||||||
@@ -299,14 +304,27 @@ export function MessageBubble({
|
|||||||
<span className="italic opacity-70">…cannot decrypt</span>
|
<span className="italic opacity-70">…cannot decrypt</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{bodyText.length > 0 && <div>{bodyText}</div>}
|
{bodyText.length > 0 && <div>{renderBodyWithMentions(bodyText)}</div>}
|
||||||
{attachments.map((a) =>
|
{bodyText.length > 0 &&
|
||||||
a.mimeType.startsWith('audio/') ? (
|
(() => {
|
||||||
<AttachmentAudio key={a.id} handle={a} />
|
const url = extractFirstUrl(bodyText);
|
||||||
) : (
|
return url ? <LinkPreviewCard url={url} /> : null;
|
||||||
<AttachmentImage key={a.id} handle={a} />
|
})()}
|
||||||
),
|
{attachments.map((a) => {
|
||||||
)}
|
if (a.mimeType.startsWith('audio/')) {
|
||||||
|
return <AttachmentAudio key={a.id} handle={a} />;
|
||||||
|
}
|
||||||
|
if (a.mimeType.startsWith('image/')) {
|
||||||
|
return <AttachmentImage key={a.id} handle={a} />;
|
||||||
|
}
|
||||||
|
if (a.mimeType.startsWith('video/')) {
|
||||||
|
return <AttachmentVideo key={a.id} handle={a} />;
|
||||||
|
}
|
||||||
|
if (a.mimeType === 'application/pdf') {
|
||||||
|
return <AttachmentPdf key={a.id} handle={a} />;
|
||||||
|
}
|
||||||
|
return <AttachmentGeneric key={a.id} handle={a} />;
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
@@ -510,6 +528,33 @@ function formatDuration(totalSec: number): string {
|
|||||||
return m + ':' + s.toString().padStart(2, '0');
|
return m + ':' + s.toString().padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Splits body text on `@username` tokens, rendering matches as highlighted
|
||||||
|
// pills. Username alphabet matches Supabase citext usernames: alphanumerics
|
||||||
|
// + underscores, length 1..32 (we don't bound here — regex is permissive
|
||||||
|
// and keys off a leading `@` with an alnum/underscore follow).
|
||||||
|
const MENTION_RE = /@([A-Za-z0-9_]{1,32})/g;
|
||||||
|
|
||||||
|
function renderBodyWithMentions(text: string): React.ReactNode[] {
|
||||||
|
const out: React.ReactNode[] = [];
|
||||||
|
let lastIdx = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
MENTION_RE.lastIndex = 0;
|
||||||
|
while ((m = MENTION_RE.exec(text)) !== null) {
|
||||||
|
if (m.index > lastIdx) out.push(text.slice(lastIdx, m.index));
|
||||||
|
out.push(
|
||||||
|
<span
|
||||||
|
key={m.index + ':' + m[1]}
|
||||||
|
className="rounded bg-accent/20 px-1 text-accent"
|
||||||
|
>
|
||||||
|
{m[0]}
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
lastIdx = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (lastIdx < text.length) out.push(text.slice(lastIdx));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function DeliveryTicks({ state }: { state: 'sent' | 'delivered' | 'read' }) {
|
function DeliveryTicks({ state }: { state: 'sent' | 'delivered' | 'read' }) {
|
||||||
// One checkmark for sent, two for delivered/read. Read shifts color to the
|
// One checkmark for sent, two for delivered/read. Read shifts color to the
|
||||||
// accent to match WhatsApp/Telegram blue-tick convention.
|
// accent to match WhatsApp/Telegram blue-tick convention.
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearIncomingRingtone,
|
||||||
|
getIncomingRingtone,
|
||||||
|
MAX_RINGTONE_BYTES,
|
||||||
|
saveIncomingRingtone,
|
||||||
|
type StoredRingtone,
|
||||||
|
} from '../lib/ringtoneStorage';
|
||||||
|
import { PhoneIcon, SpinnerIcon, TrashIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Disable interactions while a parent action is in flight. */
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BYTES_PER_MB = 1024 * 1024;
|
||||||
|
|
||||||
|
// UI for the custom incoming-call ringtone. Single file slot. Upload
|
||||||
|
// validates size + mime and surfaces errors inline. Preview button plays
|
||||||
|
// the stored blob through a local <audio> element without touching the
|
||||||
|
// shared ringtone singleton so we don't interfere with a live call.
|
||||||
|
export function RingtoneSettings({ disabled = false }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const previewRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const previewUrlRef = useRef<string | null>(null);
|
||||||
|
const [current, setCurrent] = useState<StoredRingtone | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const cur = await getIncomingRingtone();
|
||||||
|
setCurrent(cur);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('getIncomingRingtone failed', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Revoke any preview blob URL when the component unmounts so long-lived
|
||||||
|
// pages don't leak memory.
|
||||||
|
return () => {
|
||||||
|
stopPreview();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function stopPreview(): void {
|
||||||
|
const el = previewRef.current;
|
||||||
|
if (el) {
|
||||||
|
try {
|
||||||
|
el.pause();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
el.src = '';
|
||||||
|
}
|
||||||
|
previewRef.current = null;
|
||||||
|
if (previewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(previewUrlRef.current);
|
||||||
|
previewUrlRef.current = null;
|
||||||
|
}
|
||||||
|
setPlaying(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFile(file: File): Promise<void> {
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await saveIncomingRingtone(file);
|
||||||
|
await refresh();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const code = err instanceof Error ? err.message : 'upload_failed';
|
||||||
|
if (code === 'ringtone_too_large') {
|
||||||
|
setError(
|
||||||
|
t('app:settings.ringtone_error_too_large', {
|
||||||
|
defaultValue: 'Datei zu groß (max 2 MB).',
|
||||||
|
max: MAX_RINGTONE_BYTES / BYTES_PER_MB,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (code === 'ringtone_not_audio') {
|
||||||
|
setError(
|
||||||
|
t('app:settings.ringtone_error_not_audio', {
|
||||||
|
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(
|
||||||
|
t('app:settings.ringtone_error_generic', {
|
||||||
|
defaultValue: 'Ringtone konnte nicht gespeichert werden.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
if (inputRef.current) inputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset(): Promise<void> {
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
stopPreview();
|
||||||
|
try {
|
||||||
|
await clearIncomingRingtone();
|
||||||
|
await refresh();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('clearIncomingRingtone failed', err);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePreview(): void {
|
||||||
|
if (!current) return;
|
||||||
|
if (playing) {
|
||||||
|
stopPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(current.blob);
|
||||||
|
const el = new Audio(url);
|
||||||
|
el.loop = false;
|
||||||
|
el.volume = 0.85;
|
||||||
|
el.onended = () => stopPreview();
|
||||||
|
el.onerror = () => {
|
||||||
|
setError(
|
||||||
|
t('app:settings.ringtone_error_play', {
|
||||||
|
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
stopPreview();
|
||||||
|
};
|
||||||
|
el.play().catch(() => {
|
||||||
|
setError(
|
||||||
|
t('app:settings.ringtone_error_play', {
|
||||||
|
defaultValue: 'Ringtone konnte nicht abgespielt werden.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
stopPreview();
|
||||||
|
});
|
||||||
|
previewRef.current = el;
|
||||||
|
previewUrlRef.current = url;
|
||||||
|
setPlaying(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCustom = current !== null;
|
||||||
|
const sizeMb = current ? (current.blob.size / BYTES_PER_MB).toFixed(2) : null;
|
||||||
|
const interactionsDisabled = disabled || busy;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-fg">
|
||||||
|
<PhoneIcon className="h-4 w-4 text-fg-muted" />
|
||||||
|
{t('app:settings.ringtone_incoming', { defaultValue: 'Eingehender Anruf' })}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-fg-muted">
|
||||||
|
{hasCustom && current
|
||||||
|
? t('app:settings.ringtone_custom_active', {
|
||||||
|
defaultValue: '{{name}} · {{size}} MB',
|
||||||
|
name: current.filename,
|
||||||
|
size: sizeMb,
|
||||||
|
})
|
||||||
|
: t('app:settings.ringtone_default_active', {
|
||||||
|
defaultValue: 'Standard-Klingelton (Doppelton)',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{hasCustom && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={interactionsDisabled}
|
||||||
|
className="cursor-pointer rounded-lg border border-line bg-surface-3 px-3 py-1.5 text-xs font-semibold text-fg transition hover:brightness-95 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{playing
|
||||||
|
? t('app:settings.ringtone_stop', { defaultValue: 'Stop' })
|
||||||
|
: t('app:settings.ringtone_preview', { defaultValue: 'Vorhören' })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void handleFile(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
disabled={interactionsDisabled}
|
||||||
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy && <SpinnerIcon className="h-3.5 w-3.5" />}
|
||||||
|
<span>
|
||||||
|
{hasCustom
|
||||||
|
? t('app:settings.ringtone_replace', { defaultValue: 'Ersetzen' })
|
||||||
|
: t('app:settings.ringtone_upload', { defaultValue: 'Hochladen' })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{hasCustom && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleReset()}
|
||||||
|
disabled={interactionsDisabled}
|
||||||
|
aria-label={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
|
||||||
|
title={t('app:settings.ringtone_reset', { defaultValue: 'Zurücksetzen' })}
|
||||||
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-rose-500/40 bg-rose-500/10 text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[11px] text-fg-muted">
|
||||||
|
{t('app:settings.ringtone_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'MP3, WAV, OGG oder M4A bis 2 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { getPttSettings } from '../lib/pttSettings';
|
||||||
|
import { codeToShortcut } from '../lib/globalShortcut';
|
||||||
|
import { invalidate as invalidateSoundCache, preload } from '../lib/soundboardPlayback';
|
||||||
|
import {
|
||||||
|
addSound,
|
||||||
|
deleteSound,
|
||||||
|
getSoundBlob,
|
||||||
|
listSounds,
|
||||||
|
MAX_SOUND_BYTES,
|
||||||
|
reorderCategory,
|
||||||
|
type SoundboardEntry,
|
||||||
|
subscribeSoundboardChanges,
|
||||||
|
updateSound,
|
||||||
|
} from '../lib/soundboardStorage';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
import {
|
||||||
|
AlertIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SpinnerIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BYTES_PER_MB = 1024 * 1024;
|
||||||
|
const CATEGORY_LIST_ID = 'sb-category-list';
|
||||||
|
|
||||||
|
// Admin UI for the soundboard. Users add, rename, categorise, reorder,
|
||||||
|
// assign hotkeys, adjust per-sound volume, preview and delete clips here.
|
||||||
|
// In-call panel only reads the resulting manifest.
|
||||||
|
export function SoundboardManagerDialog({ open, onClose }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const previewRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const previewUrlRef = useRef<string | null>(null);
|
||||||
|
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setEntries(await listSounds());
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('listSounds failed', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
void refresh();
|
||||||
|
// External mutations (hotkey fires, multi-tab edits) should reflect
|
||||||
|
// immediately while the dialog is open.
|
||||||
|
const unsub = subscribeSoundboardChanges(() => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unsub();
|
||||||
|
stopPreview();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => stopPreview();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const e of entries) if (e.category) set.add(e.category);
|
||||||
|
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, SoundboardEntry[]>();
|
||||||
|
for (const e of entries) {
|
||||||
|
const key = e.category ?? '';
|
||||||
|
const arr = map.get(key);
|
||||||
|
if (arr) arr.push(e);
|
||||||
|
else map.set(key, [e]);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
function stopPreview(): void {
|
||||||
|
const el = previewRef.current;
|
||||||
|
if (el) {
|
||||||
|
try {
|
||||||
|
el.pause();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
el.src = '';
|
||||||
|
}
|
||||||
|
previewRef.current = null;
|
||||||
|
if (previewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(previewUrlRef.current);
|
||||||
|
previewUrlRef.current = null;
|
||||||
|
}
|
||||||
|
setPreviewingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdd(file: File): Promise<void> {
|
||||||
|
setError(null);
|
||||||
|
setBusyId('__add');
|
||||||
|
try {
|
||||||
|
await addSound({ file });
|
||||||
|
await refresh();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const code = err instanceof Error ? err.message : 'add_failed';
|
||||||
|
if (code === 'sound_too_large') {
|
||||||
|
setError(
|
||||||
|
t('app:soundboard.error_too_large', {
|
||||||
|
defaultValue: 'Datei zu groß (max {{max}} MB).',
|
||||||
|
max: MAX_SOUND_BYTES / BYTES_PER_MB,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (code === 'sound_not_audio') {
|
||||||
|
setError(
|
||||||
|
t('app:soundboard.error_not_audio', {
|
||||||
|
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(
|
||||||
|
t('app:soundboard.error_generic', {
|
||||||
|
defaultValue: 'Sound konnte nicht gespeichert werden.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
if (fileRef.current) fileRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePatch(
|
||||||
|
id: string,
|
||||||
|
patch: Parameters<typeof updateSound>[1],
|
||||||
|
): Promise<void> {
|
||||||
|
setBusyId(id);
|
||||||
|
try {
|
||||||
|
await updateSound(id, patch);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('updateSound failed', err);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string): Promise<void> {
|
||||||
|
if (!window.confirm(t('app:soundboard.delete_confirm', { defaultValue: 'Sound löschen?' }))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusyId(id);
|
||||||
|
try {
|
||||||
|
await deleteSound(id);
|
||||||
|
invalidateSoundCache(id);
|
||||||
|
if (previewingId === id) stopPreview();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('deleteSound failed', err);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePreview(entry: SoundboardEntry): Promise<void> {
|
||||||
|
if (previewingId === entry.id) {
|
||||||
|
stopPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopPreview();
|
||||||
|
const blob = await getSoundBlob(entry.id);
|
||||||
|
if (!blob) return;
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const el = new Audio(url);
|
||||||
|
el.volume = entry.gain;
|
||||||
|
el.onended = () => stopPreview();
|
||||||
|
el.onerror = () => stopPreview();
|
||||||
|
el.play().catch(() => stopPreview());
|
||||||
|
previewRef.current = el;
|
||||||
|
previewUrlRef.current = url;
|
||||||
|
setPreviewingId(entry.id);
|
||||||
|
// Opportunistic warm-up of the AudioBuffer cache so the first in-call
|
||||||
|
// playback doesn't pause on decode.
|
||||||
|
void preload(entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReorder(
|
||||||
|
category: string | null,
|
||||||
|
idx: number,
|
||||||
|
dir: -1 | 1,
|
||||||
|
): Promise<void> {
|
||||||
|
const bucket = grouped.get(category ?? '') ?? [];
|
||||||
|
const next = idx + dir;
|
||||||
|
if (next < 0 || next >= bucket.length) return;
|
||||||
|
const reordered = bucket.slice();
|
||||||
|
const tmp = reordered[idx]!;
|
||||||
|
reordered[idx] = reordered[next]!;
|
||||||
|
reordered[next] = tmp;
|
||||||
|
setBusyId('__reorder:' + (category ?? ''));
|
||||||
|
try {
|
||||||
|
await reorderCategory(
|
||||||
|
category,
|
||||||
|
reordered.map((e) => e.id),
|
||||||
|
);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('reorderCategory failed', err);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('app:soundboard.manager_title', { defaultValue: 'Soundboard verwalten' })}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-xs text-fg-muted">
|
||||||
|
{t('app:soundboard.manager_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'Beliebig viele Sounds, kein Hotkey nötig. Hotkeys feuern nur während eines Anrufs.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void handleAdd(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={busyId !== null}
|
||||||
|
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busyId === '__add' ? (
|
||||||
|
<SpinnerIcon className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<PlusIcon className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span>{t('app:soundboard.add', { defaultValue: 'Sound hinzufügen' })}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
|
||||||
|
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<p className="min-w-0 flex-1 break-words">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<datalist id={CATEGORY_LIST_ID}>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c} value={c} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
||||||
|
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||||||
|
</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-line bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
||||||
|
{t('app:soundboard.empty', {
|
||||||
|
defaultValue: 'Noch keine Sounds. Lade oben welche hoch.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{Array.from(grouped.entries()).map(([categoryKey, bucket]) => {
|
||||||
|
const category = categoryKey === '' ? null : categoryKey;
|
||||||
|
return (
|
||||||
|
<SoundboardCategoryGroup
|
||||||
|
key={categoryKey || '__uncat'}
|
||||||
|
category={category}
|
||||||
|
entries={bucket}
|
||||||
|
entriesTotal={entries}
|
||||||
|
busyId={busyId}
|
||||||
|
previewingId={previewingId}
|
||||||
|
onPatch={handlePatch}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onPreview={handlePreview}
|
||||||
|
onReorder={handleReorder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface GroupProps {
|
||||||
|
category: string | null;
|
||||||
|
entries: SoundboardEntry[];
|
||||||
|
entriesTotal: SoundboardEntry[];
|
||||||
|
busyId: string | null;
|
||||||
|
previewingId: string | null;
|
||||||
|
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||||||
|
onDelete: (id: string) => Promise<void>;
|
||||||
|
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||||||
|
onReorder: (category: string | null, idx: number, dir: -1 | 1) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SoundboardCategoryGroup({
|
||||||
|
category,
|
||||||
|
entries,
|
||||||
|
entriesTotal,
|
||||||
|
busyId,
|
||||||
|
previewingId,
|
||||||
|
onPatch,
|
||||||
|
onDelete,
|
||||||
|
onPreview,
|
||||||
|
onReorder,
|
||||||
|
}: GroupProps) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const label =
|
||||||
|
category ??
|
||||||
|
t('app:soundboard.category_uncategorized', { defaultValue: '(Ohne Kategorie)' });
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-line bg-surface-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5 text-left"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
||||||
|
{label} · {entries.length}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={'h-4 w-4 text-fg-muted transition ' + (open ? '' : '-rotate-90')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<ul className="flex flex-col gap-2 border-t border-line p-3">
|
||||||
|
{entries.map((entry, idx) => (
|
||||||
|
<SoundboardRow
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
entriesTotal={entriesTotal}
|
||||||
|
isFirst={idx === 0}
|
||||||
|
isLast={idx === entries.length - 1}
|
||||||
|
busy={busyId === entry.id}
|
||||||
|
previewing={previewingId === entry.id}
|
||||||
|
onPatch={onPatch}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onPreview={onPreview}
|
||||||
|
onReorderUp={() => onReorder(category, idx, -1)}
|
||||||
|
onReorderDown={() => onReorder(category, idx, 1)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface RowProps {
|
||||||
|
entry: SoundboardEntry;
|
||||||
|
entriesTotal: SoundboardEntry[];
|
||||||
|
isFirst: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
previewing: boolean;
|
||||||
|
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||||||
|
onDelete: (id: string) => Promise<void>;
|
||||||
|
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||||||
|
onReorderUp: () => Promise<void>;
|
||||||
|
onReorderDown: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SoundboardRow({
|
||||||
|
entry,
|
||||||
|
entriesTotal,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
busy,
|
||||||
|
previewing,
|
||||||
|
onPatch,
|
||||||
|
onDelete,
|
||||||
|
onPreview,
|
||||||
|
onReorderUp,
|
||||||
|
onReorderDown,
|
||||||
|
}: RowProps) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
const [nameDraft, setNameDraft] = useState(entry.name);
|
||||||
|
const [categoryDraft, setCategoryDraft] = useState(entry.category ?? '');
|
||||||
|
const [capturingHotkey, setCapturingHotkey] = useState(false);
|
||||||
|
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNameDraft(entry.name);
|
||||||
|
setCategoryDraft(entry.category ?? '');
|
||||||
|
}, [entry.name, entry.category]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!capturingHotkey) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.code === 'Escape') {
|
||||||
|
setCapturingHotkey(false);
|
||||||
|
setHotkeyError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Conflict checks: PTT + other soundboard entries with this code.
|
||||||
|
const ptt = getPttSettings();
|
||||||
|
if (ptt.enabled && ptt.key === e.code) {
|
||||||
|
setHotkeyError(
|
||||||
|
t('app:soundboard.hotkey_conflict_ptt', {
|
||||||
|
defaultValue: 'Konflikt mit Push-to-Talk.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const taken = entriesTotal.find((s) => s.id !== entry.id && s.hotkey === e.code);
|
||||||
|
if (taken) {
|
||||||
|
setHotkeyError(
|
||||||
|
t('app:soundboard.hotkey_conflict_sound', {
|
||||||
|
defaultValue: 'Bereits von "{{name}}" belegt.',
|
||||||
|
name: taken.name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHotkeyError(null);
|
||||||
|
setCapturingHotkey(false);
|
||||||
|
void onPatch(entry.id, { hotkey: e.code });
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey, { capture: true });
|
||||||
|
return () => window.removeEventListener('keydown', onKey, { capture: true });
|
||||||
|
}, [capturingHotkey, entriesTotal, entry.id, onPatch, t]);
|
||||||
|
|
||||||
|
const hotkeyLabel = entry.hotkey ? codeToShortcut(entry.hotkey) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="flex flex-wrap items-center gap-3 rounded-md border border-line bg-surface-3 px-3 py-2">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
{editingName ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={nameDraft}
|
||||||
|
onChange={(e) => setNameDraft(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
setEditingName(false);
|
||||||
|
if (nameDraft.trim() && nameDraft !== entry.name) {
|
||||||
|
void onPatch(entry.id, { name: nameDraft });
|
||||||
|
} else {
|
||||||
|
setNameDraft(entry.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
(e.target as HTMLInputElement).blur();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setNameDraft(entry.name);
|
||||||
|
setEditingName(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full rounded border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingName(true)}
|
||||||
|
className="flex cursor-pointer items-center gap-1.5 self-start text-sm font-semibold text-fg hover:text-accent"
|
||||||
|
>
|
||||||
|
{entry.name}
|
||||||
|
<PencilIcon className="h-3 w-3 opacity-50" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-fg-muted">
|
||||||
|
{(entry.size / BYTES_PER_MB).toFixed(2)} MB · {entry.mime}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={categoryDraft}
|
||||||
|
onChange={(e) => setCategoryDraft(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const next = categoryDraft.trim() || null;
|
||||||
|
if (next !== (entry.category ?? null)) {
|
||||||
|
void onPatch(entry.id, { category: next });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
list={CATEGORY_LIST_ID}
|
||||||
|
placeholder={t('app:soundboard.category_placeholder', {
|
||||||
|
defaultValue: 'Kategorie…',
|
||||||
|
})}
|
||||||
|
className="w-32 shrink-0 rounded border border-line bg-surface-2 px-2 py-1 text-xs text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setHotkeyError(null);
|
||||||
|
setCapturingHotkey((v) => !v);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
'inline-flex min-w-[5rem] cursor-pointer items-center justify-center rounded border px-2 py-1 text-[11px] font-mono font-semibold transition ' +
|
||||||
|
(capturingHotkey
|
||||||
|
? 'animate-pulse border-accent bg-accent/20 text-fg'
|
||||||
|
: hotkeyLabel
|
||||||
|
? 'border-accent/40 bg-accent/10 text-accent'
|
||||||
|
: 'border-line bg-surface-2 text-fg-muted hover:bg-surface')
|
||||||
|
}
|
||||||
|
title={t('app:soundboard.hotkey_capture', {
|
||||||
|
defaultValue: 'Hotkey binden (Esc = abbrechen)',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{capturingHotkey
|
||||||
|
? t('app:soundboard.hotkey_press', { defaultValue: 'Drücke…' })
|
||||||
|
: hotkeyLabel ??
|
||||||
|
t('app:soundboard.hotkey_none', { defaultValue: 'Kein Hotkey' })}
|
||||||
|
</button>
|
||||||
|
{entry.hotkey && !capturingHotkey && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onPatch(entry.id, { hotkey: null })}
|
||||||
|
aria-label={t('app:soundboard.hotkey_clear', { defaultValue: 'Hotkey entfernen' })}
|
||||||
|
title={t('app:soundboard.hotkey_clear', { defaultValue: 'Hotkey entfernen' })}
|
||||||
|
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={entry.gain}
|
||||||
|
onChange={(e) => void onPatch(entry.id, { gain: Number(e.target.value) })}
|
||||||
|
className="accent-accent w-20"
|
||||||
|
title={t('app:soundboard.gain_title', {
|
||||||
|
defaultValue: 'Lautstärke',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onPreview(entry)}
|
||||||
|
disabled={busy}
|
||||||
|
className="inline-flex cursor-pointer items-center gap-1 rounded border border-line bg-surface-2 px-2 py-1 text-[11px] font-medium text-fg transition hover:bg-surface disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{previewing
|
||||||
|
? t('app:soundboard.preview_stop', { defaultValue: 'Stop' })
|
||||||
|
: t('app:soundboard.preview', { defaultValue: 'Vorhören' })}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReorderUp}
|
||||||
|
disabled={busy || isFirst}
|
||||||
|
aria-label={t('app:soundboard.move_up', { defaultValue: 'Nach oben' })}
|
||||||
|
title={t('app:soundboard.move_up', { defaultValue: 'Nach oben' })}
|
||||||
|
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReorderDown}
|
||||||
|
disabled={busy || isLast}
|
||||||
|
aria-label={t('app:soundboard.move_down', { defaultValue: 'Nach unten' })}
|
||||||
|
title={t('app:soundboard.move_down', { defaultValue: 'Nach unten' })}
|
||||||
|
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onDelete(entry.id)}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={t('app:soundboard.delete', { defaultValue: 'Löschen' })}
|
||||||
|
title={t('app:soundboard.delete', { defaultValue: 'Löschen' })}
|
||||||
|
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded border border-rose-500/30 bg-rose-500/10 text-rose-600 hover:bg-rose-500/20 disabled:opacity-50 dark:text-rose-300"
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hotkeyError && capturingHotkey && (
|
||||||
|
<p className="basis-full text-[11px] text-rose-600 dark:text-rose-300">
|
||||||
|
{hotkeyError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useCall } from '../context/CallContext';
|
||||||
|
import { codeToShortcut } from '../lib/globalShortcut';
|
||||||
|
import {
|
||||||
|
DEFAULT_PREFS,
|
||||||
|
getPrefs,
|
||||||
|
listSounds,
|
||||||
|
type SoundboardEntry,
|
||||||
|
type SoundboardPrefs,
|
||||||
|
subscribeSoundboardChanges,
|
||||||
|
} from '../lib/soundboardStorage';
|
||||||
|
import { ChevronDownIcon, XIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-call popover that lists every stored sound grouped by category. Click a
|
||||||
|
// pad to play through the active pipeline. Hotkey-badge shows the bound
|
||||||
|
// accelerator (if any). Master + monitor sliders adjust the pipeline gains.
|
||||||
|
export function SoundboardPanel({ onClose }: Props) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const {
|
||||||
|
playSoundboard,
|
||||||
|
stopSoundboard,
|
||||||
|
activeSoundboardIds,
|
||||||
|
setSoundboardMasterGain,
|
||||||
|
setSoundboardMonitorGain,
|
||||||
|
} = useCall();
|
||||||
|
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
|
||||||
|
const [prefs, setPrefs] = useState<SoundboardPrefs>(() => ({ ...DEFAULT_PREFS }));
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const closeRef = useRef(onClose);
|
||||||
|
closeRef.current = onClose;
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [all, p] = await Promise.all([listSounds(), getPrefs()]);
|
||||||
|
setEntries(all);
|
||||||
|
setPrefs(p);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('soundboard panel refresh failed', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
const unsub = subscribeSoundboardChanges(() => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
// Close on Esc — tapping outside is handled by the trigger's parent.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') closeRef.current();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return entries;
|
||||||
|
return entries.filter(
|
||||||
|
(e) =>
|
||||||
|
e.name.toLowerCase().includes(q) ||
|
||||||
|
(e.category ?? '').toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [entries, query]);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, SoundboardEntry[]>();
|
||||||
|
for (const e of filtered) {
|
||||||
|
const key = e.category ?? '';
|
||||||
|
const arr = map.get(key);
|
||||||
|
if (arr) arr.push(e);
|
||||||
|
else map.set(key, [e]);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [filtered]);
|
||||||
|
|
||||||
|
function toggleCategory(key: string): void {
|
||||||
|
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label={t('app:soundboard.panel_title', { defaultValue: 'Soundboard' })}
|
||||||
|
className="flex w-[360px] max-h-[70vh] flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-2xl"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between border-b border-line px-4 py-2.5">
|
||||||
|
<h3 className="font-display text-sm font-semibold text-fg">
|
||||||
|
{t('app:soundboard.panel_title', { defaultValue: 'Soundboard' })}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('app:soundboard.panel_close', { defaultValue: 'Schließen' })}
|
||||||
|
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{entries.length > 0 && (
|
||||||
|
<div className="border-b border-line px-3 pb-2 pt-2">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={t('app:soundboard.panel_search', { defaultValue: 'Suche…' })}
|
||||||
|
className="w-full rounded-md border border-line bg-surface-2 px-3 py-1.5 text-xs text-fg placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-xs text-fg-muted">
|
||||||
|
{t('app:soundboard.panel_empty', {
|
||||||
|
defaultValue:
|
||||||
|
'Keine Sounds gespeichert. Füge welche in den Einstellungen hinzu.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-xs text-fg-muted">
|
||||||
|
{t('app:soundboard.panel_no_matches', {
|
||||||
|
defaultValue: 'Keine Treffer.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{Array.from(grouped.entries()).map(([key, bucket]) => (
|
||||||
|
<CategorySection
|
||||||
|
key={key || '__uncat'}
|
||||||
|
categoryKey={key}
|
||||||
|
entries={bucket}
|
||||||
|
collapsed={collapsed[key] ?? false}
|
||||||
|
activeIds={activeSoundboardIds}
|
||||||
|
onToggle={() => toggleCategory(key)}
|
||||||
|
onActivate={(id) => {
|
||||||
|
if (activeSoundboardIds.has(id)) {
|
||||||
|
stopSoundboard(id);
|
||||||
|
} else {
|
||||||
|
void playSoundboard(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex flex-col gap-2 border-t border-line bg-surface-2 px-4 py-3">
|
||||||
|
<VolumeSlider
|
||||||
|
label={t('app:soundboard.panel_master', { defaultValue: 'Master' })}
|
||||||
|
value={prefs.masterGain}
|
||||||
|
onChange={(v) => {
|
||||||
|
setPrefs((p) => ({ ...p, masterGain: v }));
|
||||||
|
void setSoundboardMasterGain(v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<VolumeSlider
|
||||||
|
label={t('app:soundboard.panel_monitor', { defaultValue: 'Mithören' })}
|
||||||
|
value={prefs.monitorGain}
|
||||||
|
onChange={(v) => {
|
||||||
|
setPrefs((p) => ({ ...p, monitorGain: v }));
|
||||||
|
void setSoundboardMonitorGain(v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => stopSoundboard()}
|
||||||
|
className="mt-1 inline-flex cursor-pointer items-center justify-center rounded-md border border-line bg-surface-3 px-3 py-1.5 text-[11px] font-semibold text-fg transition hover:brightness-95"
|
||||||
|
>
|
||||||
|
{t('app:soundboard.panel_stop_all', { defaultValue: 'Alle stoppen' })}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategorySectionProps {
|
||||||
|
categoryKey: string;
|
||||||
|
entries: SoundboardEntry[];
|
||||||
|
collapsed: boolean;
|
||||||
|
activeIds: ReadonlySet<string>;
|
||||||
|
onToggle: () => void;
|
||||||
|
onActivate: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategorySection({
|
||||||
|
categoryKey,
|
||||||
|
entries,
|
||||||
|
collapsed,
|
||||||
|
activeIds,
|
||||||
|
onToggle,
|
||||||
|
onActivate,
|
||||||
|
}: CategorySectionProps) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const label =
|
||||||
|
categoryKey === ''
|
||||||
|
? t('app:soundboard.category_uncategorized', { defaultValue: '(Ohne Kategorie)' })
|
||||||
|
: categoryKey;
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="mb-1.5 flex w-full cursor-pointer items-center justify-between gap-2 text-left text-[10px] font-semibold uppercase tracking-[0.1em] text-fg-muted"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{label} · {entries.length}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={'h-3 w-3 transition ' + (collapsed ? '-rotate-90' : '')}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<SoundPad
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
active={activeIds.has(entry.id)}
|
||||||
|
onActivate={() => onActivate(entry.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PadProps {
|
||||||
|
entry: SoundboardEntry;
|
||||||
|
active: boolean;
|
||||||
|
onActivate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SoundPad({ entry, active, onActivate }: PadProps) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const hotkey = entry.hotkey ? codeToShortcut(entry.hotkey) : null;
|
||||||
|
const base =
|
||||||
|
'group relative flex min-h-[54px] cursor-pointer flex-col justify-center gap-0.5 rounded-md border px-2.5 py-2 text-left text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40';
|
||||||
|
const toneClass = active
|
||||||
|
? 'border-rose-500 bg-rose-500/20 text-fg hover:brightness-110'
|
||||||
|
: 'border-line bg-surface-2 text-fg hover:border-accent hover:bg-surface-3';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onActivate}
|
||||||
|
className={`${base} ${toneClass}`}
|
||||||
|
title={
|
||||||
|
active
|
||||||
|
? t('app:soundboard.pad_stop', { defaultValue: 'Stoppen' })
|
||||||
|
: entry.name
|
||||||
|
}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5 truncate">
|
||||||
|
{active && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-2 w-2 shrink-0 rounded-full bg-rose-500 animate-live-dot"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{entry.name}</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center justify-between gap-1.5">
|
||||||
|
{hotkey ? (
|
||||||
|
<span className="inline-flex w-fit items-center rounded border border-line bg-surface-3 px-1 py-0.5 font-mono text-[9px] text-fg-muted">
|
||||||
|
{hotkey}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{active && (
|
||||||
|
<span className="text-[9px] font-semibold uppercase tracking-[0.08em] text-rose-500 dark:text-rose-300">
|
||||||
|
{t('app:soundboard.pad_stop', { defaultValue: 'Stoppen' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VolumeSlider({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onChange: (v: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-3 text-[11px] text-fg">
|
||||||
|
<span className="w-16 shrink-0 text-fg-muted">{label}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.02}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
className="accent-accent flex-1"
|
||||||
|
/>
|
||||||
|
<span className="w-9 shrink-0 text-right tabular-nums text-fg-muted">
|
||||||
|
{Math.round(value * 100)}%
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
listSounds,
|
||||||
|
subscribeSoundboardChanges,
|
||||||
|
type SoundboardEntry,
|
||||||
|
} from '../lib/soundboardStorage';
|
||||||
|
import { SoundboardManagerDialog } from './SoundboardManagerDialog';
|
||||||
|
import { ArrowRightIcon } from './icons';
|
||||||
|
|
||||||
|
// Entry point into the soundboard manager from the settings page. Shows a
|
||||||
|
// tiny summary (count, category count) and opens the big dialog on click.
|
||||||
|
export function SoundboardSettings() {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const refresh = async () => {
|
||||||
|
try {
|
||||||
|
const all = await listSounds();
|
||||||
|
if (!cancelled) setEntries(all);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('listSounds failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void refresh();
|
||||||
|
const unsub = subscribeSoundboardChanges(() => {
|
||||||
|
void refresh();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unsub();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const categoryCount = new Set(entries.map((e) => e.category ?? '__uncat')).size;
|
||||||
|
const withHotkey = entries.filter((e) => e.hotkey !== null).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-fg">
|
||||||
|
{t('app:soundboard.summary_title', { defaultValue: 'Deine Sounds' })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-fg-muted">
|
||||||
|
{entries.length === 0
|
||||||
|
? t('app:soundboard.summary_empty', {
|
||||||
|
defaultValue: 'Noch keine Sounds vorhanden.',
|
||||||
|
})
|
||||||
|
: t('app:soundboard.summary_counts', {
|
||||||
|
defaultValue:
|
||||||
|
'{{sounds}} Sounds · {{categories}} Kategorien · {{hotkeys}} mit Hotkey',
|
||||||
|
sounds: entries.length,
|
||||||
|
categories: categoryCount,
|
||||||
|
hotkeys: withHotkey,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{t('app:soundboard.manage', { defaultValue: 'Verwalten' })}
|
||||||
|
</span>
|
||||||
|
<ArrowRightIcon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-fg-muted">
|
||||||
|
{t('app:soundboard.settings_hint', {
|
||||||
|
defaultValue:
|
||||||
|
'Hotkeys sind optional. Sounds lassen sich auch während eines Anrufs direkt im UI abspielen.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<SoundboardManagerDialog open={open} onClose={() => setOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { updateOwnProfile } from '@chat-app/shared/auth';
|
import { updateOwnProfile } from '@chat-app/shared/auth';
|
||||||
import type { PresenceState } from '@chat-app/shared/supabase';
|
import type { PresenceState } from '@chat-app/shared/supabase';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
@@ -8,6 +8,8 @@ import { supabase } from '../lib/supabase';
|
|||||||
import { Avatar } from './Avatar';
|
import { Avatar } from './Avatar';
|
||||||
import { ChevronDownIcon } from './icons';
|
import { ChevronDownIcon } from './icons';
|
||||||
|
|
||||||
|
const STATUS_MAX = 128;
|
||||||
|
|
||||||
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
|
const PRESENCE_OPTIONS: PresenceState[] = ['online', 'idle', 'dnd', 'invisible', 'offline'];
|
||||||
|
|
||||||
const PRESENCE_DOT: Record<PresenceState, string> = {
|
const PRESENCE_DOT: Record<PresenceState, string> = {
|
||||||
@@ -25,6 +27,23 @@ export function UserBar() {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const presence = profile?.presenceState ?? 'offline';
|
const presence = profile?.presenceState ?? 'offline';
|
||||||
|
const persistedStatus = profile?.statusMessage ?? '';
|
||||||
|
const [statusDraft, setStatusDraft] = useState(persistedStatus);
|
||||||
|
|
||||||
|
// Re-sync local draft with the server when the profile refreshes (e.g. after
|
||||||
|
// a successful save) — without this the input would forget any incoming
|
||||||
|
// updates from another device.
|
||||||
|
useEffect(() => {
|
||||||
|
setStatusDraft(persistedStatus);
|
||||||
|
}, [persistedStatus]);
|
||||||
|
|
||||||
|
// Subtitle priority: custom status when online and set, else label, else "Offline".
|
||||||
|
const subtitle =
|
||||||
|
presence === 'offline'
|
||||||
|
? t('app:presence.offline')
|
||||||
|
: persistedStatus.trim().length > 0
|
||||||
|
? persistedStatus.trim()
|
||||||
|
: t('app:presence.' + presence);
|
||||||
|
|
||||||
async function changePresence(next: PresenceState) {
|
async function changePresence(next: PresenceState) {
|
||||||
if (busy || next === presence) {
|
if (busy || next === presence) {
|
||||||
@@ -43,6 +62,20 @@ export function UserBar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveStatus() {
|
||||||
|
const next = statusDraft.trim().slice(0, STATUS_MAX);
|
||||||
|
if (next === persistedStatus) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await updateOwnProfile(supabase, { statusMessage: next.length === 0 ? null : next });
|
||||||
|
await refreshProfile();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('updateStatusMessage failed', err);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
@@ -69,9 +102,7 @@ export function UserBar() {
|
|||||||
<p className="truncate text-sm font-medium text-fg">
|
<p className="truncate text-sm font-medium text-fg">
|
||||||
{profile?.displayName ?? '—'}
|
{profile?.displayName ?? '—'}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs text-fg-muted">
|
<p className="truncate text-xs text-fg-muted">{subtitle}</p>
|
||||||
{t('app:presence.' + presence)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className={'h-4 w-4 text-fg-muted transition ' + (open ? 'rotate-180' : '')}
|
className={'h-4 w-4 text-fg-muted transition ' + (open ? 'rotate-180' : '')}
|
||||||
@@ -83,6 +114,30 @@ export function UserBar() {
|
|||||||
role="menu"
|
role="menu"
|
||||||
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-line bg-surface-3 shadow-xl"
|
className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-lg border border-line bg-surface-3 shadow-xl"
|
||||||
>
|
>
|
||||||
|
<div className="border-b border-line p-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={statusDraft}
|
||||||
|
onChange={(e) => setStatusDraft(e.target.value.slice(0, STATUS_MAX))}
|
||||||
|
onBlur={() => void saveStatus()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
void saveStatus();
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setStatusDraft(persistedStatus);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('app:presence.status_placeholder', {
|
||||||
|
defaultValue: 'Status setzen…',
|
||||||
|
})}
|
||||||
|
maxLength={STATUS_MAX}
|
||||||
|
className="w-full rounded-md border border-line bg-surface-2 px-2 py-1.5 text-sm text-fg placeholder-fg-muted outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{PRESENCE_OPTIONS.map((opt) => (
|
{PRESENCE_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt}
|
key={opt}
|
||||||
|
|||||||
@@ -401,6 +401,16 @@ export function CrownIcon(props: IconProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function MusicIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<Base {...props}>
|
||||||
|
<path d="M9 18V5l12-2v13" />
|
||||||
|
<circle cx="6" cy="18" r="3" />
|
||||||
|
<circle cx="18" cy="16" r="3" />
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SendIcon(props: IconProps) {
|
export function SendIcon(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
getOwnProfile,
|
getOwnProfile,
|
||||||
signOut as supabaseSignOut,
|
signOut as supabaseSignOut,
|
||||||
type Profile,
|
type Profile,
|
||||||
|
updateOwnProfile,
|
||||||
} from '@chat-app/shared/auth';
|
} from '@chat-app/shared/auth';
|
||||||
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
|
import { changeLocale, isSupportedLocale } from '@chat-app/shared/i18n';
|
||||||
import type { Session } from '@supabase/supabase-js';
|
import type { Session } from '@supabase/supabase-js';
|
||||||
@@ -143,6 +144,43 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
void registerWebPush(device.id);
|
void registerWebPush(device.id);
|
||||||
}, [device?.id]);
|
}, [device?.id]);
|
||||||
|
|
||||||
|
// Auto online/offline transition.
|
||||||
|
//
|
||||||
|
// - On mount with a session whose last persisted state is `offline`, flip
|
||||||
|
// to `online`. We never override an explicit `idle`, `dnd`, or
|
||||||
|
// `invisible` choice — those are user intent.
|
||||||
|
// - On `pagehide` / `beforeunload`, fire a best-effort update to
|
||||||
|
// `offline`. Browsers don't guarantee delivery during unload, but the
|
||||||
|
// request usually slips through; the next page load corrects state if it
|
||||||
|
// didn't.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session || !profile) return;
|
||||||
|
if (profile.presenceState === 'offline') {
|
||||||
|
void updateOwnProfile(supabase, { presenceState: 'online' })
|
||||||
|
.then(() => refreshProfile())
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.warn('auto online flip failed', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const onLeave = () => {
|
||||||
|
// Skip if user explicitly chose a non-online state — they probably
|
||||||
|
// want to look unavailable on next reconnect too.
|
||||||
|
if (
|
||||||
|
profile.presenceState !== 'online' &&
|
||||||
|
profile.presenceState !== 'offline'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void updateOwnProfile(supabase, { presenceState: 'offline' }).catch(() => {});
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', onLeave);
|
||||||
|
window.addEventListener('pagehide', onLeave);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', onLeave);
|
||||||
|
window.removeEventListener('pagehide', onLeave);
|
||||||
|
};
|
||||||
|
}, [session, profile, refreshProfile]);
|
||||||
|
|
||||||
const signOut = useCallback(async () => {
|
const signOut = useCallback(async () => {
|
||||||
await supabaseSignOut(supabase);
|
await supabaseSignOut(supabase);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -47,7 +47,15 @@ import {
|
|||||||
getCallE2EESettings,
|
getCallE2EESettings,
|
||||||
isE2EESupported,
|
isE2EESupported,
|
||||||
} from '../lib/callE2EE';
|
} from '../lib/callE2EE';
|
||||||
|
import { createMicPipeline, type MicPipeline } from '../lib/micPipeline';
|
||||||
import { getParticipantVolume } from '../lib/participantVolumes';
|
import { getParticipantVolume } from '../lib/participantVolumes';
|
||||||
|
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
|
||||||
|
import { playEntry } from '../lib/soundboardPlayback';
|
||||||
|
import {
|
||||||
|
getPrefs as getSoundboardPrefs,
|
||||||
|
listSounds as listSoundboard,
|
||||||
|
updatePrefs as updateSoundboardPrefs,
|
||||||
|
} from '../lib/soundboardStorage';
|
||||||
import {
|
import {
|
||||||
type DisplaySurfaceHint,
|
type DisplaySurfaceHint,
|
||||||
getPresetParams,
|
getPresetParams,
|
||||||
@@ -110,6 +118,11 @@ interface CallContextValue {
|
|||||||
isDeafened: boolean;
|
isDeafened: boolean;
|
||||||
/** identity -> their deafen state, received via data channel. */
|
/** identity -> their deafen state, received via data channel. */
|
||||||
remoteDeafen: Record<string, boolean>;
|
remoteDeafen: Record<string, boolean>;
|
||||||
|
/** identity -> mute state. Broadcast from peer whenever mic-gain flips.
|
||||||
|
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
|
||||||
|
* the mic pipeline keeps the track published with sound flowing even
|
||||||
|
* while the mic path is gain-silenced, so LK never sees "muted". */
|
||||||
|
remoteMute: Record<string, boolean>;
|
||||||
// Zero or more remote screen shares (LiveKit supports multiple simultaneous).
|
// Zero or more remote screen shares (LiveKit supports multiple simultaneous).
|
||||||
remoteScreenShares: RemoteScreenShare[];
|
remoteScreenShares: RemoteScreenShare[];
|
||||||
// Remembers the conversation of the last call we left so a sidebar widget
|
// Remembers the conversation of the last call we left so a sidebar widget
|
||||||
@@ -139,6 +152,19 @@ interface CallContextValue {
|
|||||||
dismissLastCall: () => void;
|
dismissLastCall: () => void;
|
||||||
setCallMode: (mode: CallMode) => void;
|
setCallMode: (mode: CallMode) => void;
|
||||||
setFocusedId: (id: string | null) => void;
|
setFocusedId: (id: string | null) => void;
|
||||||
|
/** Play a soundboard entry through the active call's mic pipeline.
|
||||||
|
* No-op when not connected. Default single-fire per id (spamming the
|
||||||
|
* hotkey cuts the previous instance); set overlap=true to layer. */
|
||||||
|
playSoundboard: (id: string, opts?: { overlap?: boolean }) => Promise<void>;
|
||||||
|
/** Stop every active sb source, or just the one matching `id` if given. */
|
||||||
|
stopSoundboard: (id?: string) => void;
|
||||||
|
/** Ids of soundboard entries currently emitting audio. Updated live so
|
||||||
|
* the in-call panel can show a stop icon on active pads. */
|
||||||
|
activeSoundboardIds: ReadonlySet<string>;
|
||||||
|
/** Apply new sb master/monitor gains to the live pipeline. Persists via
|
||||||
|
* updatePrefs in the storage module. */
|
||||||
|
setSoundboardMasterGain: (value: number) => Promise<void>;
|
||||||
|
setSoundboardMonitorGain: (value: number) => Promise<void>;
|
||||||
// Runtime mic switch. Persists choice so subsequent calls reuse it, and
|
// Runtime mic switch. Persists choice so subsequent calls reuse it, and
|
||||||
// hot-swaps the input on an active call without a reconnect.
|
// hot-swaps the input on an active call without a reconnect.
|
||||||
setAudioInputDevice: (deviceId: string | null) => Promise<void>;
|
setAudioInputDevice: (deviceId: string | null) => Promise<void>;
|
||||||
@@ -157,7 +183,7 @@ function newCallId(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CallProvider({ children }: { children: ReactNode }) {
|
export function CallProvider({ children }: { children: ReactNode }) {
|
||||||
const { session, device } = useAuth();
|
const { session, device, profile } = useAuth();
|
||||||
const { conversations } = useConversationsContext();
|
const { conversations } = useConversationsContext();
|
||||||
const myId = session?.user.id;
|
const myId = session?.user.id;
|
||||||
|
|
||||||
@@ -173,12 +199,18 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
|
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
|
||||||
const [callMode, setCallModeState] = useState<CallMode>('grid');
|
const [callMode, setCallModeState] = useState<CallMode>('grid');
|
||||||
const [focusedId, setFocusedIdState] = useState<string | null>(null);
|
const [focusedId, setFocusedIdState] = useState<string | null>(null);
|
||||||
|
const [activeSoundboardIds, setActiveSoundboardIds] = useState<ReadonlySet<string>>(
|
||||||
|
() => new Set<string>(),
|
||||||
|
);
|
||||||
|
|
||||||
const signalChannelRef = useRef<RealtimeChannel | null>(null);
|
const signalChannelRef = useRef<RealtimeChannel | null>(null);
|
||||||
const presenceChannelRef = useRef<RealtimeChannel | null>(null);
|
const presenceChannelRef = useRef<RealtimeChannel | null>(null);
|
||||||
const ringTimerRef = useRef<number | null>(null);
|
const ringTimerRef = useRef<number | null>(null);
|
||||||
const soloTimerRef = useRef<number | null>(null);
|
const soloTimerRef = useRef<number | null>(null);
|
||||||
const roomRef = useRef<Room | null>(null);
|
const roomRef = useRef<Room | null>(null);
|
||||||
|
// Web Audio graph that mixes live mic + soundboard sources into a single
|
||||||
|
// published track. Created per call in joinRoom, destroyed in disconnectRoom.
|
||||||
|
const pipelineRef = useRef<MicPipeline | null>(null);
|
||||||
// Tracks whether the current call was ever in the connected state — needed
|
// Tracks whether the current call was ever in the connected state — needed
|
||||||
// so hangup/solo-timeout can emit a real duration message vs. "missed".
|
// so hangup/solo-timeout can emit a real duration message vs. "missed".
|
||||||
const everConnectedRef = useRef<boolean>(false);
|
const everConnectedRef = useRef<boolean>(false);
|
||||||
@@ -191,12 +223,21 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
// channel messages ({ type: 'presence', deafened: bool }). Exposed as
|
// channel messages ({ type: 'presence', deafened: bool }). Exposed as
|
||||||
// state so consumer components re-render on change.
|
// state so consumer components re-render on change.
|
||||||
const [remoteDeafen, setRemoteDeafen] = useState<Record<string, boolean>>({});
|
const [remoteDeafen, setRemoteDeafen] = useState<Record<string, boolean>>({});
|
||||||
|
const [remoteMute, setRemoteMute] = useState<Record<string, boolean>>({});
|
||||||
|
// Mirror of isMuted for use inside LiveKit-event callbacks that run outside
|
||||||
|
// the React component (ParticipantConnected rebroadcast etc).
|
||||||
|
const mutedRef = useRef<boolean>(false);
|
||||||
const stateRef = useRef<CallState>(state);
|
const stateRef = useRef<CallState>(state);
|
||||||
stateRef.current = state;
|
stateRef.current = state;
|
||||||
// Keep latest conversations accessible from signal-channel closures without
|
// Keep latest conversations accessible from signal-channel closures without
|
||||||
// re-subscribing the channel on every conversations update.
|
// re-subscribing the channel on every conversations update.
|
||||||
const conversationsRef = useRef(conversations);
|
const conversationsRef = useRef(conversations);
|
||||||
conversationsRef.current = conversations;
|
conversationsRef.current = conversations;
|
||||||
|
// Mirror own presence state for use inside signal-channel callbacks. DND
|
||||||
|
// suppresses incoming-call OS notifications (ringtone is handled in CallUI
|
||||||
|
// which has direct access to the auth profile).
|
||||||
|
const presenceRef = useRef(profile?.presenceState ?? 'offline');
|
||||||
|
presenceRef.current = profile?.presenceState ?? 'offline';
|
||||||
|
|
||||||
// --- Helpers -----------------------------------------------------------
|
// --- Helpers -----------------------------------------------------------
|
||||||
|
|
||||||
@@ -271,8 +312,25 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsDeafened(false);
|
setIsDeafened(false);
|
||||||
deafenedActive = false;
|
deafenedActive = false;
|
||||||
setRemoteDeafen({});
|
setRemoteDeafen({});
|
||||||
|
setRemoteMute({});
|
||||||
|
setIsMuted(false);
|
||||||
|
mutedRef.current = false;
|
||||||
setIsE2EEActive(false);
|
setIsE2EEActive(false);
|
||||||
|
|
||||||
|
// Tear down the mic pipeline AFTER LiveKit disconnects so the published
|
||||||
|
// track is unpublished cleanly first; then close AudioContext + stop
|
||||||
|
// raw mic + output tracks we own.
|
||||||
|
const pipeline = pipelineRef.current;
|
||||||
|
if (pipeline) {
|
||||||
|
try {
|
||||||
|
pipeline.destroy();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
pipelineRef.current = null;
|
||||||
|
}
|
||||||
|
setActiveSoundboardIds(new Set<string>());
|
||||||
|
|
||||||
const pres = presenceChannelRef.current;
|
const pres = presenceChannelRef.current;
|
||||||
if (pres) {
|
if (pres) {
|
||||||
try {
|
try {
|
||||||
@@ -511,23 +569,36 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
if (!participant?.identity) return;
|
if (!participant?.identity) return;
|
||||||
try {
|
try {
|
||||||
const text = new TextDecoder().decode(payload);
|
const text = new TextDecoder().decode(payload);
|
||||||
const msg = JSON.parse(text) as { type?: string; deafened?: boolean };
|
const msg = JSON.parse(text) as {
|
||||||
if (msg.type === 'presence' && typeof msg.deafened === 'boolean') {
|
type?: string;
|
||||||
const id: string = participant.identity;
|
deafened?: boolean;
|
||||||
|
muted?: boolean;
|
||||||
|
};
|
||||||
|
if (msg.type !== 'presence') return;
|
||||||
|
const id: string = participant.identity;
|
||||||
|
if (typeof msg.deafened === 'boolean') {
|
||||||
const deafened: boolean = msg.deafened;
|
const deafened: boolean = msg.deafened;
|
||||||
setRemoteDeafen((prev) => {
|
setRemoteDeafen((prev) => {
|
||||||
if (prev[id] === deafened) return prev;
|
if (prev[id] === deafened) return prev;
|
||||||
return { ...prev, [id]: deafened };
|
return { ...prev, [id]: deafened };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (typeof msg.muted === 'boolean') {
|
||||||
|
const muted: boolean = msg.muted;
|
||||||
|
setRemoteMute((prev) => {
|
||||||
|
if (prev[id] === muted) return prev;
|
||||||
|
return { ...prev, [id]: muted };
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore malformed */
|
/* ignore malformed */
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// When someone joins, re-send our current deafen state so they know.
|
// When someone joins, re-send our current presence (deafen + mute) so
|
||||||
|
// they know immediately instead of waiting for the next toggle.
|
||||||
r.on(RoomEvent.ParticipantConnected, () => {
|
r.on(RoomEvent.ParticipantConnected, () => {
|
||||||
void broadcastPresence(r, deafenedActive);
|
void broadcastPresence(r, deafenedActive, mutedRef.current);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track my own screen-share state via LocalTrack events so the toggle
|
// Track my own screen-share state via LocalTrack events so the toggle
|
||||||
@@ -557,18 +628,43 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const inputId = getAudioSettings().inputDeviceId;
|
const inputId = getAudioSettings().inputDeviceId;
|
||||||
await r.localParticipant.setMicrophoneEnabled(true, {
|
// Grab the raw mic ourselves instead of going through LiveKit's
|
||||||
echoCancellation: aParams.echoCancellation,
|
// setMicrophoneEnabled. The resulting MediaStreamTrack is routed
|
||||||
noiseSuppression: aParams.noiseSuppression,
|
// through createMicPipeline, which mixes in soundboard buffers and
|
||||||
autoGainControl: aParams.autoGainControl,
|
// exposes a single output track we hand to publishTrack. Mute / PTT
|
||||||
channelCount: aParams.stereo ? 2 : 1,
|
// are gain-based from here on, never track.enabled or device stop.
|
||||||
sampleRate: aParams.sampleRateHz,
|
const rawStream = await navigator.mediaDevices.getUserMedia({
|
||||||
// Plain string maps to `ideal` — if the device is gone we fall back
|
audio: {
|
||||||
// to OS default instead of throwing NotFoundError.
|
echoCancellation: aParams.echoCancellation,
|
||||||
...(inputId ? { deviceId: inputId } : {}),
|
noiseSuppression: aParams.noiseSuppression,
|
||||||
|
autoGainControl: aParams.autoGainControl,
|
||||||
|
channelCount: aParams.stereo ? 2 : 1,
|
||||||
|
sampleRate: aParams.sampleRateHz,
|
||||||
|
...(inputId ? { deviceId: { ideal: inputId } } : {}),
|
||||||
|
},
|
||||||
|
video: false,
|
||||||
|
});
|
||||||
|
const rawTrack = rawStream.getAudioTracks()[0];
|
||||||
|
if (!rawTrack) throw new Error('no audio track from getUserMedia');
|
||||||
|
const pipeline = createMicPipeline(rawTrack);
|
||||||
|
pipelineRef.current = pipeline;
|
||||||
|
// Pull the user's last-saved soundboard gains onto the live pipeline
|
||||||
|
// before the first sound ever plays so nothing blasts at 100%.
|
||||||
|
try {
|
||||||
|
const prefs = await getSoundboardPrefs();
|
||||||
|
pipeline.setSoundboardGain(prefs.masterGain);
|
||||||
|
pipeline.setMonitorGain(prefs.monitorGain);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('getSoundboardPrefs failed', err);
|
||||||
|
}
|
||||||
|
await r.localParticipant.publishTrack(pipeline.outputTrack, {
|
||||||
|
source: Track.Source.Microphone,
|
||||||
|
red: true,
|
||||||
|
dtx: aParams.stereo ? false : true,
|
||||||
|
forceStereo: aParams.stereo,
|
||||||
});
|
});
|
||||||
} catch (micErr: unknown) {
|
} catch (micErr: unknown) {
|
||||||
console.error('setMicrophoneEnabled failed', micErr);
|
console.error('mic pipeline setup failed', micErr);
|
||||||
}
|
}
|
||||||
if (mediaKind === 'video') {
|
if (mediaKind === 'video') {
|
||||||
try {
|
try {
|
||||||
@@ -807,12 +903,15 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
const toggleMute = useCallback(() => {
|
||||||
const r = roomRef.current;
|
const pipeline = pipelineRef.current;
|
||||||
if (!r) return;
|
if (!pipeline) return;
|
||||||
const lp = r.localParticipant;
|
setIsMuted((prev) => {
|
||||||
const shouldEnable = !lp.isMicrophoneEnabled;
|
const nextMuted = !prev;
|
||||||
void lp.setMicrophoneEnabled(shouldEnable).then(() => {
|
pipeline.setMicGain(nextMuted ? 0 : 1);
|
||||||
setIsMuted(!shouldEnable);
|
mutedRef.current = nextMuted;
|
||||||
|
const r = roomRef.current;
|
||||||
|
if (r) void broadcastPresence(r, deafenedActive, nextMuted);
|
||||||
|
return nextMuted;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -921,7 +1020,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
// headphones-off badge. Data channel works on any LiveKit server
|
// headphones-off badge. Data channel works on any LiveKit server
|
||||||
// version, unlike `setAttributes` which requires a newer server.
|
// version, unlike `setAttributes` which requires a newer server.
|
||||||
const r = roomRef.current;
|
const r = roomRef.current;
|
||||||
if (r) void broadcastPresence(r, next);
|
if (r) void broadcastPresence(r, next, mutedRef.current);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -957,9 +1056,14 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
let globalRegisteredFor: string | null = null;
|
let globalRegisteredFor: string | null = null;
|
||||||
|
|
||||||
const setMic = (on: boolean) => {
|
const setMic = (on: boolean) => {
|
||||||
|
const pipeline = pipelineRef.current;
|
||||||
|
if (!pipeline) return;
|
||||||
|
pipeline.setMicGain(on ? 1 : 0);
|
||||||
|
const nextMuted = !on;
|
||||||
|
mutedRef.current = nextMuted;
|
||||||
|
setIsMuted(nextMuted);
|
||||||
const r = roomRef.current;
|
const r = roomRef.current;
|
||||||
if (!r) return;
|
if (r) void broadcastPresence(r, deafenedActive, nextMuted);
|
||||||
void r.localParticipant.setMicrophoneEnabled(on).then(() => setIsMuted(!on));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const pressPtt = () => {
|
const pressPtt = () => {
|
||||||
@@ -1088,14 +1192,16 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
conv?.peer?.displayName ??
|
conv?.peer?.displayName ??
|
||||||
'…';
|
'…';
|
||||||
const isGroup = conv?.type === 'group';
|
const isGroup = conv?.type === 'group';
|
||||||
void notify({
|
if (presenceRef.current !== 'dnd') {
|
||||||
title: isGroup
|
void notify({
|
||||||
? (conv?.name ?? 'Gruppenanruf')
|
title: isGroup
|
||||||
: 'Eingehender Anruf',
|
? (conv?.name ?? 'Gruppenanruf')
|
||||||
body: isGroup
|
: 'Eingehender Anruf',
|
||||||
? callerName + ' ruft die Gruppe'
|
body: isGroup
|
||||||
: callerName + ' ruft dich an',
|
? callerName + ' ruft die Gruppe'
|
||||||
});
|
: callerName + ' ruft dich an',
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'accept':
|
case 'accept':
|
||||||
@@ -1136,10 +1242,12 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
conv?.members.find((m) => m.userId === cur.fromUserId)?.profile?.displayName ??
|
conv?.members.find((m) => m.userId === cur.fromUserId)?.profile?.displayName ??
|
||||||
conv?.peer?.displayName ??
|
conv?.peer?.displayName ??
|
||||||
'…';
|
'…';
|
||||||
void notify({
|
if (presenceRef.current !== 'dnd') {
|
||||||
title: 'Verpasster Anruf',
|
void notify({
|
||||||
body: callerName + ' hat aufgelegt',
|
title: 'Verpasster Anruf',
|
||||||
});
|
body: callerName + ' hat aufgelegt',
|
||||||
|
});
|
||||||
|
}
|
||||||
setState({ kind: 'idle' });
|
setState({ kind: 'idle' });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -1156,16 +1264,96 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setFocusedIdState(id);
|
setFocusedIdState(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const markActive = useCallback((id: string, on: boolean) => {
|
||||||
|
setActiveSoundboardIds((prev) => {
|
||||||
|
const has = prev.has(id);
|
||||||
|
if (on && has) return prev;
|
||||||
|
if (!on && !has) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (on) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const playSoundboard = useCallback(
|
||||||
|
async (id: string, opts?: { overlap?: boolean }) => {
|
||||||
|
const pipeline = pipelineRef.current;
|
||||||
|
if (!pipeline) return;
|
||||||
|
const entries = await listSoundboard();
|
||||||
|
const entry = entries.find((e) => e.id === id);
|
||||||
|
if (!entry) return;
|
||||||
|
const handle = await playEntry(pipeline, entry, {
|
||||||
|
...(opts?.overlap !== undefined ? { overlap: opts.overlap } : {}),
|
||||||
|
onEnded: () => markActive(id, false),
|
||||||
|
});
|
||||||
|
if (handle) markActive(id, true);
|
||||||
|
},
|
||||||
|
[markActive],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopSoundboard = useCallback(
|
||||||
|
(id?: string) => {
|
||||||
|
const pipeline = pipelineRef.current;
|
||||||
|
if (!pipeline) return;
|
||||||
|
pipeline.stopAll(id);
|
||||||
|
if (id) {
|
||||||
|
markActive(id, false);
|
||||||
|
} else {
|
||||||
|
setActiveSoundboardIds(new Set<string>());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[markActive],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setSoundboardMasterGain = useCallback(async (value: number) => {
|
||||||
|
const prefs = await updateSoundboardPrefs({ masterGain: value });
|
||||||
|
pipelineRef.current?.setSoundboardGain(prefs.masterGain);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSoundboardMonitorGain = useCallback(async (value: number) => {
|
||||||
|
const prefs = await updateSoundboardPrefs({ monitorGain: value });
|
||||||
|
pipelineRef.current?.setMonitorGain(prefs.monitorGain);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Global soundboard hotkey registration — runs only while connected so the
|
||||||
|
// OS-level shortcuts don't fire when the user is outside of a call.
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.kind !== 'connected') return;
|
||||||
|
const teardown = startSoundboardHotkeys((id) => {
|
||||||
|
void playSoundboard(id);
|
||||||
|
});
|
||||||
|
return teardown;
|
||||||
|
}, [state.kind, playSoundboard]);
|
||||||
|
|
||||||
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
|
const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
|
||||||
updateAudioSettings({ inputDeviceId: deviceId });
|
updateAudioSettings({ inputDeviceId: deviceId });
|
||||||
const r = roomRef.current;
|
const pipeline = pipelineRef.current;
|
||||||
if (!r) return;
|
if (!pipeline) return;
|
||||||
try {
|
try {
|
||||||
// LiveKit API: `switchActiveDevice(kind, deviceId)` hot-swaps without a
|
// We own the mic track (see joinRoom pipeline setup), so LiveKit's
|
||||||
// reconnect. Pass empty string or `default` to revert to OS default.
|
// switchActiveDevice no longer applies. Fetch a new raw track with the
|
||||||
await r.switchActiveDevice('audioinput', deviceId ?? 'default');
|
// updated deviceId + the same quality constraints, then hand ownership
|
||||||
|
// to the pipeline. It disconnects the old source, stops the old track,
|
||||||
|
// and rewires micGain onto the new source — the published track stays
|
||||||
|
// stable so peers don't see a republish.
|
||||||
|
const aParams = getAudioQualityParams(getAudioSettings().quality);
|
||||||
|
const newStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: aParams.echoCancellation,
|
||||||
|
noiseSuppression: aParams.noiseSuppression,
|
||||||
|
autoGainControl: aParams.autoGainControl,
|
||||||
|
channelCount: aParams.stereo ? 2 : 1,
|
||||||
|
sampleRate: aParams.sampleRateHz,
|
||||||
|
...(deviceId ? { deviceId: { ideal: deviceId } } : {}),
|
||||||
|
},
|
||||||
|
video: false,
|
||||||
|
});
|
||||||
|
const newTrack = newStream.getAudioTracks()[0];
|
||||||
|
if (!newTrack) throw new Error('no audio track for device');
|
||||||
|
pipeline.replaceMicTrack(newTrack);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('switchActiveDevice(audioinput) failed', err);
|
console.error('setAudioInputDevice failed', err);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1229,6 +1417,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
isCameraEnabled,
|
isCameraEnabled,
|
||||||
isDeafened,
|
isDeafened,
|
||||||
remoteDeafen,
|
remoteDeafen,
|
||||||
|
remoteMute,
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
lastCallConversationId,
|
lastCallConversationId,
|
||||||
callMode,
|
callMode,
|
||||||
@@ -1249,6 +1438,11 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setFocusedId,
|
setFocusedId,
|
||||||
setAudioInputDevice,
|
setAudioInputDevice,
|
||||||
setAudioOutputDevice,
|
setAudioOutputDevice,
|
||||||
|
playSoundboard,
|
||||||
|
stopSoundboard,
|
||||||
|
activeSoundboardIds,
|
||||||
|
setSoundboardMasterGain,
|
||||||
|
setSoundboardMonitorGain,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
state,
|
state,
|
||||||
@@ -1260,6 +1454,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
isCameraEnabled,
|
isCameraEnabled,
|
||||||
isDeafened,
|
isDeafened,
|
||||||
remoteDeafen,
|
remoteDeafen,
|
||||||
|
remoteMute,
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
lastCallConversationId,
|
lastCallConversationId,
|
||||||
callMode,
|
callMode,
|
||||||
@@ -1280,6 +1475,11 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setFocusedId,
|
setFocusedId,
|
||||||
setAudioInputDevice,
|
setAudioInputDevice,
|
||||||
setAudioOutputDevice,
|
setAudioOutputDevice,
|
||||||
|
playSoundboard,
|
||||||
|
stopSoundboard,
|
||||||
|
activeSoundboardIds,
|
||||||
|
setSoundboardMasterGain,
|
||||||
|
setSoundboardMonitorGain,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1297,10 +1497,14 @@ export function useCall(): CallContextValue {
|
|||||||
// audio elements. Toggled by toggleDeafen in sync with the React state.
|
// audio elements. Toggled by toggleDeafen in sync with the React state.
|
||||||
let deafenedActive = false;
|
let deafenedActive = false;
|
||||||
|
|
||||||
async function broadcastPresence(room: Room, deafened: boolean): Promise<void> {
|
async function broadcastPresence(
|
||||||
|
room: Room,
|
||||||
|
deafened: boolean,
|
||||||
|
muted: boolean,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const payload = new TextEncoder().encode(
|
const payload = new TextEncoder().encode(
|
||||||
JSON.stringify({ type: 'presence', deafened }),
|
JSON.stringify({ type: 'presence', deafened, muted }),
|
||||||
);
|
);
|
||||||
await room.localParticipant.publishData(payload, { reliable: true });
|
await room.localParticipant.publishData(payload, { reliable: true });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@@ -55,3 +55,72 @@ export async function unregisterPttShortcut(code: string): Promise<void> {
|
|||||||
export function isTauriRuntime(): boolean {
|
export function isTauriRuntime(): boolean {
|
||||||
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Soundboard shortcuts --------------------------------------------------
|
||||||
|
//
|
||||||
|
// Separate from the single PTT shortcut: the soundboard needs to register
|
||||||
|
// many fire-and-forget press bindings at once, keep track of which ids own
|
||||||
|
// which accelerators so we can unregister just one, and expose conflict
|
||||||
|
// detection for the settings UI.
|
||||||
|
|
||||||
|
interface SoundShortcutRegistration {
|
||||||
|
shortcut: string;
|
||||||
|
onPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map of logical id (sound uuid) -> registration.
|
||||||
|
const soundRegistry = new Map<string, SoundShortcutRegistration>();
|
||||||
|
|
||||||
|
export async function registerSoundShortcut(
|
||||||
|
id: string,
|
||||||
|
code: string,
|
||||||
|
onPress: () => void,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!isTauriRuntime()) return false;
|
||||||
|
const shortcut = codeToShortcut(code);
|
||||||
|
// Unregister any previous binding for this id first — caller may be
|
||||||
|
// re-registering after the user changed the hotkey for the same sound.
|
||||||
|
await unregisterSoundShortcut(id);
|
||||||
|
try {
|
||||||
|
if (await isRegistered(shortcut)) {
|
||||||
|
await unregister(shortcut);
|
||||||
|
}
|
||||||
|
await register(shortcut, (event: ShortcutEvent) => {
|
||||||
|
if (event.state === 'Pressed') onPress();
|
||||||
|
});
|
||||||
|
soundRegistry.set(id, { shortcut, onPress });
|
||||||
|
return true;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('registerSoundShortcut failed', { id, code, err });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unregisterSoundShortcut(id: string): Promise<void> {
|
||||||
|
const reg = soundRegistry.get(id);
|
||||||
|
if (!reg) return;
|
||||||
|
soundRegistry.delete(id);
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
try {
|
||||||
|
if (await isRegistered(reg.shortcut)) {
|
||||||
|
await unregister(reg.shortcut);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('unregisterSoundShortcut failed', { id, err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unregisterAllSoundShortcuts(): Promise<void> {
|
||||||
|
const ids = Array.from(soundRegistry.keys());
|
||||||
|
await Promise.all(ids.map((id) => unregisterSoundShortcut(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a DOM code to the registry's current owner (if any). Used by the
|
||||||
|
// settings UI to surface conflicts before saving a new hotkey.
|
||||||
|
export function soundShortcutOwnerFor(code: string): string | null {
|
||||||
|
const shortcut = codeToShortcut(code);
|
||||||
|
for (const [id, reg] of soundRegistry) {
|
||||||
|
if (reg.shortcut === shortcut) return id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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:
|
// Looping ringtones. Two patterns:
|
||||||
// - outgoing: long calling tone, 3s cycle
|
// - outgoing: long calling tone, 3s cycle (oscillator only)
|
||||||
// - incoming: classic "ring ring" double beep, 2s cycle
|
// - incoming: classic "ring ring" double beep (oscillator), optionally
|
||||||
|
// upgraded to a user-supplied audio file stored in ringtoneStorage
|
||||||
|
//
|
||||||
|
// The custom file plays immediately if we can load it; otherwise we fall
|
||||||
|
// back to the generated oscillator pattern so ringing never misses an
|
||||||
|
// incoming call due to an IO failure.
|
||||||
|
|
||||||
|
import { getIncomingRingtone } from './ringtoneStorage';
|
||||||
|
|
||||||
type Pattern = 'outgoing' | 'incoming';
|
type Pattern = 'outgoing' | 'incoming';
|
||||||
|
|
||||||
@@ -9,15 +16,90 @@ class Ringtone {
|
|||||||
private interval: number | null = null;
|
private interval: number | null = null;
|
||||||
private pattern: Pattern | null = null;
|
private pattern: Pattern | null = null;
|
||||||
|
|
||||||
|
// Custom-file playback path (incoming only).
|
||||||
|
private audioEl: HTMLAudioElement | null = null;
|
||||||
|
private customUrl: string | null = null;
|
||||||
|
// Sequence token to ignore slow IO completing after user changed state.
|
||||||
|
private startSeq = 0;
|
||||||
|
|
||||||
start(pattern: Pattern): void {
|
start(pattern: Pattern): void {
|
||||||
if (this.pattern === pattern) return; // already playing this pattern
|
if (this.pattern === pattern) return; // already playing this pattern
|
||||||
this.stop();
|
this.stop();
|
||||||
|
this.pattern = pattern;
|
||||||
|
const seq = ++this.startSeq;
|
||||||
|
|
||||||
|
if (pattern === 'incoming') {
|
||||||
|
// Kick off oscillator immediately so we never miss ringing feedback
|
||||||
|
// while the custom file (if any) loads asynchronously. Once the blob
|
||||||
|
// is ready we hand playback over to the <audio> element.
|
||||||
|
this.startOscillator(pattern);
|
||||||
|
void this.tryUpgradeToCustom(seq);
|
||||||
|
} else {
|
||||||
|
this.startOscillator(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.startSeq++;
|
||||||
|
this.stopOscillator();
|
||||||
|
this.stopCustom();
|
||||||
|
this.pattern = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Custom file path (incoming only) ----------------------------------
|
||||||
|
|
||||||
|
private async tryUpgradeToCustom(seq: number): Promise<void> {
|
||||||
|
let stored;
|
||||||
|
try {
|
||||||
|
stored = await getIncomingRingtone();
|
||||||
|
} catch {
|
||||||
|
return; // keep oscillator
|
||||||
|
}
|
||||||
|
// User stopped or switched patterns while we were loading.
|
||||||
|
if (seq !== this.startSeq || this.pattern !== 'incoming' || !stored) return;
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(stored.blob);
|
||||||
|
const el = new Audio(url);
|
||||||
|
el.loop = true;
|
||||||
|
el.volume = 0.85;
|
||||||
|
// Chrome/WKWebView autoplay policy: muted playback is always allowed,
|
||||||
|
// but ringtones must be audible, so play() may reject the first time
|
||||||
|
// before the user interacted. If it rejects, we keep the oscillator.
|
||||||
|
el.play().catch(() => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.audioEl = el;
|
||||||
|
this.customUrl = url;
|
||||||
|
// Only swap off the oscillator once the custom element is actually
|
||||||
|
// wired — avoids a silent gap on transition.
|
||||||
|
this.stopOscillator();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopCustom(): void {
|
||||||
|
if (this.audioEl) {
|
||||||
|
try {
|
||||||
|
this.audioEl.pause();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.audioEl.src = '';
|
||||||
|
this.audioEl = null;
|
||||||
|
}
|
||||||
|
if (this.customUrl) {
|
||||||
|
URL.revokeObjectURL(this.customUrl);
|
||||||
|
this.customUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Oscillator fallback ----------------------------------------------
|
||||||
|
|
||||||
|
private startOscillator(pattern: Pattern): void {
|
||||||
const AudioCtx =
|
const AudioCtx =
|
||||||
window.AudioContext ??
|
window.AudioContext ??
|
||||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||||
if (!AudioCtx) return;
|
if (!AudioCtx) return;
|
||||||
this.ctx = new AudioCtx();
|
this.ctx = new AudioCtx();
|
||||||
this.pattern = pattern;
|
|
||||||
|
|
||||||
const play = pattern === 'outgoing' ? this.playOutgoing : this.playIncoming;
|
const play = pattern === 'outgoing' ? this.playOutgoing : this.playIncoming;
|
||||||
play.call(this);
|
play.call(this);
|
||||||
@@ -27,7 +109,7 @@ class Ringtone {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
private stopOscillator(): void {
|
||||||
if (this.interval !== null) {
|
if (this.interval !== null) {
|
||||||
window.clearInterval(this.interval);
|
window.clearInterval(this.interval);
|
||||||
this.interval = null;
|
this.interval = null;
|
||||||
@@ -38,7 +120,6 @@ class Ringtone {
|
|||||||
});
|
});
|
||||||
this.ctx = null;
|
this.ctx = null;
|
||||||
}
|
}
|
||||||
this.pattern = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private beep(freq: number, durationSec: number, delaySec: number, gain = 0.18): void {
|
private beep(freq: number, durationSec: number, delaySec: number, gain = 0.18): void {
|
||||||
|
|||||||
@@ -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';
|
import { supabase } from './supabase';
|
||||||
|
|
||||||
// Subscribe to a single peer's presence_state via Supabase realtime.
|
export interface PeerPresence {
|
||||||
// Returns null until the first row arrives, or when userId is undefined.
|
state: PresenceState;
|
||||||
export function usePeerPresence(userId: string | undefined): PresenceState | null {
|
statusMessage: string | null;
|
||||||
const [presence, setPresence] = useState<PresenceState | null>(null);
|
}
|
||||||
|
|
||||||
|
// Subscribe to a single peer's presence_state + status_message via Supabase
|
||||||
|
// realtime. Returns null until the first row arrives, or when userId is
|
||||||
|
// undefined.
|
||||||
|
export function usePeerPresence(userId: string | undefined): PeerPresence | null {
|
||||||
|
const [presence, setPresence] = useState<PeerPresence | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -17,11 +23,15 @@ export function usePeerPresence(userId: string | undefined): PresenceState | nul
|
|||||||
|
|
||||||
void supabase
|
void supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('presence_state')
|
.select('presence_state, status_message')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
if (!cancelled) setPresence(data?.presence_state ?? null);
|
if (cancelled || !data) return;
|
||||||
|
setPresence({
|
||||||
|
state: (data.presence_state as PresenceState | null) ?? 'offline',
|
||||||
|
statusMessage: data.status_message ?? null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const channel = supabase
|
const channel = supabase
|
||||||
@@ -35,8 +45,21 @@ export function usePeerPresence(userId: string | undefined): PresenceState | nul
|
|||||||
filter: 'user_id=eq.' + userId,
|
filter: 'user_id=eq.' + userId,
|
||||||
},
|
},
|
||||||
(payload: { new: Record<string, unknown> }) => {
|
(payload: { new: Record<string, unknown> }) => {
|
||||||
const next = payload.new['presence_state'];
|
const nextState = payload.new['presence_state'];
|
||||||
if (typeof next === 'string') setPresence(next as PresenceState);
|
const nextMsg = payload.new['status_message'];
|
||||||
|
setPresence((prev) => {
|
||||||
|
const state =
|
||||||
|
typeof nextState === 'string'
|
||||||
|
? (nextState as PresenceState)
|
||||||
|
: prev?.state ?? 'offline';
|
||||||
|
const statusMessage =
|
||||||
|
nextMsg === null
|
||||||
|
? null
|
||||||
|
: typeof nextMsg === 'string'
|
||||||
|
? nextMsg
|
||||||
|
: prev?.statusMessage ?? null;
|
||||||
|
return { state, statusMessage };
|
||||||
|
});
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.subscribe();
|
.subscribe();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { ConversationHeader } from '../components/ConversationHeader';
|
import { ConversationHeader } from '../components/ConversationHeader';
|
||||||
|
import { EmojiPicker } from '../components/EmojiPicker';
|
||||||
import { ForwardDialog } from '../components/ForwardDialog';
|
import { ForwardDialog } from '../components/ForwardDialog';
|
||||||
import { GroupInfoPanel } from '../components/GroupInfoPanel';
|
import { GroupInfoPanel } from '../components/GroupInfoPanel';
|
||||||
import {
|
import {
|
||||||
@@ -15,11 +16,13 @@ import {
|
|||||||
PlusIcon,
|
PlusIcon,
|
||||||
ReplyIcon,
|
ReplyIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
|
SmileIcon,
|
||||||
SpinnerIcon,
|
SpinnerIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '../components/icons';
|
} from '../components/icons';
|
||||||
import { InCallPanel } from '../components/InCallPanel';
|
import { InCallPanel } from '../components/InCallPanel';
|
||||||
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||||
|
import { MentionAutocomplete } from '../components/MentionAutocomplete';
|
||||||
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
|
||||||
import { TypingIndicator } from '../components/TypingIndicator';
|
import { TypingIndicator } from '../components/TypingIndicator';
|
||||||
import { VoiceRecorder } from '../components/VoiceRecorder';
|
import { VoiceRecorder } from '../components/VoiceRecorder';
|
||||||
@@ -141,6 +144,11 @@ export function ConversationPage() {
|
|||||||
const [searchDateTo, setSearchDateTo] = useState<string>('');
|
const [searchDateTo, setSearchDateTo] = useState<string>('');
|
||||||
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
||||||
const [displayCount, setDisplayCount] = useState<number>(150);
|
const [displayCount, setDisplayCount] = useState<number>(150);
|
||||||
|
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||||
|
const [mentionState, setMentionState] = useState<
|
||||||
|
{ query: string; start: number } | null
|
||||||
|
>(null);
|
||||||
|
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -345,13 +353,9 @@ export function ConversationPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFilesChosen(list: FileList | null) {
|
function ingestFiles(files: File[]) {
|
||||||
if (!list) return;
|
|
||||||
const next: File[] = [];
|
const next: File[] = [];
|
||||||
for (let i = 0; i < list.length; i++) {
|
for (const f of files) {
|
||||||
const f = list[i];
|
|
||||||
if (!f) continue;
|
|
||||||
if (!f.type.startsWith('image/')) continue;
|
|
||||||
if (f.size > 10 * 1024 * 1024) {
|
if (f.size > 10 * 1024 * 1024) {
|
||||||
setSendError('Datei zu groß (max 10 MB)');
|
setSendError('Datei zu groß (max 10 MB)');
|
||||||
continue;
|
continue;
|
||||||
@@ -361,6 +365,11 @@ export function ConversationPage() {
|
|||||||
setAttachments((prev) => [...prev, ...next].slice(0, 4));
|
setAttachments((prev) => [...prev, ...next].slice(0, 4));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleFilesChosen(list: FileList | null) {
|
||||||
|
if (!list) return;
|
||||||
|
ingestFiles(Array.from(list));
|
||||||
|
}
|
||||||
|
|
||||||
const { state: callState } = useCall();
|
const { state: callState } = useCall();
|
||||||
// Hide the chat header while this conversation hosts an active call — the
|
// Hide the chat header while this conversation hosts an active call — the
|
||||||
// call topbar inside the dock already shows the channel name + duration,
|
// call topbar inside the dock already shows the channel name + duration,
|
||||||
@@ -374,7 +383,31 @@ export function ConversationPage() {
|
|||||||
callState.kind === 'incoming' && callState.conversationId === id;
|
callState.kind === 'incoming' && callState.conversationId === id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full flex-col">
|
<div
|
||||||
|
className="relative flex h-full flex-col"
|
||||||
|
onDragEnter={(e) => {
|
||||||
|
if (e.dataTransfer?.types.includes('Files')) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDraggingFile(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (e.dataTransfer?.types.includes('Files')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
// leave fires on child enter too; only clear when leaving the page container.
|
||||||
|
if (e.currentTarget === e.target) setIsDraggingFile(false);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (!e.dataTransfer?.files?.length) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDraggingFile(false);
|
||||||
|
ingestFiles(Array.from(e.dataTransfer.files));
|
||||||
|
}}
|
||||||
|
>
|
||||||
{!callHereActive && (
|
{!callHereActive && (
|
||||||
<ConversationHeader
|
<ConversationHeader
|
||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
@@ -530,6 +563,17 @@ export function ConversationPage() {
|
|||||||
members={conversation?.members ?? []}
|
members={conversation?.members ?? []}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isDraggingFile && (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center rounded-lg border-2 border-dashed border-accent/60 bg-accent/10 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div className="rounded-xl border border-accent/40 bg-surface-3/90 px-4 py-3 text-sm font-semibold text-fg shadow-xl">
|
||||||
|
Datei hier ablegen zum Anhängen
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSend} className="border-t border-line bg-surface-3 p-4">
|
<form onSubmit={handleSend} className="border-t border-line bg-surface-3 p-4">
|
||||||
{sendError && (
|
{sendError && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
@@ -583,11 +627,36 @@ export function ConversationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-end gap-2">
|
<div className="relative flex items-end gap-2">
|
||||||
|
{isGroup && mentionState && conversation && (
|
||||||
|
<MentionAutocomplete
|
||||||
|
members={conversation.members}
|
||||||
|
query={mentionState.query}
|
||||||
|
excludeUserId={myId}
|
||||||
|
onSelect={(username) => {
|
||||||
|
// Replace `@{query}` at `start..caret` with `@{username} `.
|
||||||
|
const start = mentionState.start;
|
||||||
|
const before = text.slice(0, start);
|
||||||
|
const afterCaret = text.slice(start + 1 + mentionState.query.length);
|
||||||
|
const inserted = '@' + username + ' ';
|
||||||
|
const next = before + inserted + afterCaret;
|
||||||
|
setText(next);
|
||||||
|
setMentionState(null);
|
||||||
|
// Restore caret position after inserted mention.
|
||||||
|
const caret = (before + inserted).length;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = composerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(caret, caret);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onClose={() => setMentionState(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => handleFilesChosen(e.target.files)}
|
onChange={(e) => handleFilesChosen(e.target.files)}
|
||||||
@@ -595,12 +664,41 @@ export function ConversationPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
aria-label="Bild anhängen"
|
aria-label="Datei anhängen"
|
||||||
title="Bild anhängen"
|
title="Datei anhängen"
|
||||||
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-emoji-trigger
|
||||||
|
onClick={() => setEmojiOpen((v) => !v)}
|
||||||
|
aria-label="Emoji einfügen"
|
||||||
|
title="Emoji einfügen"
|
||||||
|
aria-expanded={emojiOpen}
|
||||||
|
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-surface-2 text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||||
|
>
|
||||||
|
<SmileIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<EmojiPicker
|
||||||
|
open={emojiOpen}
|
||||||
|
onPick={(emoji) => {
|
||||||
|
const el = composerRef.current;
|
||||||
|
const caret = el?.selectionStart ?? text.length;
|
||||||
|
const next = text.slice(0, caret) + emoji + text.slice(caret);
|
||||||
|
setText(next);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
const pos = caret + emoji.length;
|
||||||
|
el.setSelectionRange(pos, pos);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onClose={() => setEmojiOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<VoiceRecorder
|
<VoiceRecorder
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
onComplete={async (file) => {
|
onComplete={async (file) => {
|
||||||
@@ -617,8 +715,26 @@ export function ConversationPage() {
|
|||||||
ref={composerRef}
|
ref={composerRef}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setText(e.target.value);
|
const next = e.target.value;
|
||||||
if (e.target.value.length > 0) notifyTyping();
|
setText(next);
|
||||||
|
if (next.length > 0) notifyTyping();
|
||||||
|
// Detect an in-progress @mention: find the last '@' before the
|
||||||
|
// caret, with no whitespace between it and the caret. If
|
||||||
|
// present, open the autocomplete with the partial query.
|
||||||
|
const caret = e.target.selectionStart ?? next.length;
|
||||||
|
const before = next.slice(0, caret);
|
||||||
|
const atIdx = before.lastIndexOf('@');
|
||||||
|
if (
|
||||||
|
atIdx >= 0 &&
|
||||||
|
(atIdx === 0 || /\s/.test(before[atIdx - 1] ?? ''))
|
||||||
|
) {
|
||||||
|
const q = before.slice(atIdx + 1);
|
||||||
|
if (!/\s/.test(q)) {
|
||||||
|
setMentionState({ query: q, start: atIdx });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMentionState(null);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
@@ -626,6 +742,21 @@ export function ConversationPage() {
|
|||||||
void handleSend();
|
void handleSend();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onPaste={(e) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
const pics: File[] = [];
|
||||||
|
for (const it of Array.from(items)) {
|
||||||
|
if (it.kind === 'file') {
|
||||||
|
const f = it.getAsFile();
|
||||||
|
if (f && f.type.startsWith('image/')) pics.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pics.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
ingestFiles(pics);
|
||||||
|
}
|
||||||
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
placeholder="Nachricht schreiben…"
|
placeholder="Nachricht schreiben…"
|
||||||
className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
@@ -885,18 +1016,30 @@ function Banner({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => void }) {
|
function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => void }) {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isImage) return;
|
||||||
const u = URL.createObjectURL(file);
|
const u = URL.createObjectURL(file);
|
||||||
setUrl(u);
|
setUrl(u);
|
||||||
return () => URL.revokeObjectURL(u);
|
return () => URL.revokeObjectURL(u);
|
||||||
}, [file]);
|
}, [file, isImage]);
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-lg border border-line bg-surface-2">
|
<div className="relative overflow-hidden rounded-lg border border-line bg-surface-2">
|
||||||
{url ? (
|
{isImage && url ? (
|
||||||
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="h-20 w-20" />
|
<div className="flex h-20 w-32 flex-col justify-center gap-0.5 px-2 text-[10px]">
|
||||||
|
<span className="truncate font-semibold text-fg" title={file.name}>
|
||||||
|
{file.name || 'Datei'}
|
||||||
|
</span>
|
||||||
|
<span className="text-fg-muted">
|
||||||
|
{file.type || 'unbekannt'}
|
||||||
|
</span>
|
||||||
|
<span className="text-fg-muted">
|
||||||
|
{(file.size / 1024).toFixed(0)} KB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { Avatar } from '../components/Avatar';
|
import { Avatar } from '../components/Avatar';
|
||||||
import { BackupExportDialog } from '../components/BackupExportDialog';
|
import { BackupExportDialog } from '../components/BackupExportDialog';
|
||||||
|
import { RingtoneSettings } from '../components/RingtoneSettings';
|
||||||
|
import { SoundboardSettings } from '../components/SoundboardSettings';
|
||||||
import { LockIcon } from '../components/icons';
|
import { LockIcon } from '../components/icons';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useCall } from '../context/CallContext';
|
import { useCall } from '../context/CallContext';
|
||||||
@@ -141,6 +143,16 @@ export function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Ringtone (incoming custom) */}
|
||||||
|
<Section title={t('app:settings.section_ringtone', { defaultValue: 'Klingelton' })}>
|
||||||
|
<RingtoneSettings disabled={busy} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Soundboard */}
|
||||||
|
<Section title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}>
|
||||||
|
<SoundboardSettings />
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
|
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
|
||||||
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
|
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
|
||||||
<AudioDeviceControls />
|
<AudioDeviceControls />
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"idle": "Abwesend",
|
"idle": "Abwesend",
|
||||||
"dnd": "Nicht stören",
|
"dnd": "Nicht stören",
|
||||||
"invisible": "Unsichtbar",
|
"invisible": "Unsichtbar",
|
||||||
"offline": "Offline"
|
"offline": "Offline",
|
||||||
|
"status_placeholder": "Status setzen…"
|
||||||
},
|
},
|
||||||
"chats": {
|
"chats": {
|
||||||
"empty_title": "Noch keine Unterhaltungen",
|
"empty_title": "Noch keine Unterhaltungen",
|
||||||
@@ -194,7 +195,61 @@
|
|||||||
"allow_dms_strangers_hint": "Wenn aus, können dich nur Freunde direkt anschreiben.",
|
"allow_dms_strangers_hint": "Wenn aus, können dich nur Freunde direkt anschreiben.",
|
||||||
"this_device": "Dieses Gerät",
|
"this_device": "Dieses Gerät",
|
||||||
"danger_zone": "Gefahrenzone",
|
"danger_zone": "Gefahrenzone",
|
||||||
"sign_out": "Abmelden"
|
"sign_out": "Abmelden",
|
||||||
|
"section_ringtone": "Klingelton",
|
||||||
|
"ringtone_incoming": "Eingehender Anruf",
|
||||||
|
"ringtone_default_active": "Standard-Klingelton (Doppelton)",
|
||||||
|
"ringtone_custom_active": "{{name}} · {{size}} MB",
|
||||||
|
"ringtone_upload": "Hochladen",
|
||||||
|
"ringtone_replace": "Ersetzen",
|
||||||
|
"ringtone_preview": "Vorhören",
|
||||||
|
"ringtone_stop": "Stop",
|
||||||
|
"ringtone_reset": "Zurücksetzen",
|
||||||
|
"ringtone_hint": "MP3, WAV, OGG oder M4A bis 2 MB. Nur für eingehende Anrufe — ausgehend bleibt der Standard.",
|
||||||
|
"ringtone_error_too_large": "Datei zu groß (max {{max}} MB).",
|
||||||
|
"ringtone_error_not_audio": "Nur Audio-Dateien werden unterstützt.",
|
||||||
|
"ringtone_error_generic": "Ringtone konnte nicht gespeichert werden.",
|
||||||
|
"ringtone_error_play": "Ringtone konnte nicht abgespielt werden.",
|
||||||
|
"section_soundboard": "Soundboard"
|
||||||
|
},
|
||||||
|
"soundboard": {
|
||||||
|
"summary_title": "Deine Sounds",
|
||||||
|
"summary_empty": "Noch keine Sounds vorhanden.",
|
||||||
|
"summary_counts": "{{sounds}} Sounds · {{categories}} Kategorien · {{hotkeys}} mit Hotkey",
|
||||||
|
"settings_hint": "Hotkeys sind optional. Sounds lassen sich auch während eines Anrufs direkt im UI abspielen.",
|
||||||
|
"manage": "Verwalten",
|
||||||
|
"manager_title": "Soundboard verwalten",
|
||||||
|
"manager_hint": "Beliebig viele Sounds, kein Hotkey nötig. Hotkeys feuern nur während eines Anrufs.",
|
||||||
|
"add": "Sound hinzufügen",
|
||||||
|
"empty": "Noch keine Sounds. Lade oben welche hoch.",
|
||||||
|
"category_placeholder": "Kategorie…",
|
||||||
|
"category_uncategorized": "(Ohne Kategorie)",
|
||||||
|
"hotkey_capture": "Hotkey binden (Esc = abbrechen)",
|
||||||
|
"hotkey_press": "Drücke…",
|
||||||
|
"hotkey_none": "Kein Hotkey",
|
||||||
|
"hotkey_clear": "Hotkey entfernen",
|
||||||
|
"hotkey_conflict_ptt": "Konflikt mit Push-to-Talk.",
|
||||||
|
"hotkey_conflict_sound": "Bereits von \"{{name}}\" belegt.",
|
||||||
|
"gain_title": "Lautstärke",
|
||||||
|
"preview": "Vorhören",
|
||||||
|
"preview_stop": "Stop",
|
||||||
|
"move_up": "Nach oben",
|
||||||
|
"move_down": "Nach unten",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"delete_confirm": "Sound löschen?",
|
||||||
|
"error_too_large": "Datei zu groß (max {{max}} MB).",
|
||||||
|
"error_not_audio": "Nur Audio-Dateien werden unterstützt.",
|
||||||
|
"error_generic": "Sound konnte nicht gespeichert werden.",
|
||||||
|
"toggle": "Soundboard",
|
||||||
|
"panel_title": "Soundboard",
|
||||||
|
"panel_close": "Schließen",
|
||||||
|
"panel_search": "Suche…",
|
||||||
|
"panel_empty": "Keine Sounds gespeichert. Füge welche in den Einstellungen hinzu.",
|
||||||
|
"panel_no_matches": "Keine Treffer.",
|
||||||
|
"panel_master": "Master",
|
||||||
|
"panel_monitor": "Mithören",
|
||||||
|
"panel_stop_all": "Alle stoppen",
|
||||||
|
"pad_stop": "Stoppen"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"available": "Update verfügbar",
|
"available": "Update verfügbar",
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"idle": "Idle",
|
"idle": "Idle",
|
||||||
"dnd": "Do not disturb",
|
"dnd": "Do not disturb",
|
||||||
"invisible": "Invisible",
|
"invisible": "Invisible",
|
||||||
"offline": "Offline"
|
"offline": "Offline",
|
||||||
|
"status_placeholder": "Set status…"
|
||||||
},
|
},
|
||||||
"chats": {
|
"chats": {
|
||||||
"empty_title": "No conversations yet",
|
"empty_title": "No conversations yet",
|
||||||
@@ -194,7 +195,61 @@
|
|||||||
"allow_dms_strangers_hint": "When off, only friends can DM you.",
|
"allow_dms_strangers_hint": "When off, only friends can DM you.",
|
||||||
"this_device": "This device",
|
"this_device": "This device",
|
||||||
"danger_zone": "Danger zone",
|
"danger_zone": "Danger zone",
|
||||||
"sign_out": "Sign out"
|
"sign_out": "Sign out",
|
||||||
|
"section_ringtone": "Ringtone",
|
||||||
|
"ringtone_incoming": "Incoming call",
|
||||||
|
"ringtone_default_active": "Default ringtone (double beep)",
|
||||||
|
"ringtone_custom_active": "{{name}} · {{size}} MB",
|
||||||
|
"ringtone_upload": "Upload",
|
||||||
|
"ringtone_replace": "Replace",
|
||||||
|
"ringtone_preview": "Preview",
|
||||||
|
"ringtone_stop": "Stop",
|
||||||
|
"ringtone_reset": "Reset",
|
||||||
|
"ringtone_hint": "MP3, WAV, OGG or M4A up to 2 MB. Incoming calls only — outgoing keeps the default.",
|
||||||
|
"ringtone_error_too_large": "File too large (max {{max}} MB).",
|
||||||
|
"ringtone_error_not_audio": "Only audio files are supported.",
|
||||||
|
"ringtone_error_generic": "Could not save the ringtone.",
|
||||||
|
"ringtone_error_play": "Could not play the ringtone.",
|
||||||
|
"section_soundboard": "Soundboard"
|
||||||
|
},
|
||||||
|
"soundboard": {
|
||||||
|
"summary_title": "Your sounds",
|
||||||
|
"summary_empty": "No sounds stored yet.",
|
||||||
|
"summary_counts": "{{sounds}} sounds · {{categories}} categories · {{hotkeys}} with hotkey",
|
||||||
|
"settings_hint": "Hotkeys are optional. Sounds can also be played from the in-call panel.",
|
||||||
|
"manage": "Manage",
|
||||||
|
"manager_title": "Manage soundboard",
|
||||||
|
"manager_hint": "Any number of sounds, hotkeys optional. Hotkeys fire only while in a call.",
|
||||||
|
"add": "Add sound",
|
||||||
|
"empty": "No sounds yet. Upload some above.",
|
||||||
|
"category_placeholder": "Category…",
|
||||||
|
"category_uncategorized": "(Uncategorized)",
|
||||||
|
"hotkey_capture": "Bind hotkey (Esc = cancel)",
|
||||||
|
"hotkey_press": "Press…",
|
||||||
|
"hotkey_none": "No hotkey",
|
||||||
|
"hotkey_clear": "Clear hotkey",
|
||||||
|
"hotkey_conflict_ptt": "Conflicts with Push-to-Talk.",
|
||||||
|
"hotkey_conflict_sound": "Already bound to \"{{name}}\".",
|
||||||
|
"gain_title": "Volume",
|
||||||
|
"preview": "Preview",
|
||||||
|
"preview_stop": "Stop",
|
||||||
|
"move_up": "Move up",
|
||||||
|
"move_down": "Move down",
|
||||||
|
"delete": "Delete",
|
||||||
|
"delete_confirm": "Delete this sound?",
|
||||||
|
"error_too_large": "File too large (max {{max}} MB).",
|
||||||
|
"error_not_audio": "Only audio files are supported.",
|
||||||
|
"error_generic": "Could not save the sound.",
|
||||||
|
"toggle": "Soundboard",
|
||||||
|
"panel_title": "Soundboard",
|
||||||
|
"panel_close": "Close",
|
||||||
|
"panel_search": "Search…",
|
||||||
|
"panel_empty": "No sounds stored. Add some from settings.",
|
||||||
|
"panel_no_matches": "No matches.",
|
||||||
|
"panel_master": "Master",
|
||||||
|
"panel_monitor": "Monitor",
|
||||||
|
"panel_stop_all": "Stop all",
|
||||||
|
"pad_stop": "Stop"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"available": "Update available",
|
"available": "Update available",
|
||||||
|
|||||||
@@ -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