diff options
Diffstat (limited to 'src/features/chat/adapter')
| -rw-r--r-- | src/features/chat/adapter/image-encode.test.ts | 20 | ||||
| -rw-r--r-- | src/features/chat/adapter/image-encode.ts | 67 | ||||
| -rw-r--r-- | src/features/chat/adapter/pending-inputs.ts | 49 |
3 files changed, 136 insertions, 0 deletions
diff --git a/src/features/chat/adapter/image-encode.test.ts b/src/features/chat/adapter/image-encode.test.ts new file mode 100644 index 0000000..dbaa8ae --- /dev/null +++ b/src/features/chat/adapter/image-encode.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { convertToJpeg } from "./image-encode"; + +describe("convertToJpeg (canvas edge — real environment, no mocks)", () => { + it("skips non-convertible formats without touching the canvas", async () => { + const jpeg = new File([new Uint8Array([0xff, 0xd8])], "photo.jpg", { type: "image/jpeg" }); + await expect(convertToJpeg(jpeg)).resolves.toBe(null); + const gif = new File([new Uint8Array([0x47])], "anim.gif", { type: "image/gif" }); + await expect(convertToJpeg(gif)).resolves.toBe(null); + const unnamed = new File([new Uint8Array([0x00])], "blob", { type: "" }); + await expect(convertToJpeg(unnamed)).resolves.toBe(null); + }); + + it("falls back to null on decode failure (no real canvas in this environment)", async () => { + const png = new File([new Uint8Array([0x89, 0x50])], "shot.png", { type: "image/png" }); + // jsdom has no `createImageBitmap` → the decode throws → the failure path + // must resolve to `null` (never reject), so sending is never blocked. + await expect(convertToJpeg(png)).resolves.toBe(null); + }); +}); diff --git a/src/features/chat/adapter/image-encode.ts b/src/features/chat/adapter/image-encode.ts new file mode 100644 index 0000000..4570a5a --- /dev/null +++ b/src/features/chat/adapter/image-encode.ts @@ -0,0 +1,67 @@ +import { + computeResizedDimensions, + JPEG_QUALITY, + shouldConvertImage, + useConvertedImage, +} from "../logic/image-compress"; + +/** A successfully converted image: a JPEG blob at `JPEG_QUALITY`. */ +export interface ConvertedJpeg { + readonly blob: Blob; + readonly mimeType: "image/jpeg"; +} + +/** + * The injected browser edge for attach-time compression: a `File` in, a + * (maybe) smaller JPEG out. Returns `null` when the source is not converted + * (non-convertible format) or the conversion is skipped/failed (decode or + * encode error, smaller-wins rule) — the caller then sends the original + * bytes. Compression must never block or break sending, so every failure is + * swallowed into `null`. + */ +export type ImageJpegEncoder = (file: File) => Promise<ConvertedJpeg | null>; + +/** + * Canvas-based JPEG re-encode: decode the source, rescale to cap the longest + * edge at `MAX_IMAGE_EDGE_PX` (never upscale, high-quality smoothing), flatten + * any transparency onto a white background (JPEG has no alpha), and encode at + * `JPEG_QUALITY`. Applied only to convertible (lossless) sources; the result + * is used only under the smaller-wins rule. + * + * Thin by design — every decision comes from `../logic/image-compress`. + */ +export const convertToJpeg: ImageJpegEncoder = async (file: File) => { + try { + if (!shouldConvertImage(file.type)) return null; + + const bitmap = await createImageBitmap(file); + const target = computeResizedDimensions(bitmap.width, bitmap.height); + + const canvas = document.createElement("canvas"); + canvas.width = target.width; + canvas.height = target.height; + const ctx = canvas.getContext("2d"); + if (ctx === null) { + bitmap.close(); + return null; + } + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + // Flatten transparency: JPEG has no alpha channel. + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, target.width, target.height); + ctx.drawImage(bitmap, 0, 0, target.width, target.height); + bitmap.close(); + + const blob = await new Promise<Blob | null>((resolve) => { + canvas.toBlob(resolve, "image/jpeg", JPEG_QUALITY); + }); + if (blob === null) return null; + // Smaller-wins: keep the original when the re-encode didn't actually help. + if (!useConvertedImage(file.size, blob.size)) return null; + return { blob, mimeType: "image/jpeg" }; + } catch { + // Decode/encode failure → the original image is sent untouched. + return null; + } +}; diff --git a/src/features/chat/adapter/pending-inputs.ts b/src/features/chat/adapter/pending-inputs.ts new file mode 100644 index 0000000..5798fdb --- /dev/null +++ b/src/features/chat/adapter/pending-inputs.ts @@ -0,0 +1,49 @@ +import type { PendingInput, PendingInputStorage } from "../pending-inputs"; + +export function createPendingInputStorage( + storage: Storage, + conversationId: string, +): PendingInputStorage { + const prefix = `dispatch.pending-inputs:${conversationId}:`; + let known = new Map<string, string>(); + return { + load() { + const inputs: PendingInput[] = []; + const loaded = new Map<string, string>(); + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (key === null || !key.startsWith(prefix)) continue; + const raw = storage.getItem(key); + if (raw === null) continue; + const input = JSON.parse(raw); + if ( + (input?.request?.type !== "chat.send" && input?.request?.type !== "chat.queue") || + input.request.conversationId !== conversationId || + typeof input.request.requestId !== "string" || + key !== prefix + input.request.requestId || + (input.request.type === "chat.send" + ? typeof input.request.message !== "string" + : typeof input.request.text !== "string") || + (input.rejection !== undefined && typeof input.rejection !== "string") + ) + throw new Error("Invalid saved input"); + inputs.push(input); + loaded.set(input.request.requestId, raw); + } + known = loaded; + return inputs; + }, + save(inputs) { + // Per-request keys prevent another browser tab's send/receipt from overwriting ours. + // Quota/security failures reach the composer BEFORE it clears its staged input. + const next = new Map(inputs.map((input) => [input.request.requestId, JSON.stringify(input)])); + for (const [id, raw] of next) { + if (known.get(id) !== raw) storage.setItem(prefix + id, raw); + } + for (const id of known.keys()) { + if (!next.has(id)) storage.removeItem(prefix + id); + } + known = next; + }, + }; +} |
