a tool for shared writing and social publishing
1import { Replicache } from "replicache";
2import { ReplicacheMutators } from "../replicache";
3import { supabaseBrowserClient } from "supabase/browserClient";
4import type { FilterAttributes } from "src/replicache/attributes";
5import { rgbaToDataURL, rgbaToThumbHash, thumbHashToDataURL } from "thumbhash";
6import { v7 } from "uuid";
7
8export const localImages = new Map<string, boolean>();
9export async function addImage(
10 file: File,
11 rep: Replicache<ReplicacheMutators>,
12 args: {
13 entityID: string;
14 attribute: keyof FilterAttributes<{ type: "image" }>;
15 },
16) {
17 let client = supabaseBrowserClient();
18 let cache = await caches.open("minilink-user-assets");
19 let fileID = v7();
20 let url = client.storage.from("minilink-user-assets").getPublicUrl(fileID)
21 .data.publicUrl;
22 // Re-encode through canvas to bake EXIF orientation into pixel data.
23 // iPhone photos have EXIF rotation metadata that browsers respect, but
24 // Supabase's image transformation pipeline strips without applying.
25 let { blob: uploadBlob, width, height } = await normalizeOrientation(file);
26
27 await cache.put(
28 new URL(url + "?local"),
29 new Response(uploadBlob, {
30 headers: {
31 "Content-Type": uploadBlob.type,
32 "Content-Length": uploadBlob.size.toString(),
33 },
34 }),
35 );
36 localImages.set(url, true);
37
38 let thumbhash = await getThumbHash(file);
39 if (navigator.serviceWorker)
40 await rep.mutate.assertFact({
41 entity: args.entityID,
42 attribute: "block/image",
43 data: {
44 fallback: thumbhash,
45 type: "image",
46 local: rep.clientID,
47 src: url,
48 height,
49 width,
50 },
51 });
52 await client.storage.from("minilink-user-assets").upload(fileID, uploadBlob, {
53 cacheControl: "public, max-age=31560000, immutable",
54 });
55 await rep.mutate.assertFact({
56 entity: args.entityID,
57 attribute: args.attribute,
58 data: {
59 fallback: thumbhash,
60 type: "image",
61 src: url,
62 height,
63 width,
64 },
65 });
66}
67
68async function getThumbHash(file: File) {
69 const arrayBuffer = await file.arrayBuffer();
70 const blob = new Blob([arrayBuffer], { type: file.type });
71 const imageBitmap = await createImageBitmap(blob);
72
73 const canvas = document.createElement("canvas");
74 const context = canvas.getContext("2d") as CanvasRenderingContext2D;
75 const maxDimension = 100;
76 let width = imageBitmap.width;
77 let height = imageBitmap.height;
78
79 if (width > height) {
80 if (width > maxDimension) {
81 height *= maxDimension / width;
82 width = maxDimension;
83 }
84 } else {
85 if (height > maxDimension) {
86 width *= maxDimension / height;
87 height = maxDimension;
88 }
89 }
90
91 canvas.width = width;
92 canvas.height = height;
93 context.drawImage(imageBitmap, 0, 0, width, height);
94
95 const imageData = context.getImageData(0, 0, width, height);
96 const thumbHash = thumbHashToDataURL(
97 rgbaToThumbHash(imageData.width, imageData.height, imageData.data),
98 );
99 return thumbHash;
100}
101
102async function normalizeOrientation(
103 file: File,
104): Promise<{ blob: Blob; width: number; height: number }> {
105 let bitmap = await createImageBitmap(file);
106 let canvas = document.createElement("canvas");
107 canvas.width = bitmap.width;
108 canvas.height = bitmap.height;
109 let ctx = canvas.getContext("2d")!;
110 ctx.drawImage(bitmap, 0, 0);
111 bitmap.close();
112 let blob = await new Promise<Blob>((resolve) =>
113 canvas.toBlob((b) => resolve(b!), "image/webp", 0.92),
114 );
115 return { blob, width: canvas.width, height: canvas.height };
116}