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<DraftComposerImageAttachment>,): ReadonlyArray<UploadChatImageAttachment> { 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<DraftComposerImageAttachment>; 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<ReturnType<typeof loadImagePicker>>; 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<DraftComposerImageAttachment>; readonly text: string | null; readonly error: string | null;}> { let clipboard: Awaited<ReturnType<typeof loadClipboard>>; 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<string>; readonly existingCount: number;}): Promise<ReadonlyArray<DraftComposerImageAttachment>> { 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;}
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<DraftComposerImageAttachment>,
): ReadonlyArray<UploadChatImageAttachment> {
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<DraftComposerImageAttachment>;
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<ReturnType<typeof loadImagePicker>>;
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<DraftComposerImageAttachment>;
readonly text: string | null;
readonly error: string | null;
}> {
let clipboard: Awaited<ReturnType<typeof loadClipboard>>;
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<string>;
readonly existingCount: number;
}): Promise<ReadonlyArray<DraftComposerImageAttachment>> {
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;
}