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 isAnimated = isAnimatedFormat(file.type);
20 let fileID = v7() + (isAnimated ? "." + file.name.split(".").pop() : "");
21 let url = client.storage.from("minilink-user-assets").getPublicUrl(fileID)
22 .data.publicUrl;
23
24 let uploadBlob: Blob;
25 let width: number;
26 let height: number;
27 if (isAnimated) {
28 // Skip re-encoding for animated formats (GIF, APNG, animated WebP)
29 // to preserve animation frames
30 uploadBlob = file;
31 let bitmap = await createImageBitmap(file);
32 width = bitmap.width;
33 height = bitmap.height;
34 bitmap.close();
35 } else {
36 // Re-encode through canvas to bake EXIF orientation into pixel data.
37 // iPhone photos have EXIF rotation metadata that browsers respect, but
38 // Supabase's image transformation pipeline strips without applying.
39 let normalized = await normalizeOrientation(file);
40 uploadBlob = normalized.blob;
41 width = normalized.width;
42 height = normalized.height;
43 }
44
45 await cache.put(
46 new URL(url + "?local"),
47 new Response(uploadBlob, {
48 headers: {
49 "Content-Type": uploadBlob.type,
50 "Content-Length": uploadBlob.size.toString(),
51 },
52 }),
53 );
54 localImages.set(url, true);
55
56 let thumbhash = await getThumbHash(file);
57 if (navigator.serviceWorker)
58 await rep.mutate.assertFact({
59 entity: args.entityID,
60 attribute: "block/image",
61 data: {
62 fallback: thumbhash,
63 type: "image",
64 local: rep.clientID,
65 src: url,
66 height,
67 width,
68 },
69 });
70 await client.storage.from("minilink-user-assets").upload(fileID, uploadBlob, {
71 cacheControl: "public, max-age=31560000, immutable",
72 });
73 await rep.mutate.assertFact({
74 entity: args.entityID,
75 attribute: args.attribute,
76 data: {
77 fallback: thumbhash,
78 type: "image",
79 src: url,
80 height,
81 width,
82 },
83 });
84}
85
86async function getThumbHash(file: File) {
87 const arrayBuffer = await file.arrayBuffer();
88 const blob = new Blob([arrayBuffer], { type: file.type });
89 const imageBitmap = await createImageBitmap(blob);
90
91 const canvas = document.createElement("canvas");
92 const context = canvas.getContext("2d") as CanvasRenderingContext2D;
93 const maxDimension = 100;
94 let width = imageBitmap.width;
95 let height = imageBitmap.height;
96
97 if (width > height) {
98 if (width > maxDimension) {
99 height *= maxDimension / width;
100 width = maxDimension;
101 }
102 } else {
103 if (height > maxDimension) {
104 width *= maxDimension / height;
105 height = maxDimension;
106 }
107 }
108
109 canvas.width = width;
110 canvas.height = height;
111 context.drawImage(imageBitmap, 0, 0, width, height);
112
113 const imageData = context.getImageData(0, 0, width, height);
114 const thumbHash = thumbHashToDataURL(
115 rgbaToThumbHash(imageData.width, imageData.height, imageData.data),
116 );
117 return thumbHash;
118}
119
120function isAnimatedFormat(mimeType: string): boolean {
121 return mimeType === "image/gif" || mimeType === "image/apng";
122}
123
124async function normalizeOrientation(
125 file: File,
126): Promise<{ blob: Blob; width: number; height: number }> {
127 let bitmap = await createImageBitmap(file);
128 let canvas = document.createElement("canvas");
129 canvas.width = bitmap.width;
130 canvas.height = bitmap.height;
131 let ctx = canvas.getContext("2d")!;
132 ctx.drawImage(bitmap, 0, 0);
133 bitmap.close();
134 let blob = await new Promise<Blob>((resolve) =>
135 canvas.toBlob((b) => resolve(b!), "image/webp", 0.92),
136 );
137 return { blob, width: canvas.width, height: canvas.height };
138}