From c4353bc6b972fa527579b530f01cc8744b2407d2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 10:20:53 -0700 Subject: [PATCH] fix(mobile): read file-backed image drafts before enabling them (#9713) Accept file-backed image drafts and v4 outbox records while keeping current inline image creation and v3 outbox writes. Retain image files during previews, uploads, and legacy inline reads. Preserve image MIME types and stop canceled sends after asynchronous reads. The later file-backed writers remain held until new native runtime fingerprints contain these readers and the storage guards. Continue Wout Stiens' mobile draft work with separate reader-only corrections. The original contribution remains unchanged. Created with GPT-6 Astra (preview) in Codex. Co-Authored-By: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Co-Authored-By: Claude Fable 5 --- .../components/ComposerAttachmentStrip.tsx | 100 +++-- .../src/components/FilePreviewModal.tsx | 4 +- .../features/threads/use-project-actions.ts | 1 - apps/mobile/src/lib/attachmentUpload.test.ts | 117 ++++++ apps/mobile/src/lib/attachmentUpload.ts | 86 ++++- apps/mobile/src/lib/composer-image-schema.ts | 13 +- apps/mobile/src/lib/composerImages.test.ts | 32 +- apps/mobile/src/lib/composerImages.ts | 25 +- .../src/lib/localAttachmentPreview.test.ts | 18 + apps/mobile/src/lib/localAttachmentPreview.ts | 9 +- .../src/lib/projectThreadStartTurn.test.ts | 6 +- apps/mobile/src/lib/projectThreadStartTurn.ts | 11 +- .../src/state/composer-attachment-uploads.ts | 8 +- apps/mobile/src/state/thread-outbox-model.ts | 3 +- apps/mobile/src/state/thread-outbox.test.ts | 24 ++ .../src/state/use-composer-drafts.test.ts | 358 +++++++++++++----- apps/mobile/src/state/use-composer-drafts.ts | 17 +- .../src/state/use-thread-outbox-drain.ts | 1 - 18 files changed, 615 insertions(+), 218 deletions(-) diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index b5d86c44109d2af31bb1c2065c0243befac21ce2..40465012e5da3bfc02e3ec66294087a1496f4110 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -1,10 +1,16 @@ import { SymbolView } from "../components/AppSymbol"; import { videoMimeType } from "@t3tools/shared/video"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Image, Pressable, ScrollView, View } from "react-native"; import { AppText as Text } from "./AppText"; -import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; +import { + isFileBackedComposerAttachment, + type DraftComposerAttachment, + type DraftComposerFileAttachment, + type DraftComposerImageAttachment, +} from "../lib/composerImages"; +import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachmentFiles"; import { VideoAttachmentTile } from "./VideoAttachmentTile"; import type { MediaActionsSource } from "../lib/mediaActions"; import { PresentationSource } from "./NativePresentation"; @@ -87,35 +93,75 @@ export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailPr ); } +/** + * Thumbnail URI for a draft image. File-backed previews rebase into the + * current iOS data container (its UUID changes across installs); the raw + * persisted URI renders meanwhile, which is correct everywhere but after a + * container move. + */ +function useComposerImagePreviewUri(attachment: DraftComposerImageAttachment): string { + const { fileUri, previewUri } = attachment; + const [rebased, setRebased] = useState<{ fileUri: string; uri: string } | null>(null); + useEffect(() => { + if (fileUri === undefined) return; + let cancelled = false; + void (async () => { + const { Paths } = await import("expo-file-system"); + const owned = resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri); + // Re-render only when the container actually moved. + if (!cancelled && owned !== null && owned !== previewUri) setRebased({ fileUri, uri: owned }); + })(); + return () => { + cancelled = true; + }; + }, [fileUri, previewUri]); + return fileUri !== undefined && rebased?.fileUri === fileUri ? rebased.uri : previewUri; +} + +function ComposerImageAttachment( + props: ComposerAttachmentThumbnailProps & { readonly attachment: DraftComposerImageAttachment }, +) { + const { attachment } = props; + const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; + const previewUri = useComposerImagePreviewUri(attachment); + const sourceIdentifier = `draft-image:${attachment.id}`; + return ( + + + props.onPressPreview?.( + // File-backed images open through the retain-lease + container + // rebase path; legacy drafts still carry their inline bytes. + isFileBackedComposerAttachment(attachment) + ? { kind: "image", attachment, name: attachment.name, sourceIdentifier } + : { + kind: "image", + uri: attachment.dataUrl ?? attachment.previewUri, + name: attachment.name, + sourceIdentifier, + }, + ) + } + > + + + + ); +} + function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { const { attachment } = props; const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; if (attachment.type === "image") { - const sourceIdentifier = `draft-image:${attachment.id}`; - return ( - - - props.onPressPreview?.({ - kind: "image", - uri: attachment.dataUrl, - name: attachment.name, - sourceIdentifier, - }) - } - > - - - - ); + return ; } const onPressVideo = props.onPressVideo; if (onPressVideo && videoMimeType(attachment) !== null) { diff --git a/apps/mobile/src/components/FilePreviewModal.tsx b/apps/mobile/src/components/FilePreviewModal.tsx index c8a6d429194793ccdf94f0aee6690af1c9a96977..1cdb6a6854a6939dd00ec7197916628b40b28b7a 100644 --- a/apps/mobile/src/components/FilePreviewModal.tsx +++ b/apps/mobile/src/components/FilePreviewModal.tsx @@ -3,7 +3,7 @@ import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; import { useEffect, useEffectEvent, useState } from "react"; import { Alert, Keyboard } from "react-native"; -import type { DraftComposerFileAttachment } from "../lib/composerImages"; +import type { FileBackedComposerAttachment } from "../lib/composerImages"; import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; import type { MediaActionsSource } from "../lib/mediaActions"; import { useAssetUrlState } from "../state/assets"; @@ -22,7 +22,7 @@ export interface ResolvedFilePreviewSource { export type FilePreviewSource = Omit & ( | { readonly uri: string } - | { readonly attachment: DraftComposerFileAttachment } + | { readonly attachment: FileBackedComposerAttachment } | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } ); diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index cb2c3c47e6f3389531866f33b84b35a2e3ad9516..fe39168192400656428a39c9af0c376ca59a3e3b 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -138,7 +138,6 @@ export function useCreateProjectThread() { messageId: metadata.messageId, createdAt: metadata.createdAt, text: initialMessageText, - attachments: input.initialAttachments, uploadedAttachments: prepared.attachments, modelSelection: input.modelSelection, runtimeMode: input.runtimeMode, diff --git a/apps/mobile/src/lib/attachmentUpload.test.ts b/apps/mobile/src/lib/attachmentUpload.test.ts index 5e8a34dd1cdbbe5f689e037ee1618d0db47ced5f..c7289d8cb693bd7d652628034236cb67e49721f6 100644 --- a/apps/mobile/src/lib/attachmentUpload.test.ts +++ b/apps/mobile/src/lib/attachmentUpload.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ upload: vi.fn(), writeFile: vi.fn(), deleteFile: vi.fn(), + readBase64: vi.fn(), })); vi.mock("@t3tools/client-runtime/state/runtime", () => ({ @@ -30,6 +31,11 @@ vi.mock("../state/atom-registry", () => ({ appAtomRegistry: { get: mocks.readAtom }, })); +// The real read lease and cleanup are covered by the composer ownership suite. +vi.mock("../state/use-composer-drafts", () => ({ + retainComposerAttachmentFileForPreview: () => () => {}, +})); + vi.mock("../state/assets", () => ({ assetEnvironment: { createUrl: mocks.createAssetUrl }, })); @@ -68,6 +74,10 @@ vi.mock("expo-file-system", () => ({ mocks.deleteFile(this.uri); } + async base64() { + return mocks.readBase64(this.uri); + } + upload(url: string, options: unknown) { return mocks.upload(this.uri, url, options); } @@ -102,6 +112,16 @@ const image = { previewUri: "file:///images/screenshot.png", } as const satisfies DraftComposerAttachment; +const fileBackedImage = { + id: "image-2", + type: "image", + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + fileUri: "file:///documents/t3-composer-attachments/photo.png", + previewUri: "file:///documents/t3-composer-attachments/photo.png", +} as const satisfies DraftComposerAttachment; + const file = { id: "file-1", type: "file", @@ -174,6 +194,8 @@ describe("prepareTurnAttachments", () => { mocks.upload.mockReset(); mocks.writeFile.mockReset(); mocks.deleteFile.mockReset(); + mocks.readBase64.mockReset(); + mocks.readBase64.mockResolvedValue("YWJj"); mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" })); mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) => command === mocks.createUploadUrl @@ -208,6 +230,101 @@ describe("prepareTurnAttachments", () => { expect(mocks.upload).not.toHaveBeenCalled(); }); + it("inlines a file-backed image lazily when the server lacks image uploads", async () => { + const prepared = await prepareTurnAttachments({ + environmentId, + attachments: [fileBackedImage], + }); + + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") return; + expect(prepared.attachments).toEqual([ + { + type: "image", + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + }, + ]); + expect(mocks.readBase64).toHaveBeenCalledExactlyOnceWith(fileBackedImage.fileUri); + expect(mocks.upload).not.toHaveBeenCalled(); + }); + + it("reads restored images from the current iOS document container for legacy sends", async () => { + const fileName = "33333333-3333-4333-8333-333333333333-photo.png"; + mocks.documentUri = + "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents"; + const oldUri = `file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`; + const prepared = await prepareTurnAttachments({ + environmentId, + attachments: [{ ...fileBackedImage, fileUri: oldUri, previewUri: oldUri }], + }); + expect(prepared.status).toBe("ready"); + expect(mocks.readBase64).toHaveBeenCalledExactlyOnceWith( + `${mocks.documentUri}/t3-composer-attachments/${fileName}`, + ); + }); + + it("rejects a legacy image without inline bytes or a file", async () => { + const { fileUri: _, ...missingImage } = fileBackedImage; + await expect( + prepareTurnAttachments({ environmentId, attachments: [missingImage] }), + ).rejects.toThrow("'photo.png' is no longer available. Attach the image again."); + }); + + it.each(["resolve", "reject"] as const)( + "abandons a canceled legacy image read that later %ss", + async (outcome) => { + const readStarted = Promise.withResolvers(); + const read = Promise.withResolvers(); + const controller = new AbortController(); + mocks.readBase64.mockImplementation(() => { + readStarted.resolve(); + return read.promise; + }); + const preparing = prepareTurnAttachments({ + environmentId, + attachments: [fileBackedImage], + signal: controller.signal, + }); + await readStarted.promise; + controller.abort(); + if (outcome === "resolve") read.resolve("YWJj"); + else read.reject(new Error("Native read failed after cancellation")); + + await expect(preparing).resolves.toEqual({ status: "abandoned" }); + }, + ); + + it("uploads a file-backed image from its owned copy without staging base64", async () => { + const prepared = await prepareTurnAttachments({ + environmentId, + attachments: [fileBackedImage], + supportsImageUploads: true, + }); + + expect(mocks.upload).toHaveBeenCalledWith( + fileBackedImage.fileUri, + "https://environment.example/api/attachments/upload/signed", + expect.objectContaining({ headers: { "Content-Type": "image/png" } }), + ); + expect(mocks.readBase64).not.toHaveBeenCalled(); + expect(mocks.writeFile).not.toHaveBeenCalled(); + expect(mocks.deleteFile).not.toHaveBeenCalled(); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") return; + expect(prepared.attachments).toEqual([ + { + type: "image", + id: MINTED_ID, + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + }, + ]); + }); + it("uploads generic file bytes directly and keeps mixed attachment order", async () => { const prepared = await prepareTurnAttachments({ environmentId, attachments: [file, image] }); diff --git a/apps/mobile/src/lib/attachmentUpload.ts b/apps/mobile/src/lib/attachmentUpload.ts index f39329373dd7f4e7e9aa26b52cc2287666b650eb..6c970a4fc08bb915018a24ece82c9839df4799ec 100644 --- a/apps/mobile/src/lib/attachmentUpload.ts +++ b/apps/mobile/src/lib/attachmentUpload.ts @@ -20,8 +20,13 @@ import { appAtomRegistry } from "../state/atom-registry"; import { assetEnvironment } from "../state/assets"; import { attachmentEnvironment } from "../state/attachments"; import { environmentSession } from "../state/session"; +import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; -import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; +import { + isFileBackedComposerAttachment, + type DraftComposerAttachment, + type DraftComposerImageAttachment, +} from "./composerImages"; import { uuidv4 } from "./uuid"; /** @@ -179,6 +184,48 @@ function attachmentUploadInput(attachment: DraftComposerAttachment) { return { ...fields, mimeType }; } +/** + * Wire shape for startTurn on servers without attachment uploads: pure inline + * uploads without client draft id / previewUri. File-backed images read their + * base64 from disk lazily, only when this legacy path is actually taken. + */ +async function toUploadChatImageAttachments( + attachments: ReadonlyArray, +): Promise> { + return Promise.all( + attachments.map(async (attachment) => ({ + type: attachment.type, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: await composerImageAttachmentDataUrl(attachment), + })), + ); +} + +/** Inline bytes for one image: legacy drafts carry them, file-backed ones read them from disk. */ +async function composerImageAttachmentDataUrl( + attachment: DraftComposerImageAttachment, +): Promise { + if (attachment.dataUrl !== undefined) { + return attachment.dataUrl; + } + if (!isFileBackedComposerAttachment(attachment)) { + throw new Error(`'${attachment.name}' is no longer available. Attach the image again.`); + } + const release = retainComposerAttachmentFileForPreview(attachment); + try { + const { File, Paths } = await import("expo-file-system"); + const uri = + resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? + attachment.fileUri; + const base64 = await new File(uri).base64(); + return `data:${attachment.mimeType};base64,${base64}`; + } finally { + release(); + } +} + async function uploadFileBytes( attachment: DraftComposerAttachment, url: string, @@ -187,17 +234,21 @@ async function uploadFileBytes( ): Promise { const { File, Paths, UploadType } = await import("expo-file-system"); if (signal.aborted) throw new Error("Upload cancelled."); + // Legacy image drafts persisted inline bytes and stage them in a temp cache + // file for the native uploader. Everything else uploads its owned copy. + const fileUri = attachment.fileUri; + const inlineDataUrl = attachment.type === "image" ? attachment.dataUrl : undefined; + if (fileUri === undefined && inlineDataUrl === undefined) { + throw new Error(`'${attachment.name}' is no longer available. Attach the image again.`); + } const file = - attachment.type === "image" + fileUri === undefined ? new File(Paths.cache, `t3-upload-${uuidv4()}`) - : new File( - resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? - attachment.fileUri, - ); + : new File(resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri) ?? fileUri); try { - if (attachment.type === "image") { + if (fileUri === undefined && inlineDataUrl !== undefined) { file.create(); - file.write(attachment.dataUrl.slice(attachment.dataUrl.indexOf(",") + 1), { + file.write(inlineDataUrl.slice(inlineDataUrl.indexOf(",") + 1), { encoding: "base64", }); } @@ -218,7 +269,7 @@ async function uploadFileBytes( throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`); } } finally { - if (attachment.type === "image" && file.exists) file.delete(); + if (fileUri === undefined && file.exists) file.delete(); } } @@ -259,13 +310,16 @@ export async function prepareTurnAttachments(input: { }); if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) { - return ready( - toUploadChatImageAttachments( + try { + const imageAttachments = await toUploadChatImageAttachments( input.attachments.filter((attachment) => attachment.type === "image"), - ), - [], - input.attachments, - ); + ); + if (input.signal?.aborted) return { status: "abandoned" }; + return ready(imageAttachments, [], input.attachments); + } catch (error) { + if (input.signal?.aborted) return { status: "abandoned" }; + throw error; + } } const connection = appAtomRegistry.get( @@ -285,7 +339,7 @@ export async function prepareTurnAttachments(input: { for (const attachment of input.attachments) { if (controller.signal.aborted) throw new Error("Upload cancelled."); if (attachment.type === "image" && !input.supportsImageUploads) { - uploadedAttachments.push(...toUploadChatImageAttachments([attachment])); + uploadedAttachments.push(...(await toUploadChatImageAttachments([attachment]))); continue; } diff --git a/apps/mobile/src/lib/composer-image-schema.ts b/apps/mobile/src/lib/composer-image-schema.ts index 3303dad36b0c159f256d049ac2f235a9d0e3c844..41aced8c98e5bcb40dc959b44bd38a56d3ff596d 100644 --- a/apps/mobile/src/lib/composer-image-schema.ts +++ b/apps/mobile/src/lib/composer-image-schema.ts @@ -8,10 +8,19 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({ name: Schema.String, mimeType: Schema.String, sizeBytes: Schema.Number, - dataUrl: Schema.String, + // Accept future file-backed records before enabling the new image writers. + fileUri: Schema.optional(Schema.String.check(Schema.isNonEmpty())), + dataUrl: Schema.optional(Schema.String.check(Schema.isNonEmpty())), uploadedAttachmentId: Schema.optional(Schema.String), uploadEnvironmentId: Schema.optional(EnvironmentId), -}); +}).check( + Schema.makeFilter( + ({ fileUri, dataUrl }) => + fileUri !== undefined || + dataUrl !== undefined || + "Image attachment has no file or inline bytes.", + ), +); export const DraftComposerFileAttachmentSchema = Schema.Struct({ id: Schema.String, diff --git a/apps/mobile/src/lib/composerImages.test.ts b/apps/mobile/src/lib/composerImages.test.ts index 21f2edaf52f623eab412cccfd821740c718c9fad..40e00a271f767965890d7b2666def7cdfe662918 100644 --- a/apps/mobile/src/lib/composerImages.test.ts +++ b/apps/mobile/src/lib/composerImages.test.ts @@ -36,37 +36,7 @@ vi.mock("./uuid", () => ({ uuidv4: () => "attachment-id", })); -import { - convertPastedImagesToAttachments, - isOwnedPastedImageUri, - toUploadChatImageAttachments, -} from "./composerImages"; - -describe("toUploadChatImageAttachments", () => { - it("strips client draft id and previewUri for the startTurn wire shape", () => { - expect( - toUploadChatImageAttachments([ - { - id: "client-draft-id", - type: "image", - name: "pasted-image.png", - mimeType: "image/png", - sizeBytes: 12, - dataUrl: "data:image/png;base64,AA==", - previewUri: "file:///tmp/preview.png", - }, - ]), - ).toEqual([ - { - type: "image", - name: "pasted-image.png", - mimeType: "image/png", - sizeBytes: 12, - dataUrl: "data:image/png;base64,AA==", - }, - ]); - }); -}); +import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages"; describe("native pasted image cleanup", () => { beforeEach(() => { diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index 77c2ec225564d167339c06c4b621a26c846ff984..16faaedd96c278b814bbfd306aba90fb1abedcc1 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -20,9 +20,13 @@ import { import { beginForegroundHandoff } from "./foreground-handoff"; import { uuidv4 } from "./uuid"; -export interface DraftComposerImageAttachment extends UploadChatImageAttachment { +export interface DraftComposerImageAttachment extends Omit { readonly id: string; readonly previewUri: string; + /** Owned image bytes from a file-backed draft. Current writers still use inline bytes. */ + readonly fileUri?: string; + /** Inline bytes from current writers and older drafts. */ + readonly dataUrl?: string; readonly uploadedAttachmentId?: string; readonly uploadEnvironmentId?: EnvironmentId; } @@ -40,17 +44,14 @@ export interface DraftComposerFileAttachment { export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment; -/** 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, - })); +/** Any composer attachment whose bytes live in the app-owned attachment directory. */ +export type FileBackedComposerAttachment = DraftComposerAttachment & { readonly fileUri: string }; + +/** Files have a local copy. Images can have one after a file-backed draft is restored. */ +export function isFileBackedComposerAttachment( + attachment: DraftComposerAttachment, +): attachment is FileBackedComposerAttachment { + return attachment.fileUri !== undefined; } const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; diff --git a/apps/mobile/src/lib/localAttachmentPreview.test.ts b/apps/mobile/src/lib/localAttachmentPreview.test.ts index 2ed83b26f6405b9b4b732cc5b5501d9aaa1568ad..3693caa4118508a85ef39de93729973ce148b02f 100644 --- a/apps/mobile/src/lib/localAttachmentPreview.test.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.test.ts @@ -46,6 +46,24 @@ beforeEach(() => { }); describe("loadLocalAttachmentPreview", () => { + it("keeps an image MIME type when its filename has a video extension", async () => { + const image = { + ...attachment, + type: "image" as const, + name: "photo.mov", + mimeType: "image/png", + previewUri: attachment.fileUri, + }; + const preview = await loadLocalAttachmentPreview(image, new AbortController().signal); + await preview!.share(new AbortController().signal); + expect(mocks.share).toHaveBeenCalledWith( + expect.objectContaining({ + attachment: { name: "photo.mov", mimeType: "image/png" }, + }), + ); + preview!.dispose(); + }); + it("retains and shares a PDF with its original filename and type", async () => { const pdf = { ...attachment, name: "report.pdf", mimeType: "application/pdf" }; const preview = await loadLocalAttachmentPreview(pdf, new AbortController().signal); diff --git a/apps/mobile/src/lib/localAttachmentPreview.ts b/apps/mobile/src/lib/localAttachmentPreview.ts index bdd20e2e63d576acc3633e5a50505a1c06486368..0d90f7210babafd01bc4576cc8c1d5d2ab780405 100644 --- a/apps/mobile/src/lib/localAttachmentPreview.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.ts @@ -1,13 +1,13 @@ import { videoMimeType } from "@t3tools/shared/video"; -import type { DraftComposerFileAttachment } from "./composerImages"; +import type { FileBackedComposerAttachment } from "./composerImages"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; /** Retains the draft original for preview and gives each outgoing share its own lease. */ export async function loadLocalAttachmentPreview( - attachment: DraftComposerFileAttachment, + attachment: FileBackedComposerAttachment, signal: AbortSignal, ): Promise { if (signal.aborted) return null; @@ -41,7 +41,10 @@ export async function loadLocalAttachmentPreview( uri: file.uri, attachment: { name: attachment.name, - mimeType: videoMimeType(attachment) ?? attachment.mimeType, + mimeType: + attachment.type === "file" + ? (videoMimeType(attachment) ?? attachment.mimeType) + : attachment.mimeType, }, signal: shareSignal, sourceIdentifier, diff --git a/apps/mobile/src/lib/projectThreadStartTurn.test.ts b/apps/mobile/src/lib/projectThreadStartTurn.test.ts index 754555dc2a82a7420492bfdbc848d8516e63141a..bd7918e52d79737b3f3b5eeb38ce9f217ce04a5b 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.test.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.test.ts @@ -6,15 +6,13 @@ import { ThreadId, } from "@t3tools/contracts"; import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { buildProjectThreadStartTurnInput, deriveThreadTitleFromPrompt, } from "./projectThreadStartTurn"; -vi.mock("./composerImages", () => ({ toUploadChatImageAttachments: () => [] })); - describe("project thread title", () => { it("keeps ordinary titles and the empty-prompt fallback", () => { expect(deriveThreadTitleFromPrompt(" Fix\n the parser ")).toBe("Fix the parser"); @@ -52,7 +50,7 @@ describe("project thread title", () => { messageId: "message", createdAt: "2026-09-01T00:00:00Z", text, - attachments: [], + uploadedAttachments: [], modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, runtimeMode: "full-access", interactionMode: "default", diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index 3c3da90f962dac8241f7c5b010ef51d28287f105..37190780c5ac837e9c9349d9208e7839bbdb137f 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -9,7 +9,6 @@ import { } from "@t3tools/contracts"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; -import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; import type { UploadedMobileAttachment } from "./attachmentUpload"; export function deriveThreadTitleFromPrompt(value: string): string { @@ -30,8 +29,8 @@ export interface ProjectThreadStartTurnSpec { readonly messageId: string; readonly createdAt: string; readonly text: string; - readonly attachments: ReadonlyArray; - readonly uploadedAttachments?: ReadonlyArray; + /** Wire attachments from `prepareTurnAttachments`, in composer order. */ + readonly uploadedAttachments: ReadonlyArray; readonly modelSelection: ModelSelection; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; @@ -58,11 +57,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe messageId: MessageId.make(spec.messageId), role: "user" as const, text: spec.text, - attachments: - spec.uploadedAttachments ?? - toUploadChatImageAttachments( - spec.attachments.filter((attachment) => attachment.type === "image"), - ), + attachments: spec.uploadedAttachments, }, modelSelection: spec.modelSelection, titleSeed: title, diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts index efc3fe1c39e521e6becfc2e9ed3110225ae5d7b0..ad38551d59153d20197e412a84b4a89cd82c8645 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -4,6 +4,7 @@ import { Atom } from "effect/unstable/reactivity"; import { useEffect, useRef } from "react"; import { prepareTurnAttachments } from "../lib/attachmentUpload"; +import { isFileBackedComposerAttachment } from "../lib/composerImages"; import { composerAttachmentUploadKey, composerDraftEnvironmentId, @@ -60,10 +61,9 @@ export function useComposerAttachmentUploadWorker() { const queue = createComposerAttachmentUploadQueue({ onChange: (states) => appAtomRegistry.set(composerAttachmentUploadsAtom, states), upload: async ({ environmentId, attachment }, signal, onProgress) => { - const release = - attachment.type === "file" - ? retainComposerAttachmentFileForPreview(attachment) - : undefined; + const release = isFileBackedComposerAttachment(attachment) + ? retainComposerAttachmentFileForPreview(attachment) + : undefined; try { const result = await prepareTurnAttachments({ environmentId, diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 9895d51952ad34e638d07b9f793cd145a05c3c40..99f32e5c1764ff38ad9a14b7b9e2995312400c72 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -27,6 +27,7 @@ import type { DraftComposerAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; +// Keep current writes until a compatible native baseline includes the v4 reader. const THREAD_OUTBOX_SCHEMA_VERSION = 3; const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; @@ -43,7 +44,7 @@ const QueuedThreadCreationSchema = Schema.Struct({ }); export const QueuedThreadMessageSchema = Schema.Struct({ - schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION]), + schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION, 4]), environmentId: EnvironmentId, threadId: ThreadId, messageId: MessageId, diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index ff2d97898b42de5d4095547bf06bbf21686734d1..a4d89246c4c342c5f558347d5363bc225b58a3b3 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -196,6 +196,30 @@ describe("thread outbox", () => { expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(message))).toEqual(message); }); + it("reads file-backed images from v4 queued messages", () => { + const message = { + ...queuedMessage({ + messageId: "message-image", + createdAt: "2026-06-08T10:00:01.000Z", + }), + attachments: [ + { + id: "image-1", + type: "image" as const, + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + fileUri: "file:///documents/t3-composer-attachments/photo.png", + previewUri: "file:///documents/t3-composer-attachments/photo.png", + uploadedAttachmentId: "pending-photo-png", + uploadEnvironmentId: EnvironmentId.make("environment-1"), + }, + ], + } satisfies QueuedThreadMessage; + + expect(decodeQueuedThreadMessage({ ...message, schemaVersion: 4 })).toEqual(message); + }); + it("persists the exact selector snapshot while remaining compatible with v1 messages", () => { const legacyMessage = queuedMessage({ messageId: "message-1", diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 9cfc63af3881ce063643aa40e8a8905829206d0b..68d901546fef1845dcab6a7add93c34045785125 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -17,8 +17,10 @@ const composerDraftFileMocks = vi.hoisted(() => { let nextWriteBarrier: Promise | null = null; let onWrite: (() => void) | null = null; const writes: string[] = []; + const readImage = vi.fn(async () => "YWJj"); return { + readImage, blockRead() { readBarrier = new Promise((resolve) => { releaseRead = resolve; @@ -73,6 +75,10 @@ const composerDraftFileMocks = vi.hoisted(() => { return document; } + async base64() { + return readImage(); + } + write(value: string) { if (writeError) { throw writeError; @@ -108,13 +114,29 @@ const incomingShareStorageMocks = vi.hoisted(() => ({ vi.mock("expo-file-system", () => ({ Directory: composerDraftFileMocks.Directory, File: composerDraftFileMocks.File, - Paths: { document: "/documents" }, + Paths: { document: { uri: "file:///documents" } }, })); -vi.mock("../lib/composerImages", () => ({ +vi.mock("../lib/composerImages", async (importOriginal) => ({ + ...(await importOriginal()), removePersistedComposerAttachmentFile: composerAttachmentCleanupMocks.remove, })); +vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "0000" })); +vi.mock("./assets", () => ({ assetEnvironment: {} })); +vi.mock("./attachments", () => ({ attachmentEnvironment: {} })); +vi.mock("./session", () => ({ environmentSession: {} })); +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + createEnvironmentRpcCommand: () => Symbol("rpc-command"), + executeAtomQuery: () => { + throw new Error("Unexpected network query in the inline read test"); + }, + runAtomCommand: () => { + throw new Error("Unexpected network command in the inline read test"); + }, + squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error, +})); + vi.mock("../lib/attachmentUpload", () => ({ releasePendingAttachmentUploads: composerAttachmentCleanupMocks.releaseUploads, })); @@ -128,6 +150,7 @@ import { threadOutboxManager } from "./thread-outbox"; import { appendComposerDraftAttachments, archiveCloudComposerDrafts, + clearComposerDraftContent, clearComposerDraftContentState, clearComposerDraftsEnvironment, ComposerDraftPersistenceError, @@ -171,6 +194,8 @@ afterEach(() => { composerDraftFileMocks.setNextWriteBarrier(null); composerDraftFileMocks.setOnWrite(null); composerDraftFileMocks.resetWrites(); + composerDraftFileMocks.readImage.mockReset(); + composerDraftFileMocks.readImage.mockResolvedValue("YWJj"); appAtomRegistry.set(composerDraftsAtom, {}); appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); appAtomRegistry.set(stickyComposerModelSelectionAtom, null); @@ -313,6 +338,106 @@ describe("mobile composer drafts", () => { expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri); }); + it("retains a referenced file-backed image and releases it once unreferenced", async () => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const image = { + id: "image-1", + type: "image" as const, + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + fileUri: "file:///documents/t3-composer-attachments/photo.png", + previewUri: "file:///documents/t3-composer-attachments/photo.png", + }; + appAtomRegistry.set(composerDraftsAtom, { + "environment-1:thread-1": { text: "look at this", attachments: [image] }, + }); + + await releaseUnusedComposerAttachmentFiles([image]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + + // A queued outbox message must keep the bytes alive after the draft clears. + appAtomRegistry.set(composerDraftsAtom, {}); + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { + "environment-1:thread-1": [ + { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("queued-image"), + commandId: CommandId.make("command-image"), + text: "look at this", + attachments: [image], + createdAt: "2026-08-31T12:00:00.000Z", + }, + ], + }); + await releaseUnusedComposerAttachmentFiles([image]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); + await releaseUnusedComposerAttachmentFiles([image]); + expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith(image.fileUri); + }); + + it("keeps an image through an inline read after its draft is removed", async () => { + const { prepareTurnAttachments } = + await vi.importActual("../lib/attachmentUpload"); + const image = { + id: "image-reading", + type: "image" as const, + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + fileUri: "file:///documents/t3-composer-attachments/photo.png", + previewUri: "file:///documents/t3-composer-attachments/photo.png", + }; + const readStarted = Promise.withResolvers(); + const read = Promise.withResolvers(); + const removed = Promise.withResolvers(); + let imageExists = true; + composerDraftFileMocks.readImage.mockImplementation(async () => { + readStarted.resolve(); + await read.promise; + if (!imageExists) throw new Error("The image was deleted during its read"); + return "YWJj"; + }); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + imageExists = false; + removed.resolve(); + }); + appendComposerDraftAttachments("environment-1:thread-1", [image]); + await flushComposerDrafts(); + const preparing = prepareTurnAttachments({ + environmentId: EnvironmentId.make("environment-1"), + attachments: [image], + }); + onTestFinished(async () => { + read.resolve(); + await preparing.catch(() => undefined); + }); + await readStarted.promise; + clearComposerDraftContent("environment-1:thread-1", { deferAttachmentCleanup: true }); + await releaseUnusedComposerAttachmentFiles([image]); + expect(imageExists).toBe(true); + + read.resolve(); + const prepared = await preparing; + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") return; + expect(prepared.attachments).toEqual([ + { + type: "image", + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + dataUrl: "data:image/png;base64,YWJj", + }, + ]); + await removed.promise; + expect(imageExists).toBe(false); + }); + it("keeps a failed-send draft's pending upload for retry", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); onTestFinished(() => outboxLoad.mockRestore()); @@ -394,68 +519,75 @@ describe("mobile composer drafts", () => { expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); }); - it("keeps signed-out files through cleanup and restart, and restores only the owning account", async () => { - const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); - onTestFinished(() => load.mockRestore()); - await waitForComposerDraftsLoaded(); - const environmentId = EnvironmentId.make("cloud-environment"); - const key = `${environmentId}:thread-1`; - const file = { - id: "local-pdf", - type: "file" as const, - name: "notes.pdf", - mimeType: "application/pdf", - sizeBytes: 42, - fileUri: "file:///documents/t3-composer-attachments/notes.pdf", - uploadEnvironmentId: environmentId, - uploadedAttachmentId: "pending-pdf", - }; - const queued = { - environmentId, - threadId: ThreadId.make("thread-2"), - messageId: MessageId.make("queued-1"), - commandId: CommandId.make("command-1"), - text: "Send later", - attachments: [file], - createdAt: "2026-08-31T12:00:00.000Z", - }; - appAtomRegistry.set(composerDraftsAtom, { - [key]: { text: "Unsent notes", attachments: [file] }, - "direct-environment:thread-1": DRAFT, - "pending-task:queued-1": { text: "Edited queued task", attachments: [file] }, - }); - appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { queued: [queued] }); - await archiveCloudComposerDrafts("account-a", new Set([environmentId])); - expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ - "direct-environment:thread-1": DRAFT, - }); - // The registry can remove the active outbox and drafts after the backup lands. - appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); - await clearComposerDraftsEnvironment(environmentId); - await releaseUnusedComposerAttachmentFiles([file]); - expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); - expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); + it.each(["file", "image"] as const)( + "keeps signed-out %s attachments through cleanup and restart, and restores only the owning account", + async (type) => { + const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => load.mockRestore()); + await waitForComposerDraftsLoaded(); + const environmentId = EnvironmentId.make("cloud-environment"); + const key = `${environmentId}:thread-1`; + const name = type === "image" ? "notes.png" : "notes.pdf"; + const metadata = { + id: "local-notes", + name, + mimeType: type === "image" ? "image/png" : "application/pdf", + sizeBytes: 42, + fileUri: `file:///documents/t3-composer-attachments/${name}`, + uploadEnvironmentId: environmentId, + uploadedAttachmentId: "pending-notes", + }; + const file = + type === "image" + ? { ...metadata, type, previewUri: metadata.fileUri } + : { ...metadata, type }; + const queued = { + environmentId, + threadId: ThreadId.make("thread-2"), + messageId: MessageId.make("queued-1"), + commandId: CommandId.make("command-1"), + text: "Send later", + attachments: [file], + createdAt: "2026-08-31T12:00:00.000Z", + }; + appAtomRegistry.set(composerDraftsAtom, { + [key]: { text: "Unsent notes", attachments: [file] }, + "direct-environment:thread-1": DRAFT, + "pending-task:queued-1": { text: "Edited queued task", attachments: [file] }, + }); + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { queued: [queued] }); + await archiveCloudComposerDrafts("account-a", new Set([environmentId])); + expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ + "direct-environment:thread-1": DRAFT, + }); + // The registry can remove the active outbox and drafts after the backup lands. + appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); + await clearComposerDraftsEnvironment(environmentId); + await releaseUnusedComposerAttachmentFiles([file]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); - appAtomRegistry.set(composerDraftsAtom, {}); - appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); - resetComposerDraftsLoadState(); - await waitForComposerDraftsLoaded(); - await restoreCloudComposerDrafts("account-b"); - expect(getComposerDraftSnapshot(key).attachments).toEqual([]); - expect(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)).toEqual({}); - const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue(); - onTestFinished(() => enqueue.mockRestore()); - await restoreCloudComposerDrafts("account-a"); - expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] }); - expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task"); - expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued); - expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({}); - const persisted = decodePersistedComposerState( - JSON.parse(composerDraftFileMocks.getDocument()), - ); - expect(persisted.drafts[key]?.attachments).toEqual([file]); - expect(persisted.cloudDrafts.accountId).toBe("account-a"); - }); + appAtomRegistry.set(composerDraftsAtom, {}); + appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); + resetComposerDraftsLoadState(); + await waitForComposerDraftsLoaded(); + await restoreCloudComposerDrafts("account-b"); + expect(getComposerDraftSnapshot(key).attachments).toEqual([]); + expect(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)).toEqual({}); + const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue(); + onTestFinished(() => enqueue.mockRestore()); + await restoreCloudComposerDrafts("account-a"); + expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] }); + expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task"); + expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued); + expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({}); + const persisted = decodePersistedComposerState( + JSON.parse(composerDraftFileMocks.getDocument()), + ); + expect(persisted.drafts[key]?.attachments).toEqual([file]); + expect(persisted.cloudDrafts.accountId).toBe("account-a"); + }, + ); it("fails sign-out preservation before cleanup if a durable backup cannot be written", async () => { const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); @@ -480,45 +612,52 @@ describe("mobile composer drafts", () => { ).toEqual(DRAFT); }); - it("keeps a removed file until both playback and a share copy finish", async () => { - const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); - onTestFinished(() => outboxLoad.mockRestore()); - const fileName = "33333333-3333-4333-8333-333333333333-recording.mp4"; - const file = { - id: "file-preview", - type: "file" as const, - name: "recording.mp4", - mimeType: "video/mp4", - sizeBytes: 42, - fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`, - }; - const currentFile = { - ...file, - fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`, - }; - const releasePlayback = retainComposerAttachmentFileForPreview(file); - const releaseShareCopy = retainComposerAttachmentFileForPreview(currentFile); - onTestFinished(releasePlayback); - onTestFinished(releaseShareCopy); + it.each(["file", "image"] as const)( + "keeps a removed %s until both preview and a share copy finish", + async (type) => { + const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); + onTestFinished(() => outboxLoad.mockRestore()); + const name = type === "image" ? "photo.png" : "recording.mp4"; + const fileName = `33333333-3333-4333-8333-333333333333-${name}`; + const metadata = { + id: "file-preview", + name, + mimeType: type === "image" ? "image/png" : "video/mp4", + sizeBytes: 42, + fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`, + }; + const file = + type === "image" + ? { ...metadata, type, previewUri: metadata.fileUri } + : { ...metadata, type }; + const currentFile = { + ...file, + fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`, + }; + const releasePlayback = retainComposerAttachmentFileForPreview(file); + const releaseShareCopy = retainComposerAttachmentFileForPreview(currentFile); + onTestFinished(releasePlayback); + onTestFinished(releaseShareCopy); - await releaseUnusedComposerAttachmentFiles([currentFile]); - expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + await releaseUnusedComposerAttachmentFiles([currentFile]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); - releasePlayback(); - releasePlayback(); - await releaseUnusedComposerAttachmentFiles([file]); - expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); + releasePlayback(); + releasePlayback(); + await releaseUnusedComposerAttachmentFiles([file]); + expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); - const deleted = Promise.withResolvers(); - composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { - deleted.resolve(); - return undefined; - }); - releaseShareCopy(); - await deleted.promise; + const deleted = Promise.withResolvers(); + composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { + deleted.resolve(); + return undefined; + }); + releaseShareCopy(); + await deleted.promise; - expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]); - }); + expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]); + }, + ); it("preserves a preview opened while cleanup is checking the incoming inbox", async () => { const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); @@ -846,6 +985,29 @@ describe("mobile composer drafts", () => { } }); + it("rejects persisted images without image bytes or a file URI", () => { + expect(() => + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": { + text: "Saved image", + attachments: [ + { + id: "image-1", + type: "image", + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + previewUri: "file:///photo.png", + }, + ], + }, + }, + }), + ).toThrow(); + }); + it("hydrates selector state even when the message content is empty", () => { expect( decodePersistedComposerDrafts({ diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 9736be78d29b434760fbeaecf359829769ebfc09..065331d67064d210be1132546a5e9a6f5bf0fbcb 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -20,7 +20,7 @@ import { isComposerAttachmentFileRetained, retainComposerAttachmentFile, } from "../lib/composerAttachmentFiles"; -import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; +import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages"; import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; import { appAtomRegistry } from "./atom-registry"; import { @@ -376,7 +376,7 @@ function isComposerAttachmentFileReferenced(fileUri: string): boolean { return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => owner.attachments.some( (attachment) => - attachment.type === "file" && + attachment.fileUri !== undefined && composerAttachmentFileReferenceKey(attachment.fileUri) === referenceKey, ), ); @@ -403,9 +403,9 @@ export async function releaseUnusedComposerAttachmentFiles( attachments: ReadonlyArray, ): Promise { const candidates = new Set( - attachments - .filter((attachment) => attachment.type === "file") - .map((attachment) => attachment.fileUri), + attachments.flatMap((attachment) => + attachment.fileUri !== undefined ? [attachment.fileUri] : [], + ), ); const uploadCandidates = new Map>(); for (const attachment of attachments) { @@ -454,7 +454,7 @@ export async function releaseUnusedComposerAttachmentFiles( incomingShareFileUris = new Set( incomingShares.flatMap((share) => share.attachments.flatMap((attachment) => - attachment.type === "file" + attachment.fileUri !== undefined ? [composerAttachmentFileReferenceKey(attachment.fileUri)] : [], ), @@ -509,7 +509,8 @@ export function scheduleUnusedComposerAttachmentCleanup( ): void { if ( !attachments.some( - (attachment) => attachment.type === "file" || attachment.uploadedAttachmentId !== undefined, + (attachment) => + attachment.fileUri !== undefined || attachment.uploadedAttachmentId !== undefined, ) ) { return; @@ -521,7 +522,7 @@ export function scheduleUnusedComposerAttachmentCleanup( /** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ export function retainComposerAttachmentFileForPreview( - attachment: DraftComposerFileAttachment, + attachment: FileBackedComposerAttachment, ): () => void { return retainComposerAttachmentFile(attachment.fileUri, () => { scheduleUnusedComposerAttachmentCleanup([attachment]); diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index fc7fa2e408166a34f0b30cdee41ba4d38d03ee9c..651d6499c659fcc6be816fa784e643c92f0d89d8 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -873,7 +873,6 @@ export function useThreadOutboxDrain(): void { messageId: queuedMessage.messageId, createdAt: queuedMessage.createdAt, text: queuedMessage.text.trim(), - attachments: queuedMessage.attachments, uploadedAttachments: prepared.attachments, modelSelection: sendSettings.modelSelection, runtimeMode: sendSettings.runtimeMode, -- 2.54.0