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