Skip to content
Source
Sign inSign up
Open History to see the last commit

composerImagePreview.ts

72 lines2.7 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​
35class ImagePreviewCacheError extends Schema.TaggedError<ImagePreviewCacheError>()(
36 "ImagePreviewCacheError",
37 { cause: Schema.Defect() },
38) {
39 override get message(): string {
40 return "Could not cache an image preview.";
41 }
43​
44/**
45 * Keyed by attachment id only: the cached file is named by id, and an upload
46 * reference landing on the same draft must not restart materialization and
47 * blank the preview. Hashing the id also avoids rehashing a multi-megabyte data URL.
48 */
49export class MaterializedPreviewKey implements Equal.Equal {
50 constructor(readonly attachment: DraftComposerImageAttachment) {}
51 [Equal.symbol](that: unknown): boolean {
52 return that instanceof MaterializedPreviewKey && that.attachment.id === this.attachment.id;
53 }
54 [Hash.symbol](): number {
55 return Hash.string(this.attachment.id);
56 }
58​
59export const materializedPreviewAtom = Atom.family(({ attachment }: MaterializedPreviewKey) =>
60 Atom.make(
61 Effect.tryPromise({
62 try: () => materializeDataUrlPreview(attachment.id, attachment.previewUri),
63 catch: (cause) => new ImagePreviewCacheError({ cause }),
64 }).pipe(
65 Effect.catch((error) =>
66 Effect.logWarning("[composer-attachments] could not cache an image preview", error).pipe(
67 Effect.as(null),
68 ),
69 ),
70 ),
71 ).pipe(Atom.withLabel(`composer-image-preview:${attachment.id}`)),
72);