Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 2 additions & 89 deletions web/apps/hive/src/lib/components/notification_card.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import {
Image,
FileDown,
Video,
ChevronUp,
EllipsisVertical,
Check,
Expand All @@ -12,10 +11,9 @@
} from '@lucide/svelte';
import type { Notification, NotificationPriority } from '@beebuzz/shared/types';
import { notificationsStore, formatRelativeTime } from '$lib/stores/notifications.svelte';
import { fetchAndCacheAttachment, isImageMime, isVideoMime } from '$lib/utils/attachmentCache';
import { fetchAndCacheAttachment, isImageMime } 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';

Expand Down Expand Up @@ -51,13 +49,8 @@

let cachedAttachment = $state<CachedAttachment | null>(null);
let showImageModal = $state(false);
let showVideoModal = $state(false);
let attachmentLoading = $state(false);
let imageDialog = $state<HTMLDialogElement | undefined>(undefined);
let videoDialog = $state<HTMLDialogElement | undefined>(undefined);
// Why: Safari can't reliably play <video> from data: URLs (no range requests).
// Create a blob URL on demand for the modal and revoke it on close.
let videoObjectUrl = $state<string | null>(null);

// Why: DaisyUI's focus-based dropdown breaks in Safari — focus leaves the
// trigger before onclick fires on menu items, swallowing the event.
Expand Down Expand Up @@ -93,39 +86,6 @@
}
});

$effect(() => {
if (!videoDialog) return;
if (showVideoModal) {
videoDialog.showModal();
} else {
videoDialog.close();
}
});

/** Create the blob URL and open the video modal. */
function openVideoModal() {
if (!cachedAttachment) return;
if (!videoObjectUrl) {
videoObjectUrl = URL.createObjectURL(cachedAttachment.blob);
}
showVideoModal = true;
}

/**
* Close the video modal and revoke the blob URL to free memory.
* Awaits tick() so the <video> element unmounts before the URL is revoked,
* avoiding a microtask window where the still-mounted element holds a
* revoked src.
*/
async function closeVideoModal() {
showVideoModal = false;
await tick();
if (videoObjectUrl) {
URL.revokeObjectURL(videoObjectUrl);
videoObjectUrl = null;
}
}

function handleMarkRead() {
notificationsStore.markAsRead(notification.id);
menuOpen = false;
Expand Down Expand Up @@ -161,8 +121,6 @@
if (!cachedAttachment) return;
if (isImageMime(cachedAttachment.mimeType)) {
showImageModal = true;
} else if (isVideoMime(cachedAttachment.mimeType)) {
openVideoModal();
} else {
const filename = notification.attachment?.filename || 'attachment.bin';
triggerDownload(cachedAttachment.dataUrl, filename);
Expand All @@ -177,15 +135,8 @@
}

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);
}
const blob = new Blob([bytes], { type: mimeType });
return {
dataUrl: `data:${mimeType};base64,${inlineData}`,
blob,
mimeType,
timestamp: Date.now()
};
Expand All @@ -198,9 +149,6 @@
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';
});

Expand Down Expand Up @@ -412,8 +360,6 @@
>
{#if notification.attachment?.mime && isImageMime(notification.attachment.mime)}
<Image size={16} />
{:else if notification.attachment?.mime && isVideoMime(notification.attachment.mime)}
<Video size={16} />
{:else}
<FileDown size={16} />
{/if}
Expand All @@ -427,7 +373,7 @@
</div>

<!-- Image modal — rendered outside the card to avoid inheriting opacity-60 on read messages -->
<dialog bind:this={imageDialog} class="modal" onclose={() => (showImageModal = false)}>
<dialog bind:this={imageDialog} class="modal">
<div class="modal-box max-w-4xl w-11/12 flex flex-col items-center">
<form method="dialog">
<button
Expand All @@ -446,36 +392,3 @@
</div>
<form method="dialog" class="modal-backdrop"><button type="submit">close</button></form>
</dialog>

<!-- Video modal — iOS PWAs handle programmatic downloads poorly, so playable media opens inline -->
<dialog bind:this={videoDialog} class="modal" onclose={closeVideoModal}>
<div class="modal-box max-w-4xl w-11/12 flex flex-col items-center">
<form method="dialog">
<button
type="submit"
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
aria-label="Close video viewer"
>
</button>
</form>
{#if showVideoModal && videoObjectUrl}
<video
src={videoObjectUrl}
controls
muted
playsinline
preload="metadata"
class="rounded-lg max-w-full max-h-[80vh]"
>
<a
href={cachedAttachment?.dataUrl || ''}
download={notification.attachment?.filename || 'attachment'}
>
Download video
</a>
</video>
{/if}
</div>
<form method="dialog" class="modal-backdrop"><button type="submit">close</button></form>
</dialog>
9 changes: 0 additions & 9 deletions web/apps/hive/src/lib/utils/attachmentCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@ const attachmentCache = new Map<string, CachedAttachment>();

export interface CachedAttachment {
dataUrl: string;
// Why: Safari can't reliably play video from data: URLs (no range requests),
// so video playback uses URL.createObjectURL(blob) instead of dataUrl.
blob: Blob;
mimeType: string;
timestamp: number;
}
Expand Down Expand Up @@ -58,7 +55,6 @@ export async function fetchAndCacheAttachment(
const dataUrl = await blobToDataUrl(finalBlob);
const result: CachedAttachment = {
dataUrl,
blob: finalBlob,
mimeType: finalBlob.type || mimeType,
timestamp: Date.now()
};
Expand All @@ -72,11 +68,6 @@ export function isImageMime(mime: string): boolean {
return mime.startsWith('image/');
}

/** Returns true if the MIME type represents a video. */
export function isVideoMime(mime: string): boolean {
return mime.startsWith('video/');
}

/** Clear attachment cache */
export function clearAttachmentCache(): void {
attachmentCache.clear();
Expand Down