Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 73 additions & 27 deletions apps/mobile/src/components/ComposerAttachmentStrip.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { SymbolView } from "../components/AppSymbol";
import { videoMimeType } from "@t3tools/shared/video";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { Image, Pressable, ScrollView, View } from "react-native";

import { AppText as Text } from "./AppText";
import type { DraftComposerAttachment, DraftComposerFileAttachment } from "../lib/composerImages";
import {
isFileBackedComposerAttachment,
type DraftComposerAttachment,
type DraftComposerFileAttachment,
type DraftComposerImageAttachment,
} from "../lib/composerImages";
import { resolveOwnedComposerAttachmentFileUri } from "../lib/composerAttachmentFiles";
import { VideoAttachmentTile } from "./VideoAttachmentTile";
import type { MediaActionsSource } from "../lib/mediaActions";
import { PresentationSource } from "./NativePresentation";
Expand Down Expand Up @@ -87,35 +93,75 @@ export function ComposerAttachmentThumbnail(props: ComposerAttachmentThumbnailPr
);
}

/**
* Thumbnail URI for a draft image. File-backed previews rebase into the
* current iOS data container (its UUID changes across installs); the raw
* persisted URI renders meanwhile, which is correct everywhere but after a
* container move.
*/
function useComposerImagePreviewUri(attachment: DraftComposerImageAttachment): string {
const { fileUri, previewUri } = attachment;
const [rebased, setRebased] = useState<{ fileUri: string; uri: string } | null>(null);
useEffect(() => {
if (fileUri === undefined) return;
let cancelled = false;
void (async () => {
const { Paths } = await import("expo-file-system");
const owned = resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri);
// Re-render only when the container actually moved.
if (!cancelled && owned !== null && owned !== previewUri) setRebased({ fileUri, uri: owned });
})();
return () => {
cancelled = true;
};
}, [fileUri, previewUri]);
return fileUri !== undefined && rebased?.fileUri === fileUri ? rebased.uri : previewUri;
}

function ComposerImageAttachment(
props: ComposerAttachmentThumbnailProps & { readonly attachment: DraftComposerImageAttachment },
) {
const { attachment } = props;
const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
const previewUri = useComposerImagePreviewUri(attachment);
const sourceIdentifier = `draft-image:${attachment.id}`;
return (
<PresentationSource identifier={sourceIdentifier}>
<Pressable
accessibilityRole="imagebutton"
accessibilityLabel={`Open ${attachment.name}`}
disabled={!props.onPressPreview}
onPress={() =>
props.onPressPreview?.(
// File-backed images open through the retain-lease + container
// rebase path; legacy drafts still carry their inline bytes.
isFileBackedComposerAttachment(attachment)
? { kind: "image", attachment, name: attachment.name, sourceIdentifier }
: {
kind: "image",
uri: attachment.dataUrl ?? attachment.previewUri,
name: attachment.name,
sourceIdentifier,
},
)
}
>
<Image
source={{ uri: previewUri }}
style={style}
className="bg-subtle"
resizeMode="cover"
/>
</Pressable>
</PresentationSource>
);
}

function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) {
const { attachment } = props;
const style = { width: props.size, height: props.size, borderRadius: props.borderRadius };
if (attachment.type === "image") {
const sourceIdentifier = `draft-image:${attachment.id}`;
return (
<PresentationSource identifier={sourceIdentifier}>
<Pressable
accessibilityRole="imagebutton"
accessibilityLabel={`Open ${attachment.name}`}
disabled={!props.onPressPreview}
onPress={() =>
props.onPressPreview?.({
kind: "image",
uri: attachment.dataUrl,
name: attachment.name,
sourceIdentifier,
})
}
>
<Image
source={{ uri: attachment.previewUri }}
style={style}
className="bg-subtle"
resizeMode="cover"
/>
</Pressable>
</PresentationSource>
);
return <ComposerImageAttachment {...props} attachment={attachment} />;
}
const onPressVideo = props.onPressVideo;
if (onPressVideo && videoMimeType(attachment) !== null) {
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/components/FilePreviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
import { useEffect, useEffectEvent, useState } from "react";
import { Alert, Keyboard } from "react-native";

import type { DraftComposerFileAttachment } from "../lib/composerImages";
import type { FileBackedComposerAttachment } from "../lib/composerImages";
import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
import type { MediaActionsSource } from "../lib/mediaActions";
import { useAssetUrlState } from "../state/assets";
Expand All @@ -22,7 +22,7 @@ export interface ResolvedFilePreviewSource {
export type FilePreviewSource = Omit<ResolvedFilePreviewSource, "uri"> &
(
| { readonly uri: string }
| { readonly attachment: DraftComposerFileAttachment }
| { readonly attachment: FileBackedComposerAttachment }
| { readonly environmentId: EnvironmentId; readonly resource: AssetResource }
);

Expand Down
1 change: 0 additions & 1 deletion apps/mobile/src/features/threads/use-project-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,6 @@ export function useCreateProjectThread() {
messageId: metadata.messageId,
createdAt: metadata.createdAt,
text: initialMessageText,
attachments: input.initialAttachments,
uploadedAttachments: prepared.attachments,
modelSelection: input.modelSelection,
runtimeMode: input.runtimeMode,
Expand Down
66 changes: 66 additions & 0 deletions apps/mobile/src/lib/attachmentUpload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
upload: vi.fn(),
writeFile: vi.fn(),
deleteFile: vi.fn(),
readBase64: vi.fn(),
}));

vi.mock("@t3tools/client-runtime/state/runtime", () => ({
Expand Down Expand Up @@ -68,6 +69,10 @@ vi.mock("expo-file-system", () => ({
mocks.deleteFile(this.uri);
}

async base64() {
return mocks.readBase64(this.uri);
}

upload(url: string, options: unknown) {
return mocks.upload(this.uri, url, options);
}
Expand Down Expand Up @@ -102,6 +107,16 @@ const image = {
previewUri: "file:///images/screenshot.png",
} as const satisfies DraftComposerAttachment;

const fileBackedImage = {
id: "image-2",
type: "image",
name: "photo.png",
mimeType: "image/png",
sizeBytes: 3,
fileUri: "file:///documents/t3-composer-attachments/photo.png",
previewUri: "file:///documents/t3-composer-attachments/photo.png",
} as const satisfies DraftComposerAttachment;

const file = {
id: "file-1",
type: "file",
Expand Down Expand Up @@ -174,6 +189,8 @@ describe("prepareTurnAttachments", () => {
mocks.upload.mockReset();
mocks.writeFile.mockReset();
mocks.deleteFile.mockReset();
mocks.readBase64.mockReset();
mocks.readBase64.mockResolvedValue("YWJj");
mocks.readAtom.mockReturnValue(Option.some({ httpBaseUrl: "https://environment.example/" }));
mocks.runAtomCommand.mockImplementation(async (_registry: unknown, command: unknown) =>
command === mocks.createUploadUrl
Expand Down Expand Up @@ -208,6 +225,55 @@ describe("prepareTurnAttachments", () => {
expect(mocks.upload).not.toHaveBeenCalled();
});

it("inlines a file-backed image lazily when the server lacks image uploads", async () => {
const prepared = await prepareTurnAttachments({
environmentId,
attachments: [fileBackedImage],
});

expect(prepared.status).toBe("ready");
if (prepared.status !== "ready") return;
expect(prepared.attachments).toEqual([
{
type: "image",
name: "photo.png",
mimeType: "image/png",
sizeBytes: 3,
dataUrl: "data:image/png;base64,YWJj",
},
]);
expect(mocks.readBase64).toHaveBeenCalledExactlyOnceWith(fileBackedImage.fileUri);
expect(mocks.upload).not.toHaveBeenCalled();
});

it("uploads a file-backed image from its owned copy without staging base64", async () => {
const prepared = await prepareTurnAttachments({
environmentId,
attachments: [fileBackedImage],
supportsImageUploads: true,
});

expect(mocks.upload).toHaveBeenCalledWith(
fileBackedImage.fileUri,
"https://environment.example/api/attachments/upload/signed",
expect.objectContaining({ headers: { "Content-Type": "image/png" } }),
);
expect(mocks.readBase64).not.toHaveBeenCalled();
expect(mocks.writeFile).not.toHaveBeenCalled();
expect(mocks.deleteFile).not.toHaveBeenCalled();
expect(prepared.status).toBe("ready");
if (prepared.status !== "ready") return;
expect(prepared.attachments).toEqual([
{
type: "image",
id: MINTED_ID,
name: "photo.png",
mimeType: "image/png",
sizeBytes: 3,
},
]);
});

it("uploads generic file bytes directly and keeps mixed attachment order", async () => {
const prepared = await prepareTurnAttachments({ environmentId, attachments: [file, image] });

Expand Down
24 changes: 14 additions & 10 deletions apps/mobile/src/lib/attachmentUpload.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -187,17 +187,21 @@ async function uploadFileBytes(
): Promise<void> {
const { File, Paths, UploadType } = await import("expo-file-system");
if (signal.aborted) throw new Error("Upload cancelled.");
// Legacy image drafts persisted inline bytes and stage them in a temp cache
// file for the native uploader. Everything else uploads its owned copy.
const fileUri = attachment.fileUri;
const inlineDataUrl = attachment.type === "image" ? attachment.dataUrl : undefined;
if (fileUri === undefined && inlineDataUrl === undefined) {
throw new Error(`'${attachment.name}' is no longer available. Attach the image again.`);
}
const file =
attachment.type === "image"
fileUri === undefined
? new File(Paths.cache, `t3-upload-${uuidv4()}`)
: new File(
resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ??
attachment.fileUri,
);
: new File(resolveOwnedComposerAttachmentFileUri(fileUri, Paths.document.uri) ?? fileUri);
try {
if (attachment.type === "image") {
if (fileUri === undefined && inlineDataUrl !== undefined) {
file.create();
file.write(attachment.dataUrl.slice(attachment.dataUrl.indexOf(",") + 1), {
file.write(inlineDataUrl.slice(inlineDataUrl.indexOf(",") + 1), {
encoding: "base64",
});
}
Expand All @@ -218,7 +222,7 @@ async function uploadFileBytes(
throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`);
}
} finally {
if (attachment.type === "image" && file.exists) file.delete();
if (fileUri === undefined && file.exists) file.delete();
}
}

Expand Down Expand Up @@ -260,7 +264,7 @@ export async function prepareTurnAttachments(input: {

if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) {
return ready(
toUploadChatImageAttachments(
await toUploadChatImageAttachments(
input.attachments.filter((attachment) => attachment.type === "image"),
),
[],
Expand All @@ -285,7 +289,7 @@ export async function prepareTurnAttachments(input: {
for (const attachment of input.attachments) {
if (controller.signal.aborted) throw new Error("Upload cancelled.");
if (attachment.type === "image" && !input.supportsImageUploads) {
uploadedAttachments.push(...toUploadChatImageAttachments([attachment]));
uploadedAttachments.push(...(await toUploadChatImageAttachments([attachment])));
continue;
}

Expand Down
13 changes: 11 additions & 2 deletions apps/mobile/src/lib/composer-image-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,19 @@ export const DraftComposerImageAttachmentSchema = Schema.Struct({
name: Schema.String,
mimeType: Schema.String,
sizeBytes: Schema.Number,
dataUrl: Schema.String,
// Accept future file-backed records before enabling the new image writers.
fileUri: Schema.optional(Schema.String.check(Schema.isNonEmpty())),
dataUrl: Schema.optional(Schema.String.check(Schema.isNonEmpty())),
uploadedAttachmentId: Schema.optional(Schema.String),
uploadEnvironmentId: Schema.optional(EnvironmentId),
});
}).check(
Schema.makeFilter(
({ fileUri, dataUrl }) =>
fileUri !== undefined ||
dataUrl !== undefined ||
"Image attachment has no file or inline bytes.",
),
);

export const DraftComposerFileAttachmentSchema = Schema.Struct({
id: Schema.String,
Expand Down
Loading
Loading