feat: support image lightbox viewer and file download #151

Merged
talksik merged 3 commits from lightbox-attachements into master 2026-04-12 21:14:23 +00:00
10 changed files with 602 additions and 146 deletions
+3
View File
@@ -44,6 +44,9 @@ declare global {
fetchMetadata: (url: string) => Promise<LinkMetadata | null>; fetchMetadata: (url: string) => Promise<LinkMetadata | null>;
openExternal: (url: string) => Promise<void>; openExternal: (url: string) => Promise<void>;
}; };
electronAttachment: {
download: (url: string, filename?: string) => void;
};
electronApp: { electronApp: {
setDockBadge: (count: number) => void; setDockBadge: (count: number) => void;
}; };
@@ -0,0 +1,333 @@
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`;
}
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<string | null>(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;
window.electronAttachment.download(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 (
<DialogPrimitive.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) onOpenChange(null);
}}
>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/90 backdrop-blur-sm duration-100" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 flex items-center justify-center p-16 outline-none duration-100"
>
{current && (
<>
<DialogPrimitive.Title className="sr-only">
{current.filename}
</DialogPrimitive.Title>
{/* Top-left: filename chip */}
<div className="absolute left-4 top-4 flex items-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-3 py-1.5 backdrop-blur-xl">
<span className="max-w-[40vw] truncate text-sm text-white/80">
{current.filename}
</span>
{sizeLabel && (
<span className="text-xs text-white/40">{sizeLabel}</span>
)}
{hasMultiple && (
<span className="border-l border-white/10 pl-2 text-xs text-white/40">
{openIndex! + 1} / {items.length}
</span>
)}
</div>
{/* Top-right: actions */}
<div className="absolute right-4 top-4 flex items-center gap-1 no-drag">
{canDownload && (
<Button
variant="ghost"
size="icon-sm"
onClick={handleDownload}
className="text-white/70 hover:bg-white/10 hover:text-white"
title="Download"
>
<Download />
</Button>
)}
{onRemove && (
<Button
variant="ghost"
size="icon-sm"
onClick={handleRemove}
className="text-white/70 hover:bg-white/10 hover:text-white"
title="Remove"
>
<Trash2 />
</Button>
)}
<DialogPrimitive.Close asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-white/70 hover:bg-white/10 hover:text-white"
title="Close"
>
<X />
</Button>
</DialogPrimitive.Close>
</div>
{/* Side navigation */}
{hasMultiple && (
<>
<Button
variant="ghost"
size="icon"
onClick={() => goTo(-1)}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/70 hover:bg-white/10 hover:text-white"
aria-label="Previous"
>
<ChevronLeft />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => goTo(1)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/70 hover:bg-white/10 hover:text-white"
aria-label="Next"
>
<ChevronRight />
</Button>
</>
)}
{/* Media body */}
<div className="flex max-h-full max-w-full items-center justify-center">
{!url && isRemoteLoading && (
<Loader2 className="size-8 animate-spin text-white/50" />
)}
{url && isImage && (
<img
key={current.id}
src={url}
alt={current.filename}
onError={() => onOpenChange(null)}
className="max-h-[85vh] max-w-[85vw] rounded-lg object-contain shadow-2xl"
/>
)}
{url && isVideo && (
<video
key={current.id}
src={url}
controls
autoPlay
className="max-h-[85vh] max-w-[85vw] rounded-lg shadow-2xl"
/>
)}
{url && !isImage && !isVideo && (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-white/10 bg-white/5 px-8 py-6 backdrop-blur-xl">
<FileIcon className="size-12 text-white/50" />
<span className="text-sm text-white/80">{current.filename}</span>
</div>
)}
</div>
{/* Footer kbd hints */}
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-4 text-xs text-white/50">
{hasMultiple && (
<span className="flex items-center gap-1.5">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
</kbd>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
</kbd>
navigate
</span>
)}
{canDownload && (
<span className="flex items-center gap-1.5">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
D
</kbd>
download
</span>
)}
{onRemove && (
<span className="flex items-center gap-1.5">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
</kbd>
remove
</span>
)}
<span className="flex items-center gap-1.5">
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>
close
</span>
</div>
</>
)}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
+72 -25
View File
@@ -1,8 +1,14 @@
import { useMemo, useState } from "react";
import { FileIcon, Globe, Loader2, Plus, X } from "lucide-react"; import { FileIcon, Globe, Loader2, Plus, X } from "lucide-react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { LinkPreviewEntry } from "@/hooks/use-link-metadata"; import type { LinkPreviewEntry } from "@/hooks/use-link-metadata";
import {
AttachmentLightbox,
getAttachmentHandler,
type AttachmentItem,
} from "@/features/attachments/attachment-lightbox";
export interface PendingAttachment { export interface PendingAttachment {
id: string; id: string;
@@ -18,6 +24,16 @@ interface AttachmentStripProps {
linkPreviews?: LinkPreviewEntry[]; 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 { function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
@@ -27,18 +43,25 @@ function formatFileSize(bytes: number): string {
function AttachmentThumbnail({ function AttachmentThumbnail({
attachment, attachment,
onRemove, onRemove,
onPreview,
}: { }: {
attachment: PendingAttachment; attachment: PendingAttachment;
onRemove: () => void; onRemove: () => void;
onPreview?: () => void;
}) { }) {
const isImage = attachment.file.type.startsWith("image/"); const isImage = attachment.file.type.startsWith("image/");
const isUploading = attachment.status === "uploading"; const isUploading = attachment.status === "uploading";
const isError = attachment.status === "error"; const isError = attachment.status === "error";
const previewable = getAttachmentHandler(attachment.file.type) === "lightbox";
return ( return (
<div <div
role={previewable ? "button" : undefined}
tabIndex={previewable ? 0 : undefined}
onClick={previewable && onPreview ? onPreview : undefined}
className={cn( className={cn(
"group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10", "group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10",
previewable && "cursor-pointer",
isError && "ring-1 ring-red-400/50", isError && "ring-1 ring-red-400/50",
)} )}
> >
@@ -131,35 +154,59 @@ export function AttachmentStrip({
linkPreviews, linkPreviews,
}: AttachmentStripProps) { }: AttachmentStripProps) {
const hasLinks = linkPreviews && linkPreviews.length > 0; 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<number | null>(null);
if (attachments.length === 0 && !hasLinks) return null; if (attachments.length === 0 && !hasLinks) return null;
return ( return (
<ScrollArea className="w-full"> <>
<div className="flex items-center gap-2 py-2"> <ScrollArea className="w-full">
{attachments.map((a) => ( <div className="flex items-center gap-2 py-2">
<AttachmentThumbnail {attachments.map((a) => (
key={a.id} <AttachmentThumbnail
attachment={a} key={a.id}
onRemove={() => onRemove(a.id)} attachment={a}
/> onRemove={() => onRemove(a.id)}
))} onPreview={() => {
const idx = previewable.indexOf(a);
if (idx >= 0) setOpenIndex(idx);
}}
/>
))}
{linkPreviews?.map((entry) => ( {linkPreviews?.map((entry) => (
<LinkPreviewThumbnail key={entry.url} entry={entry} /> <LinkPreviewThumbnail key={entry.url} entry={entry} />
))} ))}
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onAddClick(); onAddClick();
}} }}
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg border border-dashed border-white/20 text-white/40 transition-colors hover:border-white/40 hover:text-white/60" className="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg border border-dashed border-white/20 text-white/40 transition-colors hover:border-white/40 hover:text-white/60"
> >
<Plus className="size-5" /> <Plus className="size-5" />
</button> </button>
</div> </div>
<ScrollBar orientation="horizontal" /> <ScrollBar orientation="horizontal" />
</ScrollArea> </ScrollArea>
{items.length > 0 && (
<AttachmentLightbox
items={items}
openIndex={openIndex}
onOpenChange={setOpenIndex}
onRemove={(item) => onRemove(item.id)}
/>
)}
</>
); );
} }
@@ -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 { Download, ExternalLink, FileIcon, ImageIcon } from "lucide-react";
import type { Particle } from "@/api/types"; import type { Particle } from "@/api/types";
import { useDownloadUrl } from "@/hooks/use-download-url"; import { useDownloadUrl } from "@/hooks/use-download-url";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
AttachmentLightbox,
getAttachmentHandler,
type AttachmentItem,
} from "@/features/attachments/attachment-lightbox";
type FileParticle = Extract<Particle, { type: "file" }>; type FileParticle = Extract<Particle, { type: "file" }>;
@@ -18,7 +24,39 @@ function formatFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; 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); const { data: url, isLoading } = useDownloadUrl(particle.properties.object_id);
if (isLoading || !url) { if (isLoading || !url) {
@@ -27,21 +65,16 @@ export function ImageAttachment({ particle }: { particle: FileParticle }) {
const handleDownload = (e: React.MouseEvent) => { const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); window.electronAttachment.download(url, particle.properties.filename);
const a = document.createElement("a");
a.href = url;
a.download = particle.properties.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}; };
return ( return (
<a <button
href={url} type="button"
target="_blank" onClick={(e) => {
rel="noopener noreferrer" e.stopPropagation();
onClick={(e) => e.stopPropagation()} onPreview();
}}
className="group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg bg-white/10" className="group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg bg-white/10"
> >
<img <img
@@ -49,34 +82,38 @@ export function ImageAttachment({ particle }: { particle: FileParticle }) {
alt={particle.properties.filename} alt={particle.properties.filename}
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
<button <span
type="button" role="button"
tabIndex={0}
onClick={handleDownload} 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" 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" /> <Download className="size-3.5" />
</button> </span>
</a> </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 { data: url } = useDownloadUrl(particle.properties.object_id);
const handleOpen = (e: React.MouseEvent) => { const handleOpen = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (url) window.electronLink.openExternal(url); openParticle(particle, index, url, onPreview);
}; };
const handleDownload = (e: React.MouseEvent) => { const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!url) return; if (!url) return;
const a = document.createElement("a"); window.electronAttachment.download(url, particle.properties.filename);
a.href = url;
a.download = particle.properties.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}; };
return ( 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 isImage = particle.properties.mime_type.startsWith("image/");
const { data: url } = useDownloadUrl(particle.properties.object_id); const { data: url } = useDownloadUrl(particle.properties.object_id);
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (url) window.electronLink.openExternal(url);
};
return ( return (
<button <button
type="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" 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 ? ( {isImage ? (
@@ -157,31 +200,76 @@ function CompactAttachmentItem({ particle }: { particle: FileParticle }) {
} }
export function ParticleAttachments({ attachments, variant = "inline" }: ParticleAttachmentsProps) { 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; if (attachments.length === 0) return null;
const lightbox = items.length > 0 && (
<AttachmentLightbox
items={items}
openIndex={openIndex}
onOpenChange={setOpenIndex}
/>
);
if (variant === "compact") { if (variant === "compact") {
return ( return (
<div className="flex max-w-48 flex-col gap-1"> <>
{attachments.map((attachment) => ( <div className="flex max-w-48 flex-col gap-1">
<CompactAttachmentItem key={attachment.id} particle={attachment} /> {attachments.map((attachment, i) => (
))} <CompactAttachmentItem
</div> key={attachment.id}
particle={attachment}
index={i}
onPreview={handlePreview}
/>
))}
</div>
{lightbox}
</>
); );
} }
return ( return (
<ScrollArea className="w-full"> <>
<div className="flex items-center gap-2 py-1"> <ScrollArea className="w-full">
{attachments.map((attachment) => { <div className="flex items-center gap-2 py-1">
const isImage = attachment.properties.mime_type.startsWith("image/"); {attachments.map((attachment, i) => {
return isImage ? ( const isImage = attachment.properties.mime_type.startsWith("image/");
<ImageAttachment key={attachment.id} particle={attachment} /> return isImage ? (
) : ( <ImageAttachment
<FileAttachment key={attachment.id} particle={attachment} /> key={attachment.id}
); particle={attachment}
})} onPreview={() => handlePreview(i)}
</div> />
<ScrollBar orientation="horizontal" /> ) : (
</ScrollArea> <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 ( 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 /> <WindowControls />
<Breadcrumb className="no-drag rounded-full bg-black/30 backdrop-blur-sm px-3 py-1 mx-auto"> <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 { ComposingIndicator } from "@/components/composing-indicator";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useMount } from "react-use"; import { useMount } from "react-use";
import { usePlaybackStore } from "@/stores/playback-store";
function getReactions(particle: Particle): Record<string, string[]> | undefined { function getReactions(particle: Particle): Record<string, string[]> | undefined {
if (isParticleDeleted(particle)) return undefined; if (isParticleDeleted(particle)) return undefined;
@@ -192,6 +193,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
const [composeActive, setComposeActive] = useState(false); const [composeActive, setComposeActive] = useState(false);
const [composeStep, setComposeStep] = useState<ComposeStep>("idle"); const [composeStep, setComposeStep] = useState<ComposeStep>("idle");
const playbackSuspended = usePlaybackStore((s) => s.suspendCount > 0);
const playbackBlocked = composeActive || playbackSuspended;
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [fastPlayback, setFastPlayback] = useState(false); const [fastPlayback, setFastPlayback] = useState(false);
const [showKeybindings, setShowKeybindings] = useState(false); const [showKeybindings, setShowKeybindings] = useState(false);
@@ -243,11 +246,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
setProgress(0); setProgress(0);
}, [currentParticle?.id]); }, [currentParticle?.id]);
// Pause/resume playback when compose overlay opens/closes // Pause/resume playback when compose overlay or lightbox is open.
useEffect(() => { useEffect(() => {
if (composeActive) pause(); if (playbackBlocked) pause();
else resume(); else resume();
}, [composeActive, pause, resume]); }, [playbackBlocked, pause, resume]);
// Playback keyboard: arrows, escape, hold-space-to-pause // Playback keyboard: arrows, escape, hold-space-to-pause
useEffect(() => { useEffect(() => {
@@ -261,7 +264,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
} }
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (composeActive) return; if (playbackBlocked) return;
if (isInputTarget(e)) return; if (isInputTarget(e)) return;
switch (e.key) { switch (e.key) {
@@ -313,7 +316,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
}; };
const handleKeyUp = (e: KeyboardEvent) => { const handleKeyUp = (e: KeyboardEvent) => {
if (composeActive) return; if (playbackBlocked) return;
if (isInputTarget(e)) return; if (isInputTarget(e)) return;
if (e.key === " ") { if (e.key === " ") {
@@ -333,22 +336,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("keyup", handleKeyUp);
}; };
}, },
[composeActive, next, prev, pause, resume, navigate, networkId, streamParticle.id, setShowKeybindings, handleToggleReaction, recordingMode, setRecordingMode], [playbackBlocked, 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],
); );
if (children.length === 0) { if (children.length === 0) {
@@ -430,10 +418,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
{/* Main playback area */} {/* Main playback area */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{currentParticle && ( {currentParticle && (
<div <div className="relative flex h-full w-full cursor-pointer items-center justify-center">
className="relative flex h-full w-full cursor-pointer items-center justify-center"
onClick={handlePlaybackClick}
>
{renderParticle(currentParticle)} {renderParticle(currentParticle)}
{fastPlayback && ( {fastPlayback && (
+22
View File
@@ -454,6 +454,28 @@ ipcMain.handle('link:open-external', async (_event, url: string) => {
await shell.openExternal(url); await shell.openExternal(url);
}); });
// --- Attachment download ---
// Triggers a native download with save-as dialog. Cross-origin safe — unlike
// the web `<a download>` hack, which is ignored for cross-origin URLs.
ipcMain.on(
'attachment:download',
(event, payload: { url: string; filename?: string }) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return;
const { url, filename } = payload ?? {};
if (typeof url !== 'string') return;
if (!url.startsWith('http://') && !url.startsWith('https://')) return;
const dlSession = win.webContents.session;
const onWillDownload = (_e: Electron.Event, item: Electron.DownloadItem) => {
if (filename) item.setSaveDialogOptions({ defaultPath: filename });
dlSession.removeListener('will-download', onWillDownload);
};
dlSession.on('will-download', onWillDownload);
win.webContents.downloadURL(url);
},
);
// This method will be called when Electron has finished // This method will be called when Electron has finished
// initialization and is ready to create browser windows. // initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs. // Some APIs can only be used after this event occurs.
+5
View File
@@ -66,6 +66,11 @@ contextBridge.exposeInMainWorld('electronLink', {
openExternal: (url: string) => ipcRenderer.invoke('link:open-external', url), openExternal: (url: string) => ipcRenderer.invoke('link:open-external', url),
}); });
contextBridge.exposeInMainWorld('electronAttachment', {
download: (url: string, filename?: string) =>
ipcRenderer.send('attachment:download', { url, filename }),
});
contextBridge.exposeInMainWorld('electronApp', { contextBridge.exposeInMainWorld('electronApp', {
setDockBadge: (count: number) => ipcRenderer.send('app:set-dock-badge', count), setDockBadge: (count: number) => ipcRenderer.send('app:set-dock-badge', count),
}); });
+18
View File
@@ -0,0 +1,18 @@
import { create } from "zustand";
/**
* Overlays that should pause stream playback (e.g. attachment lightbox) call
* `suspend()` on mount and `release()` on unmount. Stream playback is suspended
* whenever `suspendCount > 0`.
*/
interface PlaybackState {
suspendCount: number;
suspend: () => void;
release: () => void;
}
export const usePlaybackStore = create<PlaybackState>((set) => ({
suspendCount: 0,
suspend: () => set((s) => ({ suspendCount: s.suspendCount + 1 })),
release: () => set((s) => ({ suspendCount: Math.max(0, s.suspendCount - 1) })),
}));