Skip to content
Source
Sign inSign up

composerImagePreview.ts

84 lines3.2 KB

1import * as Effect from "effect/Effect";
2import * as Equal from "effect/Equal";
3import * as Hash from "effect/Hash";
4import * as Schema from "effect/Schema";
5import { Atom } from "effect/reactivity";
6​
7import type { DraftComposerImageAttachment } from "./composerImages";
8​
9const PREVIEW_CACHE_DIRECTORY = "t3-composer-previews";
10​
11async function materializeDataUrlPreview(id: string, dataUrl: string): Promise<string | null> {
12 const comma = dataUrl.indexOf(",");
13 if (comma < 0) return null;
14 const { Directory, File, Paths } = await import("expo-file-system");
15 const mimeType = /^data:([^;,]+)/.exec(dataUrl)?.[1] ?? "image/jpeg";
16 const extension = (mimeType.split("/")[1] ?? "jpg").replace("jpeg", "jpg");
17 const directory = new Directory(Paths.cache, PREVIEW_CACHE_DIRECTORY);
18 directory.create({ idempotent: true, intermediates: true });
19 const file = new File(directory, `${id}.${extension}`);
20 if (file.exists) return file.uri;
21 // Write beside the cache entry and move it in, so a failed or interrupted write is never reused.
22 const partial = new File(directory, `${id}.${extension}.partial`);
23 if (partial.exists) partial.delete();
24 partial.create();
25 try {
26 await partial.write(dataUrl.slice(comma + 1), { encoding: "base64" });
27 } catch (error) {
28 partial.delete();
29 throw error;
30 }
31 await partial.move(file);
32 return file.uri;
34​
35const inFlightPreviews = new Map<string, Promise<string | null>>();
36​
37// A render reads the atom before its effect subscribes, so the registry can drop and rebuild it
38// mid-write; a second writer would delete the first one's partial file and fail its own move.
39function materializePreviewOnce(id: string, dataUrl: string): Promise<string | null> {
40 const pending = inFlightPreviews.get(id);
41 if (pending) return pending;
42 const next = materializeDataUrlPreview(id, dataUrl).finally(() => inFlightPreviews.delete(id));
43 inFlightPreviews.set(id, next);
44 return next;
46​
47class ImagePreviewCacheError extends Schema.TaggedError<ImagePreviewCacheError>()(
48 "ImagePreviewCacheError",
49 { cause: Schema.Defect() },
50) {
51 override get message(): string {
52 return "Could not cache an image preview.";
53 }
55​
56/**
57 * Keyed by attachment id only: the cached file is named by id, and an upload
58 * reference landing on the same draft must not restart materialization and
59 * blank the preview. Hashing the id also avoids rehashing a multi-megabyte data URL.
60 */
61export class MaterializedPreviewKey implements Equal.Equal {
62 constructor(readonly attachment: DraftComposerImageAttachment) {}
63 [Equal.symbol](that: unknown): boolean {
64 return that instanceof MaterializedPreviewKey && that.attachment.id === this.attachment.id;
65 }
66 [Hash.symbol](): number {
67 return Hash.string(this.attachment.id);
68 }
70​
71export const materializedPreviewAtom = Atom.family(({ attachment }: MaterializedPreviewKey) =>
72 Atom.make(
73 Effect.tryPromise({
74 try: () => materializePreviewOnce(attachment.id, attachment.previewUri),
75 catch: (cause) => new ImagePreviewCacheError({ cause }),
76 }).pipe(
77 Effect.catch((error) =>
78 Effect.logWarning("[composer-attachments] could not cache an image preview", error).pipe(
79 Effect.as(null),
80 ),
81 ),
82 ),
83 ).pipe(Atom.withLabel(`composer-image-preview:${attachment.id}`)),
84);