fix(app): paste clipboard images on mobile (#2793)

Native composers had no path from the device clipboard into image attachments. Route clipboard data URLs through the existing attachment persistence flow and expose the action only in the native attachment sheet.
This commit is contained in:
Mohamed Boudra
2026-08-02 14:46:25 +02:00
committed by GitHub
parent 2b474f7df9
commit 0ad51e26a1
14 changed files with 189 additions and 13 deletions
+26
View File
@@ -143,20 +143,27 @@ function browserElementWorkspaceAttachment(): Extract<
function createFakePersister(): AttachmentPersister & {
blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }>;
dataUrlCalls: Array<{ dataUrl: string; mimeType: string; fileName: string | null }>;
fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }>;
deletedBatches: AttachmentMetadata[][];
} {
const blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }> = [];
const dataUrlCalls: Array<{ dataUrl: string; mimeType: string; fileName: string | null }> = [];
const fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }> = [];
const deletedBatches: AttachmentMetadata[][] = [];
return {
blobCalls,
dataUrlCalls,
fileUriCalls,
deletedBatches,
persistFromBlob: async ({ blob, mimeType, fileName }) => {
blobCalls.push({ blob, mimeType, fileName });
return { ...imageMetadata, id: `blob-${blobCalls.length}` };
},
persistFromDataUrl: async ({ dataUrl, mimeType, fileName }) => {
dataUrlCalls.push({ dataUrl, mimeType, fileName });
return { ...imageMetadata, id: `data-url-${dataUrlCalls.length}` };
},
persistFromFileUri: async ({ uri, mimeType, fileName }) => {
fileUriCalls.push({ uri, mimeType, fileName });
return { ...imageMetadata, id: `uri-${fileUriCalls.length}` };
@@ -388,6 +395,25 @@ describe("pickAndPersistImages", () => {
]);
expect(result).toHaveLength(1);
});
it("persists data_url sources via persistFromDataUrl", async () => {
const persister = createFakePersister();
const dataUrl = "data:image/png;base64,AAEC";
const result = await pickAndPersistImages({
pickImages: async () => [
{
source: { kind: "data_url", dataUrl },
mimeType: "image/png",
fileName: "clipboard.png",
},
],
persister,
});
expect(persister.dataUrlCalls).toEqual([
{ dataUrl, mimeType: "image/png", fileName: "clipboard.png" },
]);
expect(result).toHaveLength(1);
});
});
describe("dispatchComposerAgentMessage", () => {
+16 -1
View File
@@ -34,6 +34,11 @@ export interface AttachmentPersister {
mimeType: string;
fileName: string | null;
}) => Promise<AttachmentMetadata>;
persistFromDataUrl: (input: {
dataUrl: string;
mimeType: string;
fileName: string | null;
}) => Promise<AttachmentMetadata>;
deleteAttachments: (metadata: AttachmentMetadata[]) => Promise<void> | void;
}
@@ -80,7 +85,10 @@ export interface QueueWriter {
export async function pickAndPersistImages(input: {
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
persister: Pick<AttachmentPersister, "persistFromBlob" | "persistFromFileUri">;
persister: Pick<
AttachmentPersister,
"persistFromBlob" | "persistFromFileUri" | "persistFromDataUrl"
>;
}): Promise<AttachmentMetadata[]> {
const result = await input.pickImages();
if (!result?.length) return [];
@@ -95,6 +103,13 @@ export async function pickAndPersistImages(input: {
fileName,
});
}
if (picked.source.kind === "data_url") {
return await input.persister.persistFromDataUrl({
dataUrl: picked.source.dataUrl,
mimeType,
fileName,
});
}
return await input.persister.persistFromFileUri({
uri: picked.source.uri,
mimeType,
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { readClipboardImage, type ClipboardImageReader } from "./clipboard-image";
function createClipboard(input: {
hasImage: boolean;
imageData?: string | null;
}): ClipboardImageReader {
return {
hasImageAsync: async () => input.hasImage,
getImageAsync: async () =>
input.imageData === undefined || input.imageData === null ? null : { data: input.imageData },
};
}
describe("readClipboardImage", () => {
it("returns null when the clipboard does not contain an image", async () => {
await expect(readClipboardImage(createClipboard({ hasImage: false }))).resolves.toBeNull();
});
it("returns the clipboard image as a persistable data URL", async () => {
const dataUrl = "data:image/png;base64,AAEC";
await expect(
readClipboardImage(createClipboard({ hasImage: true, imageData: dataUrl })),
).resolves.toEqual({
source: { kind: "data_url", dataUrl },
mimeType: "image/png",
fileName: "clipboard.png",
});
});
it("returns null when the clipboard image disappears before it is read", async () => {
await expect(
readClipboardImage(createClipboard({ hasImage: true, imageData: null })),
).resolves.toBeNull();
});
});
@@ -0,0 +1,29 @@
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
interface ClipboardImage {
data: string;
}
export interface ClipboardImageReader {
hasImageAsync: () => Promise<boolean>;
getImageAsync: (options: { format: "png" }) => Promise<ClipboardImage | null>;
}
export async function readClipboardImage(
clipboard: ClipboardImageReader,
): Promise<PickedImageAttachmentInput | null> {
if (!(await clipboard.hasImageAsync())) {
return null;
}
const image = await clipboard.getImageAsync({ format: "png" });
if (!image) {
return null;
}
return {
source: { kind: "data_url", dataUrl: image.data },
mimeType: "image/png",
fileName: "clipboard.png",
};
}
+53 -11
View File
@@ -30,8 +30,10 @@ import {
FileText,
GitPullRequest,
Image as ImageIcon,
ClipboardPaste,
Paperclip,
} from "lucide-react-native";
import * as Clipboard from "expo-clipboard";
import Animated from "react-native-reanimated";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import {
@@ -64,6 +66,7 @@ import {
sendQueuedComposerMessageNow,
toggleGithubAttachmentFromPicker,
uploadFileAttachments,
type AttachmentPersister,
type QueueWriter,
type QueuedComposerMessage,
} from "@/composer/actions";
@@ -82,6 +85,7 @@ import {
import {
deleteAttachments,
persistAttachmentFromBlob,
persistAttachmentFromDataUrl,
persistAttachmentFromFileUri,
} from "@/attachments/service";
import { resolveAgentControlsMode } from "@/composer/agent-controls/mode";
@@ -119,6 +123,7 @@ import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { getForgePresentation } from "@/git/forge";
import { ForgeBrandIcon } from "@/git/forge-icon";
import { useComposerGithubAutoAttach } from "./github/auto-attach";
import { readClipboardImage } from "./clipboard-image";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
import {
appendWorkspaceFileAttachment,
@@ -130,6 +135,15 @@ import {
type WorkspaceFileDragPayload,
} from "@/attachments/workspace-file-drag";
const composerImageAttachmentPersister: Pick<
AttachmentPersister,
"persistFromBlob" | "persistFromDataUrl" | "persistFromFileUri"
> = {
persistFromBlob: persistAttachmentFromBlob,
persistFromDataUrl: persistAttachmentFromDataUrl,
persistFromFileUri: persistAttachmentFromFileUri,
};
type QueuedMessage = QueuedComposerMessage;
type AttachmentListUpdater =
@@ -1408,17 +1422,32 @@ export function Composer({
const handlePickImage = useCallback(async () => {
const newImages = await pickAndPersistImages({
pickImages,
persister: {
persistFromBlob: ({ blob, mimeType, fileName }) =>
persistAttachmentFromBlob({ blob, mimeType, fileName }),
persistFromFileUri: ({ uri, mimeType, fileName }) =>
persistAttachmentFromFileUri({ uri, mimeType, fileName }),
},
persister: composerImageAttachmentPersister,
});
if (newImages.length === 0) return;
addImages(newImages);
}, [addImages, pickImages]);
const handlePasteImage = useCallback(async () => {
try {
const newImages = await pickAndPersistImages({
pickImages: async () => {
const image = await readClipboardImage(Clipboard);
return image ? [image] : null;
},
persister: composerImageAttachmentPersister,
});
if (newImages.length === 0) {
toastErrorRef.current(t("composer.errors.noClipboardImage"));
return;
}
addImages(newImages);
} catch (error) {
console.error("[Composer] Failed to paste clipboard image:", error);
toastErrorRef.current(t("composer.errors.pasteImageFailed"));
}
}, [addImages, t]);
const uploadPickedFiles = useCallback(
async (files: PickedFile[]) => {
if (files.length === 0) return;
@@ -1823,8 +1852,8 @@ export function Composer({
[githubSearchItems, githubSearchQueryTrimmed],
);
const attachmentMenuItems = useMemo<AttachmentMenuItem[]>(
() => [
const attachmentMenuItems = useMemo<AttachmentMenuItem[]>(() => {
const items: AttachmentMenuItem[] = [
{
id: "image",
label: t("composer.attachments.addImage"),
@@ -1833,6 +1862,18 @@ export function Composer({
void handlePickImage();
},
},
];
if (isNative) {
items.push({
id: "paste-image",
label: t("composer.attachments.pasteImage"),
icon: <ThemedClipboardPaste size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
void handlePasteImage();
},
});
}
items.push(
{
id: "github",
label: t("composer.attachments.addIssueOrPr", {
@@ -1851,9 +1892,9 @@ export function Composer({
void handlePickFile();
},
},
],
[handlePickImage, handlePickFile, t, forgePresentation],
);
);
return items;
}, [forgePresentation, handlePasteImage, handlePickFile, handlePickImage, t]);
const handleToggleGithubItem = useCallback(
(item: ForgeSearchItem) => {
@@ -2248,6 +2289,7 @@ const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedAudioLines = withUnistyles(AudioLines);
const ThemedPaperclip = withUnistyles(Paperclip);
const ThemedImageIcon = withUnistyles(ImageIcon);
const ThemedClipboardPaste = withUnistyles(ClipboardPaste);
const ThemedFileText = withUnistyles(FileText);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });