import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat'; import { useState } from 'react'; import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache'; 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(null); const download = async () => { if (busy) return; setBusy(true); setError(null); try { let blob = await getCachedAttachment(handle.id); if (!blob) { blob = await downloadAndDecryptAttachment({ client: supabase, handle }); void putCachedAttachment(handle.id, blob); } 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 (

{prettyMime(handle.mimeType)}

{formatSize(handle.sizeBytes)}

{error && (

{error}

)}
); } function FileGlyph() { return ( ); } function DownloadGlyph() { return ( ); } 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 = { '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'; }