Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions web/src/components/InputBox.attach.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* @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 } from "./InputBox";
import {
createOrdinaryDraftStore,
type OrdinaryDraftStore,
} from "../ordinaryDraftStore";

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,
store: OrdinaryDraftStore,
) {
await act(() => {
render(
<InputBox
onSend={() => {}}
onInterrupt={() => {}}
isProcessing={false}
stdinClosed={false}
disabled={false}
sessionId={SESSION}
ordinaryDraftStore={store}
/>,
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<HTMLInputElement>("input.input-file");
expect(input).not.toBeNull();
// a FileList stand-in: indexed access, length, item(), and iteration
const list: Record<string | number | symbol, unknown> = {
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;
let store: OrdinaryDraftStore;

beforeEach(() => {
store = createOrdinaryDraftStore();
container = document.createElement("div");
document.body.appendChild(container);
});

it("offers an attach control that reaches the system picker", async () => {
await mountComposer(container, store);

const button = container.querySelector<HTMLButtonElement>(".btn-attach");
const input = container.querySelector<HTMLInputElement>("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, store);
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, store);
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, store);
const input = container.querySelector<HTMLInputElement>("input.input-file");
await selectFiles(container, [imageFile("one.jpg", "image/jpeg")]);
await act(async () => {
await flushReader();
});
expect(input!.value).toBe("");
});
});
120 changes: 120 additions & 0 deletions web/src/components/InputBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,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,
Expand All @@ -168,6 +178,7 @@ function useImageAttachments(
() => imageEntry?.generation ?? 0,
);
const [isDragging, setIsDragging] = useState(false);
const [attachError, setAttachError] = useState<string | null>(null);
const enabledRef = useRef(enabled);
const resetTokenRef = useRef(resetToken);
const generationRef = useRef(imageEntry?.generation ?? 0);
Expand Down Expand Up @@ -234,6 +245,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();
Expand Down Expand Up @@ -262,6 +280,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;
Expand Down Expand Up @@ -314,13 +337,96 @@ function useImageAttachments(
images: enabled && imagesGeneration === generationRef.current ? images : [],
setImages,
isDragging: enabled && isDragging,
attachError,
dismissAttachError: () => {
setAttachError(null);
},
addFiles,
onPaste,
onDragOver,
onDragLeave,
onDrop,
};
}

/** 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<HTMLInputElement>(null);
return (
<>
<input
ref={inputRef}
class="input-file"
type="file"
accept="image/*"
multiple
tabIndex={-1}
aria-hidden="true"
onChange={(event) => {
const input = event.target as HTMLInputElement;
onFiles(input.files);
// clearing lets the same photo be picked again right after removing it
input.value = "";
}}
/>
<button
key="attach"
class="btn btn-attach"
title="Attach images"
aria-label="Attach images"
disabled={disabled}
onClick={() => inputRef.current?.click()}
>
{/* drawn rather than typed: an emoji is a font glyph, so its artwork
changes per platform and its advance width adds side bearings that
no padding rule can reclaim. drawn upright, and the viewBox is
cropped to the artwork's width so the button is exactly as wide as
the clip */}
<svg
viewBox="5 0 14 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M17 5.75v10.85a5 5 0 0 1-10 0V5.75a3.35 3.35 0 0 1 6.7 0v10.85a1.65 1.65 0 0 1-3.3 0V6.6" />
</svg>
</button>
</>
);
}

function AttachError({
message,
onDismiss,
}: {
message: string;
onDismiss: () => void;
}) {
return (
<div class="attach-error" role="status" onClick={onDismiss}>
{message}
</div>
);
}

function ImagePreviews({
images,
onRemove,
Expand Down Expand Up @@ -425,6 +531,9 @@ function OrdinaryInputBox({
onDragOver,
onDragLeave,
onDrop,
attachError,
dismissAttachError,
addFiles,
} = useImageAttachments();

const saveDraftDebounced = useMemo(
Expand Down Expand Up @@ -593,6 +702,9 @@ function OrdinaryInputBox({
setImages((previous) => previous.filter((image) => image.id !== id));
}}
/>
{attachError && (
<AttachError message={attachError} onDismiss={dismissAttachError} />
)}
<textarea
key="textarea"
ref={textareaRef}
Expand All @@ -610,6 +722,7 @@ function OrdinaryInputBox({
disabled={disabled}
rows={1}
/>
<AttachButton disabled={disabled || !!stdinClosed} onFiles={addFiles} />
{isProcessing && (
<button key="stop" class="btn btn-stop" onClick={onInterrupt}>
Stop
Expand Down Expand Up @@ -655,6 +768,9 @@ function ControlledInputBox({
onDragOver,
onDragLeave,
onDrop,
attachError,
dismissAttachError,
addFiles,
} = useImageAttachments(!disabled, composerResetToken, imageStore, imageKey);
const internalRef = useRef<HTMLTextAreaElement>(null);
const textareaRef = inputRef ?? internalRef;
Expand Down Expand Up @@ -754,6 +870,9 @@ function ControlledInputBox({
setImages((previous) => previous.filter((image) => image.id !== id));
}}
/>
{attachError && (
<AttachError message={attachError} onDismiss={dismissAttachError} />
)}
<textarea
key="textarea"
ref={textareaRef}
Expand All @@ -769,6 +888,7 @@ function ControlledInputBox({
disabled={disabled}
rows={1}
/>
<AttachButton disabled={disabled || !!stdinClosed} onFiles={addFiles} />
{isProcessing && (
<button key="stop" class="btn btn-stop" onClick={onInterrupt}>
Stop
Expand Down
Loading