From 006ed969f943196e444ba69472e7c02957aec856 Mon Sep 17 00:00:00 2001 From: talksik Date: Sun, 12 Apr 2026 13:59:54 -0700 Subject: [PATCH 1/3] feat: add lightbox overlay with nice mechanics --- .../attachments/attachment-lightbox.tsx | 342 ++++++++++++++++++ js/src/features/compose/attachment-strip.tsx | 97 +++-- .../features/particles/attachments-dialog.tsx | 45 --- .../particles/particle-attachments.tsx | 174 +++++++-- js/src/features/particles/stream-view.tsx | 35 +- js/src/stores/playback-store.ts | 18 + 6 files changed, 578 insertions(+), 133 deletions(-) create mode 100644 js/src/features/attachments/attachment-lightbox.tsx delete mode 100644 js/src/features/particles/attachments-dialog.tsx create mode 100644 js/src/stores/playback-store.ts diff --git a/js/src/features/attachments/attachment-lightbox.tsx b/js/src/features/attachments/attachment-lightbox.tsx new file mode 100644 index 0000000..95a3780 --- /dev/null +++ b/js/src/features/attachments/attachment-lightbox.tsx @@ -0,0 +1,342 @@ +import { useEffect, useState } from "react"; +import { Dialog as DialogPrimitive } from "radix-ui"; +import { + ChevronLeft, + ChevronRight, + Download, + FileIcon, + Loader2, + Trash2, + X, +} from "lucide-react"; +import { useDownloadUrl } from "@/hooks/use-download-url"; +import { Button } from "@/components/ui/button"; +import { usePlaybackStore } from "@/stores/playback-store"; + +export interface AttachmentItem { + id: string; + filename: string; + mimeType: string; + sizeBytes?: number; + source: + | { kind: "remote"; objectId: string } + | { kind: "local"; file: File }; +} + +interface AttachmentLightboxProps { + items: AttachmentItem[]; + openIndex: number | null; + onOpenChange: (index: number | null) => void; + /** When provided, enables the trash button + Backspace/Delete to remove. */ + onRemove?: (item: AttachmentItem) => void; +} + +/** + * Return `"lightbox"` for mime types that preview in-app, `"external"` otherwise. + * Callers use this to decide whether to open the lightbox or hand off to the OS. + */ +export function getAttachmentHandler(mimeType: string): "lightbox" | "external" { + if (mimeType.startsWith("image/") || mimeType.startsWith("video/")) { + return "lightbox"; + } + return "external"; +} + +function formatSize(bytes?: number): string | null { + if (bytes == null) return null; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function downloadFromUrl(url: string, filename: string) { + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} + +export function AttachmentLightbox({ + items, + openIndex, + onOpenChange, + onRemove, +}: AttachmentLightboxProps) { + const current = + openIndex !== null && openIndex >= 0 && openIndex < items.length + ? items[openIndex] + : null; + const isOpen = current !== null; + const hasMultiple = items.length > 1; + + // Remote items resolve through the signed-URL cache; disabled when not remote. + const remoteObjectId = + current?.source.kind === "remote" ? current.source.objectId : undefined; + const { data: remoteUrl, isLoading: isRemoteLoading } = + useDownloadUrl(remoteObjectId); + + // Local items get a fresh blob URL per item, revoked on change/close. + const [localUrl, setLocalUrl] = useState(null); + useEffect(() => { + if (current?.source.kind !== "local") { + setLocalUrl(null); + return; + } + const url = URL.createObjectURL(current.source.file); + setLocalUrl(url); + return () => URL.revokeObjectURL(url); + }, [current?.id, current?.source.kind]); + + const url = + current?.source.kind === "remote" + ? remoteUrl ?? null + : localUrl; + + const canDownload = current?.source.kind === "remote" && !!url; + + const goTo = (delta: number) => { + if (openIndex === null || items.length === 0) return; + const next = (openIndex + delta + items.length) % items.length; + onOpenChange(next); + }; + + const handleDownload = () => { + if (!current || !url || current.source.kind !== "remote") return; + downloadFromUrl(url, current.filename); + }; + + const handleRemove = () => { + if (!current || !onRemove) return; + const wasLast = items.length <= 1; + const wasAtEnd = openIndex === items.length - 1; + onRemove(current); + if (wasLast) { + onOpenChange(null); + } else if (wasAtEnd) { + onOpenChange(openIndex! - 1); + } + // Otherwise openIndex stays — the next item shifts into its place. + }; + + // Suspend stream playback while the lightbox is open. + useEffect(() => { + if (!isOpen) return; + const { suspend, release } = usePlaybackStore.getState(); + suspend(); + return release; + }, [isOpen]); + + // Keyboard handling — only listens while open. Registered in the capture + // phase with stopImmediatePropagation so we consume keys (arrows, D, ⌫) + // before global listeners like stream-view's particle-navigation. + useEffect(() => { + if (!isOpen) return; + const handle = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + if ( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable) + ) { + return; + } + const consume = () => { + e.preventDefault(); + e.stopImmediatePropagation(); + }; + if (e.key === "Escape") { + consume(); + onOpenChange(null); + } else if (e.key === "ArrowLeft" && hasMultiple) { + consume(); + goTo(-1); + } else if (e.key === "ArrowRight" && hasMultiple) { + consume(); + goTo(1); + } else if ((e.key === "d" || e.key === "D") && canDownload) { + consume(); + handleDownload(); + } else if ((e.key === "Backspace" || e.key === "Delete") && onRemove) { + consume(); + handleRemove(); + } + }; + window.addEventListener("keydown", handle, true); + return () => window.removeEventListener("keydown", handle, true); + }, [isOpen, openIndex, items, url, onOpenChange, onRemove, hasMultiple, canDownload]); + + const isImage = current?.mimeType.startsWith("image/"); + const isVideo = current?.mimeType.startsWith("video/"); + const sizeLabel = formatSize(current?.sizeBytes); + + return ( + { + if (!open) onOpenChange(null); + }} + > + + + + {current && ( + <> + + {current.filename} + + + {/* Top-left: filename chip */} +
+ + {current.filename} + + {sizeLabel && ( + {sizeLabel} + )} + {hasMultiple && ( + + {openIndex! + 1} / {items.length} + + )} +
+ + {/* Top-right: actions */} +
+ {canDownload && ( + + )} + {onRemove && ( + + )} + + + +
+ + {/* Side navigation */} + {hasMultiple && ( + <> + + + + )} + + {/* Media body */} +
+ {!url && isRemoteLoading && ( + + )} + {url && isImage && ( + {current.filename} onOpenChange(null)} + className="max-h-[85vh] max-w-[85vw] rounded-lg object-contain shadow-2xl" + /> + )} + {url && isVideo && ( +
+ + {/* Footer kbd hints */} +
+ {hasMultiple && ( + + + ← + + + → + + navigate + + )} + {canDownload && ( + + + D + + download + + )} + {onRemove && ( + + + ⌫ + + remove + + )} + + + Esc + + close + +
+ + )} +
+
+
+ ); +} diff --git a/js/src/features/compose/attachment-strip.tsx b/js/src/features/compose/attachment-strip.tsx index 33c6ac5..2bd3b75 100644 --- a/js/src/features/compose/attachment-strip.tsx +++ b/js/src/features/compose/attachment-strip.tsx @@ -1,8 +1,14 @@ +import { useMemo, useState } from "react"; import { FileIcon, Globe, Loader2, Plus, X } from "lucide-react"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import type { LinkPreviewEntry } from "@/hooks/use-link-metadata"; +import { + AttachmentLightbox, + getAttachmentHandler, + type AttachmentItem, +} from "@/features/attachments/attachment-lightbox"; export interface PendingAttachment { id: string; @@ -18,6 +24,16 @@ interface AttachmentStripProps { linkPreviews?: LinkPreviewEntry[]; } +function pendingToItem(p: PendingAttachment): AttachmentItem { + return { + id: p.id, + filename: p.file.name, + mimeType: p.file.type || "application/octet-stream", + sizeBytes: p.file.size, + source: { kind: "local", file: p.file }, + }; +} + function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; @@ -27,18 +43,25 @@ function formatFileSize(bytes: number): string { function AttachmentThumbnail({ attachment, onRemove, + onPreview, }: { attachment: PendingAttachment; onRemove: () => void; + onPreview?: () => void; }) { const isImage = attachment.file.type.startsWith("image/"); const isUploading = attachment.status === "uploading"; const isError = attachment.status === "error"; + const previewable = getAttachmentHandler(attachment.file.type) === "lightbox"; return (
@@ -131,35 +154,59 @@ export function AttachmentStrip({ linkPreviews, }: AttachmentStripProps) { const hasLinks = linkPreviews && linkPreviews.length > 0; + + // Lightbox state — only previewable attachments go in. + const previewable = useMemo( + () => attachments.filter((a) => getAttachmentHandler(a.file.type) === "lightbox"), + [attachments], + ); + const items = useMemo(() => previewable.map(pendingToItem), [previewable]); + const [openIndex, setOpenIndex] = useState(null); + if (attachments.length === 0 && !hasLinks) return null; return ( - -
- {attachments.map((a) => ( - onRemove(a.id)} - /> - ))} + <> + +
+ {attachments.map((a) => ( + onRemove(a.id)} + onPreview={() => { + const idx = previewable.indexOf(a); + if (idx >= 0) setOpenIndex(idx); + }} + /> + ))} - {linkPreviews?.map((entry) => ( - - ))} + {linkPreviews?.map((entry) => ( + + ))} - -
- -
+ +
+ +
+ + {items.length > 0 && ( + onRemove(item.id)} + /> + )} + ); } diff --git a/js/src/features/particles/attachments-dialog.tsx b/js/src/features/particles/attachments-dialog.tsx deleted file mode 100644 index cf1c5d1..0000000 --- a/js/src/features/particles/attachments-dialog.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { Particle } from "@/api/types"; -import { - ImageAttachment, - FileAttachment, -} from "@/features/particles/particle-attachments"; -import { - Dialog, - DialogContent, - DialogTitle, -} from "@/components/ui/dialog"; -import { VisuallyHidden } from "radix-ui"; - -type FileParticle = Extract; - -interface AttachmentsDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - attachments: FileParticle[]; -} - -export function AttachmentsDialog({ - open, - onOpenChange, - attachments, -}: AttachmentsDialogProps) { - return ( - - - - Attachments - -
- {attachments.map((attachment) => { - const isImage = attachment.properties.mime_type.startsWith("image/"); - return isImage ? ( - - ) : ( - - ); - })} -
-
-
- ); -} diff --git a/js/src/features/particles/particle-attachments.tsx b/js/src/features/particles/particle-attachments.tsx index 684c364..365ee3a 100644 --- a/js/src/features/particles/particle-attachments.tsx +++ b/js/src/features/particles/particle-attachments.tsx @@ -1,9 +1,15 @@ +import { useMemo, useState } from "react"; import { Download, ExternalLink, FileIcon, ImageIcon } from "lucide-react"; import type { Particle } from "@/api/types"; import { useDownloadUrl } from "@/hooks/use-download-url"; import { Skeleton } from "@/components/ui/skeleton"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { Button } from "@/components/ui/button"; +import { + AttachmentLightbox, + getAttachmentHandler, + type AttachmentItem, +} from "@/features/attachments/attachment-lightbox"; type FileParticle = Extract; @@ -18,7 +24,39 @@ function formatFileSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } -export function ImageAttachment({ particle }: { particle: FileParticle }) { +function particleToItem(p: FileParticle): AttachmentItem { + return { + id: p.id, + filename: p.properties.filename, + mimeType: p.properties.mime_type, + sizeBytes: p.properties.size_bytes, + source: { kind: "remote", objectId: p.properties.object_id }, + }; +} + +/** + * Open the lightbox for previewable types; hand off to the OS for files. + */ +function openParticle( + particle: FileParticle, + index: number, + url: string | undefined, + onPreview: (index: number) => void, +) { + if (getAttachmentHandler(particle.properties.mime_type) === "lightbox") { + onPreview(index); + } else if (url) { + window.electronLink.openExternal(url); + } +} + +function ImageAttachment({ + particle, + onPreview, +}: { + particle: FileParticle; + onPreview: () => void; +}) { const { data: url, isLoading } = useDownloadUrl(particle.properties.object_id); if (isLoading || !url) { @@ -27,7 +65,6 @@ export function ImageAttachment({ particle }: { particle: FileParticle }) { const handleDownload = (e: React.MouseEvent) => { e.stopPropagation(); - e.preventDefault(); const a = document.createElement("a"); a.href = url; a.download = particle.properties.filename; @@ -37,11 +74,12 @@ export function ImageAttachment({ particle }: { particle: FileParticle }) { }; return ( - e.stopPropagation()} + - + + ); } -export function FileAttachment({ particle }: { particle: FileParticle }) { +function FileAttachment({ + particle, + index, + onPreview, +}: { + particle: FileParticle; + index: number; + onPreview: (index: number) => void; +}) { const { data: url } = useDownloadUrl(particle.properties.object_id); const handleOpen = (e: React.MouseEvent) => { e.stopPropagation(); - if (url) window.electronLink.openExternal(url); + openParticle(particle, index, url, onPreview); }; const handleDownload = (e: React.MouseEvent) => { @@ -118,19 +165,25 @@ export function FileAttachment({ particle }: { particle: FileParticle }) { ); } -function CompactAttachmentItem({ particle }: { particle: FileParticle }) { +function CompactAttachmentItem({ + particle, + index, + onPreview, +}: { + particle: FileParticle; + index: number; + onPreview: (index: number) => void; +}) { const isImage = particle.properties.mime_type.startsWith("image/"); const { data: url } = useDownloadUrl(particle.properties.object_id); - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation(); - if (url) window.electronLink.openExternal(url); - }; - return (