authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 10:20:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 10:20:53-07:00
logc4353bc6b972fa527579b530f01cc8744b2407d2
tree23b3e95bb6a146489ca46d76790dbd26d68306b6
parent5eab021a5185e492b6b021f83564143d5263e8a6
signature Signed by PGP key B5690EEEBB952194

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 <noreply@anthropic.com>

18 files changed, 615 insertions(+), 218 deletions(-)

apps/mobile/src/components/ComposerAttachmentStrip.tsx+73-27
......@@ -1,10 +1,16 @@
11import { SymbolView } from "../components/AppSymbol";
22import { videoMimeType } from "@t3tools/shared/video";
3import { useMemo } from "react";
3import { useEffect, useMemo, useState } from "react";
44import { Image, Pressable, ScrollView, View } from "react-native";
55
66import { AppText as Text } from "./AppText";
7import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages";
7import {
8 isFileBackedComposerAttachment,
9 type DraftComposerAttachment,
10 type DraftComposerFileAttachment,
11 type DraftComposerImageAttachment,
12} from "../lib/composerImages";
13import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachmentFiles";
814import { VideoAttachmentTile } from "./VideoAttachmentTile";
915import type { MediaActionsSource } from "../lib/mediaActions";
1016import { PresentationSource } from "./NativePresentation";
......@@ -87,35 +93,75 @@ export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailPr
8793 );
8894}
8995
96/**
97 * Thumbnail URI for a draft image. File-backed previews rebase into the
98 * current iOS data container (its UUID changes across installs); the raw
99 * persisted URI renders meanwhile, which is correct everywhere but after a
100 * container move.
101 */
102function useComposerImagePreviewUri(attachment: DraftComposerImageAttachment): string {
103 const { fileUri, previewUri } = attachment;
104 const [rebased, setRebased] = useState<{ fileUri: string; uri: string } | null>(null);
105 useEffect(() => {
106 if (fileUri === undefined) return;
107 let cancelled = false;
108 void (async () => {
109 const { Paths } = await import("expo-file-system");
110 const owned = resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri);
111 // Re-render only when the container actually moved.
112 if (!cancelled && owned !== null && owned !== previewUri) setRebased({ fileUri, uri: owned });
113 })();
114 return () => {
115 cancelled = true;
116 };
117 }, [fileUri, previewUri]);
118 return fileUri !== undefined && rebased?.fileUri === fileUri ? rebased.uri : previewUri;
119}
120
121function ComposerImageAttachment(
122 props: ComposerAttachmentThumbnailProps & { readonly attachment: DraftComposerImageAttachment },
123) {
124 const { attachment } = props;
125 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
126 const previewUri = useComposerImagePreviewUri(attachment);
127 const sourceIdentifier = `draft-image:${attachment.id}`;
128 return (
129 <PresentationSource identifier={sourceIdentifier}>
130 <Pressable
131 accessibilityRole="imagebutton"
132 accessibilityLabel={`Open ${attachment.name}`}
133 disabled={!props.onPressPreview}
134 onPress={() =>
135 props.onPressPreview?.(
136 // File-backed images open through the retain-lease + container
137 // rebase path; legacy drafts still carry their inline bytes.
138 isFileBackedComposerAttachment(attachment)
139 ? { kind: "image", attachment, name: attachment.name, sourceIdentifier }
140 : {
141 kind: "image",
142 uri: attachment.dataUrl ?? attachment.previewUri,
143 name: attachment.name,
144 sourceIdentifier,
145 },
146 )
147 }
148 >
149 <Image
150 source={{ uri: previewUri }}
151 style={style}
152 className="bg-subtle"
153 resizeMode="cover"
154 />
155 </Pressable>
156 </PresentationSource>
157 );
158}
159
90160function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) {
91161 const { attachment } = props;
92162 const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
93163 if (attachment.type === "image") {
94 const sourceIdentifier = `draft-image:${attachment.id}`;
95 return (
96 <PresentationSource identifier={sourceIdentifier}>
97 <Pressable
98 accessibilityRole="imagebutton"
99 accessibilityLabel={`Open ${attachment.name}`}
100 disabled={!props.onPressPreview}
101 onPress={() =>
102 props.onPressPreview?.({
103 kind: "image",
104 uri: attachment.dataUrl,
105 name: attachment.name,
106 sourceIdentifier,
107 })
108 }
109 >
110 <Image
111 source={{ uri: attachment.previewUri }}
112 style={style}
113 className="bg-subtle"
114 resizeMode="cover"
115 />
116 </Pressable>
117 </PresentationSource>
118 );
164 return <ComposerImageAttachment {...props} attachment={attachment} />;
119165 }
120166 const onPressVideo = props.onPressVideo;
121167 if (onPressVideo && videoMimeType(attachment) !== null) {
apps/mobile/src/components/FilePreviewModal.tsx+2-2
......@@ -3,7 +3,7 @@ import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
33import { useEffect, useEffectEvent, useState } from "react";
44import { Alert, Keyboard } from "react-native";
55
6import type { DraftComposerFileAttachment } from "../lib/composerImages";
6import type { FileBackedComposerAttachment } from "../lib/composerImages";
77import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
88import type { MediaActionsSource } from "../lib/mediaActions";
99import { useAssetUrlState } from "../state/assets";
......@@ -22,7 +22,7 @@ export interface ResolvedFilePreviewSource {
2222export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> &
2323 (
2424 | { readonly uri: string }
25 | { readonly attachment: DraftComposerFileAttachment }
25 | { readonly attachment: FileBackedComposerAttachment }
2626 | { readonly environmentId: EnvironmentId; readonly resource: AssetResource }
2727 );
2828
apps/mobile/src/features/threads/use-project-actions.ts-1
......@@ -138,7 +138,6 @@ export function useCreateProjectThread() {
138138 messageId: metadata.messageId,
139139 createdAt: metadata.createdAt,
140140 text: initialMessageText,
141 attachments: input.initialAttachments,
142141 uploadedAttachments: prepared.attachments,
143142 modelSelection: input.modelSelection,
144143 runtimeMode: input.runtimeMode,
apps/mobile/src/lib/attachmentUpload.test.ts+117
......@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
1414 upload: vi.fn(),
1515 writeFile: vi.fn(),
1616 deleteFile: vi.fn(),
17 readBase64: vi.fn(),
1718}));
1819
1920vi.mock("@t3tools/client-runtime/state/runtime", () => ({
......@@ -30,6 +31,11 @@ vi.mock("../state/atom-registry", () => ({
3031 appAtomRegistry: { get: mocks.readAtom },
3132}));
3233
34// The real read lease and cleanup are covered by the composer ownership suite.
35vi.mock("../state/use-composer-drafts", () => ({
36 retainComposerAttachmentFileForPreview: () => () => {},
37}));
38
3339vi.mock("../state/assets", () => ({
3440 assetEnvironment: { createUrl: mocks.createAssetUrl },
3541}));
......@@ -68,6 +74,10 @@ vi.mock("expo-file-system", () => ({
6874 mocks.deleteFile(this.uri);
6975 }
7076
77 async base64() {
78 return mocks.readBase64(this.uri);
79 }
80
7181 upload(url: string, options: unknown) {
7282 return mocks.upload(this.uri, url, options);
7383 }
......@@ -102,6 +112,16 @@ const image = {
102112 previewUri: "file:///images/screenshot.png",
103113} as const satisfies DraftComposerAttachment;
104114
115const fileBackedImage = {
116 id: "image-2",
117 type: "image",
118 name: "photo.png",
119 mimeType: "image/png",
120 sizeBytes: 3,
121 fileUri: "file:///documents/t3-composer-attachments/photo.png",
122 previewUri: "file:///documents/t3-composer-attachments/photo.png",
123} as const satisfies DraftComposerAttachment;
124
105125const file = {
106126 id: "file-1",
107127 type: "file",
......@@ -174,6 +194,8 @@ describe("prepareTurnAttachments", () => {
174194 mocks.upload.mockReset();
175195 mocks.writeFile.mockReset();
176196 mocks.deleteFile.mockReset();
197 mocks.readBase64.mockReset();
198 mocks.readBase64.mockResolvedValue("YWJj");
177199 mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" }));
178200 mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) =>
179201 command === mocks.createUploadUrl
......@@ -208,6 +230,101 @@ describe("prepareTurnAttachments", () => {
208230 expect(mocks.upload).not.toHaveBeenCalled();
209231 });
210232
233 it("inlines a file-backed image lazily when the server lacks image uploads", async () => {
234 const prepared = await prepareTurnAttachments({
235 environmentId,
236 attachments: [fileBackedImage],
237 });
238
239 expect(prepared.status).toBe("ready");
240 if (prepared.status !== "ready") return;
241 expect(prepared.attachments).toEqual([
242 {
243 type: "image",
244 name: "photo.png",
245 mimeType: "image/png",
246 sizeBytes: 3,
247 dataUrl: "data:image/png;base64,YWJj",
248 },
249 ]);
250 expect(mocks.readBase64).toHaveBeenCalledExactlyOnceWith(fileBackedImage.fileUri);
251 expect(mocks.upload).not.toHaveBeenCalled();
252 });
253
254 it("reads restored images from the current iOS document container for legacy sends", async () => {
255 const fileName = "33333333-3333-4333-8333-333333333333-photo.png";
256 mocks.documentUri =
257 "file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents";
258 const oldUri = `file:///var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`;
259 const prepared = await prepareTurnAttachments({
260 environmentId,
261 attachments: [{ ...fileBackedImage, fileUri: oldUri, previewUri: oldUri }],
262 });
263 expect(prepared.status).toBe("ready");
264 expect(mocks.readBase64).toHaveBeenCalledExactlyOnceWith(
265 `${mocks.documentUri}/t3-composer-attachments/${fileName}`,
266 );
267 });
268
269 it("rejects a legacy image without inline bytes or a file", async () => {
270 const { fileUri: _, ...missingImage } = fileBackedImage;
271 await expect(
272 prepareTurnAttachments({ environmentId, attachments: [missingImage] }),
273 ).rejects.toThrow("'photo.png' is no longer available. Attach the image again.");
274 });
275
276 it.each(["resolve", "reject"] as const)(
277 "abandons a canceled legacy image read that later %ss",
278 async (outcome) => {
279 const readStarted = Promise.withResolvers<void>();
280 const read = Promise.withResolvers<string>();
281 const controller = new AbortController();
282 mocks.readBase64.mockImplementation(() => {
283 readStarted.resolve();
284 return read.promise;
285 });
286 const preparing = prepareTurnAttachments({
287 environmentId,
288 attachments: [fileBackedImage],
289 signal: controller.signal,
290 });
291 await readStarted.promise;
292 controller.abort();
293 if (outcome === "resolve") read.resolve("YWJj");
294 else read.reject(new Error("Native read failed after cancellation"));
295
296 await expect(preparing).resolves.toEqual({ status: "abandoned" });
297 },
298 );
299
300 it("uploads a file-backed image from its owned copy without staging base64", async () => {
301 const prepared = await prepareTurnAttachments({
302 environmentId,
303 attachments: [fileBackedImage],
304 supportsImageUploads: true,
305 });
306
307 expect(mocks.upload).toHaveBeenCalledWith(
308 fileBackedImage.fileUri,
309 "https://environment.example/api/attachments/upload/signed",
310 expect.objectContaining({ headers: { "Content-Type": "image/png" } }),
311 );
312 expect(mocks.readBase64).not.toHaveBeenCalled();
313 expect(mocks.writeFile).not.toHaveBeenCalled();
314 expect(mocks.deleteFile).not.toHaveBeenCalled();
315 expect(prepared.status).toBe("ready");
316 if (prepared.status !== "ready") return;
317 expect(prepared.attachments).toEqual([
318 {
319 type: "image",
320 id: MINTED_ID,
321 name: "photo.png",
322 mimeType: "image/png",
323 sizeBytes: 3,
324 },
325 ]);
326 });
327
211328 it("uploads generic file bytes directly and keeps mixed attachment order", async () => {
212329 const prepared = await prepareTurnAttachments({ environmentId, attachments: [file, image] });
213330
apps/mobile/src/lib/attachmentUpload.ts+70-16
......@@ -20,8 +20,13 @@ import { appAtomRegistry } from "../state/atom-registry";
2020import { assetEnvironment } from "../state/assets";
2121import { attachmentEnvironment } from "../state/attachments";
2222import { environmentSession } from "../state/session";
23import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts";
2324import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
24import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages";
25import {
26 isFileBackedComposerAttachment,
27 type DraftComposerAttachment,
28 type DraftComposerImageAttachment,
29} from "./composerImages";
2530import { uuidv4 } from "./uuid";
2631
2732/**
......@@ -179,6 +184,48 @@ function attachmentUploadInput(attachment: DraftComposerAttachment) {
179184 return { ...fields, mimeType };
180185}
181186
187/**
188 * Wire shape for startTurn on servers without attachment uploads: pure inline
189 * uploads without client draft id / previewUri. File-backed images read their
190 * base64 from disk lazily, only when this legacy path is actually taken.
191 */
192async function toUploadChatImageAttachments(
193 attachments: ReadonlyArray<DraftComposerImageAttachment>,
194): Promise<ReadonlyArray<UploadChatImageAttachment>> {
195 return Promise.all(
196 attachments.map(async (attachment) => ({
197 type: attachment.type,
198 name: attachment.name,
199 mimeType: attachment.mimeType,
200 sizeBytes: attachment.sizeBytes,
201 dataUrl: await composerImageAttachmentDataUrl(attachment),
202 })),
203 );
204}
205
206/** Inline bytes for one image: legacy drafts carry them, file-backed ones read them from disk. */
207async function composerImageAttachmentDataUrl(
208 attachment: DraftComposerImageAttachment,
209): Promise<string> {
210 if (attachment.dataUrl !== undefined) {
211 return attachment.dataUrl;
212 }
213 if (!isFileBackedComposerAttachment(attachment)) {
214 throw new Error(`'${attachment.name}' is no longer available. Attach the image again.`);
215 }
216 const release = retainComposerAttachmentFileForPreview(attachment);
217 try {
218 const { File, Paths } = await import("expo-file-system");
219 const uri =
220 resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ??
221 attachment.fileUri;
222 const base64 = await new File(uri).base64();
223 return `data:${attachment.mimeType};base64,${base64}`;
224 } finally {
225 release();
226 }
227}
228
182229async function uploadFileBytes(
183230 attachment: DraftComposerAttachment,
184231 url: string,
......@@ -187,17 +234,21 @@ async function uploadFileBytes(
187234): Promise<void> {
188235 const { File, Paths, UploadType } = await import("expo-file-system");
189236 if (signal.aborted) throw new Error("Upload cancelled.");
237 // Legacy image drafts persisted inline bytes and stage them in a temp cache
238 // file for the native uploader. Everything else uploads its owned copy.
239 const fileUri = attachment.fileUri;
240 const inlineDataUrl = attachment.type === "image" ? attachment.dataUrl : undefined;
241 if (fileUri === undefined && inlineDataUrl === undefined) {
242 throw new Error(`'${attachment.name}' is no longer available. Attach the image again.`);
243 }
190244 const file =
191 attachment.type === "image"
245 fileUri === undefined
192246 ? new File(Paths.cache, `t3-upload-${uuidv4()}`)
193 : new File(
194 resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ??
195 attachment.fileUri,
196 );
247 : new File(resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri) ?? fileUri);
197248 try {
198 if (attachment.type === "image") {
249 if (fileUri === undefined && inlineDataUrl !== undefined) {
199250 file.create();
200 file.write(attachment.dataUrl.slice(attachment.dataUrl.indexOf(",") + 1), {
251 file.write(inlineDataUrl.slice(inlineDataUrl.indexOf(",") + 1), {
201252 encoding: "base64",
202253 });
203254 }
......@@ -218,7 +269,7 @@ async function uploadFileBytes(
218269 throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`);
219270 }
220271 } finally {
221 if (attachment.type === "image" && file.exists) file.delete();
272 if (fileUri === undefined && file.exists) file.delete();
222273 }
223274}
224275
......@@ -259,13 +310,16 @@ export async function prepareTurnAttachments(input: {
259310 });
260311
261312 if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) {
262 return ready(
263 toUploadChatImageAttachments(
313 try {
314 const imageAttachments = await toUploadChatImageAttachments(
264315 input.attachments.filter((attachment) => attachment.type === "image"),
265 ),
266 [],
267 input.attachments,
268 );
316 );
317 if (input.signal?.aborted) return { status: "abandoned" };
318 return ready(imageAttachments, [], input.attachments);
319 } catch (error) {
320 if (input.signal?.aborted) return { status: "abandoned" };
321 throw error;
322 }
269323 }
270324
271325 const connection = appAtomRegistry.get(
......@@ -285,7 +339,7 @@ export async function prepareTurnAttachments(input: {
285339 for (const attachment of input.attachments) {
286340 if (controller.signal.aborted) throw new Error("Upload cancelled.");
287341 if (attachment.type === "image" && !input.supportsImageUploads) {
288 uploadedAttachments.push(...toUploadChatImageAttachments([attachment]));
342 uploadedAttachments.push(...(await toUploadChatImageAttachments([attachment])));
289343 continue;
290344 }
291345
apps/mobile/src/lib/composer-image-schema.ts+11-2
......@@ -8,10 +8,19 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({
88 name: Schema.String,
99 mimeType: Schema.String,
1010 sizeBytes: Schema.Number,
11 dataUrl: Schema.String,
11 // Accept future file-backed records before enabling the new image writers.
12 fileUri: Schema.optional(Schema.String.check(Schema.isNonEmpty())),
13 dataUrl: Schema.optional(Schema.String.check(Schema.isNonEmpty())),
1214 uploadedAttachmentId: Schema.optional(Schema.String),
1315 uploadEnvironmentId: Schema.optional(EnvironmentId),
14});
16}).check(
17 Schema.makeFilter(
18 ({ fileUri, dataUrl }) =>
19 fileUri !== undefined ||
20 dataUrl !== undefined ||
21 "Image attachment has no file or inline bytes.",
22 ),
23);
1524
1625export const DraftComposerFileAttachmentSchema = Schema.Struct({
1726 id: Schema.String,
apps/mobile/src/lib/composerImages.test.ts+1-31
......@@ -36,37 +36,7 @@ vi.mock("./uuid", () => ({
3636 uuidv4: () => "attachment-id",
3737}));
3838
39import {
40 convertPastedImagesToAttachments,
41 isOwnedPastedImageUri,
42 toUploadChatImageAttachments,
43} from "./composerImages";
44
45describe("toUploadChatImageAttachments", () => {
46 it("strips client draft id and previewUri for the startTurn wire shape", () => {
47 expect(
48 toUploadChatImageAttachments([
49 {
50 id: "client-draft-id",
51 type: "image",
52 name: "pasted-image.png",
53 mimeType: "image/png",
54 sizeBytes: 12,
55 dataUrl: "data:image/png;base64,AA==",
56 previewUri: "file:///tmp/preview.png",
57 },
58 ]),
59 ).toEqual([
60 {
61 type: "image",
62 name: "pasted-image.png",
63 mimeType: "image/png",
64 sizeBytes: 12,
65 dataUrl: "data:image/png;base64,AA==",
66 },
67 ]);
68 });
69});
39import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages";
7040
7141describe("native pasted image cleanup", () => {
7242 beforeEach(() => {
apps/mobile/src/lib/composerImages.ts+13-12
......@@ -20,9 +20,13 @@ import {
2020import { beginForegroundHandoff } from "./foreground-handoff";
2121import { uuidv4 } from "./uuid";
2222
23export interface DraftComposerImageAttachment extends UploadChatImageAttachment {
23export interface DraftComposerImageAttachment extends Omit<UploadChatImageAttachment, "dataUrl"> {
2424 readonly id: string;
2525 readonly previewUri: string;
26 /** Owned image bytes from a file-backed draft. Current writers still use inline bytes. */
27 readonly fileUri?: string;
28 /** Inline bytes from current writers and older drafts. */
29 readonly dataUrl?: string;
2630 readonly uploadedAttachmentId?: string;
2731 readonly uploadEnvironmentId?: EnvironmentId;
2832}
......@@ -40,17 +44,14 @@ export interface DraftComposerFileAttachment {
4044
4145export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment;
4246
43/** Wire shape for startTurn: pure uploads without client draft id / previewUri. */
44export function toUploadChatImageAttachments(
45 attachments: ReadonlyArray<DraftComposerImageAttachment>,
46): ReadonlyArray<UploadChatImageAttachment> {
47 return attachments.map((attachment) => ({
48 type: attachment.type,
49 name: attachment.name,
50 mimeType: attachment.mimeType,
51 sizeBytes: attachment.sizeBytes,
52 dataUrl: attachment.dataUrl,
53 }));
47/** Any composer attachment whose bytes live in the app-owned attachment directory. */
48export type FileBackedComposerAttachment = DraftComposerAttachment & { readonly fileUri: string };
49
50/** Files have a local copy. Images can have one after a file-backed draft is restored. */
51export function isFileBackedComposerAttachment(
52 attachment: DraftComposerAttachment,
53): attachment is FileBackedComposerAttachment {
54 return attachment.fileUri !== undefined;
5455}
5556
5657const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste";
apps/mobile/src/lib/localAttachmentPreview.test.ts+18
......@@ -46,6 +46,24 @@ beforeEach(() => {
4646});
4747
4848describe("loadLocalAttachmentPreview", () => {
49 it("keeps an image MIME type when its filename has a video extension", async () => {
50 const image = {
51 ...attachment,
52 type: "image" as const,
53 name: "photo.mov",
54 mimeType: "image/png",
55 previewUri: attachment.fileUri,
56 };
57 const preview = await loadLocalAttachmentPreview(image, new AbortController().signal);
58 await preview!.share(new AbortController().signal);
59 expect(mocks.share).toHaveBeenCalledWith(
60 expect.objectContaining({
61 attachment: { name: "photo.mov", mimeType: "image/png" },
62 }),
63 );
64 preview!.dispose();
65 });
66
4967 it("retains and shares a PDF with its original filename and type", async () => {
5068 const pdf = { ...attachment, name: "report.pdf", mimeType: "application/pdf" };
5169 const preview = await loadLocalAttachmentPreview(pdf, new AbortController().signal);
apps/mobile/src/lib/localAttachmentPreview.ts+6-3
......@@ -1,13 +1,13 @@
11import { videoMimeType } from "@t3tools/shared/video";
22
3import type { DraftComposerFileAttachment } from "./composerImages";
3import type { FileBackedComposerAttachment } from "./composerImages";
44import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles";
55import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload";
66import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts";
77
88/** Retains the draft original for preview and gives each outgoing share its own lease. */
99export async function loadLocalAttachmentPreview(
10 attachment: DraftComposerFileAttachment,
10 attachment: FileBackedComposerAttachment,
1111 signal: AbortSignal,
1212): Promise<AttachmentPreviewFile | null> {
1313 if (signal.aborted) return null;
......@@ -41,7 +41,10 @@ export async function loadLocalAttachmentPreview(
4141 uri: file.uri,
4242 attachment: {
4343 name: attachment.name,
44 mimeType: videoMimeType(attachment) ?? attachment.mimeType,
44 mimeType:
45 attachment.type === "file"
46 ? (videoMimeType(attachment) ?? attachment.mimeType)
47 : attachment.mimeType,
4548 },
4649 signal: shareSignal,
4750 sourceIdentifier,
apps/mobile/src/lib/projectThreadStartTurn.test.ts+2-4
......@@ -6,15 +6,13 @@ import {
66 ThreadId,
77} from "@t3tools/contracts";
88import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations";
9import { describe, expect, it, vi } from "vite-plus/test";
9import { describe, expect, it } from "vite-plus/test";
1010
1111import {
1212 buildProjectThreadStartTurnInput,
1313 deriveThreadTitleFromPrompt,
1414} from "./projectThreadStartTurn";
1515
16vi.mock("./composerImages", () => ({ toUploadChatImageAttachments: () => [] }));
17
1816describe("project thread title", () => {
1917 it("keeps ordinary titles and the empty-prompt fallback", () => {
2018 expect(deriveThreadTitleFromPrompt(" Fix\n the parser ")).toBe("Fix the parser");
......@@ -52,7 +50,7 @@ describe("project thread title", () => {
5250 messageId: "message",
5351 createdAt: "2026-09-01T00:00:00Z",
5452 text,
55 attachments: [],
53 uploadedAttachments: [],
5654 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" },
5755 runtimeMode: "full-access",
5856 interactionMode: "default",
apps/mobile/src/lib/projectThreadStartTurn.ts+3-8
......@@ -9,7 +9,6 @@ import {
99} from "@t3tools/contracts";
1010import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
1111
12import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages";
1312import type { UploadedMobileAttachment } from "./attachmentUpload";
1413
1514export function deriveThreadTitleFromPrompt(value: string): string {
......@@ -30,8 +29,8 @@ export interface ProjectThreadStartTurnSpec {
3029 readonly messageId: string;
3130 readonly createdAt: string;
3231 readonly text: string;
33 readonly attachments: ReadonlyArray<DraftComposerAttachment>;
34 readonly uploadedAttachments?: ReadonlyArray<UploadedMobileAttachment>;
32 /** Wire attachments from `prepareTurnAttachments`, in composer order. */
33 readonly uploadedAttachments: ReadonlyArray<UploadedMobileAttachment>;
3534 readonly modelSelection: ModelSelection;
3635 readonly runtimeMode: RuntimeMode;
3736 readonly interactionMode: ProviderInteractionMode;
......@@ -58,11 +57,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe
5857 messageId: MessageId.make(spec.messageId),
5958 role: "user" as const,
6059 text: spec.text,
61 attachments:
62 spec.uploadedAttachments ??
63 toUploadChatImageAttachments(
64 spec.attachments.filter((attachment) => attachment.type === "image"),
65 ),
60 attachments: spec.uploadedAttachments,
6661 },
6762 modelSelection: spec.modelSelection,
6863 titleSeed: title,
apps/mobile/src/state/composer-attachment-uploads.ts+4-4
......@@ -4,6 +4,7 @@ import { Atom } from "effect/unstable/reactivity";
44import { useEffect, useRef } from "react";
55
66import { prepareTurnAttachments } from "../lib/attachmentUpload";
7import { isFileBackedComposerAttachment } from "../lib/composerImages";
78import {
89 composerAttachmentUploadKey,
910 composerDraftEnvironmentId,
......@@ -60,10 +61,9 @@ export function useComposerAttachmentUploadWorker() {
6061 const queue = createComposerAttachmentUploadQueue({
6162 onChange: (states) => appAtomRegistry.set(composerAttachmentUploadsAtom, states),
6263 upload: async ({ environmentId, attachment }, signal, onProgress) => {
63 const release =
64 attachment.type === "file"
65 ? retainComposerAttachmentFileForPreview(attachment)
66 : undefined;
64 const release = isFileBackedComposerAttachment(attachment)
65 ? retainComposerAttachmentFileForPreview(attachment)
66 : undefined;
6767 try {
6868 const result = await prepareTurnAttachments({
6969 environmentId,
apps/mobile/src/state/thread-outbox-model.ts+2-1
......@@ -27,6 +27,7 @@ import type { DraftComposerAttachment } from "../lib/composerImages";
2727import { scopedThreadKey } from "../lib/scopedEntities";
2828import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode";
2929
30// Keep current writes until a compatible native baseline includes the v4 reader.
3031const THREAD_OUTBOX_SCHEMA_VERSION = 3;
3132const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;
3233
......@@ -43,7 +44,7 @@ const QueuedThreadCreationSchema = Schema.Struct({
4344});
4445
4546export const QueuedThreadMessageSchema = Schema.Struct({
46 schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION]),
47 schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION, 4]),
4748 environmentId: EnvironmentId,
4849 threadId: ThreadId,
4950 messageId: MessageId,
apps/mobile/src/state/thread-outbox.test.ts+24
......@@ -196,6 +196,30 @@ describe("thread outbox", () => {
196196 expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(message))).toEqual(message);
197197 });
198198
199 it("reads file-backed images from v4 queued messages", () => {
200 const message = {
201 ...queuedMessage({
202 messageId: "message-image",
203 createdAt: "2026-06-08T10:00:01.000Z",
204 }),
205 attachments: [
206 {
207 id: "image-1",
208 type: "image" as const,
209 name: "photo.png",
210 mimeType: "image/png",
211 sizeBytes: 3,
212 fileUri: "file:///documents/t3-composer-attachments/photo.png",
213 previewUri: "file:///documents/t3-composer-attachments/photo.png",
214 uploadedAttachmentId: "pending-photo-png",
215 uploadEnvironmentId: EnvironmentId.make("environment-1"),
216 },
217 ],
218 } satisfies QueuedThreadMessage;
219
220 expect(decodeQueuedThreadMessage({ ...message, schemaVersion: 4 })).toEqual(message);
221 });
222
199223 it("persists the exact selector snapshot while remaining compatible with v1 messages", () => {
200224 const legacyMessage = queuedMessage({
201225 messageId: "message-1",
apps/mobile/src/state/use-composer-drafts.test.ts+260-98
......@@ -17,8 +17,10 @@ const composerDraftFileMocks = vi.hoisted(() => {
1717 let nextWriteBarrier: Promise<void> | null = null;
1818 let onWrite: (() => void) | null = null;
1919 const writes: string[] = [];
20 const readImage = vi.fn(async () => "YWJj");
2021
2122 return {
23 readImage,
2224 blockRead() {
2325 readBarrier = new Promise<void>((resolve) => {
2426 releaseRead = resolve;
......@@ -73,6 +75,10 @@ const composerDraftFileMocks = vi.hoisted(() => {
7375 return document;
7476 }
7577
78 async base64() {
79 return readImage();
80 }
81
7682 write(value: string) {
7783 if (writeError) {
7884 throw writeError;
......@@ -108,13 +114,29 @@ const incomingShareStorageMocks = vi.hoisted(() => ({
108114vi.mock("expo-file-system", () => ({
109115 Directory: composerDraftFileMocks.Directory,
110116 File: composerDraftFileMocks.File,
111 Paths: { document: "/documents" },
117 Paths: { document: { uri: "file:///documents" } },
112118}));
113119
114vi.mock("../lib/composerImages", () => ({
120vi.mock("../lib/composerImages", async (importOriginal) => ({
121 ...(await importOriginal<typeof import("../lib/composerImages")>()),
115122 removePersistedComposerAttachmentFile: composerAttachmentCleanupMocks.remove,
116123}));
117124
125vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "0000" }));
126vi.mock("./assets", () => ({ assetEnvironment: {} }));
127vi.mock("./attachments", () => ({ attachmentEnvironment: {} }));
128vi.mock("./session", () => ({ environmentSession: {} }));
129vi.mock("@t3tools/client-runtime/state/runtime", () => ({
130 createEnvironmentRpcCommand: () => Symbol("rpc-command"),
131 executeAtomQuery: () => {
132 throw new Error("Unexpected network query in the inline read test");
133 },
134 runAtomCommand: () => {
135 throw new Error("Unexpected network command in the inline read test");
136 },
137 squashAtomCommandFailure: (result: { readonly error: unknown }) => result.error,
138}));
139
118140vi.mock("../lib/attachmentUpload", () => ({
119141 releasePendingAttachmentUploads: composerAttachmentCleanupMocks.releaseUploads,
120142}));
......@@ -128,6 +150,7 @@ import { threadOutboxManager } from "./thread-outbox";
128150import {
129151 appendComposerDraftAttachments,
130152 archiveCloudComposerDrafts,
153 clearComposerDraftContent,
131154 clearComposerDraftContentState,
132155 clearComposerDraftsEnvironment,
133156 ComposerDraftPersistenceError,
......@@ -171,6 +194,8 @@ afterEach(() => {
171194 composerDraftFileMocks.setNextWriteBarrier(null);
172195 composerDraftFileMocks.setOnWrite(null);
173196 composerDraftFileMocks.resetWrites();
197 composerDraftFileMocks.readImage.mockReset();
198 composerDraftFileMocks.readImage.mockResolvedValue("YWJj");
174199 appAtomRegistry.set(composerDraftsAtom, {});
175200 appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} });
176201 appAtomRegistry.set(stickyComposerModelSelectionAtom, null);
......@@ -313,6 +338,106 @@ describe("mobile composer drafts", () => {
313338 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri);
314339 });
315340
341 it("retains a referenced file-backed image and releases it once unreferenced", async () => {
342 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
343 onTestFinished(() => outboxLoad.mockRestore());
344 const image = {
345 id: "image-1",
346 type: "image" as const,
347 name: "photo.png",
348 mimeType: "image/png",
349 sizeBytes: 3,
350 fileUri: "file:///documents/t3-composer-attachments/photo.png",
351 previewUri: "file:///documents/t3-composer-attachments/photo.png",
352 };
353 appAtomRegistry.set(composerDraftsAtom, {
354 "environment-1:thread-1": { text: "look at this", attachments: [image] },
355 });
356
357 await releaseUnusedComposerAttachmentFiles([image]);
358 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
359
360 // A queued outbox message must keep the bytes alive after the draft clears.
361 appAtomRegistry.set(composerDraftsAtom, {});
362 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {
363 "environment-1:thread-1": [
364 {
365 environmentId: EnvironmentId.make("environment-1"),
366 threadId: ThreadId.make("thread-1"),
367 messageId: MessageId.make("queued-image"),
368 commandId: CommandId.make("command-image"),
369 text: "look at this",
370 attachments: [image],
371 createdAt: "2026-08-31T12:00:00.000Z",
372 },
373 ],
374 });
375 await releaseUnusedComposerAttachmentFiles([image]);
376 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
377
378 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});
379 await releaseUnusedComposerAttachmentFiles([image]);
380 expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledExactlyOnceWith(image.fileUri);
381 });
382
383 it("keeps an image through an inline read after its draft is removed", async () => {
384 const { prepareTurnAttachments } =
385 await vi.importActual<typeof import("../lib/attachmentUpload")>("../lib/attachmentUpload");
386 const image = {
387 id: "image-reading",
388 type: "image" as const,
389 name: "photo.png",
390 mimeType: "image/png",
391 sizeBytes: 3,
392 fileUri: "file:///documents/t3-composer-attachments/photo.png",
393 previewUri: "file:///documents/t3-composer-attachments/photo.png",
394 };
395 const readStarted = Promise.withResolvers<void>();
396 const read = Promise.withResolvers<void>();
397 const removed = Promise.withResolvers<void>();
398 let imageExists = true;
399 composerDraftFileMocks.readImage.mockImplementation(async () => {
400 readStarted.resolve();
401 await read.promise;
402 if (!imageExists) throw new Error("The image was deleted during its read");
403 return "YWJj";
404 });
405 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
406 imageExists = false;
407 removed.resolve();
408 });
409 appendComposerDraftAttachments("environment-1:thread-1", [image]);
410 await flushComposerDrafts();
411 const preparing = prepareTurnAttachments({
412 environmentId: EnvironmentId.make("environment-1"),
413 attachments: [image],
414 });
415 onTestFinished(async () => {
416 read.resolve();
417 await preparing.catch(() => undefined);
418 });
419 await readStarted.promise;
420 clearComposerDraftContent("environment-1:thread-1", { deferAttachmentCleanup: true });
421 await releaseUnusedComposerAttachmentFiles([image]);
422 expect(imageExists).toBe(true);
423
424 read.resolve();
425 const prepared = await preparing;
426 expect(prepared.status).toBe("ready");
427 if (prepared.status !== "ready") return;
428 expect(prepared.attachments).toEqual([
429 {
430 type: "image",
431 name: "photo.png",
432 mimeType: "image/png",
433 sizeBytes: 3,
434 dataUrl: "data:image/png;base64,YWJj",
435 },
436 ]);
437 await removed.promise;
438 expect(imageExists).toBe(false);
439 });
440
316441 it("keeps a failed-send draft's pending upload for retry", async () => {
317442 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
318443 onTestFinished(() => outboxLoad.mockRestore());
......@@ -394,68 +519,75 @@ describe("mobile composer drafts", () => {
394519 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
395520 });
396521
397 it("keeps signed-out files through cleanup and restart, and restores only the owning account", async () => {
398 const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
399 onTestFinished(() => load.mockRestore());
400 await waitForComposerDraftsLoaded();
401 const environmentId = EnvironmentId.make("cloud-environment");
402 const key = `${environmentId}:thread-1`;
403 const file = {
404 id: "local-pdf",
405 type: "file" as const,
406 name: "notes.pdf",
407 mimeType: "application/pdf",
408 sizeBytes: 42,
409 fileUri: "file:///documents/t3-composer-attachments/notes.pdf",
410 uploadEnvironmentId: environmentId,
411 uploadedAttachmentId: "pending-pdf",
412 };
413 const queued = {
414 environmentId,
415 threadId: ThreadId.make("thread-2"),
416 messageId: MessageId.make("queued-1"),
417 commandId: CommandId.make("command-1"),
418 text: "Send later",
419 attachments: [file],
420 createdAt: "2026-08-31T12:00:00.000Z",
421 };
422 appAtomRegistry.set(composerDraftsAtom, {
423 [key]: { text: "Unsent notes", attachments: [file] },
424 "direct-environment:thread-1": DRAFT,
425 "pending-task:queued-1": { text: "Edited queued task", attachments: [file] },
426 });
427 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { queued: [queued] });
428 await archiveCloudComposerDrafts("account-a", new Set([environmentId]));
429 expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({
430 "direct-environment:thread-1": DRAFT,
431 });
432 // The registry can remove the active outbox and drafts after the backup lands.
433 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});
434 await clearComposerDraftsEnvironment(environmentId);
435 await releaseUnusedComposerAttachmentFiles([file]);
436 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
437 expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();
522 it.each(["file", "image"] as const)(
523 "keeps signed-out %s attachments through cleanup and restart, and restores only the owning account",
524 async (type) => {
525 const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
526 onTestFinished(() => load.mockRestore());
527 await waitForComposerDraftsLoaded();
528 const environmentId = EnvironmentId.make("cloud-environment");
529 const key = `${environmentId}:thread-1`;
530 const name = type === "image" ? "notes.png" : "notes.pdf";
531 const metadata = {
532 id: "local-notes",
533 name,
534 mimeType: type === "image" ? "image/png" : "application/pdf",
535 sizeBytes: 42,
536 fileUri: `file:///documents/t3-composer-attachments/${name}`,
537 uploadEnvironmentId: environmentId,
538 uploadedAttachmentId: "pending-notes",
539 };
540 const file =
541 type === "image"
542 ? { ...metadata, type, previewUri: metadata.fileUri }
543 : { ...metadata, type };
544 const queued = {
545 environmentId,
546 threadId: ThreadId.make("thread-2"),
547 messageId: MessageId.make("queued-1"),
548 commandId: CommandId.make("command-1"),
549 text: "Send later",
550 attachments: [file],
551 createdAt: "2026-08-31T12:00:00.000Z",
552 };
553 appAtomRegistry.set(composerDraftsAtom, {
554 [key]: { text: "Unsent notes", attachments: [file] },
555 "direct-environment:thread-1": DRAFT,
556 "pending-task:queued-1": { text: "Edited queued task", attachments: [file] },
557 });
558 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { queued: [queued] });
559 await archiveCloudComposerDrafts("account-a", new Set([environmentId]));
560 expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({
561 "direct-environment:thread-1": DRAFT,
562 });
563 // The registry can remove the active outbox and drafts after the backup lands.
564 appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {});
565 await clearComposerDraftsEnvironment(environmentId);
566 await releaseUnusedComposerAttachmentFiles([file]);
567 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
568 expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled();
438569
439 appAtomRegistry.set(composerDraftsAtom, {});
440 appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} });
441 resetComposerDraftsLoadState();
442 await waitForComposerDraftsLoaded();
443 await restoreCloudComposerDrafts("account-b");
444 expect(getComposerDraftSnapshot(key).attachments).toEqual([]);
445 expect(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)).toEqual({});
446 const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue();
447 onTestFinished(() => enqueue.mockRestore());
448 await restoreCloudComposerDrafts("account-a");
449 expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] });
450 expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task");
451 expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued);
452 expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({});
453 const persisted = decodePersistedComposerState(
454 JSON.parse(composerDraftFileMocks.getDocument()),
455 );
456 expect(persisted.drafts[key]?.attachments).toEqual([file]);
457 expect(persisted.cloudDrafts.accountId).toBe("account-a");
458 });
570 appAtomRegistry.set(composerDraftsAtom, {});
571 appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} });
572 resetComposerDraftsLoadState();
573 await waitForComposerDraftsLoaded();
574 await restoreCloudComposerDrafts("account-b");
575 expect(getComposerDraftSnapshot(key).attachments).toEqual([]);
576 expect(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)).toEqual({});
577 const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue();
578 onTestFinished(() => enqueue.mockRestore());
579 await restoreCloudComposerDrafts("account-a");
580 expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] });
581 expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task");
582 expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued);
583 expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({});
584 const persisted = decodePersistedComposerState(
585 JSON.parse(composerDraftFileMocks.getDocument()),
586 );
587 expect(persisted.drafts[key]?.attachments).toEqual([file]);
588 expect(persisted.cloudDrafts.accountId).toBe("account-a");
589 },
590 );
459591
460592 it("fails sign-out preservation before cleanup if a durable backup cannot be written", async () => {
461593 const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
......@@ -480,45 +612,52 @@ describe("mobile composer drafts", () => {
480612 ).toEqual(DRAFT);
481613 });
482614
483 it("keeps a removed file until both playback and a share copy finish", async () => {
484 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
485 onTestFinished(() => outboxLoad.mockRestore());
486 const fileName = "33333333-3333-4333-8333-333333333333-recording.mp4";
487 const file = {
488 id: "file-preview",
489 type: "file" as const,
490 name: "recording.mp4",
491 mimeType: "video/mp4",
492 sizeBytes: 42,
493 fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`,
494 };
495 const currentFile = {
496 ...file,
497 fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`,
498 };
499 const releasePlayback = retainComposerAttachmentFileForPreview(file);
500 const releaseShareCopy = retainComposerAttachmentFileForPreview(currentFile);
501 onTestFinished(releasePlayback);
502 onTestFinished(releaseShareCopy);
615 it.each(["file", "image"] as const)(
616 "keeps a removed %s until both preview and a share copy finish",
617 async (type) => {
618 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
619 onTestFinished(() => outboxLoad.mockRestore());
620 const name = type === "image" ? "photo.png" : "recording.mp4";
621 const fileName = `33333333-3333-4333-8333-333333333333-${name}`;
622 const metadata = {
623 id: "file-preview",
624 name,
625 mimeType: type === "image" ? "image/png" : "video/mp4",
626 sizeBytes: 42,
627 fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`,
628 };
629 const file =
630 type === "image"
631 ? { ...metadata, type, previewUri: metadata.fileUri }
632 : { ...metadata, type };
633 const currentFile = {
634 ...file,
635 fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`,
636 };
637 const releasePlayback = retainComposerAttachmentFileForPreview(file);
638 const releaseShareCopy = retainComposerAttachmentFileForPreview(currentFile);
639 onTestFinished(releasePlayback);
640 onTestFinished(releaseShareCopy);
503641
504 await releaseUnusedComposerAttachmentFiles([currentFile]);
505 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
642 await releaseUnusedComposerAttachmentFiles([currentFile]);
643 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
506644
507 releasePlayback();
508 releasePlayback();
509 await releaseUnusedComposerAttachmentFiles([file]);
510 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
645 releasePlayback();
646 releasePlayback();
647 await releaseUnusedComposerAttachmentFiles([file]);
648 expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled();
511649
512 const deleted = Promise.withResolvers<void>();
513 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
514 deleted.resolve();
515 return undefined;
516 });
517 releaseShareCopy();
518 await deleted.promise;
650 const deleted = Promise.withResolvers<void>();
651 composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => {
652 deleted.resolve();
653 return undefined;
654 });
655 releaseShareCopy();
656 await deleted.promise;
519657
520 expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]);
521 });
658 expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]);
659 },
660 );
522661
523662 it("preserves a preview opened while cleanup is checking the incoming inbox", async () => {
524663 const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true);
......@@ -846,6 +985,29 @@ describe("mobile composer drafts", () => {
846985 }
847986 });
848987
988 it("rejects persisted images without image bytes or a file URI", () => {
989 expect(() =>
990 decodePersistedComposerDrafts({
991 schemaVersion: 1,
992 drafts: {
993 "environment-1:thread-1": {
994 text: "Saved image",
995 attachments: [
996 {
997 id: "image-1",
998 type: "image",
999 name: "photo.png",
1000 mimeType: "image/png",
1001 sizeBytes: 3,
1002 previewUri: "file:///photo.png",
1003 },
1004 ],
1005 },
1006 },
1007 }),
1008 ).toThrow();
1009 });
1010
8491011 it("hydrates selector state even when the message content is empty", () => {
8501012 expect(
8511013 decodePersistedComposerDrafts({
apps/mobile/src/state/use-composer-drafts.ts+9-8
......@@ -20,7 +20,7 @@ import {
2020 isComposerAttachmentFileRetained,
2121 retainComposerAttachmentFile,
2222} from "../lib/composerAttachmentFiles";
23import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages";
23import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages";
2424import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
2525import { appAtomRegistry } from "./atom-registry";
2626import {
......@@ -376,7 +376,7 @@ function isComposerAttachmentFileReferenced(fileUri: string): boolean {
376376 return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) =>
377377 owner.attachments.some(
378378 (attachment) =>
379 attachment.type === "file" &&
379 attachment.fileUri !== undefined &&
380380 composerAttachmentFileReferenceKey(attachment.fileUri) === referenceKey,
381381 ),
382382 );
......@@ -403,9 +403,9 @@ export async function releaseUnusedComposerAttachmentFiles(
403403 attachments: ReadonlyArray<DraftComposerAttachment>,
404404): Promise<void> {
405405 const candidates = new Set(
406 attachments
407 .filter((attachment) => attachment.type === "file")
408 .map((attachment) => attachment.fileUri),
406 attachments.flatMap((attachment) =>
407 attachment.fileUri !== undefined ? [attachment.fileUri] : [],
408 ),
409409 );
410410 const uploadCandidates = new Map<EnvironmentId, Set<string>>();
411411 for (const attachment of attachments) {
......@@ -454,7 +454,7 @@ export async function releaseUnusedComposerAttachmentFiles(
454454 incomingShareFileUris = new Set(
455455 incomingShares.flatMap((share) =>
456456 share.attachments.flatMap((attachment) =>
457 attachment.type === "file"
457 attachment.fileUri !== undefined
458458 ? [composerAttachmentFileReferenceKey(attachment.fileUri)]
459459 : [],
460460 ),
......@@ -509,7 +509,8 @@ export function scheduleUnusedComposerAttachmentCleanup(
509509): void {
510510 if (
511511 !attachments.some(
512 (attachment) => attachment.type === "file" || attachment.uploadedAttachmentId !== undefined,
512 (attachment) =>
513 attachment.fileUri !== undefined || attachment.uploadedAttachmentId !== undefined,
513514 )
514515 ) {
515516 return;
......@@ -521,7 +522,7 @@ export function scheduleUnusedComposerAttachmentCleanup(
521522
522523/** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */
523524export function retainComposerAttachmentFileForPreview(
524 attachment: DraftComposerFileAttachment,
525 attachment: FileBackedComposerAttachment,
525526): () => void {
526527 return retainComposerAttachmentFile(attachment.fileUri, () => {
527528 scheduleUnusedComposerAttachmentCleanup([attachment]);
apps/mobile/src/state/use-thread-outbox-drain.ts-1
......@@ -873,7 +873,6 @@ export function useThreadOutboxDrain(): void {
873873 messageId: queuedMessage.messageId,
874874 createdAt: queuedMessage.createdAt,
875875 text: queuedMessage.text.trim(),
876 attachments: queuedMessage.attachments,
877876 uploadedAttachments: prepared.attachments,
878877 modelSelection: sendSettings.modelSelection,
879878 runtimeMode: sendSettings.runtimeMode,