A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import { useEffect, useRef, useState } from "react";
2import { useDidResolution } from "./useDidResolution";
3import { usePdsEndpoint } from "./usePdsEndpoint";
4import { useAtProto } from "../providers/AtProtoProvider";
5
6/**
7 * Status returned by {@link useBlob} containing blob URL and metadata flags.
8 */
9export interface UseBlobState {
10 /** Object URL pointing to the fetched blob, when available. */
11 url?: string;
12 /** Indicates whether a fetch is in progress. */
13 loading: boolean;
14 /** Error encountered while fetching the blob. */
15 error?: Error;
16}
17
18/**
19 * Fetches a blob from the DID's PDS (resolving handles when needed), exposes it as an object URL, and cleans up on unmount.
20 *
21 * @param handleOrDid - Bluesky handle or DID whose PDS hosts the blob.
22 * @param cid - Content identifier for the desired blob.
23 * @returns {UseBlobState} Object containing the object URL, loading flag, and any error.
24 */
25export function useBlob(
26 handleOrDid: string | undefined,
27 cid: string | undefined,
28): UseBlobState {
29 const {
30 did,
31 error: didError,
32 loading: didLoading,
33 } = useDidResolution(handleOrDid);
34 const {
35 endpoint,
36 error: endpointError,
37 loading: endpointLoading,
38 } = usePdsEndpoint(did);
39 const { blobCache } = useAtProto();
40 const [state, setState] = useState<UseBlobState>({
41 loading: !!(handleOrDid && cid),
42 });
43 const objectUrlRef = useRef<string | undefined>(undefined);
44
45 useEffect(
46 () => () => {
47 if (objectUrlRef.current) {
48 URL.revokeObjectURL(objectUrlRef.current);
49 objectUrlRef.current = undefined;
50 }
51 },
52 [],
53 );
54
55 useEffect(() => {
56 let cancelled = false;
57
58 const clearObjectUrl = () => {
59 if (objectUrlRef.current) {
60 URL.revokeObjectURL(objectUrlRef.current);
61 objectUrlRef.current = undefined;
62 }
63 };
64
65 if (!handleOrDid || !cid) {
66 clearObjectUrl();
67 setState({ loading: false });
68 return () => {
69 cancelled = true;
70 };
71 }
72
73 if (didError) {
74 clearObjectUrl();
75 setState({ loading: false, error: didError });
76 return () => {
77 cancelled = true;
78 };
79 }
80
81 if (endpointError) {
82 clearObjectUrl();
83 setState({ loading: false, error: endpointError });
84 return () => {
85 cancelled = true;
86 };
87 }
88
89 if (didLoading || endpointLoading || !did || !endpoint) {
90 setState((prev) => ({ ...prev, loading: true, error: undefined }));
91 return () => {
92 cancelled = true;
93 };
94 }
95
96 const cachedBlob = blobCache.get(did, cid);
97 if (cachedBlob) {
98 const nextUrl = URL.createObjectURL(cachedBlob);
99 const prevUrl = objectUrlRef.current;
100 objectUrlRef.current = nextUrl;
101 if (prevUrl) URL.revokeObjectURL(prevUrl);
102 setState({ url: nextUrl, loading: false });
103 return () => {
104 cancelled = true;
105 };
106 }
107
108 let controller: AbortController | undefined;
109 let release: (() => void) | undefined;
110
111 (async () => {
112 try {
113 setState((prev) => ({
114 ...prev,
115 loading: true,
116 error: undefined,
117 }));
118 const ensureResult = blobCache.ensure(did, cid, () => {
119 controller = new AbortController();
120 const promise = (async () => {
121 const res = await fetch(
122 `${endpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`,
123 { signal: controller?.signal },
124 );
125 if (!res.ok)
126 throw new Error(
127 `Blob fetch failed (${res.status})`,
128 );
129 return res.blob();
130 })();
131 return { promise, abort: () => controller?.abort() };
132 });
133 release = ensureResult.release;
134 const blob = await ensureResult.promise;
135 const nextUrl = URL.createObjectURL(blob);
136 const prevUrl = objectUrlRef.current;
137 objectUrlRef.current = nextUrl;
138 if (prevUrl) URL.revokeObjectURL(prevUrl);
139 if (!cancelled) setState({ url: nextUrl, loading: false });
140 } catch (e) {
141 const aborted =
142 (controller && controller.signal.aborted) ||
143 (e instanceof DOMException && e.name === "AbortError");
144 if (aborted) return;
145 clearObjectUrl();
146 if (!cancelled) setState({ loading: false, error: e as Error });
147 }
148 })();
149
150 return () => {
151 cancelled = true;
152 release?.();
153 if (
154 controller &&
155 controller.signal.aborted &&
156 objectUrlRef.current
157 ) {
158 URL.revokeObjectURL(objectUrlRef.current);
159 objectUrlRef.current = undefined;
160 }
161 };
162 }, [
163 handleOrDid,
164 cid,
165 did,
166 endpoint,
167 didLoading,
168 endpointLoading,
169 didError,
170 endpointError,
171 blobCache,
172 ]);
173
174 return state;
175}