import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type UploadChatImageAttachment, } from "@t3tools/contracts"; import { estimateBase64ByteSize } from "./base64"; import { uuidv4 } from "./uuid"; export interface DraftComposerImageAttachment extends UploadChatImageAttachment { readonly id: string; readonly previewUri: string; } /** Wire shape for startTurn: pure uploads without client draft id / previewUri. */ export function toUploadChatImageAttachments( attachments: ReadonlyArray, ): ReadonlyArray { return attachments.map((attachment) => ({ type: attachment.type, name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, dataUrl: attachment.dataUrl, })); } const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; async function loadImagePicker() { try { return await import("expo-image-picker"); } catch (error) { throw new Error("Image attachments are unavailable right now.", { cause: error }); } } async function loadClipboard() { try { return await import("expo-clipboard"); } catch (error) { throw new Error("Clipboard paste is unavailable right now.", { cause: error }); } } export async function pickComposerImages(input: { readonly existingCount: number }): Promise<{ readonly images: ReadonlyArray; readonly error: string | null; }> { const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; if (remainingSlots <= 0) { return { images: [], error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`, }; } let imagePicker: Awaited>; try { imagePicker = await loadImagePicker(); } catch (error) { return { images: [], error: error instanceof Error ? error.message : "Image attachments are unavailable right now.", }; } const result = await imagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], allowsMultipleSelection: true, selectionLimit: remainingSlots, base64: true, quality: 1, }); if (result.canceled) { return { images: [], error: null, }; } const nextImages: DraftComposerImageAttachment[] = []; let error: string | null = null; for (const asset of result.assets) { const mimeType = asset.mimeType?.toLowerCase(); if (!mimeType?.startsWith("image/")) { error = `Unsupported file type for '${asset.fileName ?? "image"}'.`; continue; } const base64 = asset.base64; if (!base64) { error = `Failed to read '${asset.fileName ?? "image"}'.`; continue; } const sizeBytes = asset.fileSize ?? estimateBase64ByteSize(base64); if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { error = `'${asset.fileName ?? "image"}' exceeds the 10 MB attachment limit.`; continue; } nextImages.push({ id: uuidv4(), type: "image", name: asset.fileName ?? "image", mimeType, sizeBytes, dataUrl: `data:${mimeType};base64,${base64}`, previewUri: asset.uri, }); } return { images: nextImages, error, }; } export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise<{ readonly images: ReadonlyArray; readonly text: string | null; readonly error: string | null; }> { let clipboard: Awaited>; try { clipboard = await loadClipboard(); } catch (error) { return { images: [], text: null, error: error instanceof Error ? error.message : "Clipboard paste is unavailable right now.", }; } const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; if (await clipboard.hasImageAsync()) { 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" }); if (!image) { return { images: [], text: null, error: "Clipboard image is unavailable.", }; } const base64 = image.data.split(",")[1] ?? ""; const sizeBytes = estimateBase64ByteSize(base64); if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { return { images: [], text: null, error: "Clipboard image exceeds the 10 MB attachment limit.", }; } return { images: [ { id: uuidv4(), type: "image", name: "pasted-image.png", mimeType: "image/png", sizeBytes, dataUrl: image.data, previewUri: image.data, }, ], text: null, error: null, }; } if (await clipboard.hasStringAsync()) { const text = await clipboard.getStringAsync(); return { images: [], text: text.length > 0 ? text : null, error: text.length > 0 ? null : "Clipboard is empty.", }; } return { images: [], text: null, error: "Clipboard does not contain pasteable text or image content.", }; } function mimeTypeFromUri(uri: string): string { const ext = uri.split(".").pop()?.toLowerCase(); switch (ext) { case "png": return "image/png"; case "jpg": case "jpeg": return "image/jpeg"; case "gif": return "image/gif"; case "webp": return "image/webp"; case "heic": return "image/heic"; default: return "image/png"; } } export function isOwnedPastedImageUri(uri: string): boolean { try { const url = new URL(uri); if (url.protocol !== "file:") { return false; } const segments = url.pathname.split("/").filter(Boolean); return ( segments.at(-2) === OWNED_PASTED_IMAGE_DIRECTORY && segments.at(-1)?.endsWith(".png") === true ); } catch { return false; } } export async function convertPastedImagesToAttachments(input: { readonly uris: ReadonlyArray; readonly existingCount: number; }): Promise> { const { File } = await import("expo-file-system"); const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount; const results: DraftComposerImageAttachment[] = []; for (const [index, uri] of input.uris.entries()) { const ownedTemporaryFile = isOwnedPastedImageUri(uri); try { if (index >= Math.max(0, remainingSlots)) { continue; } const file = new File(uri); const base64 = await file.base64(); const sizeBytes = estimateBase64ByteSize(base64); if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { continue; } const mimeType = mimeTypeFromUri(uri); results.push({ id: uuidv4(), type: "image", name: `pasted-image.${mimeType.split("/")[1] ?? "png"}`, mimeType, sizeBytes, dataUrl: `data:${mimeType};base64,${base64}`, previewUri: ownedTemporaryFile ? `data:${mimeType};base64,${base64}` : uri, }); } catch (error) { console.warn("Failed to read pasted image", uri, error); } 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); } } } } return results; }