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

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

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

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

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

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

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

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

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

Fixes
- AttachmentAudio min-width so bubbles don't collapse to 0px on peer
  side
- Focus flicker: visibility/online wake refresh throttled to 30s,
  focus listener dropped, loading flag only on first fetch
This commit is contained in:
2026-04-21 09:13:30 +02:00
parent b89ec90813
commit 672c8738c7
34 changed files with 4394 additions and 100 deletions
+159 -16
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { ConversationHeader } from '../components/ConversationHeader';
import { EmojiPicker } from '../components/EmojiPicker';
import { ForwardDialog } from '../components/ForwardDialog';
import { GroupInfoPanel } from '../components/GroupInfoPanel';
import {
@@ -15,11 +16,13 @@ import {
PlusIcon,
ReplyIcon,
SearchIcon,
SmileIcon,
SpinnerIcon,
XIcon,
} from '../components/icons';
import { InCallPanel } from '../components/InCallPanel';
import { IncomingCallPanel } from '../components/IncomingCallPanel';
import { MentionAutocomplete } from '../components/MentionAutocomplete';
import { MessageBubble, type QuotedRef } from '../components/MessageBubble';
import { TypingIndicator } from '../components/TypingIndicator';
import { VoiceRecorder } from '../components/VoiceRecorder';
@@ -141,6 +144,11 @@ export function ConversationPage() {
const [searchDateTo, setSearchDateTo] = useState<string>('');
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const [displayCount, setDisplayCount] = useState<number>(150);
const [isDraggingFile, setIsDraggingFile] = useState(false);
const [mentionState, setMentionState] = useState<
{ query: string; start: number } | null
>(null);
const [emojiOpen, setEmojiOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -345,13 +353,9 @@ export function ConversationPage() {
}
}
function handleFilesChosen(list: FileList | null) {
if (!list) return;
function ingestFiles(files: File[]) {
const next: File[] = [];
for (let i = 0; i < list.length; i++) {
const f = list[i];
if (!f) continue;
if (!f.type.startsWith('image/')) continue;
for (const f of files) {
if (f.size > 10 * 1024 * 1024) {
setSendError('Datei zu groß (max 10 MB)');
continue;
@@ -361,6 +365,11 @@ export function ConversationPage() {
setAttachments((prev) => [...prev, ...next].slice(0, 4));
}
function handleFilesChosen(list: FileList | null) {
if (!list) return;
ingestFiles(Array.from(list));
}
const { state: callState } = useCall();
// Hide the chat header while this conversation hosts an active call — the
// call topbar inside the dock already shows the channel name + duration,
@@ -374,7 +383,31 @@ export function ConversationPage() {
callState.kind === 'incoming' && callState.conversationId === id;
return (
<div className="relative flex h-full flex-col">
<div
className="relative flex h-full flex-col"
onDragEnter={(e) => {
if (e.dataTransfer?.types.includes('Files')) {
e.preventDefault();
setIsDraggingFile(true);
}
}}
onDragOver={(e) => {
if (e.dataTransfer?.types.includes('Files')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
}}
onDragLeave={(e) => {
// leave fires on child enter too; only clear when leaving the page container.
if (e.currentTarget === e.target) setIsDraggingFile(false);
}}
onDrop={(e) => {
if (!e.dataTransfer?.files?.length) return;
e.preventDefault();
setIsDraggingFile(false);
ingestFiles(Array.from(e.dataTransfer.files));
}}
>
{!callHereActive && (
<ConversationHeader
conversation={conversation}
@@ -530,6 +563,17 @@ export function ConversationPage() {
members={conversation?.members ?? []}
/>
{isDraggingFile && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center rounded-lg border-2 border-dashed border-accent/60 bg-accent/10 backdrop-blur-sm"
>
<div className="rounded-xl border border-accent/40 bg-surface-3/90 px-4 py-3 text-sm font-semibold text-fg shadow-xl">
Datei hier ablegen zum Anhängen
</div>
</div>
)}
<form onSubmit={handleSend} className="border-t border-line bg-surface-3 p-4">
{sendError && (
<div className="mb-2">
@@ -583,11 +627,36 @@ export function ConversationPage() {
</div>
)}
<div className="flex items-end gap-2">
<div className="relative flex items-end gap-2">
{isGroup && mentionState && conversation && (
<MentionAutocomplete
members={conversation.members}
query={mentionState.query}
excludeUserId={myId}
onSelect={(username) => {
// Replace `@{query}` at `start..caret` with `@{username} `.
const start = mentionState.start;
const before = text.slice(0, start);
const afterCaret = text.slice(start + 1 + mentionState.query.length);
const inserted = '@' + username + ' ';
const next = before + inserted + afterCaret;
setText(next);
setMentionState(null);
// Restore caret position after inserted mention.
const caret = (before + inserted).length;
requestAnimationFrame(() => {
const el = composerRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(caret, caret);
});
}}
onClose={() => setMentionState(null)}
/>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFilesChosen(e.target.files)}
@@ -595,12 +664,41 @@ export function ConversationPage() {
<button
type="button"
onClick={() => fileInputRef.current?.click()}
aria-label="Bild anhängen"
title="Bild anhängen"
aria-label="Datei anhängen"
title="Datei anhängen"
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-accent text-accent-fg transition hover:brightness-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
<PlusIcon className="h-4 w-4" />
</button>
<div className="relative">
<button
type="button"
data-emoji-trigger
onClick={() => setEmojiOpen((v) => !v)}
aria-label="Emoji einfügen"
title="Emoji einfügen"
aria-expanded={emojiOpen}
className="inline-flex h-11 w-11 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-surface-2 text-fg transition hover:bg-surface-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
<SmileIcon className="h-4 w-4" />
</button>
<EmojiPicker
open={emojiOpen}
onPick={(emoji) => {
const el = composerRef.current;
const caret = el?.selectionStart ?? text.length;
const next = text.slice(0, caret) + emoji + text.slice(caret);
setText(next);
requestAnimationFrame(() => {
if (!el) return;
el.focus();
const pos = caret + emoji.length;
el.setSelectionRange(pos, pos);
});
}}
onClose={() => setEmojiOpen(false)}
/>
</div>
<VoiceRecorder
disabled={sending}
onComplete={async (file) => {
@@ -617,8 +715,26 @@ export function ConversationPage() {
ref={composerRef}
value={text}
onChange={(e) => {
setText(e.target.value);
if (e.target.value.length > 0) notifyTyping();
const next = e.target.value;
setText(next);
if (next.length > 0) notifyTyping();
// Detect an in-progress @mention: find the last '@' before the
// caret, with no whitespace between it and the caret. If
// present, open the autocomplete with the partial query.
const caret = e.target.selectionStart ?? next.length;
const before = next.slice(0, caret);
const atIdx = before.lastIndexOf('@');
if (
atIdx >= 0 &&
(atIdx === 0 || /\s/.test(before[atIdx - 1] ?? ''))
) {
const q = before.slice(atIdx + 1);
if (!/\s/.test(q)) {
setMentionState({ query: q, start: atIdx });
return;
}
}
setMentionState(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -626,6 +742,21 @@ export function ConversationPage() {
void handleSend();
}
}}
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
const pics: File[] = [];
for (const it of Array.from(items)) {
if (it.kind === 'file') {
const f = it.getAsFile();
if (f && f.type.startsWith('image/')) pics.push(f);
}
}
if (pics.length > 0) {
e.preventDefault();
ingestFiles(pics);
}
}}
rows={1}
placeholder="Nachricht schreiben…"
className="max-h-40 min-h-[44px] flex-1 resize-none rounded-lg border border-line bg-surface-2 px-3 py-2.5 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
@@ -885,18 +1016,30 @@ function Banner({ children }: { children: React.ReactNode }) {
}
function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => void }) {
const isImage = file.type.startsWith('image/');
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!isImage) return;
const u = URL.createObjectURL(file);
setUrl(u);
return () => URL.revokeObjectURL(u);
}, [file]);
}, [file, isImage]);
return (
<div className="relative overflow-hidden rounded-lg border border-line bg-surface-2">
{url ? (
{isImage && url ? (
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
) : (
<div className="h-20 w-20" />
<div className="flex h-20 w-32 flex-col justify-center gap-0.5 px-2 text-[10px]">
<span className="truncate font-semibold text-fg" title={file.name}>
{file.name || 'Datei'}
</span>
<span className="text-fg-muted">
{file.type || 'unbekannt'}
</span>
<span className="text-fg-muted">
{(file.size / 1024).toFixed(0)} KB
</span>
</div>
)}
<button
type="button"
+12
View File
@@ -9,6 +9,8 @@ import { useTranslation } from 'react-i18next';
import { Avatar } from '../components/Avatar';
import { BackupExportDialog } from '../components/BackupExportDialog';
import { RingtoneSettings } from '../components/RingtoneSettings';
import { SoundboardSettings } from '../components/SoundboardSettings';
import { LockIcon } from '../components/icons';
import { useAuth } from '../context/AuthContext';
import { useCall } from '../context/CallContext';
@@ -141,6 +143,16 @@ export function SettingsPage() {
/>
</Section>
{/* Ringtone (incoming custom) */}
<Section title={t('app:settings.section_ringtone', { defaultValue: 'Klingelton' })}>
<RingtoneSettings disabled={busy} />
</Section>
{/* Soundboard */}
<Section title={t('app:settings.section_soundboard', { defaultValue: 'Soundboard' })}>
<SoundboardSettings />
</Section>
{/* Voice / Push-to-Talk + Audio Quality + E2EE */}
<Section title={t('app:settings.section_voice', { defaultValue: 'Sprache' })}>
<AudioDeviceControls />