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