| 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 | 1 | import { SymbolView } from "../components/AppSymbol"; |
| 2 | 2 | import { videoMimeType } from "@t3tools/shared/video"; |
| 3 | import { useMemo } from "react"; | |
| 3 | import { useEffect, useMemo, useState } from "react"; | |
| 4 | 4 | import { Image, Pressable, ScrollView, View } from "react-native"; |
| 5 | 5 | |
| 6 | 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 | 14 | import { VideoAttachmentTile } from "./VideoAttachmentTile"; |
| 9 | 15 | import type { MediaActionsSource } from "../lib/mediaActions"; |
| 10 | 16 | import { PresentationSource } from "./NativePresentation"; |
| ... | ... | @@ -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 | 160 | function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { |
| 91 | 161 | const { attachment } = props; |
| 92 | 162 | const style = { width: props.size, height: props.size, borderRadius: props.borderRadius }; |
| 93 | 163 | 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} />; | |
| 119 | 165 | } |
| 120 | 166 | const onPressVideo = props.onPressVideo; |
| 121 | 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 | 3 | import { useEffect, useEffectEvent, useState } from "react"; |
| 4 | 4 | import { Alert, Keyboard } from "react-native"; |
| 5 | 5 | |
| 6 | import type { DraftComposerFileAttachment } from "../lib/composerImages"; | |
| 6 | import type { FileBackedComposerAttachment } from "../lib/composerImages"; | |
| 7 | 7 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 8 | 8 | import type { MediaActionsSource } from "../lib/mediaActions"; |
| 9 | 9 | import { useAssetUrlState } from "../state/assets"; |
| ... | ... | @@ -22,7 +22,7 @@ export interface ResolvedFilePreviewSource { |
| 22 | 22 | export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> & |
| 23 | 23 | ( |
| 24 | 24 | | { readonly uri: string } |
| 25 | | { readonly attachment: DraftComposerFileAttachment } | |
| 25 | | { readonly attachment: FileBackedComposerAttachment } | |
| 26 | 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 | 138 | messageId: metadata.messageId, |
| 139 | 139 | createdAt: metadata.createdAt, |
| 140 | 140 | text: initialMessageText, |
| 141 | attachments: input.initialAttachments, | |
| 142 | 141 | uploadedAttachments: prepared.attachments, |
| 143 | 142 | modelSelection: input.modelSelection, |
| 144 | 143 | runtimeMode: input.runtimeMode, |
apps/mobile/src/lib/attachmentUpload.test.ts+117| ... | ... | @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ |
| 14 | 14 | upload: vi.fn(), |
| 15 | 15 | writeFile: vi.fn(), |
| 16 | 16 | deleteFile: vi.fn(), |
| 17 | readBase64: vi.fn(), | |
| 17 | 18 | })); |
| 18 | 19 | |
| 19 | 20 | vi.mock("@t3tools/client-runtime/state/runtime", () => ({ |
| ... | ... | @@ -30,6 +31,11 @@ vi.mock("../state/atom-registry", () => ({ |
| 30 | 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 | 39 | vi.mock("../state/assets", () => ({ |
| 34 | 40 | assetEnvironment: { createUrl: mocks.createAssetUrl }, |
| 35 | 41 | })); |
| ... | ... | @@ -68,6 +74,10 @@ vi.mock("expo-file-system", () => ({ |
| 68 | 74 | mocks.deleteFile(this.uri); |
| 69 | 75 | } |
| 70 | 76 | |
| 77 | async base64() { | |
| 78 | return mocks.readBase64(this.uri); | |
| 79 | } | |
| 80 | ||
| 71 | 81 | upload(url: string, options: unknown) { |
| 72 | 82 | return mocks.upload(this.uri, url, options); |
| 73 | 83 | } |
| ... | ... | @@ -102,6 +112,16 @@ const image = { |
| 102 | 112 | previewUri: "file:///images/screenshot.png", |
| 103 | 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 | 125 | const file = { |
| 106 | 126 | id: "file-1", |
| 107 | 127 | type: "file", |
| ... | ... | @@ -174,6 +194,8 @@ describe("prepareTurnAttachments", () => { |
| 174 | 194 | mocks.upload.mockReset(); |
| 175 | 195 | mocks.writeFile.mockReset(); |
| 176 | 196 | mocks.deleteFile.mockReset(); |
| 197 | mocks.readBase64.mockReset(); | |
| 198 | mocks.readBase64.mockResolvedValue("YWJj"); | |
| 177 | 199 | mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" })); |
| 178 | 200 | mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) => |
| 179 | 201 | command === mocks.createUploadUrl |
| ... | ... | @@ -208,6 +230,101 @@ describe("prepareTurnAttachments", () => { |
| 208 | 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 | 328 | it("uploads generic file bytes directly and keeps mixed attachment order", async () => { |
| 212 | 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 | 20 | import { assetEnvironment } from "../state/assets"; |
| 21 | 21 | import { attachmentEnvironment } from "../state/attachments"; |
| 22 | 22 | import { environmentSession } from "../state/session"; |
| 23 | import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; | |
| 23 | 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 | 30 | import { uuidv4 } from "./uuid"; |
| 26 | 31 | |
| 27 | 32 | /** |
| ... | ... | @@ -179,6 +184,48 @@ function attachmentUploadInput(attachment: DraftComposerAttachment) { |
| 179 | 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 | 229 | async function uploadFileBytes( |
| 183 | 230 | attachment: DraftComposerAttachment, |
| 184 | 231 | url: string, |
| ... | ... | @@ -187,17 +234,21 @@ async function uploadFileBytes( |
| 187 | 234 | ): Promise<void> { |
| 188 | 235 | const { File, Paths, UploadType } = await import("expo-file-system"); |
| 189 | 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 | 244 | const file = |
| 191 | attachment.type === "image" | |
| 245 | fileUri === undefined | |
| 192 | 246 | ? 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); | |
| 197 | 248 | try { |
| 198 | if (attachment.type === "image") { | |
| 249 | if (fileUri === undefined && inlineDataUrl !== undefined) { | |
| 199 | 250 | file.create(); |
| 200 | file.write(attachment.dataUrl.slice(attachment.dataUrl.indexOf(",") + 1), { | |
| 251 | file.write(inlineDataUrl.slice(inlineDataUrl.indexOf(",") + 1), { | |
| 201 | 252 | encoding: "base64", |
| 202 | 253 | }); |
| 203 | 254 | } |
| ... | ... | @@ -218,7 +269,7 @@ async function uploadFileBytes( |
| 218 | 269 | throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`); |
| 219 | 270 | } |
| 220 | 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 | 310 | }); |
| 260 | 311 | |
| 261 | 312 | if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) { |
| 262 | return ready( | |
| 263 | toUploadChatImageAttachments( | |
| 313 | try { | |
| 314 | const imageAttachments = await toUploadChatImageAttachments( | |
| 264 | 315 | 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 | } | |
| 269 | 323 | } |
| 270 | 324 | |
| 271 | 325 | const connection = appAtomRegistry.get( |
| ... | ... | @@ -285,7 +339,7 @@ export async function prepareTurnAttachments(input: { |
| 285 | 339 | for (const attachment of input.attachments) { |
| 286 | 340 | if (controller.signal.aborted) throw new Error("Upload cancelled."); |
| 287 | 341 | if (attachment.type === "image" && !input.supportsImageUploads) { |
| 288 | uploadedAttachments.push(...toUploadChatImageAttachments([attachment])); | |
| 342 | uploadedAttachments.push(...(await toUploadChatImageAttachments([attachment]))); | |
| 289 | 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 | 8 | name: Schema.String, |
| 9 | 9 | mimeType: Schema.String, |
| 10 | 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 | 14 | uploadedAttachmentId: Schema.optional(Schema.String), |
| 13 | 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 | 25 | export const DraftComposerFileAttachmentSchema = Schema.Struct({ |
| 17 | 26 | id: Schema.String, |
apps/mobile/src/lib/composerImages.test.ts+1-31| ... | ... | @@ -36,37 +36,7 @@ vi.mock("./uuid", () => ({ |
| 36 | 36 | uuidv4: () => "attachment-id", |
| 37 | 37 | })); |
| 38 | 38 | |
| 39 | import { | |
| 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 | }); | |
| 39 | import { convertPastedImagesToAttachments, isOwnedPastedImageUri } from "./composerImages"; | |
| 70 | 40 | |
| 71 | 41 | describe("native pasted image cleanup", () => { |
| 72 | 42 | beforeEach(() => { |
apps/mobile/src/lib/composerImages.ts+13-12| ... | ... | @@ -20,9 +20,13 @@ import { |
| 20 | 20 | import { beginForegroundHandoff } from "./foreground-handoff"; |
| 21 | 21 | import { uuidv4 } from "./uuid"; |
| 22 | 22 | |
| 23 | export interface DraftComposerImageAttachment extends UploadChatImageAttachment { | |
| 23 | export interface DraftComposerImageAttachment extends Omit<UploadChatImageAttachment, "dataUrl"> { | |
| 24 | 24 | readonly id: string; |
| 25 | 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 | 30 | readonly uploadedAttachmentId?: string; |
| 27 | 31 | readonly uploadEnvironmentId?: EnvironmentId; |
| 28 | 32 | } |
| ... | ... | @@ -40,17 +44,14 @@ export interface DraftComposerFileAttachment { |
| 40 | 44 | |
| 41 | 45 | export type DraftComposerAttachment = DraftComposerImageAttachment | DraftComposerFileAttachment; |
| 42 | 46 | |
| 43 | /** Wire shape for startTurn: pure uploads without client draft id / previewUri. */ | |
| 44 | export 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. */ | |
| 48 | export type FileBackedComposerAttachment = DraftComposerAttachment & { readonly fileUri: string }; | |
| 49 | ||
| 50 | /** Files have a local copy. Images can have one after a file-backed draft is restored. */ | |
| 51 | export function isFileBackedComposerAttachment( | |
| 52 | attachment: DraftComposerAttachment, | |
| 53 | ): attachment is FileBackedComposerAttachment { | |
| 54 | return attachment.fileUri !== undefined; | |
| 54 | 55 | } |
| 55 | 56 | |
| 56 | 57 | const OWNED_PASTED_IMAGE_DIRECTORY = "t3-composer-paste"; |
apps/mobile/src/lib/localAttachmentPreview.test.ts+18| ... | ... | @@ -46,6 +46,24 @@ beforeEach(() => { |
| 46 | 46 | }); |
| 47 | 47 | |
| 48 | 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 | 67 | it("retains and shares a PDF with its original filename and type", async () => { |
| 50 | 68 | const pdf = { ...attachment, name: "report.pdf", mimeType: "application/pdf" }; |
| 51 | 69 | const preview = await loadLocalAttachmentPreview(pdf, new AbortController().signal); |
apps/mobile/src/lib/localAttachmentPreview.ts+6-3| ... | ... | @@ -1,13 +1,13 @@ |
| 1 | 1 | import { videoMimeType } from "@t3tools/shared/video"; |
| 2 | 2 | |
| 3 | import type { DraftComposerFileAttachment } from "./composerImages"; | |
| 3 | import type { FileBackedComposerAttachment } from "./composerImages"; | |
| 4 | 4 | import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; |
| 5 | 5 | import { shareLocalAttachment, type AttachmentPreviewFile } from "./attachmentDownload"; |
| 6 | 6 | import { retainComposerAttachmentFileForPreview } from "../state/use-composer-drafts"; |
| 7 | 7 | |
| 8 | 8 | /** Retains the draft original for preview and gives each outgoing share its own lease. */ |
| 9 | 9 | export async function loadLocalAttachmentPreview( |
| 10 | attachment: DraftComposerFileAttachment, | |
| 10 | attachment: FileBackedComposerAttachment, | |
| 11 | 11 | signal: AbortSignal, |
| 12 | 12 | ): Promise<AttachmentPreviewFile | null> { |
| 13 | 13 | if (signal.aborted) return null; |
| ... | ... | @@ -41,7 +41,10 @@ export async function loadLocalAttachmentPreview( |
| 41 | 41 | uri: file.uri, |
| 42 | 42 | attachment: { |
| 43 | 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 | 49 | signal: shareSignal, |
| 47 | 50 | sourceIdentifier, |
apps/mobile/src/lib/projectThreadStartTurn.test.ts+2-4| ... | ... | @@ -6,15 +6,13 @@ import { |
| 6 | 6 | ThreadId, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | 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 | 11 | import { |
| 12 | 12 | buildProjectThreadStartTurnInput, |
| 13 | 13 | deriveThreadTitleFromPrompt, |
| 14 | 14 | } from "./projectThreadStartTurn"; |
| 15 | 15 | |
| 16 | vi.mock("./composerImages", () => ({ toUploadChatImageAttachments: () => [] })); | |
| 17 | ||
| 18 | 16 | describe("project thread title", () => { |
| 19 | 17 | it("keeps ordinary titles and the empty-prompt fallback", () => { |
| 20 | 18 | expect(deriveThreadTitleFromPrompt(" Fix\n the parser ")).toBe("Fix the parser"); |
| ... | ... | @@ -52,7 +50,7 @@ describe("project thread title", () => { |
| 52 | 50 | messageId: "message", |
| 53 | 51 | createdAt: "2026-09-01T00:00:00Z", |
| 54 | 52 | text, |
| 55 | attachments: [], | |
| 53 | uploadedAttachments: [], | |
| 56 | 54 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" }, |
| 57 | 55 | runtimeMode: "full-access", |
| 58 | 56 | interactionMode: "default", |
apps/mobile/src/lib/projectThreadStartTurn.ts+3-8| ... | ... | @@ -9,7 +9,6 @@ import { |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | 10 | import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; |
| 11 | 11 | |
| 12 | import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; | |
| 13 | 12 | import type { UploadedMobileAttachment } from "./attachmentUpload"; |
| 14 | 13 | |
| 15 | 14 | export function deriveThreadTitleFromPrompt(value: string): string { |
| ... | ... | @@ -30,8 +29,8 @@ export interface ProjectThreadStartTurnSpec { |
| 30 | 29 | readonly messageId: string; |
| 31 | 30 | readonly createdAt: string; |
| 32 | 31 | 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>; | |
| 35 | 34 | readonly modelSelection: ModelSelection; |
| 36 | 35 | readonly runtimeMode: RuntimeMode; |
| 37 | 36 | readonly interactionMode: ProviderInteractionMode; |
| ... | ... | @@ -58,11 +57,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe |
| 58 | 57 | messageId: MessageId.make(spec.messageId), |
| 59 | 58 | role: "user" as const, |
| 60 | 59 | text: spec.text, |
| 61 | attachments: | |
| 62 | spec.uploadedAttachments ?? | |
| 63 | toUploadChatImageAttachments( | |
| 64 | spec.attachments.filter((attachment) => attachment.type === "image"), | |
| 65 | ), | |
| 60 | attachments: spec.uploadedAttachments, | |
| 66 | 61 | }, |
| 67 | 62 | modelSelection: spec.modelSelection, |
| 68 | 63 | titleSeed: title, |
apps/mobile/src/state/composer-attachment-uploads.ts+4-4| ... | ... | @@ -4,6 +4,7 @@ import { Atom } from "effect/unstable/reactivity"; |
| 4 | 4 | import { useEffect, useRef } from "react"; |
| 5 | 5 | |
| 6 | 6 | import { prepareTurnAttachments } from "../lib/attachmentUpload"; |
| 7 | import { isFileBackedComposerAttachment } from "../lib/composerImages"; | |
| 7 | 8 | import { |
| 8 | 9 | composerAttachmentUploadKey, |
| 9 | 10 | composerDraftEnvironmentId, |
| ... | ... | @@ -60,10 +61,9 @@ export function useComposerAttachmentUploadWorker() { |
| 60 | 61 | const queue = createComposerAttachmentUploadQueue({ |
| 61 | 62 | onChange: (states) => appAtomRegistry.set(composerAttachmentUploadsAtom, states), |
| 62 | 63 | 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; | |
| 67 | 67 | try { |
| 68 | 68 | const result = await prepareTurnAttachments({ |
| 69 | 69 | environmentId, |
apps/mobile/src/state/thread-outbox-model.ts+2-1| ... | ... | @@ -27,6 +27,7 @@ import type { DraftComposerAttachment } from "../lib/composerImages"; |
| 27 | 27 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| 28 | 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 | 31 | const THREAD_OUTBOX_SCHEMA_VERSION = 3; |
| 31 | 32 | const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; |
| 32 | 33 | |
| ... | ... | @@ -43,7 +44,7 @@ const QueuedThreadCreationSchema = Schema.Struct({ |
| 43 | 44 | }); |
| 44 | 45 | |
| 45 | 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 | 48 | environmentId: EnvironmentId, |
| 48 | 49 | threadId: ThreadId, |
| 49 | 50 | messageId: MessageId, |
apps/mobile/src/state/thread-outbox.test.ts+24| ... | ... | @@ -196,6 +196,30 @@ describe("thread outbox", () => { |
| 196 | 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 | 223 | it("persists the exact selector snapshot while remaining compatible with v1 messages", () => { |
| 200 | 224 | const legacyMessage = queuedMessage({ |
| 201 | 225 | messageId: "message-1", |
apps/mobile/src/state/use-composer-drafts.test.ts+260-98| ... | ... | @@ -17,8 +17,10 @@ const composerDraftFileMocks = vi.hoisted(() => { |
| 17 | 17 | let nextWriteBarrier: Promise<void> | null = null; |
| 18 | 18 | let onWrite: (() => void) | null = null; |
| 19 | 19 | const writes: string[] = []; |
| 20 | const readImage = vi.fn(async () => "YWJj"); | |
| 20 | 21 | |
| 21 | 22 | return { |
| 23 | readImage, | |
| 22 | 24 | blockRead() { |
| 23 | 25 | readBarrier = new Promise<void>((resolve) => { |
| 24 | 26 | releaseRead = resolve; |
| ... | ... | @@ -73,6 +75,10 @@ const composerDraftFileMocks = vi.hoisted(() => { |
| 73 | 75 | return document; |
| 74 | 76 | } |
| 75 | 77 | |
| 78 | async base64() { | |
| 79 | return readImage(); | |
| 80 | } | |
| 81 | ||
| 76 | 82 | write(value: string) { |
| 77 | 83 | if (writeError) { |
| 78 | 84 | throw writeError; |
| ... | ... | @@ -108,13 +114,29 @@ const incomingShareStorageMocks = vi.hoisted(() => ({ |
| 108 | 114 | vi.mock("expo-file-system", () => ({ |
| 109 | 115 | Directory: composerDraftFileMocks.Directory, |
| 110 | 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 | 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 | 140 | vi.mock("../lib/attachmentUpload", () => ({ |
| 119 | 141 | releasePendingAttachmentUploads: composerAttachmentCleanupMocks.releaseUploads, |
| 120 | 142 | })); |
| ... | ... | @@ -128,6 +150,7 @@ import { threadOutboxManager } from "./thread-outbox"; |
| 128 | 150 | import { |
| 129 | 151 | appendComposerDraftAttachments, |
| 130 | 152 | archiveCloudComposerDrafts, |
| 153 | clearComposerDraftContent, | |
| 131 | 154 | clearComposerDraftContentState, |
| 132 | 155 | clearComposerDraftsEnvironment, |
| 133 | 156 | ComposerDraftPersistenceError, |
| ... | ... | @@ -171,6 +194,8 @@ afterEach(() => { |
| 171 | 194 | composerDraftFileMocks.setNextWriteBarrier(null); |
| 172 | 195 | composerDraftFileMocks.setOnWrite(null); |
| 173 | 196 | composerDraftFileMocks.resetWrites(); |
| 197 | composerDraftFileMocks.readImage.mockReset(); | |
| 198 | composerDraftFileMocks.readImage.mockResolvedValue("YWJj"); | |
| 174 | 199 | appAtomRegistry.set(composerDraftsAtom, {}); |
| 175 | 200 | appAtomRegistry.set(composerCloudDraftsAtom, { accountId: null, signedOut: {} }); |
| 176 | 201 | appAtomRegistry.set(stickyComposerModelSelectionAtom, null); |
| ... | ... | @@ -313,6 +338,106 @@ describe("mobile composer drafts", () => { |
| 313 | 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 | 441 | it("keeps a failed-send draft's pending upload for retry", async () => { |
| 317 | 442 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); |
| 318 | 443 | onTestFinished(() => outboxLoad.mockRestore()); |
| ... | ... | @@ -394,68 +519,75 @@ describe("mobile composer drafts", () => { |
| 394 | 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 () => { | |
| 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(); | |
| 438 | 569 | |
| 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 | ); | |
| 459 | 591 | |
| 460 | 592 | it("fails sign-out preservation before cleanup if a durable backup cannot be written", async () => { |
| 461 | 593 | const load = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); |
| ... | ... | @@ -480,45 +612,52 @@ describe("mobile composer drafts", () => { |
| 480 | 612 | ).toEqual(DRAFT); |
| 481 | 613 | }); |
| 482 | 614 | |
| 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); | |
| 503 | 641 | |
| 504 | await releaseUnusedComposerAttachmentFiles([currentFile]); | |
| 505 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | |
| 642 | await releaseUnusedComposerAttachmentFiles([currentFile]); | |
| 643 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | |
| 506 | 644 | |
| 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(); | |
| 511 | 649 | |
| 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; | |
| 519 | 657 | |
| 520 | expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]); | |
| 521 | }); | |
| 658 | expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[currentFile.fileUri]]); | |
| 659 | }, | |
| 660 | ); | |
| 522 | 661 | |
| 523 | 662 | it("preserves a preview opened while cleanup is checking the incoming inbox", async () => { |
| 524 | 663 | const outboxLoad = vi.spyOn(threadOutboxManager, "load").mockResolvedValue(true); |
| ... | ... | @@ -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 | 1011 | it("hydrates selector state even when the message content is empty", () => { |
| 850 | 1012 | expect( |
| 851 | 1013 | decodePersistedComposerDrafts({ |
apps/mobile/src/state/use-composer-drafts.ts+9-8| ... | ... | @@ -20,7 +20,7 @@ import { |
| 20 | 20 | isComposerAttachmentFileRetained, |
| 21 | 21 | retainComposerAttachmentFile, |
| 22 | 22 | } from "../lib/composerAttachmentFiles"; |
| 23 | import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages"; | |
| 23 | import type { DraftComposerAttachment, FileBackedComposerAttachment } from "../lib/composerImages"; | |
| 24 | 24 | import { SerializedAsyncQueue } from "../lib/serialized-async-queue"; |
| 25 | 25 | import { appAtomRegistry } from "./atom-registry"; |
| 26 | 26 | import { |
| ... | ... | @@ -376,7 +376,7 @@ function isComposerAttachmentFileReferenced(fileUri: string): boolean { |
| 376 | 376 | return [...drafts, ...queuedMessages, ...signedOutAttachmentOwners()].some((owner) => |
| 377 | 377 | owner.attachments.some( |
| 378 | 378 | (attachment) => |
| 379 | attachment.type === "file" && | |
| 379 | attachment.fileUri !== undefined && | |
| 380 | 380 | composerAttachmentFileReferenceKey(attachment.fileUri) === referenceKey, |
| 381 | 381 | ), |
| 382 | 382 | ); |
| ... | ... | @@ -403,9 +403,9 @@ export async function releaseUnusedComposerAttachmentFiles( |
| 403 | 403 | attachments: ReadonlyArray<DraftComposerAttachment>, |
| 404 | 404 | ): Promise<void> { |
| 405 | 405 | 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 | ), | |
| 409 | 409 | ); |
| 410 | 410 | const uploadCandidates = new Map<EnvironmentId, Set<string>>(); |
| 411 | 411 | for (const attachment of attachments) { |
| ... | ... | @@ -454,7 +454,7 @@ export async function releaseUnusedComposerAttachmentFiles( |
| 454 | 454 | incomingShareFileUris = new Set( |
| 455 | 455 | incomingShares.flatMap((share) => |
| 456 | 456 | share.attachments.flatMap((attachment) => |
| 457 | attachment.type === "file" | |
| 457 | attachment.fileUri !== undefined | |
| 458 | 458 | ? [composerAttachmentFileReferenceKey(attachment.fileUri)] |
| 459 | 459 | : [], |
| 460 | 460 | ), |
| ... | ... | @@ -509,7 +509,8 @@ export function scheduleUnusedComposerAttachmentCleanup( |
| 509 | 509 | ): void { |
| 510 | 510 | if ( |
| 511 | 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 | 516 | return; |
| ... | ... | @@ -521,7 +522,7 @@ export function scheduleUnusedComposerAttachmentCleanup( |
| 521 | 522 | |
| 522 | 523 | /** Keeps a native preview or upload readable until it finishes, then retries ownership cleanup. */ |
| 523 | 524 | export function retainComposerAttachmentFileForPreview( |
| 524 | attachment: DraftComposerFileAttachment, | |
| 525 | attachment: FileBackedComposerAttachment, | |
| 525 | 526 | ): () => void { |
| 526 | 527 | return retainComposerAttachmentFile(attachment.fileUri, () => { |
| 527 | 528 | scheduleUnusedComposerAttachmentCleanup([attachment]); |
apps/mobile/src/state/use-thread-outbox-drain.ts-1| ... | ... | @@ -873,7 +873,6 @@ export function useThreadOutboxDrain(): void { |
| 873 | 873 | messageId: queuedMessage.messageId, |
| 874 | 874 | createdAt: queuedMessage.createdAt, |
| 875 | 875 | text: queuedMessage.text.trim(), |
| 876 | attachments: queuedMessage.attachments, | |
| 877 | 876 | uploadedAttachments: prepared.attachments, |
| 878 | 877 | modelSelection: sendSettings.modelSelection, |
| 879 | 878 | runtimeMode: sendSettings.runtimeMode, |