diff --git a/web/src/components/InputBox.attach.test.tsx b/web/src/components/InputBox.attach.test.tsx new file mode 100644 index 00000000..95316515 --- /dev/null +++ b/web/src/components/InputBox.attach.test.tsx @@ -0,0 +1,128 @@ +/** + * @vitest-environment jsdom + * @vitest-environment-options { "pretendToBeVisual": true } + * + * The composer's attach button. Paste and drag-and-drop were the only ways to + * attach an image, which left phones with no way at all: iOS has no file drag + * source, and gecko-for-iOS does not put pasted photos on the DOM clipboard. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "preact"; +import { act } from "preact/test-utils"; +import { InputBox, drafts } from "./InputBox"; + +vi.hoisted(() => { + vi.stubGlobal("CSS", { supports: () => false }); +}); + +const SESSION = "attach-session"; + +/** FileReader.readAsDataURL resolves on a task, so let it land. */ +const flushReader = () => new Promise((resolve) => setTimeout(resolve, 20)); + +async function mountComposer(container: HTMLElement) { + await act(() => { + render( + {}} + onInterrupt={() => {}} + isProcessing={false} + stdinClosed={false} + disabled={false} + sessionId={SESSION} + />, + container, + ); + }); +} + +/** A real File whose bytes decode as the given data URL payload. */ +function imageFile(name: string, type: string) { + return new File([new Uint8Array([1, 2, 3, 4])], name, { type }); +} + +/** Drive the hidden input the way a picker selection does. */ +async function selectFiles(container: HTMLElement, files: File[]) { + const input = container.querySelector("input.input-file"); + expect(input).not.toBeNull(); + // a FileList stand-in: indexed access, length, item(), and iteration + const list: Record = { + item: (index: number) => files[index] ?? null, + [Symbol.iterator]: files[Symbol.iterator].bind(files), + }; + files.forEach((file, index) => { + list[index] = file; + }); + list.length = files.length; + Object.defineProperty(input!, "files", { configurable: true, value: list }); + await act(() => { + input!.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +describe("composer image attachment", () => { + let container: HTMLElement; + + beforeEach(() => { + drafts.clear(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + it("offers an attach control that reaches the system picker", async () => { + await mountComposer(container); + + const button = container.querySelector(".btn-attach"); + const input = container.querySelector("input.input-file"); + expect(button).not.toBeNull(); + expect(input).not.toBeNull(); + // accept drives which picker iOS opens; multiple lets a batch through + expect(input!.getAttribute("accept")).toBe("image/*"); + expect(input!.hasAttribute("multiple")).toBe(true); + + let clicked = false; + input!.click = () => { + clicked = true; + }; + await act(() => { + button!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(clicked).toBe(true); + }); + + it("attaches picked images as previews", async () => { + await mountComposer(container); + await selectFiles(container, [ + imageFile("one.jpg", "image/jpeg"), + imageFile("two.png", "image/png"), + ]); + await act(async () => { + await flushReader(); + }); + + expect(container.querySelectorAll(".image-preview img").length).toBe(2); + }); + + it("refuses image types the agent APIs reject, and says so", async () => { + await mountComposer(container); + await selectFiles(container, [imageFile("photo.heic", "image/heic")]); + await act(async () => { + await flushReader(); + }); + + expect(container.querySelectorAll(".image-preview img").length).toBe(0); + const error = container.querySelector(".attach-error"); + expect(error).not.toBeNull(); + expect(error!.textContent).toContain("image/heic"); + }); + + it("clears the input so the same photo can be picked twice", async () => { + await mountComposer(container); + const input = container.querySelector("input.input-file"); + await selectFiles(container, [imageFile("one.jpg", "image/jpeg")]); + await act(async () => { + await flushReader(); + }); + expect(input!.value).toBe(""); + }); +}); diff --git a/web/src/components/InputBox.tsx b/web/src/components/InputBox.tsx index 636f49e9..b722ba75 100644 --- a/web/src/components/InputBox.tsx +++ b/web/src/components/InputBox.tsx @@ -132,6 +132,16 @@ function publishImages(entry: ControlledImageEntry, images: ImageAttachment[]) { } } +// what the agent APIs actually accept; an iPhone photo arrives as JPEG because +// the picker transcodes it, but a file picked out of Files can still be HEIC, +// and attaching one silently produces a send the API rejects +const SUPPORTED_IMAGE_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +]); + function useImageAttachments( enabled = true, resetToken?: number, @@ -151,6 +161,7 @@ function useImageAttachments( () => imageEntry?.generation ?? 0, ); const [isDragging, setIsDragging] = useState(false); + const [attachError, setAttachError] = useState(null); const enabledRef = useRef(enabled); const resetTokenRef = useRef(resetToken); const generationRef = useRef(imageEntry?.generation ?? 0); @@ -217,6 +228,13 @@ function useImageAttachments( const processFile = (file: File) => { if (!enabledRef.current || !file.type.startsWith("image/")) return; + if (!SUPPORTED_IMAGE_TYPES.has(file.type)) { + setAttachError( + `${file.type} images can't be attached; JPEG, PNG, GIF and WebP work`, + ); + return; + } + setAttachError(null); const generation = generationRef.current; const entry = imageEntry; const reader = new FileReader(); @@ -245,6 +263,11 @@ function useImageAttachments( reader.readAsDataURL(file); }; + const addFiles = (files: FileList | File[] | null) => { + if (!files) return; + for (const file of Array.from(files)) processFile(file); + }; + const onPaste = (event: ClipboardEvent) => { const items = event.clipboardData?.items; if (!items) return; @@ -297,6 +320,11 @@ function useImageAttachments( images: enabled && imagesGeneration === generationRef.current ? images : [], setImages, isDragging: enabled && isDragging, + attachError, + dismissAttachError: () => { + setAttachError(null); + }, + addFiles, onPaste, onDragOver, onDragLeave, @@ -304,6 +332,84 @@ function useImageAttachments( }; } +/** Attach button plus the hidden input it drives. + * + * Paste and drag-and-drop were the only ways to attach an image, and a phone + * has neither: iOS has no file drag source, and gecko-for-iOS does not put + * pasted photos on the DOM clipboard. A plain file input is what reaches the + * system photo picker there. + * + * The input is visually hidden rather than display:none, since only the former + * is reliably clickable programmatically across engines. + */ +function AttachButton({ + disabled, + onFiles, +}: { + disabled: boolean; + onFiles: (files: FileList | null) => void; +}) { + const inputRef = useRef(null); + return ( + <> + { + const input = event.target as HTMLInputElement; + onFiles(input.files); + // clearing lets the same photo be picked again right after removing it + input.value = ""; + }} + /> + + + ); +} + +function AttachError({ + message, + onDismiss, +}: { + message: string; + onDismiss: () => void; +}) { + return ( +
+ {message} +
+ ); +} + function ImagePreviews({ images, onRemove, @@ -388,6 +494,9 @@ function OrdinaryInputBox({ onDragOver, onDragLeave, onDrop, + attachError, + dismissAttachError, + addFiles, } = useImageAttachments(); const internalRef = useRef(null); const textareaRef = inputRef ?? internalRef; @@ -568,6 +677,9 @@ function OrdinaryInputBox({ setImages((previous) => previous.filter((image) => image.id !== id)); }} /> + {attachError && ( + + )}