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