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>;
openExternal: (url: string) => Promise<void>;
};
electronAttachment: {
download: (url: string, filename?: string) => void;
};
electronApp: {
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 { 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 (
<div
role={previewable ? "button" : undefined}
tabIndex={previewable ? 0 : undefined}
onClick={previewable && onPreview ? onPreview : undefined}
className={cn(
"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",
)}
>
@@ -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<number | null>(null);
if (attachments.length === 0 && !hasLinks) return null;
return (
<ScrollArea className="w-full">
<div className="flex items-center gap-2 py-2">
{attachments.map((a) => (
<AttachmentThumbnail
key={a.id}
attachment={a}
onRemove={() => onRemove(a.id)}
/>
))}
<>
<ScrollArea className="w-full">
<div className="flex items-center gap-2 py-2">
{attachments.map((a) => (
<AttachmentThumbnail
key={a.id}
attachment={a}
onRemove={() => onRemove(a.id)}
onPreview={() => {
const idx = previewable.indexOf(a);
if (idx >= 0) setOpenIndex(idx);
}}
/>
))}
{linkPreviews?.map((entry) => (
<LinkPreviewThumbnail key={entry.url} entry={entry} />
))}
{linkPreviews?.map((entry) => (
<LinkPreviewThumbnail key={entry.url} entry={entry} />
))}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
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"
>
<Plus className="size-5" />
</button>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
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"
>
<Plus className="size-5" />
</button>
</div>
<ScrollBar orientation="horizontal" />
</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 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 && (
+22
View File
@@ -454,6 +454,28 @@ ipcMain.handle('link:open-external', async (_event, url: string) => {
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
// initialization and is ready to create browser windows.
// 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),
});
contextBridge.exposeInMainWorld('electronAttachment', {
download: (url: string, filename?: string) =>
ipcRenderer.send('attachment:download', { url, filename }),
});
contextBridge.exposeInMainWorld('electronApp', {
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) })),
}));