diff --git a/apps/desktop/src/components/AttachmentGeneric.tsx b/apps/desktop/src/components/AttachmentGeneric.tsx new file mode 100644 index 0000000..491195b --- /dev/null +++ b/apps/desktop/src/components/AttachmentGeneric.tsx @@ -0,0 +1,127 @@ +import { type AttachmentHandle, downloadAndDecryptAttachment } from '@chat-app/shared/chat'; +import { useState } from 'react'; + +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 { + const blob = await downloadAndDecryptAttachment({ client: supabase, handle }); + 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'; +} diff --git a/apps/desktop/src/components/AttachmentPdf.tsx b/apps/desktop/src/components/AttachmentPdf.tsx new file mode 100644 index 0000000..9d82e80 --- /dev/null +++ b/apps/desktop/src/components/AttachmentPdf.tsx @@ -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 with a fallback link keeps +// the implementation tiny — no pdf.js dependency. +export function AttachmentPdf({ handle }: Props) { + const [blobUrl, setBlobUrl] = useState(null); + const [error, setError] = useState(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 ( +
+ + {error} +
+ ); + } + + if (!blobUrl) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + + PDF · {formatSize(handle.sizeBytes)} + +
+ + + Download + +
+
+ {expanded && ( + +

+ Vorschau nicht verfügbar — bitte herunterladen. +

+
+ )} +
+ ); +} + +function PdfGlyph() { + return ( + + ); +} + +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'; +} diff --git a/apps/desktop/src/components/AttachmentVideo.tsx b/apps/desktop/src/components/AttachmentVideo.tsx new file mode 100644 index 0000000..490b236 --- /dev/null +++ b/apps/desktop/src/components/AttachmentVideo.tsx @@ -0,0 +1,68 @@ +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; +} + +// Decrypt the blob, render a native