Skip to content
Merged
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
29 changes: 28 additions & 1 deletion apps/mobile/src/lib/composerImages.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts";

const missingFileCause = new Error("missing file");
const files = new Map<string, { base64: string; deleted: boolean }>();

vi.mock("expo-file-system", () => ({
Expand All @@ -18,7 +19,7 @@ vi.mock("expo-file-system", () => ({
async base64(): Promise<string> {
const entry = files.get(this.uri);
if (!entry || entry.deleted) {
throw new Error("missing file");
throw missingFileCause;
}
return entry.base64;
}
Expand Down Expand Up @@ -91,4 +92,30 @@ describe("native pasted image cleanup", () => {
expect(files.get(overflow)?.deleted).toBe(true);
expect(files.get(userOwned)?.deleted).toBe(false);
});

it("reports structured context when reading a pasted image fails", async () => {
const uri = "file:///private/var/mobile/photos/signed-secret-token/missing.png?token=private";
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);

await expect(
convertPastedImagesToAttachments({ uris: [uri], existingCount: 0 }),
).resolves.toEqual([]);
expect(warn).toHaveBeenCalledWith(
"[composer-images] failed to read pasted image",
expect.objectContaining({
_tag: "ComposerImageOperationError",
operation: "read-pasted-image",
uriLength: uri.length,
uriProtocol: "file:",
cause: missingFileCause,
message: `Composer image operation read-pasted-image failed for a file: URI (length ${uri.length}).`,
}),
);
const error = warn.mock.calls[0]?.[1];
expect(error).not.toHaveProperty("uri");
expect(String(error)).not.toContain("signed-secret-token");
expect(String(error)).not.toContain("token=private");

warn.mockRestore();
});
});
136 changes: 113 additions & 23 deletions apps/mobile/src/lib/composerImages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
type UploadChatImageAttachment,
} from "@t3tools/contracts";
import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics";
import * as Schema from "effect/Schema";
import { uuidv4 } from "./uuid";

export interface DraftComposerImageAttachment extends UploadChatImageAttachment {
Expand All @@ -12,6 +14,31 @@ export interface DraftComposerImageAttachment extends UploadChatImageAttachment

const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste";

export class ComposerImageOperationError extends Schema.TaggedErrorClass<ComposerImageOperationError>()(
"ComposerImageOperationError",
{
operation: Schema.Literals([
"load-image-picker",
"request-media-library-permission",
"launch-image-library",
"load-clipboard",
"check-clipboard-image",
"read-clipboard-image",
"check-clipboard-text",
"read-clipboard-text",
"read-pasted-image",
"remove-pasted-image",
]),
uriLength: Schema.optional(Schema.Number),
uriProtocol: Schema.optional(Schema.String),
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Composer image operation ${this.operation} failed${this.uriLength === undefined ? "" : ` for a ${this.uriProtocol ?? "unknown-protocol"} URI (length ${this.uriLength})`}.`;
}
}

function estimateBase64ByteSize(base64: string): number {
const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
return Math.floor((base64.length * 3) / 4) - padding;
Expand All @@ -20,16 +47,22 @@ function estimateBase64ByteSize(base64: string): number {
async function loadImagePicker() {
try {
return await import("expo-image-picker");
} catch (error) {
throw new Error("Image attachments are unavailable right now.", { cause: error });
} catch (cause) {
throw new ComposerImageOperationError({
operation: "load-image-picker",
cause,
});
}
}

async function loadClipboard() {
try {
return await import("expo-clipboard");
} catch (error) {
throw new Error("Clipboard paste is unavailable right now.", { cause: error });
} catch (cause) {
throw new ComposerImageOperationError({
operation: "load-clipboard",
cause,
});
}
}

Expand All @@ -49,28 +82,40 @@ export async function pickComposerImages(input: { readonly existingCount: number
try {
imagePicker = await loadImagePicker();
} catch (error) {
console.warn("[composer-images] image picker unavailable", error);
return {
images: [],
error:
error instanceof Error ? error.message : "Image attachments are unavailable right now.",
error: "Image attachments are unavailable right now.",
};
}

const permission = await imagePicker.requestMediaLibraryPermissionsAsync();
const permission = await imagePicker.requestMediaLibraryPermissionsAsync().catch((cause) => {
throw new ComposerImageOperationError({
operation: "request-media-library-permission",
cause,
});
});
if (!permission.granted) {
return {
images: [],
error: "Allow photo library access to attach images.",
};
}

const result = await imagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsMultipleSelection: true,
selectionLimit: remainingSlots,
base64: true,
quality: 1,
});
const result = await imagePicker
.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsMultipleSelection: true,
selectionLimit: remainingSlots,
base64: true,
quality: 1,
})
.catch((cause) => {
throw new ComposerImageOperationError({
operation: "launch-image-library",
cause,
});
});

if (result.canceled) {
return {
Expand Down Expand Up @@ -127,24 +172,36 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu
try {
clipboard = await loadClipboard();
} catch (error) {
console.warn("[composer-images] clipboard unavailable", error);
return {
images: [],
text: null,
error: error instanceof Error ? error.message : "Clipboard paste is unavailable right now.",
error: "Clipboard paste is unavailable right now.",
};
}

const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;

if (await clipboard.hasImageAsync()) {
const hasImage = await clipboard.hasImageAsync().catch((cause) => {
throw new ComposerImageOperationError({
operation: "check-clipboard-image",
cause,
});
});
if (hasImage) {
if (remainingSlots <= 0) {
return {
images: [],
text: null,
error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`,
};
}
const image = await clipboard.getImageAsync({ format: "png" });
const image = await clipboard.getImageAsync({ format: "png" }).catch((cause) => {
throw new ComposerImageOperationError({
operation: "read-clipboard-image",
cause,
});
});
if (!image) {
return {
images: [],
Expand Down Expand Up @@ -180,8 +237,19 @@ export async function pasteComposerClipboard(input: { readonly existingCount: nu
};
}

if (await clipboard.hasStringAsync()) {
const text = await clipboard.getStringAsync();
const hasText = await clipboard.hasStringAsync().catch((cause) => {
throw new ComposerImageOperationError({
operation: "check-clipboard-text",
cause,
});
});
if (hasText) {
const text = await clipboard.getStringAsync().catch((cause) => {
throw new ComposerImageOperationError({
operation: "read-clipboard-text",
cause,
});
});
return {
images: [],
text: text.length > 0 ? text : null,
Expand Down Expand Up @@ -230,6 +298,14 @@ export function isOwnedPastedImageUri(uri: string): boolean {
}
}

function describeComposerImageUri(uri: string) {
const diagnostics = getUrlDiagnostics(uri);
return {
uriLength: diagnostics.inputLength,
...(diagnostics.protocol === undefined ? {} : { uriProtocol: diagnostics.protocol }),
};
}

export async function convertPastedImagesToAttachments(input: {
readonly uris: ReadonlyArray<string>;
readonly existingCount: number;
Expand Down Expand Up @@ -260,17 +336,31 @@ export async function convertPastedImagesToAttachments(input: {
dataUrl: `data:${mimeType};base64,${base64}`,
previewUri: ownedTemporaryFile ? `data:${mimeType};base64,${base64}` : uri,
});
} catch (error) {
console.warn("Failed to read pasted image", uri, error);
} catch (cause) {
console.warn(
"[composer-images] failed to read pasted image",
new ComposerImageOperationError({
operation: "read-pasted-image",
...describeComposerImageUri(uri),
cause,
}),
);
} finally {
if (ownedTemporaryFile) {
try {
const file = new File(uri);
if (file.exists) {
file.delete();
}
} catch (error) {
console.warn("Failed to remove temporary pasted image", uri, error);
} catch (cause) {
console.warn(
"[composer-images] failed to remove temporary pasted image",
new ComposerImageOperationError({
operation: "remove-pasted-image",
...describeComposerImageUri(uri),
cause,
}),
);
}
}
}
Expand Down
Loading