import { Signal } from "@char/aftercare"; import { MAX_IMAGES, validateImage } from "@berry/api/images.ts"; import { storedDraft } from "./draft-storage.ts"; export type DraftImage = { id: string; name: string; blob: Blob; status: "loading" | "ready" | "error"; error?: string; }; type StoredImage = Pick; const message = (error: unknown) => error instanceof Error ? error.message : String(error); /** A route's shared image draft, with serialized durable changes across mounted composers. */ class ImageDraft { readonly items = new Signal([]); readonly error = new Signal(null); readonly loading = new Signal(typeof indexedDB !== "undefined"); readonly ready: Promise; submitting = false; #queue = Promise.resolve(); constructor(readonly route: string) { this.ready = this.loading.get() ? storedDraft(route).then((images) => { this.items.set(images.map((image) => ({ ...image, status: "ready" }))); }).catch((error) => { this.error.set(`Could not restore image drafts: ${message(error)}`); }).finally(() => this.loading.set(false)) : Promise.resolve(); } #enqueue(task: () => Promise): Promise { const pending = this.#queue.then(task); this.#queue = pending.catch(() => {}); return pending; } async #save(items = this.items.get()): Promise { await storedDraft( this.route, items.filter((image) => image.status === "ready").map( ({ id, name, blob }) => ({ id, name, blob }), ), ); this.error.set(null); } async add(files: readonly File[]): Promise { await this.ready; if (this.error.get()) throw new Error(this.error.get()!); if (this.items.get().length + files.length > MAX_IMAGES) { throw new Error(`A message can have at most ${MAX_IMAGES} images`); } const added: DraftImage[] = files.map((blob) => ({ id: Array.from( crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0"), ).join(""), name: blob.name || "Pasted image", blob, status: "loading", })); this.items.set([...this.items.get(), ...added]); await Promise.all(added.map((image) => this.#enqueue(async () => { try { const bytes = new Uint8Array(await image.blob.slice(0, 12).arrayBuffer()); validateImage(bytes, image.blob.type, image.blob.size); const bitmap = await createImageBitmap(image.blob); try { if (bitmap.width * bitmap.height > 40_000_000) { throw new Error("Images must be no larger than 40 megapixels"); } } finally { bitmap.close(); } if (!this.items.get().some((item) => item.id === image.id)) return; const next = this.items.get().map((item) => item.id === image.id ? { ...item, status: "ready" as const } : item ); await this.#save(next); this.items.set( this.items.get().map((item) => item.id === image.id ? { ...item, status: "ready" } : item ), ); } catch (error) { this.items.set( this.items.get().map((item) => item.id === image.id ? { ...item, status: "error", error: message(error) } : item ), ); throw error; } }) )); } remove(ids: readonly string[]): Promise { this.items.set(this.items.get().filter((image) => !ids.includes(image.id))); return this.#enqueue(() => this.#save()).catch((error) => { this.error.set(`Could not save image drafts: ${message(error)}`); throw error; }); } } const drafts = new Map(); export function imageDraft(route: string): ImageDraft { if (!drafts.has(route)) drafts.set(route, new ImageDraft(route)); return drafts.get(route)!; }