import { useEffect, useRef, useState } from "react"; import { Platform } from "react-native"; import { apiFetch } from "../utils/api-fetch"; const FETCH_TIMEOUT_MS = 15_000; export interface UploadBlobUrlResult { uri: string | null; errored: boolean; setErrored: (v: boolean) => void; } /** * Produce a renderable URI for an upload id without going through * `apiFetch`. * * For a consumer that keeps uploads somewhere this package cannot reach — an * on-device database, a cache it populated itself — the resolver is the whole * seam. Return `null` for an id that does not resolve; the hook reports that * as `errored`, which is what a renderer draws its fallback on. * * The `signal` aborts with the effect, so a resolver doing real work can stop * when the row scrolls away. */ export type UploadResolver = (uploadId: string, signal: AbortSignal) => Promise; export interface UploadBlobUrlOptions { /** Upload-fetch path prefix. Defaults to `/uploads`. */ uploadsPath?: string; /** * Resolve the URI yourself instead of fetching it. * * Identity does not have to be stable: the resolver is held in a ref and * only `uploadId` / `uploadsPath` re-run the effect. An inline arrow would * otherwise refetch on every render, and what identifies the resource is the * id, not the function that happens to fetch it. */ resolve?: UploadResolver; } /** * A URI for an upload, fetched through `apiFetch` unless the consumer resolves * it itself. * * # Web gets a blob URL; native gets a data URI * * `URL.createObjectURL` is a browser API. React Native's `URL` does not * implement it in any way that can be relied on across both platforms — so on * native this hook used to hand `` a URI built by a function that * either threw or produced something the image loader could not open, and the * fetch fell into the `catch` below. The visible symptom was that custom * uploaded icons never appeared on a phone: they resolved to the fallback * glyph every time, on every consumer, and looked like a missing-image problem * rather than a URL one. * * Native therefore reads the bytes and builds `data:;base64,…`, which * every RN image loader accepts. The cost is that base64 inflates by a third * and the string sits in the JS heap for as long as the row is mounted, which * is affordable for the icons this exists to draw (the vault caps an upload at * 256KB) and is why web keeps the blob URL — no copy, and revocable. * * The second argument accepts a bare path for backwards compatibility with * `useUploadBlobUrl(id, "/uploads")`; new call sites should pass options. */ export function useUploadBlobUrl( uploadId: string | null | undefined, options: string | UploadBlobUrlOptions = {} ): UploadBlobUrlResult { const { uploadsPath = "/uploads", resolve } = typeof options === "string" ? { uploadsPath: options, resolve: undefined } : options; const [uri, setUri] = useState(null); const [errored, setErrored] = useState(false); // See `UploadBlobUrlOptions.resolve`: kept in a ref so a resolver defined // inline at the call site cannot turn every render into a refetch. const resolveRef = useRef(resolve); resolveRef.current = resolve; useEffect(() => { setErrored(false); if (!uploadId) { setUri(null); return; } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); let createdUrl: string | null = null; const load = async (): Promise => { const resolver = resolveRef.current; if (resolver) { return resolver(uploadId, controller.signal); } const response = await apiFetch(`${uploadsPath}/${encodeURIComponent(uploadId)}`, { signal: controller.signal, }); if (controller.signal.aborted) { return null; } if (!response.ok) { throw new Error(`Failed to load upload (${response.status})`); } if (Platform.OS === "web") { const blob = await response.blob(); if (controller.signal.aborted) { return null; } createdUrl = URL.createObjectURL(blob); return createdUrl; } const bytes = new Uint8Array(await response.arrayBuffer()); if (controller.signal.aborted) { return null; } // The server sends the stored content type; anything else is a server // that has lost track of what it is holding, and `` will refuse // the URI rather than guess — which surfaces as the fallback glyph, the // same outcome a failed fetch gives. const contentType = response.headers.get("content-type") ?? "application/octet-stream"; return `data:${contentType};base64,${toBase64(bytes)}`; }; load() .then((next) => { if (controller.signal.aborted) { return; } if (next === null) { // A resolver that has nothing for this id, or an aborted read. Either // way there is no image, which is what `errored` means to a renderer. setErrored(true); return; } setUri(next); }) .catch((e: unknown) => { if (controller.signal.aborted) { return; } if (e instanceof DOMException && e.name === "AbortError") { return; } setErrored(true); }) .finally(() => { clearTimeout(timeout); }); return () => { controller.abort(); clearTimeout(timeout); if (createdUrl) { URL.revokeObjectURL(createdUrl); } }; }, [uploadId, uploadsPath]); return { uri, errored, setErrored }; } const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; /** * Base64, without depending on the host having `btoa`. * * React Native ships no `btoa`, and whether one exists depends entirely on * which polyfill the consuming app happened to install — a library cannot * assume it. Encoding by hand is a dozen lines and removes the question. * * The `?? 0` on the tail bytes is the padding rule rather than a guard: base64 * completes a short final group with zero bits and then marks the missing * bytes with `=`. Reading past the end is therefore correct here, and doing it * this way keeps the loop free of the non-null assertions * `noUncheckedIndexedAccess` would otherwise demand at every lookup. */ function toBase64(bytes: Uint8Array): string { const chars: string[] = []; for (let index = 0; index < bytes.length; index += 3) { const b0 = bytes[index] ?? 0; const b1 = bytes[index + 1] ?? 0; const b2 = bytes[index + 2] ?? 0; const remaining = bytes.length - index; chars.push( BASE64_ALPHABET.charAt(b0 >> 2), BASE64_ALPHABET.charAt(((b0 & 3) << 4) | (b1 >> 4)), remaining > 1 ? BASE64_ALPHABET.charAt(((b1 & 15) << 2) | (b2 >> 6)) : "=", remaining > 2 ? BASE64_ALPHABET.charAt(b2 & 63) : "=" ); } return chars.join(""); }