| author | |
| committer | |
| log | c4353bc6b972fa527579b530f01cc8744b2407d2 |
| tree | 23b3e95bb6a146489ca46d76790dbd26d68306b6 |
| parent | 5eab021a5185e492b6b021f83564143d5263e8a6 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ | ... | @@ -1,10 +1,16 @@ |
| 1 | import { SymbolView } from "../components/AppSymbol"; | 1 | import { SymbolView } from "../components/AppSymbol"; |
| 2 | import { videoMimeType } from "@t3tools/shared/video"; | 2 | import { videoMimeType } from "@t3tools/shared/video"; |
| 3 | import { useMemo } from "react"; | 3 | import { useEffect, useMemo, useState } from "react"; |
| 4 | import { Image, Pressable, ScrollView, View } from "react-native"; | 4 | import { Image, Pressable, ScrollView, View } from "react-native"; |
| 5 | 5 | ||
| 6 | import { AppText as Text } from "./AppText"; | 6 | import { AppText as Text } from "./AppText"; |
| 7 | import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; | 7 | import { |
| 8 | isFileBackedComposerAttachment, | ||
| 9 | type DraftComposerAttachment, | ||
| 10 | type DraftComposerFileAttachment, | ||
| 11 | type DraftComposerImageAttachment, | ||
| 12 | } from "../lib/composerImages"; | ||
| 13 | import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachmentFiles"; | ||
| 8 | import { VideoAttachmentTile } from "./VideoAttachmentTile"; | 14 | import { VideoAttachmentTile } from "./VideoAttachmentTile"; |
| 9 | import type { MediaActionsSource } from "../lib/mediaActions"; | 15 | import type { MediaActionsSource } from "../lib/mediaActions"; |
| 10 | import { PresentationSource } from "./NativePresentation"; | 16 | import { PresentationSource } from "./NativePresentation"; |
| ... | @@ -87,35 +93,75 @@ export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailPr | ... | @@ -87,35 +93,75 @@ export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailPr |
| 87 | ); | 93 | ); |
| 88 | } | 94 | } |
| 89 | 95 | ||
| 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 | */ | ||
| 102 | function 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 | |||
| 121 | function 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 | |||
| 90 | function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { | 160 | function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { |
| 91 | const { attachment } = props; | 161 | const { attachment } = props; |
| 92 | const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; | 162 | const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; |
| 93 | if (attachment.type === "image") { | 163 | if (attachment.type === "image") { |
| 94 | const sourceIdentifier = `draft-image:${attachment.id}`; | 164 | return <ComposerImageAttachment {...props} attachment={attachment} />; |
| 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 | ); | ||
| 119 | } | 165 | } |
| 120 | const onPressVideo = props.onPressVideo; | 166 | const onPressVideo = props.onPressVideo; |
| 121 | if (onPressVideo && videoMimeType(attachment) !== null) { | 167 | if (onPressVideo && videoMimeType(attachment) !== null) { |
apps/mobile/src/components/FilePreviewModal.tsx+2-2| ... | @@ -3,7 +3,7 @@ import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | ... | @@ -3,7 +3,7 @@ import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; |
| 3 | import { useEffect, useEffectEvent, useState } from "react"; | 3 | import { useEffect, useEffectEvent, useState } from "react"; |
| 4 | import { Alert, Keyboard } from "react-native"; | 4 | import { Alert, Keyboard } from "react-native"; |
| 5 | 5 | ||
| 6 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; | 6 | import type { FileBackedComposerAttachment } from "../lib/composerImages"; |
| 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; | 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 8 | import type { MediaActionsSource } from "../lib/mediaActions"; | 8 | import type { MediaActionsSource } from "../lib/mediaActions"; |
| 9 | import { useAssetUrlState } from "../state/assets"; | 9 | import { useAssetUrlState } from "../state/assets"; |
| ... | @@ -22,7 +22,7 @@ export interface ResolvedFilePreviewSource { | ... | @@ -22,7 +22,7 @@ export interface ResolvedFilePreviewSource { |
| 22 | export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> & | 22 | export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> & |
| 23 | ( | 23 | ( |
| 24 | | { readonly uri: string } | 24 | | { readonly uri: string } |
| 25 | | { readonly attachment: DraftComposerFileAttachment } | 25 | | { readonly attachment: FileBackedComposerAttachment } |
| 26 | | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } | 26 | | { readonly environmentId: EnvironmentId; readonly resource: AssetResource } |
| 27 | ); | 27 | ); |
| 28 | 28 |
apps/mobile/src/features/threads/use-project-actions.ts-1| ... | @@ -138,7 +138,6 @@ export function useCreateProjectThread() { | ... | @@ -138,7 +138,6 @@ export function useCreateProjectThread() { |
| 138 | messageId: metadata.messageId, | 138 | messageId: metadata.messageId, |
| 139 | createdAt: metadata.createdAt, | 139 | createdAt: metadata.createdAt, |
| 140 | text: initialMessageText, | 140 | text: initialMessageText, |
| 141 | attachments: input.initialAttachments, | ||
| 142 | uploadedAttachments: prepared.attachments, | 141 | uploadedAttachments: prepared.attachments, |
| 143 | modelSelection: input.modelSelection, | 142 | modelSelection: input.modelSelection, |
| 144 | runtimeMode: input.runtimeMode, | 143 | runtimeMode: input.runtimeMode, |
apps/mobile/src/lib/attachmentUpload.test.ts+117| ... | @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ | ... | @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ |
| 14 | upload: vi.fn(), | 14 | upload: vi.fn(), |
| 15 | writeFile: vi.fn(), | 15 | writeFile: vi.fn(), |
| 16 | deleteFile: vi.fn(), | 16 | deleteFile: vi.fn(), |
| 17 | readBase64: vi.fn(), | ||
| 17 | })); | 18 | })); |
| 18 | 19 | ||
| 19 | vi.mock("@t3tools/client-runtime/state/runtime", () => ({ | 20 | vi.mock("@t3tools/client-runtime/state/runtime", () => ({ |
| ... | @@ -30,6 +31,11 @@ vi.mock("../state/atom-registry", () => ({ | ... | @@ -30,6 +31,11 @@ vi.mock("../state/atom-registry", () => ({ |
| 30 | appAtomRegistry: { get: mocks.readAtom }, | 31 | appAtomRegistry: { get: mocks.readAtom }, |
| 31 | })); | 32 | })); |
| 32 | 33 | ||
| 34 | // The real read lease and cleanup are covered by the composer ownership suite. | ||
| 35 | vi.mock("../state/use-composer-drafts", () => ({ | ||
| 36 | retainComposerAttachmentFileForPreview: () => () => {}, | ||
| 37 | })); | ||
| 38 | |||
| 33 | vi.mock("../state/assets", () => ({ | 39 | vi.mock("../state/assets", () => ({ |
| 34 | assetEnvironment: { createUrl: mocks.createAssetUrl }, | 40 | assetEnvironment: { createUrl: mocks.createAssetUrl }, |
| 35 | })); | 41 | })); |
| ... | @@ -68,6 +74,10 @@ vi.mock("expo-file-system", () => ({ | ... | @@ -68,6 +74,10 @@ vi.mock("expo-file-system", () => ({ |
| 68 | mocks.deleteFile(this.uri); | 74 | mocks.deleteFile(this.uri); |
| 69 | } | 75 | } |
| 70 | 76 | ||
| 77 | async base64() { | ||
| 78 | return mocks.readBase64(this.uri); | ||
| 79 | } | ||
| 80 | |||
| 71 | upload(url: string, options: unknown) { | 81 | upload(url: string, options: unknown) { |
| 72 | return mocks.upload(this.uri, url, options); | 82 | return mocks.upload(this.uri, url, options); |
| 73 | } | 83 | } |
| ... | @@ -102,6 +112,16 @@ const image = { | ... | @@ -102,6 +112,16 @@ const image = { |
| 102 | previewUri: "file:///images/screenshot.png", | 112 | previewUri: "file:///images/screenshot.png", |
| 103 | } as const satisfies DraftComposerAttachment; | 113 | } as const satisfies DraftComposerAttachment; |
| 104 | 114 | ||
| 115 | const 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 | |||
| 105 | const file = { | 125 | const file = { |
| 106 | id: "file-1", | 126 | id: "file-1", |
| 107 | type: "file", | 127 | type: "file", |
| ... | @@ -174,6 +194,8 @@ describe("prepareTurnAttachments", () => { | ... | @@ -174,6 +194,8 @@ describe("prepareTurnAttachments", () => { |
| 174 | mocks.upload.mockReset(); | 194 | mocks.upload.mockReset(); |
| 175 | mocks.writeFile.mockReset(); | 195 | mocks.writeFile.mockReset(); |
| 176 | mocks.deleteFile.mockReset(); | 196 | mocks.deleteFile.mockReset(); |
| 197 | mocks.readBase64.mockReset(); | ||
| 198 | mocks.readBase64.mockResolvedValue("YWJj"); | ||
| 177 | mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" })); | 199 | mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" })); |
| 178 | mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) => | 200 | mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) => |
| 179 | command === mocks.createUploadUrl | 201 | command === mocks.createUploadUrl |
| ... | @@ -208,6 +230,101 @@ describe("prepareTurnAttachments", () => { | ... | @@ -208,6 +230,101 @@ describe("prepareTurnAttachments", () => { |
| 208 | expect(mocks.upload).not.toHaveBeenCalled(); | 230 | expect(mocks.upload).not.toHaveBeenCalled(); |
| 209 | }); | 231 | }); |
| 210 | 232 | ||
| 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 | |||
| 211 | it("uploads generic file bytes directly and keeps mixed attachment order", async () => { | 328 | it("uploads generic file bytes directly and keeps mixed attachment order", async () => { |
| 212 | const prepared = await prepareTurnAttachments({ environmentId, attachments: [file, image] }); | 329 | const prepared = await prepareTurnAttachments({ environmentId, attachments: [file, image] }); |
| 213 | 330 |
apps/mobile/src/lib/attachmentUpload.ts+70-16| ... | @@ -20,8 +20,13 @@ import { appAtomRegistry } from "../state/atom-registry"; | ... | @@ -20,8 +20,13 @@ import { appAtomRegistry } from "../state/atom-registry"; |
| 20 | import { assetEnvironment } from "../state/assets"; | 20 | import { assetEnvironment } from "../state/assets"; |
| 21 | import { attachmentEnvironment } from "../state/attachments"; | 21 | import { attachmentEnvironment } from "../state/attachments"; |
| 22 | import { environmentSession } from "../state/session"; | 22 | import { environmentSession } from "../state/session"; |
| 23 | import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; | ||
| 23 | import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; | 24 | import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; |
| 24 | import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; | 25 | import { |
| 26 | isFileBackedComposerAttachment, | ||
| 27 | type DraftComposerAttachment, | ||
| 28 | type DraftComposerImageAttachment, | ||
| 29 | } from "./composerImages"; | ||
| 25 | import { uuidv4 } from "./uuid"; | 30 | import { uuidv4 } from "./uuid"; |
| 26 | 31 | ||
| 27 | /** | 32 | /** |
| ... | @@ -179,6 +184,48 @@ function attachmentUploadInput(attachment: DraftComposerAttachment) { | ... | @@ -179,6 +184,48 @@ function attachmentUploadInput(attachment: DraftComposerAttachment) { |
| 179 | return { ...fields, mimeType }; | 184 | return { ...fields, mimeType }; |
| 180 | } | 185 | } |
| 181 | 186 | ||
| 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 | */ | ||
| 192 | async 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. */ | ||
| 207 | async 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 | |||
| 182 | async function uploadFileBytes( | 229 | async function uploadFileBytes( |
| 183 | attachment: DraftComposerAttachment, | 230 | attachment: DraftComposerAttachment, |
| 184 | url: string, | 231 | url: string, |
| ... | @@ -187,17 +234,21 @@ async function uploadFileBytes( | ... | @@ -187,17 +234,21 @@ async function uploadFileBytes( |
| 187 | ): Promise<void> { | 234 | ): Promise<void> { |
| 188 | const { File, Paths, UploadType } = await import("expo-file-system"); | 235 | const { File, Paths, UploadType } = await import("expo-file-system"); |
| 189 | if (signal.aborted) throw new Error("Upload cancelled."); | 236 | 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 | } | ||
| 190 | const file = | 244 | const file = |
| 191 | attachment.type === "image" | 245 | fileUri === undefined |
| 192 | ? new File(Paths.cache, `t3-upload-${uuidv4()}`) | 246 | ? new File(Paths.cache, `t3-upload-${uuidv4()}`) |
| 193 | : new File( | 247 | : new File(resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri) ?? fileUri); |
| 194 | resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? | ||
| 195 | attachment.fileUri, | ||
| 196 | ); | ||
| 197 | try { | 248 | try { |
| 198 | if (attachment.type === "image") { | 249 | if (fileUri === undefined && inlineDataUrl !== undefined) { |
| 199 | file.create(); | 250 | file.create(); |
| 200 | file.write(attachment.dataUrl.slice(attachment.dataUrl.indexOf(",") + 1), { | 251 | file.write(inlineDataUrl.slice(inlineDataUrl.indexOf(",") + 1), { |
| 201 | encoding: "base64", | 252 | encoding: "base64", |
| 202 | }); | 253 | }); |
| 203 | } | 254 | } |
| ... | @@ -218,7 +269,7 @@ async function uploadFileBytes( | ... | @@ -218,7 +269,7 @@ async function uploadFileBytes( |
| 218 | throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`); | 269 | throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`); |
| 219 | } | 270 | } |
| 220 | } finally { | 271 | } finally { |
| 221 | if (attachment.type === "image" && file.exists) file.delete(); | 272 | if (fileUri === undefined && file.exists) file.delete(); |
| 222 | } | 273 | } |
| 223 | } | 274 | } |
| 224 | 275 | ||
| ... | @@ -259,13 +310,16 @@ export async function prepareTurnAttachments(input: { | ... | @@ -259,13 +310,16 @@ export async function prepareTurnAttachments(input: { |
| 259 | }); | 310 | }); |
| 260 | 311 | ||
| 261 | if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) { | 312 | if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) { |
| 262 | return ready( | 313 | try { |
| 263 | toUploadChatImageAttachments( | 314 | const imageAttachments = await toUploadChatImageAttachments( |
| 264 | input.attachments.filter((attachment) => attachment.type === "image"), | 315 | input.attachments.filter((attachment) => attachment.type === "image"), |
| 265 | ), | 316 | ); |
| 266 | [], | 317 | if (input.signal?.aborted) return { status: "abandoned" }; |
| 267 | input.attachments, | 318 | return ready(imageAttachments, [], input.attachments); |
| 268 | ); | 319 | } catch (error) { |
| 320 | if (input.signal?.aborted) return { status: "abandoned" }; | ||
| 321 | throw error; | ||
| 322 | } | ||
| 269 | } | 323 | } |
| 270 | 324 | ||
| 271 | const connection = appAtomRegistry.get( | 325 | const connection = appAtomRegistry.get( |
| ... | @@ -285,7 +339,7 @@ export async function prepareTurnAttachments(input: { | ... | @@ -285,7 +339,7 @@ export async function prepareTurnAttachments(input: { |
| 285 | for (const attachment of input.attachments) { | 339 | for (const attachment of input.attachments) { |
| 286 | if (controller.signal.aborted) throw new Error("Upload cancelled."); | 340 | if (controller.signal.aborted) throw new Error("Upload cancelled."); |
| 287 | if (attachment.type === "image" && !input.supportsImageUploads) { | 341 | if (attachment.type === "image" && !input.supportsImageUploads) { |
| 288 | uploadedAttachments.push(...toUploadChatImageAttachments([attachment])); | 342 | uploadedAttachments.push(...(await toUploadChatImageAttachments([attachment]))); |
| 289 | continue; | 343 | continue; |
| 290 | } | 344 | } |
| 291 | 345 |
apps/mobile/src/lib/composer-image-schema.ts+11-2| ... | @@ -8,10 +8,19 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({ | ... | @@ -8,10 +8,19 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({ |
| 8 | name: Schema.String, | 8 | name: Schema.String, |
| 9 | mimeType: Schema.String, | 9 | mimeType: Schema.String, |
| 10 | sizeBytes: Schema.Number, | 10 | 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())), | ||
| 12 | uploadedAttachmentId: Schema.optional(Schema.String), | 14 | uploadedAttachmentId: Schema.optional(Schema.String), |
| 13 | uploadEnvironmentId: Schema.optional(EnvironmentId), | 15 | 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 | ); | ||
| 15 | 24 | ||
| 16 | export const DraftComposerFileAttachmentSchema = Schema.Struct({ | 25 | export const DraftComposerFileAttachmentSchema = Schema.Struct({ |
| 17 | id: Schema.String, | 26 | id: Schema.String, |
apps/mobile/src/lib/composerImages.test.ts+1-31| ... | @@ -36,37 +36,7 @@ vi.mock("./uuid", () => ({ | ... | @@ -36,37 +36,7 @@ vi.mock("./uuid", () => ({ |
| 36 | uuidv4: () => "attachment-id", | 36 | uuidv4: () => "attachment-id", |
| 37 | })); | 37 | })); |
| 38 | 38 | ||
| 39 | import { | 39 | import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages"; |
| 40 | convertPastedImagesToAttachments, | ||
| 41 | isOwnedPastedImageUri, | ||
| 42 | toUploadChatImageAttachments, | ||
| 43 | } from "./composerImages"; | ||
| 44 | |||
| 45 | describe("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 | }); | ||
| 70 | 40 | ||
| 71 | describe("native pasted image cleanup", () => { | 41 | describe("native pasted image cleanup", () => { |
| 72 | beforeEach(() => { | 42 | beforeEach(() => { |
apps/mobile/src/lib/composerImages.ts+13-12| ... | @@ -20,9 +20,13 @@ import { | ... | @@ -20,9 +20,13 @@ import { |
| 20 | import { beginForegroundHandoff } from "./foreground-handoff"; | 20 | import { beginForegroundHandoff } from "./foreground-handoff"; |
| 21 | import { uuidv4 } from "./uuid"; | 21 | import { uuidv4 } from "./uuid"; |
| 22 | 22 | ||
| 23 | export interface DraftComposerImageAttachment extends UploadChatImageAttachment { | 23 | export interface DraftComposerImageAttachment extends Omit<UploadChatImageAttachment, "dataUrl"> { |
| 24 | readonly id: string; | 24 | readonly id: string; |
| 25 | readonly previewUri: string; | 25 | 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; | ||
| 26 | readonly uploadedAttachmentId?: string; | 30 | readonly uploadedAttachmentId?: string; |
| 27 | readonly uploadEnvironmentId?: EnvironmentId; | 31 | readonly uploadEnvironmentId?: EnvironmentId; |
| 28 | } | 32 | } |
| ... | @@ -40,17 +44,14 @@ export interface DraftComposerFileAttachment { | ... | @@ -40,17 +44,14 @@ export interface DraftComposerFileAttachment { |
| 40 | 44 | ||
| 41 | export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment; | 45 | export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment; |
| 42 | 46 | ||
| 43 | /** Wire shape for startTurn: pure uploads without client draft id / previewUri. */ | 47 | /** Any composer attachment whose bytes live in the app-owned attachment directory. */ |
| 44 | export function toUploadChatImageAttachments( | 48 | export type FileBackedComposerAttachment = DraftComposerAttachment & { readonly fileUri: string }; |
| 45 | attachments: ReadonlyArray<DraftComposerImageAttachment>, | 49 | |
| 46 | ): ReadonlyArray<UploadChatImageAttachment> { | 50 | /** Files have a local copy. Images can have one after a file-backed draft is restored. */ |
| 47 | return attachments.map((attachment) => ({ | 51 | export function isFileBackedComposerAttachment( |
| 48 | type: attachment.type, | 52 | attachment: DraftComposerAttachment, |
| 49 | name: attachment.name, | 53 | ): attachment is FileBackedComposerAttachment { |
| 50 | mimeType: attachment.mimeType, | 54 | return attachment.fileUri !== undefined; |
| 51 | sizeBytes: attachment.sizeBytes, | ||
| 52 | dataUrl: attachment.dataUrl, | ||
| 53 | })); | ||
| 54 | } | 55 | } |
| 55 | 56 | ||
| 56 | const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; | 57 | const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; |
apps/mobile/src/lib/localAttachmentPreview.test.ts+18| ... | @@ -46,6 +46,24 @@ beforeEach(() => { | ... | @@ -46,6 +46,24 @@ beforeEach(() => { |
| 46 | }); | 46 | }); |
| 47 | 47 | ||
| 48 | describe("loadLocalAttachmentPreview", () => { | 48 | describe("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 | |||
| 49 | it("retains and shares a PDF with its original filename and type", async () => { | 67 | it("retains and shares a PDF with its original filename and type", async () => { |
| 50 | const pdf = { ...attachment, name: "report.pdf", mimeType: "application/pdf" }; | 68 | const pdf = { ...attachment, name: "report.pdf", mimeType: "application/pdf" }; |
| 51 | const preview = await loadLocalAttachmentPreview(pdf, new AbortController().signal); | 69 | const preview = await loadLocalAttachmentPreview(pdf, new AbortController().signal); |
apps/mobile/src/lib/localAttachmentPreview.ts+6-3| ... | @@ -1,13 +1,13 @@ | ... | @@ -1,13 +1,13 @@ |
| 1 | import { videoMimeType } from "@t3tools/shared/video"; | 1 | import { videoMimeType } from "@t3tools/shared/video"; |
| 2 | 2 | ||
| 3 | import type { DraftComposerFileAttachment } from "./composerImages"; | 3 | import type { FileBackedComposerAttachment } from "./composerImages"; |
| 4 | import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; | 4 | import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; |
| 5 | import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; | 5 | import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; |
| 6 | import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; | 6 | import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; |
| 7 | 7 | ||
| 8 | /** Retains the draft original for preview and gives each outgoing share its own lease. */ | 8 | /** Retains the draft original for preview and gives each outgoing share its own lease. */ |
| 9 | export async function loadLocalAttachmentPreview( | 9 | export async function loadLocalAttachmentPreview( |
| 10 | attachment: DraftComposerFileAttachment, | 10 | attachment: FileBackedComposerAttachment, |
| 11 | signal: AbortSignal, | 11 | signal: AbortSignal, |
| 12 | ): Promise<AttachmentPreviewFile | null> { | 12 | ): Promise<AttachmentPreviewFile | null> { |
| 13 | if (signal.aborted) return null; | 13 | if (signal.aborted) return null; |
| ... | @@ -41,7 +41,10 @@ export async function loadLocalAttachmentPreview( | ... | @@ -41,7 +41,10 @@ export async function loadLocalAttachmentPreview( |
| 41 | uri: file.uri, | 41 | uri: file.uri, |
| 42 | attachment: { | 42 | attachment: { |
| 43 | name: attachment.name, | 43 | name: attachment.name, |
| 44 | mimeType: videoMimeType(attachment) ?? attachment.mimeType, | 44 | mimeType: |
| 45 | attachment.type === "file" | ||
| 46 | ? (videoMimeType(attachment) ?? attachment.mimeType) | ||
| 47 | : attachment.mimeType, | ||
| 45 | }, | 48 | }, |
| 46 | signal: shareSignal, | 49 | signal: shareSignal, |
| 47 | sourceIdentifier, | 50 | sourceIdentifier, |
apps/mobile/src/lib/projectThreadStartTurn.test.ts+2-4| ... | @@ -6,15 +6,13 @@ import { | ... | @@ -6,15 +6,13 @@ import { |
| 6 | ThreadId, | 6 | ThreadId, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; | 8 | import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; |
| 9 | import { describe, expect, it, vi } from "vite-plus/test"; | 9 | import { describe, expect, it } from "vite-plus/test"; |
| 10 | 10 | ||
| 11 | import { | 11 | import { |
| 12 | buildProjectThreadStartTurnInput, | 12 | buildProjectThreadStartTurnInput, |
| 13 | deriveThreadTitleFromPrompt, | 13 | deriveThreadTitleFromPrompt, |
| 14 | } from "./projectThreadStartTurn"; | 14 | } from "./projectThreadStartTurn"; |
| 15 | 15 | ||
| 16 | vi.mock("./composerImages", () => ({ toUploadChatImageAttachments: () => [] })); | ||
| 17 | |||
| 18 | describe("project thread title", () => { | 16 | describe("project thread title", () => { |
| 19 | it("keeps ordinary titles and the empty-prompt fallback", () => { | 17 | it("keeps ordinary titles and the empty-prompt fallback", () => { |
| 20 | expect(deriveThreadTitleFromPrompt(" Fix\n the parser ")).toBe("Fix the parser"); | 18 | expect(deriveThreadTitleFromPrompt(" Fix\n the parser ")).toBe("Fix the parser"); |
| ... | @@ -52,7 +50,7 @@ describe("project thread title", () => { | ... | @@ -52,7 +50,7 @@ describe("project thread title", () => { |
| 52 | messageId: "message", | 50 | messageId: "message", |
| 53 | createdAt: "2026-09-01T00:00:00Z", | 51 | createdAt: "2026-09-01T00:00:00Z", |
| 54 | text, | 52 | text, |
| 55 | attachments: [], | 53 | uploadedAttachments: [], |
| 56 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, | 54 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, |
| 57 | runtimeMode: "full-access", | 55 | runtimeMode: "full-access", |
| 58 | interactionMode: "default", | 56 | interactionMode: "default", |
apps/mobile/src/lib/projectThreadStartTurn.ts+3-8| ... | @@ -9,7 +9,6 @@ import { | ... | @@ -9,7 +9,6 @@ import { |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; | 10 | import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; |
| 11 | 11 | ||
| 12 | import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; | ||
| 13 | import type { UploadedMobileAttachment } from "./attachmentUpload"; | 12 | import type { UploadedMobileAttachment } from "./attachmentUpload"; |
| 14 | 13 | ||
| 15 | export function deriveThreadTitleFromPrompt(value: string): string { | 14 | export function deriveThreadTitleFromPrompt(value: string): string { |
| ... | @@ -30,8 +29,8 @@ export interface ProjectThreadStartTurnSpec { | ... | @@ -30,8 +29,8 @@ export interface ProjectThreadStartTurnSpec { |
| 30 | readonly messageId: string; | 29 | readonly messageId: string; |
| 31 | readonly createdAt: string; | 30 | readonly createdAt: string; |
| 32 | readonly text: string; | 31 | readonly text: string; |
| 33 | readonly attachments: ReadonlyArray<DraftComposerAttachment>; | 32 | /** Wire attachments from `prepareTurnAttachments`, in composer order. */ |
| 34 | readonly uploadedAttachments?: ReadonlyArray<UploadedMobileAttachment>; | 33 | readonly uploadedAttachments: ReadonlyArray<UploadedMobileAttachment>; |
| 35 | readonly modelSelection: ModelSelection; | 34 | readonly modelSelection: ModelSelection; |
| 36 | readonly runtimeMode: RuntimeMode; | 35 | readonly runtimeMode: RuntimeMode; |
| 37 | readonly interactionMode: ProviderInteractionMode; | 36 | readonly interactionMode: ProviderInteractionMode; |
| ... | @@ -58,11 +57,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe | ... | @@ -58,11 +57,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe |
| 58 | messageId: MessageId.make(spec.messageId), | 57 | messageId: MessageId.make(spec.messageId), |
| 59 | role: "user" as const, | 58 | role: "user" as const, |
| 60 | text: spec.text, | 59 | text: spec.text, |
| 61 | attachments: | 60 | attachments: spec.uploadedAttachments, |
| 62 | spec.uploadedAttachments ?? | ||
| 63 | toUploadChatImageAttachments( | ||
| 64 | spec.attachments.filter((attachment) => attachment.type === "image"), | ||
| 65 | ), | ||
| 66 | }, | 61 | }, |
| 67 | modelSelection: spec.modelSelection, | 62 | modelSelection: spec.modelSelection, |
| 68 | titleSeed: title, | 63 | titleSeed: title, |
apps/mobile/src/state/composer-attachment-uploads.ts+4-4| ... | @@ -4,6 +4,7 @@ import { Atom } from "effect/unstable/reactivity"; | ... | @@ -4,6 +4,7 @@ import { Atom } from "effect/unstable/reactivity"; |
| 4 | import { useEffect, useRef } from "react"; | 4 | import { useEffect, useRef } from "react"; |
| 5 | 5 | ||
| 6 | import { prepareTurnAttachments } from "../lib/attachmentUpload"; | 6 | import { prepareTurnAttachments } from "../lib/attachmentUpload"; |
| 7 | import { isFileBackedComposerAttachment } from "../lib/composerImages"; | ||
| 7 | import { | 8 | import { |
| 8 | composerAttachmentUploadKey, | 9 | composerAttachmentUploadKey, |
| 9 | composerDraftEnvironmentId, | 10 | composerDraftEnvironmentId, |
| ... | @@ -60,10 +61,9 @@ export function useComposerAttachmentUploadWorker() { | ... | @@ -60,10 +61,9 @@ export function useComposerAttachmentUploadWorker() { |
| 60 | const queue = createComposerAttachmentUploadQueue({ | 61 | const queue = createComposerAttachmentUploadQueue({ |
| 61 | onChange: (states) => appAtomRegistry.set(composerAttachmentUploadsAtom, states), | 62 | onChange: (states) => appAtomRegistry.set(composerAttachmentUploadsAtom, states), |
| 62 | upload: async ({ environmentId, attachment }, signal, onProgress) => { | 63 | upload: async ({ environmentId, attachment }, signal, onProgress) => { |
| 63 | const release = | 64 | const release = isFileBackedComposerAttachment(attachment) |
| 64 | attachment.type === "file" | 65 | ? retainComposerAttachmentFileForPreview(attachment) |
| 65 | ? retainComposerAttachmentFileForPreview(attachment) | 66 | : undefined; |
| 66 | : undefined; | ||
| 67 | try { | 67 | try { |
| 68 | const result = await prepareTurnAttachments({ | 68 | const result = await prepareTurnAttachments({ |
| 69 | environmentId, | 69 | environmentId, |
apps/mobile/src/state/thread-outbox-model.ts+2-1| ... | @@ -27,6 +27,7 @@ import type { DraftComposerAttachment } from "../lib/composerImages"; | ... | @@ -27,6 +27,7 @@ import type { DraftComposerAttachment } from "../lib/composerImages"; |
| 27 | import { scopedThreadKey } from "../lib/scopedEntities"; | 27 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| 28 | import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; | 28 | import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; |
| 29 | 29 | ||
| 30 | // Keep current writes until a compatible native baseline includes the v4 reader. | ||
| 30 | const THREAD_OUTBOX_SCHEMA_VERSION = 3; | 31 | const THREAD_OUTBOX_SCHEMA_VERSION = 3; |
| 31 | const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; | 32 | const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; |
| 32 | 33 | ||
| ... | @@ -43,7 +44,7 @@ const QueuedThreadCreationSchema = Schema.Struct({ | ... | @@ -43,7 +44,7 @@ const QueuedThreadCreationSchema = Schema.Struct({ |
| 43 | }); | 44 | }); |
| 44 | 45 | ||
| 45 | export const QueuedThreadMessageSchema = Schema.Struct({ | 46 | export 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]), |
| 47 | environmentId: EnvironmentId, | 48 | environmentId: EnvironmentId, |
| 48 | threadId: ThreadId, | 49 | threadId: ThreadId, |
| 49 | messageId: MessageId, | 50 | messageId: MessageId, |
apps/mobile/src/state/thread-outbox.test.ts+24| ... | @@ -196,6 +196,30 @@ describe("thread outbox", () => { | ... | @@ -196,6 +196,30 @@ describe("thread outbox", () => { |
| 196 | expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(message))).toEqual(message); | 196 | expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(message))).toEqual(message); |
| 197 | }); | 197 | }); |
| 198 | 198 | ||
| 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 | |||
| 199 | it("persists the exact selector snapshot while remaining compatible with v1 messages", () => { | 223 | it("persists the exact selector snapshot while remaining compatible with v1 messages", () => { |
| 200 | const legacyMessage = queuedMessage({ | 224 | const legacyMessage = queuedMessage({ |
| 201 | messageId: "message-1", | 225 | messageId: "message-1", |
apps/mobile/src/state/use-composer-drafts.test.ts+260-98| ... | @@ -17,8 +17,10 @@ const composerDraftFileMocks = vi.hoisted(() => { | ... | @@ -17,8 +17,10 @@ const composerDraftFileMocks = vi.hoisted(() => { |
| 17 | let nextWriteBarrier: Promise<void> | null = null; | 17 | let nextWriteBarrier: Promise<void> | null = null; |
| 18 | let onWrite: (() => void) | null = null; | 18 | let onWrite: (() => void) | null = null; |
| 19 | const writes: string[] = []; | 19 | const writes: string[] = []; |
| 20 | const readImage = vi.fn(async () => "YWJj"); | ||
| 20 | 21 | ||
| 21 | return { | 22 | return { |
| 23 | readImage, | ||
| 22 | blockRead() { | 24 | blockRead() { |
| 23 | readBarrier = new Promise<void>((resolve) => { | 25 | readBarrier = new Promise<void>((resolve) => { |
| 24 | releaseRead = resolve; | 26 | releaseRead = resolve; |
| ... | @@ -73,6 +75,10 @@ const composerDraftFileMocks = vi.hoisted(() => { | ... | @@ -73,6 +75,10 @@ const composerDraftFileMocks = vi.hoisted(() => { |
| 73 | return document; | 75 | return document; |
| 74 | } | 76 | } |
| 75 | 77 | ||
| 78 | async base64() { | ||
| 79 | return readImage(); | ||
| 80 | } | ||
| 81 | |||
| 76 | write(value: string) { | 82 | write(value: string) { |
| 77 | if (writeError) { | 83 | if (writeError) { |
| 78 | throw writeError; | 84 | throw writeError; |
| ... | @@ -108,13 +114,29 @@ const incomingShareStorageMocks = vi.hoisted(() => ({ | ... | @@ -108,13 +114,29 @@ const incomingShareStorageMocks = vi.hoisted(() => ({ |
| 108 | vi.mock("expo-file-system", () => ({ | 114 | vi.mock("expo-file-system", () => ({ |
| 109 | Directory: composerDraftFileMocks.Directory, | 115 | Directory: composerDraftFileMocks.Directory, |
| 110 | File: composerDraftFileMocks.File, | 116 | File: composerDraftFileMocks.File, |
| 111 | Paths: { document: "/documents" }, | 117 | Paths: { document: { uri: "file:///documents" } }, |
| 112 | })); | 118 | })); |
| 113 | 119 | ||
| 114 | vi.mock("../lib/composerImages", () => ({ | 120 | vi.mock("../lib/composerImages", async (importOriginal) => ({ |
| 121 | ...(await importOriginal<typeof import("../lib/composerImages")>()), | ||
| 115 | removePersistedComposerAttachmentFile: composerAttachmentCleanupMocks.remove, | 122 | removePersistedComposerAttachmentFile: composerAttachmentCleanupMocks.remove, |
| 116 | })); | 123 | })); |
| 117 | 124 | ||
| 125 | vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "0000" })); | ||
| 126 | vi.mock("./assets", () => ({ assetEnvironment: {} })); | ||
| 127 | vi.mock("./attachments", () => ({ attachmentEnvironment: {} })); | ||
| 128 | vi.mock("./session", () => ({ environmentSession: {} })); | ||
| 129 | vi.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 | |||
| 118 | vi.mock("../lib/attachmentUpload", () => ({ | 140 | vi.mock("../lib/attachmentUpload", () => ({ |
| 119 | releasePendingAttachmentUploads: composerAttachmentCleanupMocks.releaseUploads, | 141 | releasePendingAttachmentUploads: composerAttachmentCleanupMocks.releaseUploads, |
| 120 | })); | 142 | })); |
| ... | @@ -128,6 +150,7 @@ import { threadOutboxManager } from "./thread-outbox"; | ... | @@ -128,6 +150,7 @@ import { threadOutboxManager } from "./thread-outbox"; |
| 128 | import { | 150 | import { |
| 129 | appendComposerDraftAttachments, | 151 | appendComposerDraftAttachments, |
| 130 | archiveCloudComposerDrafts, | 152 | archiveCloudComposerDrafts, |
| 153 | clearComposerDraftContent, | ||
| 131 | clearComposerDraftContentState, | 154 | clearComposerDraftContentState, |
| 132 | clearComposerDraftsEnvironment, | 155 | clearComposerDraftsEnvironment, |
| 133 | ComposerDraftPersistenceError, | 156 | ComposerDraftPersistenceError, |
| ... | @@ -171,6 +194,8 @@ afterEach(() => { | ... | @@ -171,6 +194,8 @@ afterEach(() => { |
| 171 | composerDraftFileMocks.setNextWriteBarrier(null); | 194 | composerDraftFileMocks.setNextWriteBarrier(null); |
| 172 | composerDraftFileMocks.setOnWrite(null); | 195 | composerDraftFileMocks.setOnWrite(null); |
| 173 | composerDraftFileMocks.resetWrites(); | 196 | composerDraftFileMocks.resetWrites(); |
| 197 | composerDraftFileMocks.readImage.mockReset(); | ||
| 198 | composerDraftFileMocks.readImage.mockResolvedValue("YWJj"); | ||
| 174 | appAtomRegistry.set(composerDraftsAtom, {}); | 199 | appAtomRegistry.set(composerDraftsAtom, {}); |
| 175 | appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); | 200 | appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); |
| 176 | appAtomRegistry.set(stickyComposerModelSelectionAtom, null); | 201 | appAtomRegistry.set(stickyComposerModelSelectionAtom, null); |
| ... | @@ -313,6 +338,106 @@ describe("mobile composer drafts", () => { | ... | @@ -313,6 +338,106 @@ describe("mobile composer drafts", () => { |
| 313 | expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri); | 338 | expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri); |
| 314 | }); | 339 | }); |
| 315 | 340 | ||
| 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 | |||
| 316 | it("keeps a failed-send draft's pending upload for retry", async () => { | 441 | it("keeps a failed-send draft's pending upload for retry", async () => { |
| 317 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | 442 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); |
| 318 | onTestFinished(() => outboxLoad.mockRestore()); | 443 | onTestFinished(() => outboxLoad.mockRestore()); |
| ... | @@ -394,68 +519,75 @@ describe("mobile composer drafts", () => { | ... | @@ -394,68 +519,75 @@ describe("mobile composer drafts", () => { |
| 394 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | 519 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); |
| 395 | }); | 520 | }); |
| 396 | 521 | ||
| 397 | it("keeps signed-out files through cleanup and restart, and restores only the owning account", async () => { | 522 | it.each(["file", "image"] as const)( |
| 398 | const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | 523 | "keeps signed-out %s attachments through cleanup and restart, and restores only the owning account", |
| 399 | onTestFinished(() => load.mockRestore()); | 524 | async (type) => { |
| 400 | await waitForComposerDraftsLoaded(); | 525 | const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); |
| 401 | const environmentId = EnvironmentId.make("cloud-environment"); | 526 | onTestFinished(() => load.mockRestore()); |
| 402 | const key = `${environmentId}:thread-1`; | 527 | await waitForComposerDraftsLoaded(); |
| 403 | const file = { | 528 | const environmentId = EnvironmentId.make("cloud-environment"); |
| 404 | id: "local-pdf", | 529 | const key = `${environmentId}:thread-1`; |
| 405 | type: "file" as const, | 530 | const name = type === "image" ? "notes.png" : "notes.pdf"; |
| 406 | name: "notes.pdf", | 531 | const metadata = { |
| 407 | mimeType: "application/pdf", | 532 | id: "local-notes", |
| 408 | sizeBytes: 42, | 533 | name, |
| 409 | fileUri: "file:///documents/t3-composer-attachments/notes.pdf", | 534 | mimeType: type === "image" ? "image/png" : "application/pdf", |
| 410 | uploadEnvironmentId: environmentId, | 535 | sizeBytes: 42, |
| 411 | uploadedAttachmentId: "pending-pdf", | 536 | fileUri: `file:///documents/t3-composer-attachments/${name}`, |
| 412 | }; | 537 | uploadEnvironmentId: environmentId, |
| 413 | const queued = { | 538 | uploadedAttachmentId: "pending-notes", |
| 414 | environmentId, | 539 | }; |
| 415 | threadId: ThreadId.make("thread-2"), | 540 | const file = |
| 416 | messageId: MessageId.make("queued-1"), | 541 | type === "image" |
| 417 | commandId: CommandId.make("command-1"), | 542 | ? { ...metadata, type, previewUri: metadata.fileUri } |
| 418 | text: "Send later", | 543 | : { ...metadata, type }; |
| 419 | attachments: [file], | 544 | const queued = { |
| 420 | createdAt: "2026-08-31T12:00:00.000Z", | 545 | environmentId, |
| 421 | }; | 546 | threadId: ThreadId.make("thread-2"), |
| 422 | appAtomRegistry.set(composerDraftsAtom, { | 547 | messageId: MessageId.make("queued-1"), |
| 423 | [key]: { text: "Unsent notes", attachments: [file] }, | 548 | commandId: CommandId.make("command-1"), |
| 424 | "direct-environment:thread-1": DRAFT, | 549 | text: "Send later", |
| 425 | "pending-task:queued-1": { text: "Edited queued task", attachments: [file] }, | 550 | attachments: [file], |
| 426 | }); | 551 | createdAt: "2026-08-31T12:00:00.000Z", |
| 427 | appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { queued: [queued] }); | 552 | }; |
| 428 | await archiveCloudComposerDrafts("account-a", new Set([environmentId])); | 553 | appAtomRegistry.set(composerDraftsAtom, { |
| 429 | expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ | 554 | [key]: { text: "Unsent notes", attachments: [file] }, |
| 430 | "direct-environment:thread-1": DRAFT, | 555 | "direct-environment:thread-1": DRAFT, |
| 431 | }); | 556 | "pending-task:queued-1": { text: "Edited queued task", attachments: [file] }, |
| 432 | // The registry can remove the active outbox and drafts after the backup lands. | 557 | }); |
| 433 | appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); | 558 | appAtomRegistry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { queued: [queued] }); |
| 434 | await clearComposerDraftsEnvironment(environmentId); | 559 | await archiveCloudComposerDrafts("account-a", new Set([environmentId])); |
| 435 | await releaseUnusedComposerAttachmentFiles([file]); | 560 | expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ |
| 436 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | 561 | "direct-environment:thread-1": DRAFT, |
| 437 | expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); | 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(); | ||
| 438 | 569 | ||
| 439 | appAtomRegistry.set(composerDraftsAtom, {}); | 570 | appAtomRegistry.set(composerDraftsAtom, {}); |
| 440 | appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); | 571 | appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); |
| 441 | resetComposerDraftsLoadState(); | 572 | resetComposerDraftsLoadState(); |
| 442 | await waitForComposerDraftsLoaded(); | 573 | await waitForComposerDraftsLoaded(); |
| 443 | await restoreCloudComposerDrafts("account-b"); | 574 | await restoreCloudComposerDrafts("account-b"); |
| 444 | expect(getComposerDraftSnapshot(key).attachments).toEqual([]); | 575 | expect(getComposerDraftSnapshot(key).attachments).toEqual([]); |
| 445 | expect(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)).toEqual({}); | 576 | expect(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom)).toEqual({}); |
| 446 | const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue(); | 577 | const enqueue = vi.spyOn(threadOutboxManager, "enqueue").mockResolvedValue(); |
| 447 | onTestFinished(() => enqueue.mockRestore()); | 578 | onTestFinished(() => enqueue.mockRestore()); |
| 448 | await restoreCloudComposerDrafts("account-a"); | 579 | await restoreCloudComposerDrafts("account-a"); |
| 449 | expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] }); | 580 | expect(getComposerDraftSnapshot(key)).toEqual({ text: "Unsent notes", attachments: [file] }); |
| 450 | expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task"); | 581 | expect(getComposerDraftSnapshot("pending-task:queued-1").text).toBe("Edited queued task"); |
| 451 | expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued); | 582 | expect(enqueue).toHaveBeenCalledExactlyOnceWith(queued); |
| 452 | expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({}); | 583 | expect(appAtomRegistry.get(composerCloudDraftsAtom).signedOut).toEqual({}); |
| 453 | const persisted = decodePersistedComposerState( | 584 | const persisted = decodePersistedComposerState( |
| 454 | JSON.parse(composerDraftFileMocks.getDocument()), | 585 | JSON.parse(composerDraftFileMocks.getDocument()), |
| 455 | ); | 586 | ); |
| 456 | expect(persisted.drafts[key]?.attachments).toEqual([file]); | 587 | expect(persisted.drafts[key]?.attachments).toEqual([file]); |
| 457 | expect(persisted.cloudDrafts.accountId).toBe("account-a"); | 588 | expect(persisted.cloudDrafts.accountId).toBe("account-a"); |
| 458 | }); | 589 | }, |
| 590 | ); | ||
| 459 | 591 | ||
| 460 | it("fails sign-out preservation before cleanup if a durable backup cannot be written", async () => { | 592 | it("fails sign-out preservation before cleanup if a durable backup cannot be written", async () => { |
| 461 | const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | 593 | const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); |
| ... | @@ -480,45 +612,52 @@ describe("mobile composer drafts", () => { | ... | @@ -480,45 +612,52 @@ describe("mobile composer drafts", () => { |
| 480 | ).toEqual(DRAFT); | 612 | ).toEqual(DRAFT); |
| 481 | }); | 613 | }); |
| 482 | 614 | ||
| 483 | it("keeps a removed file until both playback and a share copy finish", async () => { | 615 | it.each(["file", "image"] as const)( |
| 484 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | 616 | "keeps a removed %s until both preview and a share copy finish", |
| 485 | onTestFinished(() => outboxLoad.mockRestore()); | 617 | async (type) => { |
| 486 | const fileName = "33333333-3333-4333-8333-333333333333-recording.mp4"; | 618 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); |
| 487 | const file = { | 619 | onTestFinished(() => outboxLoad.mockRestore()); |
| 488 | id: "file-preview", | 620 | const name = type === "image" ? "photo.png" : "recording.mp4"; |
| 489 | type: "file" as const, | 621 | const fileName = `33333333-3333-4333-8333-333333333333-${name}`; |
| 490 | name: "recording.mp4", | 622 | const metadata = { |
| 491 | mimeType: "video/mp4", | 623 | id: "file-preview", |
| 492 | sizeBytes: 42, | 624 | name, |
| 493 | fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`, | 625 | mimeType: type === "image" ? "image/png" : "video/mp4", |
| 494 | }; | 626 | sizeBytes: 42, |
| 495 | const currentFile = { | 627 | fileUri: `file:///private/var/mobile/Containers/Data/Application/11111111-1111-4111-8111-111111111111/Documents/t3-composer-attachments/${fileName}`, |
| 496 | ...file, | 628 | }; |
| 497 | fileUri: `file:///var/mobile/Containers/Data/Application/22222222-2222-4222-8222-222222222222/Documents/t3-composer-attachments/${fileName}`, | 629 | const file = |
| 498 | }; | 630 | type === "image" |
| 499 | const releasePlayback = retainComposerAttachmentFileForPreview(file); | 631 | ? { ...metadata, type, previewUri: metadata.fileUri } |
| 500 | const releaseShareCopy = retainComposerAttachmentFileForPreview(currentFile); | 632 | : { ...metadata, type }; |
| 501 | onTestFinished(releasePlayback); | 633 | const currentFile = { |
| 502 | onTestFinished(releaseShareCopy); | 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); | ||
| 503 | 641 | ||
| 504 | await releaseUnusedComposerAttachmentFiles([currentFile]); | 642 | await releaseUnusedComposerAttachmentFiles([currentFile]); |
| 505 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | 643 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); |
| 506 | 644 | ||
| 507 | releasePlayback(); | 645 | releasePlayback(); |
| 508 | releasePlayback(); | 646 | releasePlayback(); |
| 509 | await releaseUnusedComposerAttachmentFiles([file]); | 647 | await releaseUnusedComposerAttachmentFiles([file]); |
| 510 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | 648 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); |
| 511 | 649 | ||
| 512 | const deleted = Promise.withResolvers<void>(); | 650 | const deleted = Promise.withResolvers<void>(); |
| 513 | composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { | 651 | composerAttachmentCleanupMocks.remove.mockImplementationOnce(async () => { |
| 514 | deleted.resolve(); | 652 | deleted.resolve(); |
| 515 | return undefined; | 653 | return undefined; |
| 516 | }); | 654 | }); |
| 517 | releaseShareCopy(); | 655 | releaseShareCopy(); |
| 518 | await deleted.promise; | 656 | await deleted.promise; |
| 519 | 657 | ||
| 520 | expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]); | 658 | expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]); |
| 521 | }); | 659 | }, |
| 660 | ); | ||
| 522 | 661 | ||
| 523 | it("preserves a preview opened while cleanup is checking the incoming inbox", async () => { | 662 | it("preserves a preview opened while cleanup is checking the incoming inbox", async () => { |
| 524 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); | 663 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); |
| ... | @@ -846,6 +985,29 @@ describe("mobile composer drafts", () => { | ... | @@ -846,6 +985,29 @@ describe("mobile composer drafts", () => { |
| 846 | } | 985 | } |
| 847 | }); | 986 | }); |
| 848 | 987 | ||
| 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 | |||
| 849 | it("hydrates selector state even when the message content is empty", () => { | 1011 | it("hydrates selector state even when the message content is empty", () => { |
| 850 | expect( | 1012 | expect( |
| 851 | decodePersistedComposerDrafts({ | 1013 | decodePersistedComposerDrafts({ |
apps/mobile/src/state/use-composer-drafts.ts+9-8| ... | @@ -20,7 +20,7 @@ import { | ... | @@ -20,7 +20,7 @@ import { |
| 20 | isComposerAttachmentFileRetained, | 20 | isComposerAttachmentFileRetained, |
| 21 | retainComposerAttachmentFile, | 21 | retainComposerAttachmentFile, |
| 22 | } from "../lib/composerAttachmentFiles"; | 22 | } from "../lib/composerAttachmentFiles"; |
| 23 | import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; | 23 | import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages"; |
| 24 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; | 24 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; |
| 25 | import { appAtomRegistry } from "./atom-registry"; | 25 | import { appAtomRegistry } from "./atom-registry"; |
| 26 | import { | 26 | import { |
| ... | @@ -376,7 +376,7 @@ function isComposerAttachmentFileReferenced(fileUri: string): boolean { | ... | @@ -376,7 +376,7 @@ function isComposerAttachmentFileReferenced(fileUri: string): boolean { |
| 376 | return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => | 376 | return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => |
| 377 | owner.attachments.some( | 377 | owner.attachments.some( |
| 378 | (attachment) => | 378 | (attachment) => |
| 379 | attachment.type === "file" && | 379 | attachment.fileUri !== undefined && |
| 380 | composerAttachmentFileReferenceKey(attachment.fileUri) === referenceKey, | 380 | composerAttachmentFileReferenceKey(attachment.fileUri) === referenceKey, |
| 381 | ), | 381 | ), |
| 382 | ); | 382 | ); |
| ... | @@ -403,9 +403,9 @@ export async function releaseUnusedComposerAttachmentFiles( | ... | @@ -403,9 +403,9 @@ export async function releaseUnusedComposerAttachmentFiles( |
| 403 | attachments: ReadonlyArray<DraftComposerAttachment>, | 403 | attachments: ReadonlyArray<DraftComposerAttachment>, |
| 404 | ): Promise<void> { | 404 | ): Promise<void> { |
| 405 | const candidates = new Set( | 405 | const candidates = new Set( |
| 406 | attachments | 406 | attachments.flatMap((attachment) => |
| 407 | .filter((attachment) => attachment.type === "file") | 407 | attachment.fileUri !== undefined ? [attachment.fileUri] : [], |
| 408 | .map((attachment) => attachment.fileUri), | 408 | ), |
| 409 | ); | 409 | ); |
| 410 | const uploadCandidates = new Map<EnvironmentId, Set<string>>(); | 410 | const uploadCandidates = new Map<EnvironmentId, Set<string>>(); |
| 411 | for (const attachment of attachments) { | 411 | for (const attachment of attachments) { |
| ... | @@ -454,7 +454,7 @@ export async function releaseUnusedComposerAttachmentFiles( | ... | @@ -454,7 +454,7 @@ export async function releaseUnusedComposerAttachmentFiles( |
| 454 | incomingShareFileUris = new Set( | 454 | incomingShareFileUris = new Set( |
| 455 | incomingShares.flatMap((share) => | 455 | incomingShares.flatMap((share) => |
| 456 | share.attachments.flatMap((attachment) => | 456 | share.attachments.flatMap((attachment) => |
| 457 | attachment.type === "file" | 457 | attachment.fileUri !== undefined |
| 458 | ? [composerAttachmentFileReferenceKey(attachment.fileUri)] | 458 | ? [composerAttachmentFileReferenceKey(attachment.fileUri)] |
| 459 | : [], | 459 | : [], |
| 460 | ), | 460 | ), |
| ... | @@ -509,7 +509,8 @@ export function scheduleUnusedComposerAttachmentCleanup( | ... | @@ -509,7 +509,8 @@ export function scheduleUnusedComposerAttachmentCleanup( |
| 509 | ): void { | 509 | ): void { |
| 510 | if ( | 510 | if ( |
| 511 | !attachments.some( | 511 | !attachments.some( |
| 512 | (attachment) => attachment.type === "file" || attachment.uploadedAttachmentId !== undefined, | 512 | (attachment) => |
| 513 | attachment.fileUri !== undefined || attachment.uploadedAttachmentId !== undefined, | ||
| 513 | ) | 514 | ) |
| 514 | ) { | 515 | ) { |
| 515 | return; | 516 | return; |
| ... | @@ -521,7 +522,7 @@ export function scheduleUnusedComposerAttachmentCleanup( | ... | @@ -521,7 +522,7 @@ export function scheduleUnusedComposerAttachmentCleanup( |
| 521 | 522 | ||
| 522 | /** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ | 523 | /** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ |
| 523 | export function retainComposerAttachmentFileForPreview( | 524 | export function retainComposerAttachmentFileForPreview( |
| 524 | attachment: DraftComposerFileAttachment, | 525 | attachment: FileBackedComposerAttachment, |
| 525 | ): () => void { | 526 | ): () => void { |
| 526 | return retainComposerAttachmentFile(attachment.fileUri, () => { | 527 | return retainComposerAttachmentFile(attachment.fileUri, () => { |
| 527 | scheduleUnusedComposerAttachmentCleanup([attachment]); | 528 | scheduleUnusedComposerAttachmentCleanup([attachment]); |
apps/mobile/src/state/use-thread-outbox-drain.ts-1| ... | @@ -873,7 +873,6 @@ export function useThreadOutboxDrain(): void { | ... | @@ -873,7 +873,6 @@ export function useThreadOutboxDrain(): void { |
| 873 | messageId: queuedMessage.messageId, | 873 | messageId: queuedMessage.messageId, |
| 874 | createdAt: queuedMessage.createdAt, | 874 | createdAt: queuedMessage.createdAt, |
| 875 | text: queuedMessage.text.trim(), | 875 | text: queuedMessage.text.trim(), |
| 876 | attachments: queuedMessage.attachments, | ||
| 877 | uploadedAttachments: prepared.attachments, | 876 | uploadedAttachments: prepared.attachments, |
| 878 | modelSelection: sendSettings.modelSelection, | 877 | modelSelection: sendSettings.modelSelection, |
| 879 | runtimeMode: sendSettings.runtimeMode, | 878 | runtimeMode: sendSettings.runtimeMode, |