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(handleOrDid: string | undefined, cid: string | undefined): UseBlobState {
26 const { did, error: didError, loading: didLoading } = useDidResolution(handleOrDid);
27 const { endpoint, error: endpointError, loading: endpointLoading } = usePdsEndpoint(did);
28 const { blobCache } = useAtProto();
29 const [state, setState] = useState<UseBlobState>({ loading: !!(handleOrDid && cid) });
30 const objectUrlRef = useRef<string | undefined>(undefined);
31
32 useEffect(() => () => {
33 if (objectUrlRef.current) {
34 URL.revokeObjectURL(objectUrlRef.current);
35 objectUrlRef.current = undefined;
36 }
37 }, []);
38
39 useEffect(() => {
40 let cancelled = false;
41
42 const clearObjectUrl = () => {
43 if (objectUrlRef.current) {
44 URL.revokeObjectURL(objectUrlRef.current);
45 objectUrlRef.current = undefined;
46 }
47 };
48
49 if (!handleOrDid || !cid) {
50 clearObjectUrl();
51 setState({ loading: false });
52 return () => {
53 cancelled = true;
54 };
55 }
56
57 if (didError) {
58 clearObjectUrl();
59 setState({ loading: false, error: didError });
60 return () => {
61 cancelled = true;
62 };
63 }
64
65 if (endpointError) {
66 clearObjectUrl();
67 setState({ loading: false, error: endpointError });
68 return () => {
69 cancelled = true;
70 };
71 }
72
73 if (didLoading || endpointLoading || !did || !endpoint) {
74 setState(prev => ({ ...prev, loading: true, error: undefined }));
75 return () => {
76 cancelled = true;
77 };
78 }
79
80 const cachedBlob = blobCache.get(did, cid);
81 if (cachedBlob) {
82 const nextUrl = URL.createObjectURL(cachedBlob);
83 const prevUrl = objectUrlRef.current;
84 objectUrlRef.current = nextUrl;
85 if (prevUrl) URL.revokeObjectURL(prevUrl);
86 setState({ url: nextUrl, loading: false });
87 return () => {
88 cancelled = true;
89 };
90 }
91
92 let controller: AbortController | undefined;
93 let release: (() => void) | undefined;
94
95 (async () => {
96 try {
97 setState(prev => ({ ...prev, loading: true, error: undefined }));
98 const ensureResult = blobCache.ensure(did, cid, () => {
99 controller = new AbortController();
100 const promise = (async () => {
101 const res = await fetch(
102 `${endpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`,
103 { signal: controller?.signal }
104 );
105 if (!res.ok) throw new Error(`Blob fetch failed (${res.status})`);
106 return res.blob();
107 })();
108 return { promise, abort: () => controller?.abort() };
109 });
110 release = ensureResult.release;
111 const blob = await ensureResult.promise;
112 const nextUrl = URL.createObjectURL(blob);
113 const prevUrl = objectUrlRef.current;
114 objectUrlRef.current = nextUrl;
115 if (prevUrl) URL.revokeObjectURL(prevUrl);
116 if (!cancelled) setState({ url: nextUrl, loading: false });
117 } catch (e) {
118 const aborted = (controller && controller.signal.aborted) || (e instanceof DOMException && e.name === 'AbortError');
119 if (aborted) return;
120 clearObjectUrl();
121 if (!cancelled) setState({ loading: false, error: e as Error });
122 }
123 })();
124
125 return () => {
126 cancelled = true;
127 release?.();
128 if (controller && controller.signal.aborted && objectUrlRef.current) {
129 URL.revokeObjectURL(objectUrlRef.current);
130 objectUrlRef.current = undefined;
131 }
132 };
133 }, [handleOrDid, cid, did, endpoint, didLoading, endpointLoading, didError, endpointError, blobCache]);
134
135 return state;
136}