Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
153 changes: 153 additions & 0 deletions apps/mobile/src/lib/queuedAttachmentRecall.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts";
import { describe, expect, it, vi } from "vite-plus/test";

// `./uuid` pulls in expo-crypto (and through it react-native), which cannot be
// loaded in this environment; the sibling composer-image test mocks it the same way.
let recalledIdCounter = 0;
vi.mock("./uuid", () => ({
uuidv4: () => `recalled-${(recalledIdCounter += 1)}`,
}));

import {
describeQueuedAttachmentCapacity,
formatMissingAttachmentsError,
recallQueuedAttachments,
type QueuedAttachmentRecallDeps,
type RecallableQueuedAttachment,
} from "./queuedAttachmentRecall";

const attachment = (
overrides: Partial<RecallableQueuedAttachment> = {},
): RecallableQueuedAttachment => ({
id: "attachment-1",
name: "screenshot.png",
mimeType: "image/png",
sizeBytes: 4,
...overrides,
});

const deps = (overrides: Partial<QueuedAttachmentRecallDeps> = {}): QueuedAttachmentRecallDeps => ({
urlById: new Map([["attachment-1", "https://assets.test/attachment-1"]]),
fetchDataUrl: async () => "data:image/png;base64,cG5nIQ==",
...overrides,
});

describe("recallQueuedAttachments", () => {
it("rebuilds a draft attachment carrying the bytes inline", async () => {
const result = await recallQueuedAttachments([attachment()], deps());

expect(result.missing).toEqual([]);
expect(result.images).toHaveLength(1);
const image = result.images[0]!;
expect(image.type).toBe("image");
expect(image.name).toBe("screenshot.png");
expect(image.mimeType).toBe("image/png");
expect(image.sizeBytes).toBe(4);
expect(image.dataUrl).toBe("data:image/png;base64,cG5nIQ==");
expect(image.previewUri).toBe("data:image/png;base64,cG5nIQ==");
});

it("gives the recalled draft a fresh id so it outlives the removed queue entry", async () => {
const result = await recallQueuedAttachments([attachment()], deps());

expect(result.images[0]!.id).not.toBe("attachment-1");
expect(result.images[0]!.id).toMatch(/^recalled-/);
});

it("reads each attachment from its own signed url", async () => {
const requested: string[] = [];
const result = await recallQueuedAttachments(
[attachment(), attachment({ id: "attachment-2", name: "diagram.png" })],
deps({
urlById: new Map([
["attachment-1", "https://assets.test/one"],
["attachment-2", "https://assets.test/two"],
]),
fetchDataUrl: async (url: string) => {
requested.push(url);
return "data:image/png;base64,eA==";
},
}),
);

expect(requested).toEqual(["https://assets.test/one", "https://assets.test/two"]);
expect(result.images.map((image) => image.name)).toEqual(["screenshot.png", "diagram.png"]);
});

it("reports an attachment whose url has not resolved instead of dropping it silently", async () => {
const result = await recallQueuedAttachments([attachment()], deps({ urlById: new Map() }));

expect(result.images).toEqual([]);
expect(result.missing).toEqual(["screenshot.png"]);
});

it("keeps the readable attachments when one read fails", async () => {
const result = await recallQueuedAttachments(
[attachment(), attachment({ id: "attachment-2", name: "broken.png" })],
deps({
urlById: new Map([
["attachment-1", "https://assets.test/one"],
["attachment-2", "https://assets.test/two"],
]),
fetchDataUrl: async (url: string) => {
if (url.endsWith("two")) throw new Error("gone");
return "data:image/png;base64,eA==";
},
}),
);

expect(result.images.map((image) => image.name)).toEqual(["screenshot.png"]);
expect(result.missing).toEqual(["broken.png"]);
});

it("returns nothing for a queued message with no attachments", async () => {
const result = await recallQueuedAttachments([], deps());

expect(result).toEqual({ images: [], missing: [] });
});
});

describe("formatMissingAttachmentsError", () => {
it("stays silent when everything was restored", () => {
expect(formatMissingAttachmentsError([])).toBeNull();
});

it("names the single attachment that was left behind", () => {
expect(formatMissingAttachmentsError(["screenshot.png"])).toBe(
"'screenshot.png' could not be loaded, so the message is still queued. Try again — if it keeps failing, the image is no longer on the server and the message has to be sent or replaced as it is.",
);
});

it("counts them once several were left behind", () => {
expect(formatMissingAttachmentsError(["a.png", "b.png"])).toBe(
"2 attachments could not be loaded, so the message is still queued. Try again — if it keeps failing, the image is no longer on the server and the message has to be sent or replaced as it is.",
);
});
});

describe("describeQueuedAttachmentCapacity", () => {
it("allows an edit that fits in the composer", () => {
expect(describeQueuedAttachmentCapacity(3, 2)).toBeNull();
});

it("allows an edit that exactly fills the remaining room", () => {
expect(describeQueuedAttachmentCapacity(3, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 3)).toBeNull();
});

it("refuses an edit that would overflow, rather than restoring only some pictures", () => {
const message = describeQueuedAttachmentCapacity(3, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - 2);

expect(message).toContain(`${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-image limit`);
expect(message).toContain("Remove some images from the composer first.");
});

it("says image, singular, for one attachment", () => {
expect(describeQueuedAttachmentCapacity(1, PROVIDER_SEND_TURN_MAX_ATTACHMENTS)).toContain(
"bring back 1 image,",
);
});

it("never blocks a message with no attachments", () => {
expect(describeQueuedAttachmentCapacity(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS)).toBeNull();
});
});
125 changes: 125 additions & 0 deletions apps/mobile/src/lib/queuedAttachmentRecall.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts";

import type { DraftComposerImageAttachment } from "./composerImages";
import { uuidv4 } from "./uuid";

/**
* Rebuilding composer attachments when a server-queued message is edited.
*
* Server-queued attachments carry only `{id, name, mimeType, sizeBytes}`; the
* bytes live on the server. Composer drafts need the bytes inline as a data
* URL, so editing has to read each attachment back through its signed asset
* URL first. Locally-queued (outbox) messages already hold their data URLs and
* skip all of this.
*
* Callers must do this *before* removing the queued message. The removal is
* what makes the edit destructive: once the queued entry is gone, its
* attachment files are pruned server-side and a failed fetch has no second
* chance — so a caller that cannot restore everything must leave the message
* queued rather than remove it.
*/

export interface RecallableQueuedAttachment {
readonly id: string;
readonly name: string;
readonly mimeType: string;
readonly sizeBytes: number;
}

export interface QueuedAttachmentRecallResult {
readonly images: ReadonlyArray<DraftComposerImageAttachment>;
/** Attachments whose bytes could not be read back, by display name. */
readonly missing: ReadonlyArray<string>;
}

export interface QueuedAttachmentRecallDeps {
readonly urlById: ReadonlyMap<string, string>;
readonly fetchDataUrl: (url: string) => Promise<string>;
}

export const defaultFetchAttachmentDataUrl = async (url: string): Promise<string> => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Attachment request failed with status ${response.status}.`);
}
const blob = await response.blob();
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => {
reject(reader.error ?? new Error("Attachment could not be read."));
};
reader.onload = () => {
const result = reader.result;
if (typeof result !== "string") {
reject(new Error("Attachment could not be read."));
return;
}
resolve(result);
};
reader.readAsDataURL(blob);
});
};

/**
* Fetches every queued attachment back into a composer-ready draft image. Failures
* are collected rather than thrown so the caller sees the whole picture at
* once: because removing the queued message prunes its files, a caller that
* cannot restore everything must abandon the edit rather than restore part.
*/
export async function recallQueuedAttachments(
attachments: ReadonlyArray<RecallableQueuedAttachment>,
deps: QueuedAttachmentRecallDeps,
): Promise<QueuedAttachmentRecallResult> {
const images: DraftComposerImageAttachment[] = [];
const missing: string[] = [];

for (const attachment of attachments) {
const url = deps.urlById.get(attachment.id);
if (!url) {
missing.push(attachment.name);
continue;
}
try {
const dataUrl = await deps.fetchDataUrl(url);
images.push({
// A fresh id keeps the recalled draft independent of the queued entry
// that is about to be removed, matching how picked images are staged.
id: uuidv4(),
type: "image",
name: attachment.name,
mimeType: attachment.mimeType,
sizeBytes: attachment.sizeBytes,
dataUrl,
previewUri: dataUrl,
});
} catch {
missing.push(attachment.name);
}
}

return { images, missing };
}

export function formatMissingAttachmentsError(missing: ReadonlyArray<string>): string | null {
if (missing.length === 0) return null;
return missing.length === 1
? `'${missing[0]}' could not be loaded, so the message is still queued. Try again — if it keeps failing, the image is no longer on the server and the message has to be sent or replaced as it is.`
: `${missing.length} attachments could not be loaded, so the message is still queued. Try again — if it keeps failing, the image is no longer on the server and the message has to be sent or replaced as it is.`;
}

/**
* Refuses the edit up front when the composer has no room for the queued
* message's pictures. Restoring only some of them would drop the rest for good,
* since removing the queued message deletes its attachment files server-side.
*/
export function describeQueuedAttachmentCapacity(
queuedCount: number,
draftImageCount: number,
): string | null {
if (queuedCount === 0) return null;
const capacity = Math.max(0, PROVIDER_SEND_TURN_MAX_ATTACHMENTS - draftImageCount);
if (queuedCount <= capacity) return null;
return `Editing this message would bring back ${queuedCount} image${
queuedCount === 1 ? "" : "s"
}, past the ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-image limit. Remove some images from the composer first.`;
}
33 changes: 33 additions & 0 deletions apps/mobile/src/state/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
import { createAssetEnvironmentAtoms, resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { useMemo } from "react";

import { connectionAtomRuntime } from "../connection/runtime";
import { usePreparedConnection } from "./session";
Expand All @@ -12,6 +13,10 @@ const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial<never, never>(false))
Atom.withLabel("mobile-asset-url:empty"),
);

const EMPTY_ASSET_URLS_ATOM = Atom.make([] as Array<AsyncResult.AsyncResult<never, never>>).pipe(
Atom.withLabel("mobile-asset-urls:empty"),
);

export function useAssetUrl(
environmentId: EnvironmentId | null,
resource: AssetResource | null,
Expand All @@ -27,3 +32,31 @@ export function useAssetUrl(
}
return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl);
}

/**
* Batch sibling of {@link useAssetUrl}, for a set of resources whose size is
* only known at render time (a thread's attachments, say) and so cannot be
* resolved with one hook call each.
*/
export function useAssetUrls(
environmentId: EnvironmentId | null,
resources: ReadonlyArray<AssetResource>,
): ReadonlyArray<string | null> {
const preparedConnection = usePreparedConnection(environmentId);
const results = useAtomValue(
environmentId === null || resources.length === 0
? EMPTY_ASSET_URLS_ATOM
: assetEnvironment.createUrls({ environmentId, resources }),
);
return useMemo(
() =>
preparedConnection._tag === "None"
? resources.map(() => null)
: results.map((result) =>
AsyncResult.isSuccess(result)
? resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl)
: null,
),
[preparedConnection, resources, results],
);
}
Loading
Loading