Skip to content
Source
Sign inSign up

composerImages.ts

650 lines21.3 KB

1import {
2 clampFileAttachmentUploadBytes,
3 fileAttachmentTooLargeMessage,
4} from "@t3tools/client-runtime/state/attachments";
5import {
6 isProviderSendTurnSupportedImageMimeType,
7 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
8 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
9 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
10 type EnvironmentId,
11 type PastedTextAttachmentSource,
12 type UploadChatImageAttachment,
13} from "@t3tools/contracts";
14import type { DocumentPickerResult } from "expo-document-picker";
15import { estimateBase64ByteSize } from "./base64";
16import {
17 COMPOSER_ATTACHMENT_DIRECTORY,
18 isComposerAttachmentFileRetained,
19 resolveOwnedComposerAttachmentFileUri,
20} from "./composerAttachmentFiles";
21import { imageMimeType } from "@t3tools/shared/image";
22import { videoMimeType } from "@t3tools/shared/video";
23import { fitComposerImage, renderPhoto } from "./composerImageFit";
24import { beginForegroundHandoff } from "./foreground-handoff";
25import { uuidv4 } from "./uuid";
26import { writeFileAtomically } from "./atomic-file";
27​
28export interface DraftComposerImageAttachment extends Omit<UploadChatImageAttachment, "dataUrl"> {
29 readonly id: string;
30 readonly previewUri: string;
31 readonly fileUri?: string;
32 readonly dataUrl?: string;
33 readonly uploadedAttachmentId?: string;
34 readonly uploadEnvironmentId?: EnvironmentId;
36​
37export interface DraftComposerFileAttachment {
38 readonly id: string;
39 readonly type: "file";
40 readonly name: string;
41 readonly mimeType: string;
42 readonly sizeBytes: number;
43 readonly fileUri: string;
44 readonly source?: PastedTextAttachmentSource;
45 readonly uploadedAttachmentId?: string;
46 readonly uploadEnvironmentId?: EnvironmentId;
48​
49export async function createPastedTextComposerAttachment(input: {
50 readonly text: string;
51 readonly name: string;
52 readonly maxBytes: number;
53}): Promise<DraftComposerFileAttachment> {
54 const bytes = new TextEncoder().encode(input.text).byteLength;
55 if (bytes <= 0) {
56 throw new Error("Clipboard is empty.");
57 }
58 if (bytes > input.maxBytes) {
59 throw new Error(fileAttachmentTooLargeMessage(input.name, input.maxBytes));
60 }
61​
62 const { Directory, File, Paths } = await import("expo-file-system");
63 const directory = new Directory(Paths.document, COMPOSER_ATTACHMENT_DIRECTORY);
64 directory.create({ idempotent: true, intermediates: true });
65 const file = new File(directory, `${uuidv4()}-${input.name}`);
66 await writeFileAtomically(file, input.text);
67 return {
68 id: uuidv4(),
69 type: "file",
70 name: input.name,
71 mimeType: "text/plain;charset=utf-8",
72 sizeBytes: bytes,
73 fileUri: file.uri,
74 source: { _tag: "pasted-text" },
75 };
77​
78export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment;
79​
80/** Non-media attachments require chip references; missing references mean lost draft files. */
81export function composerStripAttachments(
82 attachments: ReadonlyArray<DraftComposerAttachment>,
83): ReadonlyArray<DraftComposerAttachment> {
84 return attachments.filter(
85 (attachment) =>
86 attachment.type === "image" ||
87 imageMimeType(attachment) !== null ||
88 videoMimeType(attachment) !== null,
89 );
91​
92/** Promote legacy document-picked images only when their originals fit the image contract. */
93export function isComposerImageAttachment(
94 attachment: DraftComposerAttachment,
95): attachment is DraftComposerImageAttachment {
96 return (
97 attachment.type === "image" ||
98 (attachment.sizeBytes <= PROVIDER_SEND_TURN_MAX_IMAGE_BYTES &&
99 imageMimeType(attachment) !== null)
100 );
102​
103export type FileBackedComposerAttachment = DraftComposerAttachment & { readonly fileUri: string };
104​
105export function isFileBackedComposerAttachment(
106 attachment: DraftComposerAttachment,
107): attachment is FileBackedComposerAttachment {
108 return attachment.fileUri !== undefined;
110​
111/** Inline library/clipboard images own no file; local ids cannot resolve server assets. */
112export function composerAttachmentInlineUri(
113 attachment: DraftComposerAttachment | undefined,
114): string | undefined {
115 if (attachment === undefined || isFileBackedComposerAttachment(attachment)) return undefined;
116 return attachment.type === "image" ? (attachment.dataUrl ?? attachment.previewUri) : undefined;
118​
119const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste";
120const ATTACHMENT_COPY_CHUNK_BYTES = 64 * 1024;
121​
122export async function persistComposerAttachmentFile(
123 uri: string,
124 name: string,
125 maxBytes?: number,
126): Promise<string> {
127 const { Directory, File, FileMode, Paths } = await import("expo-file-system");
128 const directory = new Directory(Paths.document, COMPOSER_ATTACHMENT_DIRECTORY);
129 directory.create({ idempotent: true, intermediates: true });
130 // Android's java.net.URI rejects characters expo-file-system leaves unencoded, such as `[` and `]`.
131 const safeName = name.replace(/[^\w. -]/g, "-") || "file";
132 const destination = new File(directory, `${uuidv4()}-${safeName}`);
133 const source = new File(uri);
134 const sourceSize = source.size;
135 if (
136 maxBytes !== undefined &&
137 (sourceSize === null || (sourceSize === 0 && uri.startsWith("content:")))
138 ) {
139 destination.create();
140 try {
141 const reader = source.open(FileMode.ReadOnly);
142 try {
143 const writer = destination.open(FileMode.WriteOnly);
144 try {
145 let copiedBytes = 0;
146 while (true) {
147 const chunk = await reader.readBytes(
148 Math.min(ATTACHMENT_COPY_CHUNK_BYTES, maxBytes - copiedBytes + 1),
149 );
150 if (chunk.byteLength === 0) {
151 break;
152 }
153 copiedBytes += chunk.byteLength;
154 if (copiedBytes > maxBytes) {
155 throw new Error(fileAttachmentTooLargeMessage(name, maxBytes));
156 }
157 await writer.writeBytes(chunk);
158 }
159 } finally {
160 writer.close();
161 }
162 } finally {
163 reader.close();
164 }
165 } catch (error) {
166 if (destination.exists) {
167 destination.delete();
168 }
169 throw error;
170 }
171 return destination.uri;
172 }
173​
174 if (maxBytes !== undefined && sourceSize !== null && sourceSize > maxBytes) {
175 throw new Error(fileAttachmentTooLargeMessage(name, maxBytes));
176 }
177 try {
178 await source.copy(destination);
179 } catch (error) {
180 // Failed copies may leave untracked partial files; delete before surfacing failure.
181 try {
182 if (destination.exists) {
183 destination.delete();
184 }
185 } catch (cleanupError) {
186 console.warn("[composer-attachments] could not remove a partial copy", cleanupError);
187 }
188 throw error;
189 }
190 // Android content streams may exceed reported size; validate the persisted copy.
191 const copiedSize = destination.size;
192 if (maxBytes !== undefined && copiedSize !== null && copiedSize > maxBytes) {
193 try {
194 if (destination.exists) {
195 destination.delete();
196 }
197 } catch (cleanupError) {
198 console.warn("[composer-attachments] could not remove an oversized copy", cleanupError);
199 }
200 throw new Error(fileAttachmentTooLargeMessage(name, maxBytes));
201 }
202 return destination.uri;
204​
205export async function removePersistedComposerAttachmentFile(uri: string): Promise<void> {
206 try {
207 const { File, Paths } = await import("expo-file-system");
208 const ownedUri = resolveOwnedComposerAttachmentFileUri(uri, Paths.document.uri);
209 if (ownedUri === null || isComposerAttachmentFileRetained(ownedUri)) {
210 return;
211 }
212 const file = new File(ownedUri);
213 if (file.exists) {
214 file.delete();
215 }
216 } catch (error) {
217 console.warn("[composer-attachments] could not remove local file", error);
218 }
220​
221export async function createComposerFileAttachment(input: {
222 readonly uri: string;
223 readonly name: string;
224 readonly mimeType: string;
225 readonly sizeBytes: number | null;
226 readonly maxBytes: number;
227}): Promise<DraftComposerFileAttachment> {
228 if (input.sizeBytes !== null && input.sizeBytes > input.maxBytes) {
229 throw new Error(fileAttachmentTooLargeMessage(input.name, input.maxBytes));
230 }
231 const { File } = await import("expo-file-system");
232 const fileUri = await persistComposerAttachmentFile(input.uri, input.name, input.maxBytes);
233 try {
234 const sizeBytes = new File(fileUri).size ?? input.sizeBytes ?? 0;
235 if (sizeBytes <= 0) {
236 throw new Error(`'${input.name}' is empty or could not be read.`);
237 }
238 if (sizeBytes > input.maxBytes) {
239 throw new Error(fileAttachmentTooLargeMessage(input.name, input.maxBytes));
240 }
241 return {
242 id: uuidv4(),
243 type: "file",
244 name: input.name,
245 mimeType: input.mimeType,
246 sizeBytes,
247 fileUri,
248 };
249 } catch (error) {
250 await removePersistedComposerAttachmentFile(fileUri);
251 throw error;
252 }
254​
255export async function pickComposerFiles(input: {
256 readonly existingCount: number;
257 readonly maxBytes?: number;
258}): Promise<{
259 readonly files: ReadonlyArray<DraftComposerAttachment>;
260 readonly error: string | null;
261}> {
262 const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;
263 if (remainingSlots <= 0) {
264 return {
265 files: [],
266 error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`,
267 };
268 }
269​
270 const { getDocumentAsync } = await import("expo-document-picker");
271 const endHandoff = beginForegroundHandoff();
272 let result: DocumentPickerResult;
273 try {
274 // Import a readable cache copy because FileSystem may not read provider URIs directly.
275 result = await getDocumentAsync({ multiple: true, copyToCacheDirectory: true });
276 } catch (cause) {
277 return {
278 files: [],
279 error: cause instanceof Error ? cause.message : "Could not open the file picker.",
280 };
281 } finally {
282 endHandoff();
283 }
284 if (result.canceled) {
285 return { files: [], error: null };
286 }
287​
288 const maxBytes = clampFileAttachmentUploadBytes(
289 input.maxBytes ?? PROVIDER_SEND_TURN_MAX_FILE_BYTES,
290 );
291 const attachments: DraftComposerAttachment[] = [];
292 let error: string | null = null;
293 let exceededAttachmentLimit = false;
294 for (const file of result.assets) {
295 if (attachments.length >= remainingSlots) {
296 exceededAttachmentLimit = true;
297 break;
298 }
299 // SAF can return blank display names, but the wire contract rejects them; fill before persisting.
300 const name = file.name.trim().length > 0 ? file.name : "file";
301 try {
302 const mimeType = imageMimeType({ name, mimeType: file.mimeType ?? "" });
303 const attachment = await createComposerFileAttachment({
304 uri: file.uri,
305 name,
306 mimeType: file.mimeType || "application/octet-stream",
307 sizeBytes: file.size ?? null,
308 maxBytes:
309 mimeType === null ? maxBytes : Math.max(maxBytes, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES),
310 });
311 if (mimeType === null || attachment.sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
312 attachments.push(attachment);
313 continue;
314 }
315 try {
316 const { File } = await import("expo-file-system");
317 const original = await new File(attachment.fileUri).base64();
318 const candidate = await fitComposerImage({
319 uri: attachment.fileUri,
320 mimeType,
321 base64: original,
322 });
323 const fitted =
324 candidate !== null &&
325 estimateBase64ByteSize(candidate.base64) <= PROVIDER_SEND_TURN_MAX_IMAGE_BYTES
326 ? candidate
327 : null;
328 const base64 = fitted?.base64 ?? original;
329 const sizeBytes = estimateBase64ByteSize(base64);
330 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
331 throw new Error(`'${name}' exceeds the 10 MB attachment limit.`);
332 }
333 const dataUrl = `data:${mimeType};base64,${base64}`;
334 attachments.push({
335 id: attachment.id,
336 type: "image",
337 name,
338 mimeType,
339 sizeBytes,
340 dataUrl,
341 previewUri: fitted?.uri ?? dataUrl,
342 });
343 } finally {
344 await removePersistedComposerAttachmentFile(attachment.fileUri);
345 }
346 } catch (cause) {
347 error = cause instanceof Error ? cause.message : `Could not read '${name}'.`;
348 }
349 }
350 if (exceededAttachmentLimit) {
351 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`;
352 }
353 return { files: attachments, error };
355​
356async function loadImagePicker() {
357 try {
358 return await import("expo-image-picker");
359 } catch (error) {
360 throw new Error("The photo library is unavailable right now.", { cause: error });
361 }
363​
364export async function pickComposerImages(input: { readonly existingCount: number }): Promise<{
365 readonly images: ReadonlyArray<DraftComposerImageAttachment>;
366 readonly error: string | null;
367}> {
368 const result = await pickComposerMedia(input);
369 return {
370 images: result.attachments.filter((attachment) => attachment.type === "image"),
371 error: result.error,
372 };
374​
375export async function pickComposerMedia(input: {
376 readonly existingCount: number;
377 readonly maxVideoBytes?: number;
378}): Promise<{
379 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
380 readonly error: string | null;
381}> {
382 const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;
383 if (remainingSlots <= 0) {
384 return {
385 attachments: [],
386 error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`,
387 };
388 }
389​
390 let imagePicker: Awaited<ReturnType<typeof loadImagePicker>>;
391 try {
392 imagePicker = await loadImagePicker();
393 } catch (error) {
394 return {
395 attachments: [],
396 error: error instanceof Error ? error.message : "The photo library is unavailable right now.",
397 };
398 }
399​
400 // Android pickers report background state; guard against deferred restarts mid-pick.
401 const endHandoff = beginForegroundHandoff();
402 let result: Awaited<ReturnType<typeof imagePicker.launchImageLibraryAsync>>;
403 try {
404 result = await imagePicker.launchImageLibraryAsync({
405 mediaTypes: input.maxVideoBytes === undefined ? ["images"] : ["images", "videos"],
406 allowsMultipleSelection: true,
407 selectionLimit: remainingSlots,
408 // Base64 makes iOS decode all full-size photos and stalls JS with large strings; read bytes only as needed.
409 base64: false,
410 quality: 1,
411 shouldDownloadFromNetwork: true,
412 });
413 } catch (error) {
414 return {
415 attachments: [],
416 error: error instanceof Error ? error.message : "Could not open the photo library.",
417 };
418 } finally {
419 endHandoff();
420 }
421​
422 if (result.canceled) {
423 return {
424 attachments: [],
425 error: null,
426 };
427 }
428​
429 const attachments: DraftComposerAttachment[] = [];
430 let error: string | null = null;
431​
432 for (const asset of result.assets) {
433 if (attachments.length >= remainingSlots) {
434 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`;
435 break;
436 }
437 const mimeType = asset.mimeType?.toLowerCase();
438 if (asset.type === "video" || mimeType?.startsWith("video/")) {
439 if (input.maxVideoBytes === undefined) {
440 error = "Video attachments are unavailable here.";
441 continue;
442 }
443 try {
444 const { File } = await import("expo-file-system");
445 const file = new File(asset.uri);
446 attachments.push(
447 await createComposerFileAttachment({
448 uri: asset.uri,
449 name: asset.fileName?.trim() || file.name || "video",
450 mimeType: mimeType || file.type || "application/octet-stream",
451 sizeBytes: asset.fileSize ?? null,
452 maxBytes: clampFileAttachmentUploadBytes(input.maxVideoBytes),
453 }),
454 );
455 } catch (cause) {
456 error =
457 cause instanceof Error ? cause.message : `Could not read '${asset.fileName ?? "video"}'.`;
458 }
459 continue;
460 }
461 if (asset.type !== "image" && !mimeType?.startsWith("image/")) {
462 error = `Unsupported file type for '${asset.fileName ?? "image"}'.`;
463 continue;
464 }
465​
466 const name = asset.fileName?.trim() || "image";
467 // Android content streams can exceed their reported size; measure the file before loading bytes into JS.
468 let sourceBytes: number | null = null;
469 try {
470 const { File } = await import("expo-file-system");
471 sourceBytes = new File(asset.uri).size;
472 } catch {
473 sourceBytes = null;
474 }
475 // Pass through supported, under-cap originals unchanged to preserve transparency and animation.
476 const originalMimeType =
477 mimeType !== undefined &&
478 isProviderSendTurnSupportedImageMimeType(mimeType) &&
479 sourceBytes !== null &&
480 sourceBytes > 0 &&
481 sourceBytes <= PROVIDER_SEND_TURN_MAX_IMAGE_BYTES
482 ? mimeType
483 : null;
484​
485 let image: { base64: string; mimeType: string; name: string; previewUri: string };
486 try {
487 if (originalMimeType !== null) {
488 const { File } = await import("expo-file-system");
489 const base64 = await new File(asset.uri).base64();
490 const fitted = await fitComposerImage({
491 uri: asset.uri,
492 mimeType: originalMimeType,
493 base64,
494 });
495 image = {
496 base64: fitted?.base64 ?? base64,
497 mimeType: originalMimeType,
498 name,
499 previewUri: fitted?.uri ?? asset.uri,
500 };
501 } else {
502 const rendered = await renderPhoto(asset.uri);
503 image = {
504 base64: rendered.base64,
505 mimeType: "image/jpeg",
506 name: /\.jpe?g$/i.test(name) ? name : `${name.replace(/\.[^.]+$/, "")}.jpg`,
507 previewUri: rendered.uri,
508 };
509 }
510 } catch {
511 error = `Failed to read '${name}'.`;
512 continue;
513 }
514​
515 const sizeBytes = estimateBase64ByteSize(image.base64);
516 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
517 error = `'${name}' exceeds the 10 MB attachment limit.`;
518 continue;
519 }
520​
521 attachments.push({
522 id: uuidv4(),
523 type: "image",
524 name: image.name,
525 mimeType: image.mimeType,
526 sizeBytes,
527 dataUrl: `data:${image.mimeType};base64,${image.base64}`,
528 previewUri: image.previewUri,
529 });
530 }
531​
532 return {
533 attachments,
534 error,
535 };
537​
538function mimeTypeFromUri(uri: string): string {
539 const ext = uri.split(".").pop()?.toLowerCase();
540 switch (ext) {
541 case "png":
542 return "image/png";
543 case "jpg":
544 case "jpeg":
545 return "image/jpeg";
546 case "gif":
547 return "image/gif";
548 case "webp":
549 return "image/webp";
550 case "heic":
551 return "image/heic";
552 default:
553 return "image/png";
554 }
556​
557function isOwnedPastedImageUri(uri: string): boolean {
558 try {
559 const url = new URL(uri);
560 if (url.protocol !== "file:") {
561 return false;
562 }
563 const segments = url.pathname.split("/").filter(Boolean);
564 return (
565 segments.at(-2) === OWNED_PASTED_IMAGE_DIRECTORY && segments.at(-1)?.endsWith(".png") === true
566 );
567 } catch {
568 return false;
569 }
571​
572export async function convertPastedImagesToAttachments(input: {
573 readonly uris: ReadonlyArray<string>;
574 readonly existingCount: number;
575}): Promise<{
576 readonly attachments: ReadonlyArray<DraftComposerImageAttachment>;
577 readonly error: string | null;
578}> {
579 const { File } = await import("expo-file-system");
580 const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;
581 const attachments: DraftComposerImageAttachment[] = [];
582 let error: string | null = null;
583​
584 for (const [index, uri] of input.uris.entries()) {
585 const temporaryUris = isOwnedPastedImageUri(uri) ? [uri] : [];
586 try {
587 if (index >= Math.max(0, remainingSlots)) {
588 continue;
589 }
590 const file = new File(uri);
591 const reportedMimeType = file.type;
592 const sourceMimeType = reportedMimeType?.startsWith("image/")
593 ? reportedMimeType
594 : mimeTypeFromUri(uri);
595 let mimeType = isProviderSendTurnSupportedImageMimeType(sourceMimeType)
596 ? sourceMimeType
597 : "image/jpeg";
598 const rendered = mimeType === sourceMimeType ? null : await renderPhoto(uri);
599 const pastedBase64 = rendered?.base64 ?? (await file.base64());
600 const fitted = rendered ?? (await fitComposerImage({ uri, mimeType, base64: pastedBase64 }));
601 if (fitted !== null) {
602 temporaryUris.push(fitted.uri);
603 }
604 let base64 = fitted?.base64 ?? pastedBase64;
605 if (
606 rendered === null &&
607 estimateBase64ByteSize(base64) > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES
608 ) {
609 const jpeg = await renderPhoto(uri);
610 temporaryUris.push(jpeg.uri);
611 mimeType = "image/jpeg";
612 base64 = jpeg.base64;
613 }
614 const sizeBytes = estimateBase64ByteSize(base64);
615 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
616 error =
617 sizeBytes > 0
618 ? "A pasted image exceeds the 10 MB attachment limit."
619 : "Could not read a pasted image.";
620 continue;
621 }
622 const dataUrl = `data:${mimeType};base64,${base64}`;
623 attachments.push({
624 id: uuidv4(),
625 type: "image",
626 name: `pasted-image.${mimeType.split("/")[1] ?? "png"}`,
627 mimeType,
628 sizeBytes,
629 dataUrl,
630 previewUri: dataUrl,
631 });
632 } catch (cause) {
633 console.warn("Failed to read pasted image", uri, cause);
634 error = "Could not read a pasted image.";
635 } finally {
636 for (const temporaryUri of temporaryUris) {
637 try {
638 const file = new File(temporaryUri);
639 if (file.exists) {
640 file.delete();
641 }
642 } catch (error) {
643 console.warn("Failed to remove temporary pasted image", temporaryUri, error);
644 }
645 }
646 }
647 }
648​
649 return { attachments, error };