From 5da34a35597ab4645694aabf971473bbb9895346 Mon Sep 17 00:00:00 2001 From: lucor Date: Sat, 2 May 2026 19:11:39 +0200 Subject: [PATCH] feat(hive): preview video attachments inline Adds a unified attachment viewer with an HTML video player when a notification attachment has a supported video MIME type, while keeping download available for every attachment and falling back cleanly when preview is not available. Uses blob-backed local playback in Hive, updates CSP for blob media sources, and keeps attachment fetch and decryption inside Hive instead of exposing attachment URLs to media elements. --- deploy/Caddyfile | 2 +- docs/MESSAGING.md | 6 + .../lib/components/notification_card.svelte | 176 +++++++++++++++--- .../src/lib/utils/attachmentCache.test.ts | 51 +++++ .../hive/src/lib/utils/attachmentCache.ts | 31 +-- 5 files changed, 222 insertions(+), 44 deletions(-) create mode 100644 web/apps/hive/src/lib/utils/attachmentCache.test.ts diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 2be0a01..d308e9f 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -3,7 +3,7 @@ } (frontend_csp) { - header Content-Security-Policy "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.{$BEEBUZZ_DOMAIN}; manifest-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'" + header Content-Security-Policy "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src 'self' https://api.{$BEEBUZZ_DOMAIN}; manifest-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'" } (security_headers) { diff --git a/docs/MESSAGING.md b/docs/MESSAGING.md index 5398fe8..904a6af 100644 --- a/docs/MESSAGING.md +++ b/docs/MESSAGING.md @@ -235,6 +235,12 @@ On the Hive side, the service worker: - decrypts the fetched blob locally - persists the resolved notification to IndexedDB before showing it +Hive attachment viewing is intentionally narrow: + +- previews inline for `image/jpeg`, `image/png`, `image/webp`, `image/gif`, and best-effort `video/mp4` +- keeps all other attachment types download-only +- renders previews and downloads from the locally fetched and decrypted blob rather than exposing the attachment token URL to browser media elements + ## BeeBuzz CLI The CLI in `cmd/beebuzz` uses E2E delivery. See [E2E_ENCRYPTION.md](E2E_ENCRYPTION.md) for the full sending flow and plaintext payload shape. diff --git a/web/apps/hive/src/lib/components/notification_card.svelte b/web/apps/hive/src/lib/components/notification_card.svelte index e88ab75..132bb20 100644 --- a/web/apps/hive/src/lib/components/notification_card.svelte +++ b/web/apps/hive/src/lib/components/notification_card.svelte @@ -2,6 +2,7 @@ import { Image, FileDown, + Video, ChevronUp, EllipsisVertical, Check, @@ -11,9 +12,10 @@ } from '@lucide/svelte'; import type { Notification, NotificationPriority } from '@beebuzz/shared/types'; import { notificationsStore, formatRelativeTime } from '$lib/stores/notifications.svelte'; - import { fetchAndCacheAttachment, isImageMime } from '$lib/utils/attachmentCache'; + import { fetchAndCacheAttachment, isImageMime, isVideoMime } from '$lib/utils/attachmentCache'; import type { CachedAttachment } from '$lib/utils/attachmentCache'; import { parseHttpsLinkSegments } from '$lib/utils/linkify'; + import { tick } from 'svelte'; const PRIORITY_HIGH: NotificationPriority = 'high'; @@ -48,9 +50,11 @@ }); let cachedAttachment = $state(null); - let showImageModal = $state(false); + let showAttachmentViewer = $state(false); let attachmentLoading = $state(false); - let imageDialog = $state(undefined); + let attachmentDialog = $state(undefined); + let viewerObjectUrl = $state(null); + let viewerPreviewFailed = $state(false); // Why: DaisyUI's focus-based dropdown breaks in Safari — focus leaves the // trigger before onclick fires on menu items, swallowing the event. @@ -78,11 +82,15 @@ }); $effect(() => { - if (!imageDialog) return; - if (showImageModal && cachedAttachment) { - imageDialog.showModal(); - } else { - imageDialog.close(); + if (!attachmentDialog) return; + if (showAttachmentViewer && cachedAttachment) { + if (!attachmentDialog.open) { + attachmentDialog.showModal(); + } + return; + } + if (attachmentDialog.open) { + attachmentDialog.close(); } }); @@ -106,25 +114,62 @@ menuOpen = false; } - /** Trigger browser download for non-image attachments. */ - function triggerDownload(dataUrl: string, filename: string) { + function attachmentFilename(): string { + return notification.attachment?.filename || 'attachment.bin'; + } + + /** Trigger browser download for an already-loaded attachment. */ + function triggerDownload(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); const a = document.createElement('a'); - a.href = dataUrl; + a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 0); + } + + function isPreviewableAttachment(mime: string): boolean { + return isImageMime(mime) || isVideoMime(mime); + } + + async function closeAttachmentViewer() { + const oldUrl = viewerObjectUrl; + viewerObjectUrl = null; + viewerPreviewFailed = false; + showAttachmentViewer = false; + await tick(); + if (oldUrl) { + URL.revokeObjectURL(oldUrl); + } + } + + function handleViewerClose() { + void closeAttachmentViewer(); + } + + function handleVideoError() { + viewerPreviewFailed = true; } - /** Show image modal or trigger download depending on MIME type. */ - function handleLoaded() { + /** Open the viewer for the already-loaded attachment. */ + function openAttachmentViewer() { if (!cachedAttachment) return; - if (isImageMime(cachedAttachment.mimeType)) { - showImageModal = true; - } else { - const filename = notification.attachment?.filename || 'attachment.bin'; - triggerDownload(cachedAttachment.dataUrl, filename); + viewerPreviewFailed = false; + if (viewerObjectUrl) { + URL.revokeObjectURL(viewerObjectUrl); + viewerObjectUrl = null; } + if (isPreviewableAttachment(cachedAttachment.mimeType)) { + viewerObjectUrl = URL.createObjectURL(cachedAttachment.blob); + } + showAttachmentViewer = true; + } + + function handleDownloadAttachment() { + if (!cachedAttachment) return; + triggerDownload(cachedAttachment.blob, attachmentFilename()); } /** Returns a cached attachment assembled from inline base64 notification data. */ @@ -135,8 +180,14 @@ } const mimeType = notification.attachment?.mime || 'application/octet-stream'; + const binary = atob(inlineData); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return { - dataUrl: `data:${mimeType};base64,${inlineData}`, + blob: new Blob([bytes], { type: mimeType }), mimeType, timestamp: Date.now() }; @@ -149,9 +200,28 @@ if (notification.attachment?.mime && isImageMime(notification.attachment.mime)) { return 'Image attachment'; } + if (notification.attachment?.mime && isVideoMime(notification.attachment.mime)) { + return 'Video attachment'; + } return 'File attachment'; }); + const showingImagePreview = $derived.by( + () => + !!cachedAttachment && + !!viewerObjectUrl && + !viewerPreviewFailed && + isImageMime(cachedAttachment.mimeType) + ); + + const showingVideoPreview = $derived.by( + () => + !!cachedAttachment && + !!viewerObjectUrl && + !viewerPreviewFailed && + isVideoMime(cachedAttachment.mimeType) + ); + const relativeTime = $derived(formatRelativeTime(notification.sentAt)); const absoluteTime = $derived( notification.sentAt.toLocaleString('en-US', { @@ -183,14 +253,14 @@ async function loadAttachment() { if (selectionMode) return; if (cachedAttachment) { - handleLoaded(); + openAttachmentViewer(); return; } const inlineAttachment = buildInlineAttachment(); if (inlineAttachment) { cachedAttachment = inlineAttachment; - handleLoaded(); + openAttachmentViewer(); return; } @@ -200,7 +270,7 @@ attachmentLoading = true; try { cachedAttachment = await fetchAndCacheAttachment(url, notification.attachment.mime, true); - handleLoaded(); + openAttachmentViewer(); } catch (err) { console.error('[NotificationCard] Failed to load attachment:', err); } finally { @@ -360,6 +430,8 @@ > {#if notification.attachment?.mime && isImageMime(notification.attachment.mime)} + {:else if notification.attachment?.mime && isVideoMime(notification.attachment.mime)} +