feat: support image lightbox viewer and file download (#151)

* feat: add lightbox overlay with nice mechanics

* ui tweak for top bar in stream view

* support locally downloading attachments
This commit was merged in pull request #151.
This commit is contained in:
Arjun Patel
2026-04-12 14:14:23 -07:00
committed by GitHub
parent dbe61e447a
commit 5b6924f7ca
10 changed files with 602 additions and 146 deletions
@@ -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<Particle, { type: "file" }>;
interface AttachmentsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
attachments: FileParticle[];
}
export function AttachmentsDialog({
open,
onOpenChange,
attachments,
}: AttachmentsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<VisuallyHidden.Root>
<DialogTitle>Attachments</DialogTitle>
</VisuallyHidden.Root>
<div className="flex flex-wrap gap-3">
{attachments.map((attachment) => {
const isImage = attachment.properties.mime_type.startsWith("image/");
return isImage ? (
<ImageAttachment key={attachment.id} particle={attachment} />
) : (
<FileAttachment key={attachment.id} particle={attachment} />
);
})}
</div>
</DialogContent>
</Dialog>
);
}
@@ -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<Particle, { type: "file" }>;
@@ -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,21 +65,16 @@ 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;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.electronAttachment.download(url, particle.properties.filename);
};
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onPreview();
}}
className="group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg bg-white/10"
>
<img
@@ -49,34 +82,38 @@ export function ImageAttachment({ particle }: { particle: FileParticle }) {
alt={particle.properties.filename}
className="h-full w-full object-cover"
/>
<button
type="button"
<span
role="button"
tabIndex={0}
onClick={handleDownload}
className="absolute bottom-1 right-1 rounded-full bg-black/60 p-1 text-white/70 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
>
<Download className="size-3.5" />
</button>
</a>
</span>
</button>
);
}
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) => {
e.stopPropagation();
if (!url) return;
const a = document.createElement("a");
a.href = url;
a.download = particle.properties.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.electronAttachment.download(url, particle.properties.filename);
};
return (
@@ -118,19 +155,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 (
<button
type="button"
onClick={handleClick}
onClick={(e) => {
e.stopPropagation();
openParticle(particle, index, url, onPreview);
}}
className="flex w-full items-center gap-2 rounded-md bg-white/10 px-2.5 py-1.5 text-left transition-colors hover:bg-white/15"
>
{isImage ? (
@@ -157,31 +200,76 @@ function CompactAttachmentItem({ particle }: { particle: FileParticle }) {
}
export function ParticleAttachments({ attachments, variant = "inline" }: ParticleAttachmentsProps) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
// Only previewable attachments populate the lightbox; the index passed to the
// lightbox is the index into this filtered list, not `attachments`.
const previewable = useMemo(
() => attachments.filter((a) => getAttachmentHandler(a.properties.mime_type) === "lightbox"),
[attachments],
);
const items = useMemo(() => previewable.map(particleToItem), [previewable]);
const handlePreview = (attachmentIndex: number) => {
const particle = attachments[attachmentIndex];
if (!particle) return;
const previewIdx = previewable.indexOf(particle);
if (previewIdx >= 0) setOpenIndex(previewIdx);
};
if (attachments.length === 0) return null;
const lightbox = items.length > 0 && (
<AttachmentLightbox
items={items}
openIndex={openIndex}
onOpenChange={setOpenIndex}
/>
);
if (variant === "compact") {
return (
<div className="flex max-w-48 flex-col gap-1">
{attachments.map((attachment) => (
<CompactAttachmentItem key={attachment.id} particle={attachment} />
))}
</div>
<>
<div className="flex max-w-48 flex-col gap-1">
{attachments.map((attachment, i) => (
<CompactAttachmentItem
key={attachment.id}
particle={attachment}
index={i}
onPreview={handlePreview}
/>
))}
</div>
{lightbox}
</>
);
}
return (
<ScrollArea className="w-full">
<div className="flex items-center gap-2 py-1">
{attachments.map((attachment) => {
const isImage = attachment.properties.mime_type.startsWith("image/");
return isImage ? (
<ImageAttachment key={attachment.id} particle={attachment} />
) : (
<FileAttachment key={attachment.id} particle={attachment} />
);
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<>
<ScrollArea className="w-full">
<div className="flex items-center gap-2 py-1">
{attachments.map((attachment, i) => {
const isImage = attachment.properties.mime_type.startsWith("image/");
return isImage ? (
<ImageAttachment
key={attachment.id}
particle={attachment}
onPreview={() => handlePreview(i)}
/>
) : (
<FileAttachment
key={attachment.id}
particle={attachment}
index={i}
onPreview={handlePreview}
/>
);
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
{lightbox}
</>
);
}
+1 -1
View File
@@ -77,7 +77,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
};
return (
<div className="drag-region flex flex-row px-4 gap-5 items-center">
<div className="drag-region flex flex-row px-4 gap-1 items-center">
<WindowControls />
<Breadcrumb className="no-drag rounded-full bg-black/30 backdrop-blur-sm px-3 py-1 mx-auto">
+10 -25
View File
@@ -24,6 +24,7 @@ import { StreamPresenceProvider, useStreamPresence, useStreamComposing, useStrea
import { ComposingIndicator } from "@/components/composing-indicator";
import { cn } from "@/lib/utils";
import { useMount } from "react-use";
import { usePlaybackStore } from "@/stores/playback-store";
function getReactions(particle: Particle): Record<string, string[]> | undefined {
if (isParticleDeleted(particle)) return undefined;
@@ -192,6 +193,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
const [composeActive, setComposeActive] = useState(false);
const [composeStep, setComposeStep] = useState<ComposeStep>("idle");
const playbackSuspended = usePlaybackStore((s) => s.suspendCount > 0);
const playbackBlocked = composeActive || playbackSuspended;
const [progress, setProgress] = useState(0);
const [fastPlayback, setFastPlayback] = useState(false);
const [showKeybindings, setShowKeybindings] = useState(false);
@@ -243,11 +246,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
setProgress(0);
}, [currentParticle?.id]);
// Pause/resume playback when compose overlay opens/closes
// Pause/resume playback when compose overlay or lightbox is open.
useEffect(() => {
if (composeActive) pause();
if (playbackBlocked) pause();
else resume();
}, [composeActive, pause, resume]);
}, [playbackBlocked, pause, resume]);
// Playback keyboard: arrows, escape, hold-space-to-pause
useEffect(() => {
@@ -261,7 +264,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
}
const handleKeyDown = (e: KeyboardEvent) => {
if (composeActive) return;
if (playbackBlocked) return;
if (isInputTarget(e)) return;
switch (e.key) {
@@ -313,7 +316,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
};
const handleKeyUp = (e: KeyboardEvent) => {
if (composeActive) return;
if (playbackBlocked) return;
if (isInputTarget(e)) return;
if (e.key === " ") {
@@ -333,22 +336,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
window.removeEventListener("keyup", handleKeyUp);
};
},
[composeActive, next, prev, pause, resume, navigate, networkId, streamParticle.id, setShowKeybindings, handleToggleReaction, recordingMode, setRecordingMode],
);
// Click-to-navigate: left 30% = prev, right 70% = next
// Suppressed when the user has selected text (drag-to-select)
const handlePlaybackClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const selection = window.getSelection();
if (selection && selection.toString().length > 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
if (x < 0.3) prev();
else if (x > 0.7) next();
},
[prev, next],
[playbackBlocked, next, prev, pause, resume, navigate, networkId, streamParticle.id, setShowKeybindings, handleToggleReaction, recordingMode, setRecordingMode],
);
if (children.length === 0) {
@@ -430,10 +418,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
{/* Main playback area */}
<div className="flex-1 overflow-hidden">
{currentParticle && (
<div
className="relative flex h-full w-full cursor-pointer items-center justify-center"
onClick={handlePlaybackClick}
>
<div className="relative flex h-full w-full cursor-pointer items-center justify-center">
{renderParticle(currentParticle)}
{fastPlayback && (