feat: support image lightbox viewer and file download #151
Vendored
+3
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) })),
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user