diff --git a/src/components/AttachmentPreview.tsx b/src/components/AttachmentPreview.tsx new file mode 100644 index 000000000..f6b227cdd --- /dev/null +++ b/src/components/AttachmentPreview.tsx @@ -0,0 +1,167 @@ +// Same-origin image thumbnails and an in-app lightbox. Transcript text can +// contain arbitrary strings, so callers pass saved paths and this component +// resolves them through attachmentImageUrl rather than loading them as URLs. +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Download, ImageOff, Maximize2, X } from "lucide-react"; + +import { attachmentBasename, attachmentImageUrl } from "@/lib/composer-attachments"; +import { cn } from "@/lib/cn"; + +export interface PreviewImage { + src: string; + name: string; +} + +export function previewImage(path: string): PreviewImage | null { + const src = attachmentImageUrl(path); + if (!src) return null; + return { src, name: attachmentBasename(path) }; +} + +export function AttachmentPreviewDialog({ image, onClose }: { image: PreviewImage; onClose: () => void }) { + const dialogRef = useRef(null); + const closeRef = useRef(onClose); + const [failed, setFailed] = useState(false); + + useLayoutEffect(() => { + closeRef.current = onClose; + }, [onClose]); + + useEffect(() => { + const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + dialogRef.current?.focus(); + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + closeRef.current(); + return; + } + if (event.key !== "Tab") return; + const dialog = dialogRef.current; + if (!dialog) return; + const focusable = [...dialog.querySelectorAll( + 'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])', + )]; + if (focusable.length === 0) { + event.preventDefault(); + dialog.focus(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && (document.activeElement === dialog || document.activeElement === first)) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("keydown", onKey); + previousFocus?.focus(); + }; + }, []); + + return createPortal( +
event.target === event.currentTarget && onClose()} + > +
+
+
+
{image.name}
+
Saved locally by OpenMausBot
+
+
+ + + + +
+
+
+ {failed ? ( +
+ + This attachment is no longer available. +
+ ) : ( + {image.name} setFailed(true)} + className="block max-h-full max-w-full rounded-lg object-contain shadow-2xl" + /> + )} +
+
+
, + document.body, + ); +} + +function Thumbnail({ image, onPreview }: { image: PreviewImage; onPreview: () => void }) { + const [failed, setFailed] = useState(false); + if (failed) return null; + return ( + + ); +} + +export function AttachedImageGallery({ paths, className }: { paths: string[]; className?: string }) { + const images = useMemo(() => paths.flatMap((path) => { + const image = previewImage(path); + return image ? [image] : []; + }), [paths]); + const [selected, setSelected] = useState(null); + if (images.length === 0) return null; + return ( + <> +
+ {images.map((image, index) => ( + setSelected(image)} /> + ))} +
+ {selected && setSelected(null)} />} + + ); +} diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 8ba658bb0..cee851cb8 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -44,6 +44,7 @@ import { ApprovalCard } from "./ApprovalCard"; import { Composer } from "./Composer"; import { ConnectorCard } from "./ConnectorCard"; import { SecretRequestCard } from "./SecretRequestCard"; +import { AttachedImageGallery } from "./AttachmentPreview"; import { ModelPicker } from "./ModelPicker"; import { RenameTitle } from "./RenameTitle"; import { TaskPicker } from "./TaskPicker"; @@ -54,7 +55,7 @@ import { cn } from "@/lib/cn"; import { COMPACT_BUBBLE, COMPACT_SQUARE } from "@/lib/compact-chip"; import { useFocusMessage } from "@/lib/focus-message"; import { webhookMessageView } from "@/lib/webhook-message"; -import { attachmentBasename, splitAttachedImages } from "@/lib/composer-attachments"; +import { splitAttachedImages } from "@/lib/composer-attachments"; import { BOTTOM_FOLLOW_THRESHOLD, shouldResumeBottomFollow } from "@/lib/bottom-follow"; import { TRANSCRIPT_WINDOW_SIZE, @@ -421,25 +422,7 @@ function Bubble({ ) : user ? ( <> {attachedImages && attachedImages.images.length > 0 && ( -
- {attachedImages.images.map((path) => ( - - Attached image - - ))} -
+ )}
void; }) { const [dragging, setDragging] = useState(false); + const [preview, setPreview] = useState(null); // dragenter/dragleave fire once per element crossed, so the overlay // tracks depth rather than the last event it happened to see const depth = useRef(0); @@ -133,15 +135,20 @@ export function ComposerAttachments({
{pasteSummary(a)}
) : a.kind === "image" ? ( - onRemove(a.id)}> -
+ onRemove(a.id)}> +
+
{formatSize(a.size)}
) : ( @@ -158,6 +165,7 @@ export function ComposerAttachments({ )}
)} + {preview && setPreview(null)} />} ); } diff --git a/src/components/GroupView.tsx b/src/components/GroupView.tsx index 4714598a5..8d3e06408 100644 --- a/src/components/GroupView.tsx +++ b/src/components/GroupView.tsx @@ -21,6 +21,7 @@ import { ChatMarkdown } from "./ChatMarkdown"; import { Composer } from "./Composer"; import { ConnectorCard } from "./ConnectorCard"; import { SecretRequestCard } from "./SecretRequestCard"; +import { AttachedImageGallery } from "./AttachmentPreview"; import { GroupCallButton, GroupCallOverlay } from "./GroupCallView"; import { ReactionBar, ReactionChips } from "./Reactions"; import { ApprovalCard } from "./ApprovalCard"; @@ -31,6 +32,7 @@ import { useFocusMessage } from "@/lib/focus-message"; import { shortPath } from "@/lib/short-path"; import { BOTTOM_FOLLOW_THRESHOLD, shouldResumeBottomFollow } from "@/lib/bottom-follow"; import { showWorkingDots } from "@/lib/turn-tail"; +import { splitAttachedImages } from "@/lib/composer-attachments"; import { TRANSCRIPT_WINDOW_SIZE, expandWindowStart, @@ -106,6 +108,7 @@ const Transcript = memo(function Transcript({ const prev = textMessages[i - 1]; const newDay = !prev || new Date(prev.at).toDateString() !== new Date(m.at).toDateString(); const user = m.role === "user"; + const attachedImages = user && m.text ? splitAttachedImages(m.text) : null; const newCluster = !prev || prev.role !== m.role || prev.from?.botId !== m.from?.botId || newDay; const row = // a member can hit a permission ask mid-turn; without this the @@ -144,7 +147,14 @@ const Transcript = memo(function Transcript({ )} title={new Date(m.at).toLocaleString()} > - {user ? m.text : } + {user ? ( + <> + {attachedImages && attachedImages.images.length > 0 && ( + + )} + {attachedImages?.display ?? m.text} + + ) : } {!user && } diff --git a/src/lib/composer-attachments.test.ts b/src/lib/composer-attachments.test.ts index 07577b23f..03083c038 100644 --- a/src/lib/composer-attachments.test.ts +++ b/src/lib/composer-attachments.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { attachmentBasename, + attachmentImageUrl, composeMessage, isImageFile, splitAttachedImages, @@ -64,6 +65,16 @@ describe("attachmentBasename", () => { expect(attachmentBasename("/a/b/c.png")).toBe("c.png"); expect(attachmentBasename("C:\\a\\b\\c.png")).toBe("c.png"); }); + + it("turns only generated image names into same-origin preview URLs", () => { + expect(attachmentImageUrl("/a/b/123e4567-e89b-12d3-a456-426614174000.png")).toBe( + "/api/attachments/123e4567-e89b-12d3-a456-426614174000.png", + ); + expect(attachmentImageUrl("C:\\a\\b\\photo.webp")).toBe("/api/attachments/photo.webp"); + expect(attachmentImageUrl("https://attacker.example/tracker.png?cookie=1")).toBeNull(); + expect(attachmentImageUrl("/a/b/payload.svg")).toBeNull(); + expect(attachmentImageUrl("/a/b/not%2Fan-image.png")).toBeNull(); + }); }); describe("isImageFile", () => { @@ -75,4 +86,3 @@ describe("isImageFile", () => { expect(isImageFile({ type: "text/plain", size: 10 })).toBe(false); }); }); - diff --git a/src/lib/composer-attachments.ts b/src/lib/composer-attachments.ts index 95ed8c5ab..f7ca60374 100644 --- a/src/lib/composer-attachments.ts +++ b/src/lib/composer-attachments.ts @@ -239,6 +239,16 @@ export function attachmentBasename(path: string): string { return parts[parts.length - 1] ?? ""; } +/** The renderer never loads a transcript-provided URL directly. Only names + * the attachment server itself can have generated become same-origin image + * URLs; malformed and executable-image paths render nothing, while a string + * that looks remote can at most resolve to a local generated filename. */ +export function attachmentImageUrl(path: string): string | null { + const name = attachmentBasename(path); + if (!/^[A-Za-z0-9-]+\.(png|jpg|gif|webp)$/.test(name)) return null; + return `/api/attachments/${encodeURIComponent(name)}`; +} + /** One intake path for files arriving by drop OR by the composer's attach * button, so a picked file and a dropped one can never behave differently. * The image uploader is injected: the caller owns the network, this owns