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 /** Owned image bytes from a file-backed draft. Current writers still use inline bytes. */
32 readonly fileUri?: string;
33 /** Inline bytes from current writers and older drafts. */
34 readonly dataUrl?: string;
35 readonly uploadedAttachmentId?: string;
36 readonly uploadEnvironmentId?: EnvironmentId;
37}
38
39export interface DraftComposerFileAttachment {
40 readonly id: string;
41 readonly type: "file";
42 readonly name: string;
43 readonly mimeType: string;
44 readonly sizeBytes: number;
45 readonly fileUri: string;
46 readonly source?: PastedTextAttachmentSource;
47 readonly uploadedAttachmentId?: string;
48 readonly uploadEnvironmentId?: EnvironmentId;
49}
50
51export async function createPastedTextComposerAttachment(input: {
52 readonly text: string;
53 readonly name: string;
54 readonly maxBytes: number;
55}): Promise<DraftComposerFileAttachment> {
56 const bytes = new TextEncoder().encode(input.text).byteLength;
57 if (bytes <= 0) {
58 throw new Error("Clipboard is empty.");
59 }
60 if (bytes > input.maxBytes) {
61 throw new Error(fileAttachmentTooLargeMessage(input.name, input.maxBytes));
62 }
63
64 const { Directory, File, Paths } = await import("expo-file-system");
65 const directory = new Directory(Paths.document, COMPOSER_ATTACHMENT_DIRECTORY);
66 directory.create({ idempotent: true, intermediates: true });
67 const file = new File(directory, `${uuidv4()}-${input.name}`);
68 await writeFileAtomically(file, input.text);
69 return {
70 id: uuidv4(),
71 type: "file",
72 name: input.name,
73 mimeType: "text/plain;charset=utf-8",
74 sizeBytes: bytes,
75 fileUri: file.uri,
76 source: { _tag: "pasted-text" },
77 };
78}
79
80export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment;
81
82/**
83 * What the strip above the composer shows: media, and nothing else. A thumbnail is the only
84 * way to see a picture or a video, so those always preview there. Everything else reads as
85 * its inline chip, which carries the name, the type and the size in the line of prose the
86 * file belongs to — a square tile showing a generic document glyph says strictly less.
87 *
88 * The chip is not optional for a non-media file. Every path that attaches one also writes
89 * its reference, so a file with no chip means the draft lost it rather than that the strip
90 * should stand in. Which attachments carry a chip is therefore not consulted at all; surfaces
91 * whose attachments never get chips (a question answer) pass them to the strip directly
92 * instead of through this filter.
93 */
94export function composerStripAttachments(
95 attachments: ReadonlyArray<DraftComposerAttachment>,
96): ReadonlyArray<DraftComposerAttachment> {
97 return attachments.filter(
98 (attachment) => isComposerImageAttachment(attachment) || videoMimeType(attachment) !== null,
99 );
100}
101
102/**
103 * Whether a draft attachment is a picture. The document picker types every pick as a plain
104 * file, so the answer comes from the attachment itself rather than from which picker made it.
105 */
106export function isComposerImageAttachment(
107 attachment: DraftComposerAttachment,
108): attachment is DraftComposerImageAttachment {
109 return attachment.type === "image" || imageMimeType(attachment) !== null;
110}
111
112/** Any composer attachment whose bytes live in the app-owned attachment directory. */
113export type FileBackedComposerAttachment = DraftComposerAttachment & { readonly fileUri: string };
114
115/** Files have a local copy. Images can have one after a file-backed draft is restored. */
116export function isFileBackedComposerAttachment(
117 attachment: DraftComposerAttachment,
118): attachment is FileBackedComposerAttachment {
119 return attachment.fileUri !== undefined;
120}
121
122/**
123 * The bytes a draft attachment can be previewed from without the server. A picture taken from
124 * the photo library or the clipboard owns no file and carries its bytes inline, and its
125 * `attachmentId` is a local draft id the server has never seen — so falling back to a remote
126 * asset for one only ever fails. Returns undefined when the attachment really is remote-only.
127 */
128export function composerAttachmentInlineUri(
129 attachment: DraftComposerAttachment | undefined,
130): string | undefined {
131 if (attachment === undefined || isFileBackedComposerAttachment(attachment)) return undefined;
132 return attachment.type === "image" ? (attachment.dataUrl ?? attachment.previewUri) : undefined;
133}
134
135const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste";
136const ATTACHMENT_COPY_CHUNK_BYTES = 64 * 1024;
137
138export async function persistComposerAttachmentFile(
139 uri: string,
140 name: string,
141 maxBytes?: number,
142): Promise<string> {
143 const { Directory, File, FileMode, Paths } = await import("expo-file-system");
144 const directory = new Directory(Paths.document, COMPOSER_ATTACHMENT_DIRECTORY);
145 directory.create({ idempotent: true, intermediates: true });
146 const safeName =
147 Array.from(name, (character) =>
148 character === "/" || character === "\\" || character.charCodeAt(0) < 32 ? "-" : character,
149 ).join("") || "file";
150 const destination = new File(directory, `${uuidv4()}-${safeName}`);
151 const source = new File(uri);
152 const sourceSize = source.size;
153 if (
154 maxBytes !== undefined &&
155 (sourceSize === null || (sourceSize === 0 && uri.startsWith("content:")))
156 ) {
157 destination.create();
158 try {
159 const reader = source.open(FileMode.ReadOnly);
160 try {
161 const writer = destination.open(FileMode.WriteOnly);
162 try {
163 let copiedBytes = 0;
164 while (true) {
165 const chunk = await reader.readBytes(
166 Math.min(ATTACHMENT_COPY_CHUNK_BYTES, maxBytes - copiedBytes + 1),
167 );
168 if (chunk.byteLength === 0) {
169 break;
170 }
171 copiedBytes += chunk.byteLength;
172 if (copiedBytes > maxBytes) {
173 throw new Error(fileAttachmentTooLargeMessage(name, maxBytes));
174 }
175 await writer.writeBytes(chunk);
176 }
177 } finally {
178 writer.close();
179 }
180 } finally {
181 reader.close();
182 }
183 } catch (error) {
184 if (destination.exists) {
185 destination.delete();
186 }
187 throw error;
188 }
189 return destination.uri;
190 }
191
192 if (maxBytes !== undefined && sourceSize !== null && sourceSize > maxBytes) {
193 throw new Error(fileAttachmentTooLargeMessage(name, maxBytes));
194 }
195 try {
196 await source.copy(destination);
197 } catch (error) {
198 // A failed copy can leave a partial destination file behind with no URI
199 // returned to release it later; delete it before surfacing the failure.
200 try {
201 if (destination.exists) {
202 destination.delete();
203 }
204 } catch (cleanupError) {
205 console.warn("[composer-attachments] could not remove a partial copy", cleanupError);
206 }
207 throw error;
208 }
209 // An Android content: stream can deliver more bytes than the size it
210 // reported before the copy. Validate the persisted copy so an oversized
211 // file is never retained under a stale recorded size.
212 const copiedSize = destination.size;
213 if (maxBytes !== undefined && copiedSize !== null && copiedSize > maxBytes) {
214 try {
215 if (destination.exists) {
216 destination.delete();
217 }
218 } catch (cleanupError) {
219 console.warn("[composer-attachments] could not remove an oversized copy", cleanupError);
220 }
221 throw new Error(fileAttachmentTooLargeMessage(name, maxBytes));
222 }
223 return destination.uri;
224}
225
226export async function removePersistedComposerAttachmentFile(uri: string): Promise<void> {
227 try {
228 const { File, Paths } = await import("expo-file-system");
229 const ownedUri = resolveOwnedComposerAttachmentFileUri(uri, Paths.document.uri);
230 if (ownedUri === null || isComposerAttachmentFileRetained(ownedUri)) {
231 return;
232 }
233 const file = new File(ownedUri);
234 if (file.exists) {
235 file.delete();
236 }
237 } catch (error) {
238 console.warn("[composer-attachments] could not remove local file", error);
239 }
240}
241
242async function createComposerFileAttachment(input: {
243 readonly uri: string;
244 readonly name: string;
245 readonly mimeType: string;
246 readonly sizeBytes: number | null;
247 readonly maxBytes: number;
248}): Promise<DraftComposerFileAttachment> {
249 if (input.sizeBytes !== null && input.sizeBytes > input.maxBytes) {
250 throw new Error(fileAttachmentTooLargeMessage(input.name, input.maxBytes));
251 }
252 const { File } = await import("expo-file-system");
253 const fileUri = await persistComposerAttachmentFile(input.uri, input.name, input.maxBytes);
254 try {
255 const sizeBytes = new File(fileUri).size ?? input.sizeBytes ?? 0;
256 if (sizeBytes <= 0) {
257 throw new Error(`'${input.name}' is empty or could not be read.`);
258 }
259 if (sizeBytes > input.maxBytes) {
260 throw new Error(fileAttachmentTooLargeMessage(input.name, input.maxBytes));
261 }
262 return {
263 id: uuidv4(),
264 type: "file",
265 name: input.name,
266 mimeType: input.mimeType,
267 sizeBytes,
268 fileUri,
269 };
270 } catch (error) {
271 await removePersistedComposerAttachmentFile(fileUri);
272 throw error;
273 }
274}
275
276export async function pickComposerFiles(input: {
277 readonly existingCount: number;
278 readonly maxBytes?: number;
279}): Promise<{
280 readonly files: ReadonlyArray<DraftComposerFileAttachment>;
281 readonly error: string | null;
282}> {
283 const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;
284 if (remainingSlots <= 0) {
285 return {
286 files: [],
287 error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`,
288 };
289 }
290
291 const { getDocumentAsync } = await import("expo-document-picker");
292 const endHandoff = beginForegroundHandoff();
293 let result: DocumentPickerResult;
294 try {
295 // File providers may expose a URI that FileSystem cannot read directly.
296 // Import a readable cache copy before persisting the draft's owned file.
297 result = await getDocumentAsync({ multiple: true, copyToCacheDirectory: true });
298 } catch (cause) {
299 return {
300 files: [],
301 error: cause instanceof Error ? cause.message : "Could not open the file picker.",
302 };
303 } finally {
304 endHandoff();
305 }
306 if (result.canceled) {
307 return { files: [], error: null };
308 }
309
310 const maxBytes = clampFileAttachmentUploadBytes(
311 input.maxBytes ?? PROVIDER_SEND_TURN_MAX_FILE_BYTES,
312 );
313 const attachments: DraftComposerFileAttachment[] = [];
314 let error: string | null = null;
315 let exceededAttachmentLimit = false;
316 for (const file of result.assets) {
317 if (attachments.length >= remainingSlots) {
318 exceededAttachmentLimit = true;
319 break;
320 }
321 // A SAF/document picker can hand back a blank display name; the wire
322 // contract rejects empty names at send time, so fall back before the name
323 // reaches storage, errors, or the attachment itself.
324 const name = file.name.trim().length > 0 ? file.name : "file";
325 try {
326 attachments.push(
327 await createComposerFileAttachment({
328 uri: file.uri,
329 name,
330 mimeType: file.mimeType || "application/octet-stream",
331 sizeBytes: file.size ?? null,
332 maxBytes,
333 }),
334 );
335 } catch (cause) {
336 error = cause instanceof Error ? cause.message : `Could not read '${name}'.`;
337 }
338 }
339 if (exceededAttachmentLimit) {
340 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`;
341 }
342 return { files: attachments, error };
343}
344
345async function loadImagePicker() {
346 try {
347 return await import("expo-image-picker");
348 } catch (error) {
349 throw new Error("The photo library is unavailable right now.", { cause: error });
350 }
351}
352
353async function loadClipboard() {
354 try {
355 return await import("expo-clipboard");
356 } catch (error) {
357 throw new Error("Clipboard paste is unavailable right now.", { cause: error });
358 }
359}
360
361export async function pickComposerImages(input: { readonly existingCount: number }): Promise<{
362 readonly images: ReadonlyArray<DraftComposerImageAttachment>;
363 readonly error: string | null;
364}> {
365 const result = await pickComposerMedia(input);
366 return {
367 images: result.attachments.filter((attachment) => attachment.type === "image"),
368 error: result.error,
369 };
370}
371
372/** Videos use file uploads; omit maxVideoBytes for image-only destinations. */
373export async function pickComposerMedia(input: {
374 readonly existingCount: number;
375 readonly maxVideoBytes?: number;
376}): Promise<{
377 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
378 readonly error: string | null;
379}> {
380 const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;
381 if (remainingSlots <= 0) {
382 return {
383 attachments: [],
384 error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`,
385 };
386 }
387
388 let imagePicker: Awaited<ReturnType<typeof loadImagePicker>>;
389 try {
390 imagePicker = await loadImagePicker();
391 } catch (error) {
392 return {
393 attachments: [],
394 error: error instanceof Error ? error.message : "The photo library is unavailable right now.",
395 };
396 }
397
398 // The picker covers the Android activity, which reports the app as
399 // backgrounded; the guard keeps background-triggered restarts away mid-pick.
400 const endHandoff = beginForegroundHandoff();
401 let result: Awaited<ReturnType<typeof imagePicker.launchImageLibraryAsync>>;
402 try {
403 result = await imagePicker.launchImageLibraryAsync({
404 mediaTypes: input.maxVideoBytes === undefined ? ["images"] : ["images", "videos"],
405 allowsMultipleSelection: true,
406 selectionLimit: remainingSlots,
407 // Bytes stay in the picker's file until we know how much of them we need. Asking for
408 // base64 here made iOS decode and re-encode every camera photo at full resolution and
409 // hand JS a 10 MB+ string, which stalled the composer for seconds.
410 base64: false,
411 quality: 1,
412 shouldDownloadFromNetwork: true,
413 });
414 } catch (error) {
415 return {
416 attachments: [],
417 error: error instanceof Error ? error.message : "Could not open the photo library.",
418 };
419 } finally {
420 endHandoff();
421 }
422
423 if (result.canceled) {
424 return {
425 attachments: [],
426 error: null,
427 };
428 }
429
430 const attachments: DraftComposerAttachment[] = [];
431 let error: string | null = null;
432
433 for (const asset of result.assets) {
434 if (attachments.length >= remainingSlots) {
435 error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments per message.`;
436 break;
437 }
438 const mimeType = asset.mimeType?.toLowerCase();
439 if (asset.type === "video" || mimeType?.startsWith("video/")) {
440 if (input.maxVideoBytes === undefined) {
441 error = "Video attachments are unavailable here.";
442 continue;
443 }
444 try {
445 const { File } = await import("expo-file-system");
446 const file = new File(asset.uri);
447 attachments.push(
448 await createComposerFileAttachment({
449 uri: asset.uri,
450 name: asset.fileName?.trim() || file.name || "video",
451 mimeType: mimeType || file.type || "application/octet-stream",
452 sizeBytes: asset.fileSize ?? null,
453 maxBytes: clampFileAttachmentUploadBytes(input.maxVideoBytes),
454 }),
455 );
456 } catch (cause) {
457 error =
458 cause instanceof Error ? cause.message : `Could not read '${asset.fileName ?? "video"}'.`;
459 }
460 continue;
461 }
462 if (asset.type !== "image" && !mimeType?.startsWith("image/")) {
463 error = `Unsupported file type for '${asset.fileName ?? "image"}'.`;
464 continue;
465 }
466
467 const name = asset.fileName?.trim() || "image";
468 // The picker's reported size is a hint, not a measurement: Android content streams can
469 // deliver more bytes than they advertise. Only a size read from the file itself decides
470 // whether the original bytes are safe to load into JS.
471 let sourceBytes: number | null = null;
472 try {
473 const { File } = await import("expo-file-system");
474 sourceBytes = new File(asset.uri).size;
475 } catch {
476 sourceBytes = null;
477 }
478 // Originals the provider can read and that fit the cap pass through byte for byte so
479 // transparency and animation survive. Everything else (HEIC/HEIF, oversized JPEGs,
480 // unmeasurable sources) is rendered to a bounded JPEG off the JS thread.
481 const originalMimeType =
482 mimeType !== undefined &&
483 isProviderSendTurnSupportedImageMimeType(mimeType) &&
484 sourceBytes !== null &&
485 sourceBytes > 0 &&
486 sourceBytes <= PROVIDER_SEND_TURN_MAX_IMAGE_BYTES
487 ? mimeType
488 : null;
489
490 let image: { base64: string; mimeType: string; name: string; previewUri: string };
491 try {
492 if (originalMimeType !== null) {
493 const { File } = await import("expo-file-system");
494 const base64 = await new File(asset.uri).base64();
495 const fitted = await fitComposerImage({
496 uri: asset.uri,
497 mimeType: originalMimeType,
498 base64,
499 });
500 image = {
501 base64: fitted?.base64 ?? base64,
502 mimeType: originalMimeType,
503 name,
504 previewUri: fitted?.uri ?? asset.uri,
505 };
506 } else {
507 const rendered = await renderPhoto(asset.uri);
508 image = {
509 base64: rendered.base64,
510 mimeType: "image/jpeg",
511 name: /\.jpe?g$/i.test(name) ? name : `${name.replace(/\.[^.]+$/, "")}.jpg`,
512 previewUri: rendered.uri,
513 };
514 }
515 } catch {
516 error = `Failed to read '${name}'.`;
517 continue;
518 }
519
520 const sizeBytes = estimateBase64ByteSize(image.base64);
521 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
522 error = `'${name}' exceeds the 10 MB attachment limit.`;
523 continue;
524 }
525
526 attachments.push({
527 id: uuidv4(),
528 type: "image",
529 name: image.name,
530 mimeType: image.mimeType,
531 sizeBytes,
532 dataUrl: `data:${image.mimeType};base64,${image.base64}`,
533 previewUri: image.previewUri,
534 });
535 }
536
537 return {
538 attachments,
539 error,
540 };
541}
542
543/** Clipboard images take priority over their alternate text representation. */
544export async function pasteComposerClipboard(input: { readonly existingCount: number }): Promise<
545 | {
546 readonly images: ReadonlyArray<DraftComposerImageAttachment>;
547 readonly text: null;
548 readonly error: string | null;
549 }
550 | {
551 readonly images: readonly [];
552 readonly text: string;
553 readonly error: null;
554 }
555> {
556 let clipboard: Awaited<ReturnType<typeof loadClipboard>>;
557 try {
558 clipboard = await loadClipboard();
559 } catch (error) {
560 return {
561 images: [],
562 text: null,
563 error: error instanceof Error ? error.message : "Clipboard paste is unavailable right now.",
564 };
565 }
566
567 const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;
568
569 if (await clipboard.hasImageAsync()) {
570 if (remainingSlots <= 0) {
571 return {
572 images: [],
573 text: null,
574 error: `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`,
575 };
576 }
577 const image = await clipboard.getImageAsync({ format: "png" });
578 if (!image) {
579 return {
580 images: [],
581 text: null,
582 error: "Clipboard image is unavailable.",
583 };
584 }
585
586 const pastedBase64 = image.data.split(",")[1] ?? "";
587 const fitted = await fitComposerImage({
588 uri: image.data,
589 mimeType: "image/png",
590 base64: pastedBase64,
591 });
592 const base64 = fitted?.base64 ?? pastedBase64;
593 const dataUrl = fitted ? `data:image/png;base64,${base64}` : image.data;
594 const sizeBytes = estimateBase64ByteSize(base64);
595 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
596 return {
597 images: [],
598 text: null,
599 error: "Clipboard image exceeds the 10 MB attachment limit.",
600 };
601 }
602
603 return {
604 images: [
605 {
606 id: uuidv4(),
607 type: "image",
608 name: "pasted-image.png",
609 mimeType: "image/png",
610 sizeBytes,
611 dataUrl,
612 previewUri: dataUrl,
613 },
614 ],
615 text: null,
616 error: null,
617 };
618 }
619
620 if (await clipboard.hasStringAsync()) {
621 const text = await clipboard.getStringAsync();
622 return {
623 images: [],
624 ...(text.length > 0 ? { text, error: null } : { text: null, error: "Clipboard is empty." }),
625 };
626 }
627
628 return {
629 images: [],
630 text: null,
631 error: "Clipboard does not contain pasteable text or image content.",
632 };
633}
634
635function mimeTypeFromUri(uri: string): string {
636 const ext = uri.split(".").pop()?.toLowerCase();
637 switch (ext) {
638 case "png":
639 return "image/png";
640 case "jpg":
641 case "jpeg":
642 return "image/jpeg";
643 case "gif":
644 return "image/gif";
645 case "webp":
646 return "image/webp";
647 case "heic":
648 return "image/heic";
649 default:
650 return "image/png";
651 }
652}
653
654export function isOwnedPastedImageUri(uri: string): boolean {
655 try {
656 const url = new URL(uri);
657 if (url.protocol !== "file:") {
658 return false;
659 }
660 const segments = url.pathname.split("/").filter(Boolean);
661 return (
662 segments.at(-2) === OWNED_PASTED_IMAGE_DIRECTORY && segments.at(-1)?.endsWith(".png") === true
663 );
664 } catch {
665 return false;
666 }
667}
668
669export async function convertPastedImagesToAttachments(input: {
670 readonly uris: ReadonlyArray<string>;
671 readonly existingCount: number;
672}): Promise<ReadonlyArray<DraftComposerImageAttachment>> {
673 const { File } = await import("expo-file-system");
674 const remainingSlots = PROVIDER_SEND_TURN_MAX_ATTACHMENTS - input.existingCount;
675 const results: DraftComposerImageAttachment[] = [];
676
677 for (const [index, uri] of input.uris.entries()) {
678 const ownedTemporaryFile = isOwnedPastedImageUri(uri);
679 try {
680 if (index >= Math.max(0, remainingSlots)) {
681 continue;
682 }
683 const file = new File(uri);
684 const mimeType = mimeTypeFromUri(uri);
685 const pastedBase64 = await file.base64();
686 const fitted = await fitComposerImage({ uri, mimeType, base64: pastedBase64 });
687 const base64 = fitted?.base64 ?? pastedBase64;
688 const sizeBytes = estimateBase64ByteSize(base64);
689 if (sizeBytes <= 0 || sizeBytes > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) {
690 continue;
691 }
692 results.push({
693 id: uuidv4(),
694 type: "image",
695 name: `pasted-image.${mimeType.split("/")[1] ?? "png"}`,
696 mimeType,
697 sizeBytes,
698 dataUrl: `data:${mimeType};base64,${base64}`,
699 previewUri: ownedTemporaryFile ? `data:${mimeType};base64,${base64}` : (fitted?.uri ?? uri),
700 });
701 } catch (error) {
702 console.warn("Failed to read pasted image", uri, error);
703 } finally {
704 if (ownedTemporaryFile) {
705 try {
706 const file = new File(uri);
707 if (file.exists) {
708 file.delete();
709 }
710 } catch (error) {
711 console.warn("Failed to remove temporary pasted image", uri, error);
712 }
713 }
714 }
715 }
716
717 return results;
718}