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';
4
5/**
6 * Status returned by {@link useBlob} containing blob URL and metadata flags.
7 */
8export interface UseBlobState {
9 /** Object URL pointing to the fetched blob, when available. */
10 url?: string;
11 /** Indicates whether a fetch is in progress. */
12 loading: boolean;
13 /** Error encountered while fetching the blob. */
14 error?: Error;
15}
16
17/**
18 * Fetches a blob from the DID's PDS (resolving handles when needed), exposes it as an object URL, and cleans up on unmount.
19 *
20 * @param handleOrDid - Bluesky handle or DID whose PDS hosts the blob.
21 * @param cid - Content identifier for the desired blob.
22 * @returns {UseBlobState} Object containing the object URL, loading flag, and any error.
23 */
24export function useBlob(handleOrDid: string | undefined, cid: string | undefined): UseBlobState {
25 const { did, error: didError, loading: didLoading } = useDidResolution(handleOrDid);
26 const { endpoint, error: endpointError, loading: endpointLoading } = usePdsEndpoint(did);
27 const [state, setState] = useState<UseBlobState>({ loading: !!(handleOrDid && cid) });
28 const objectUrlRef = useRef<string | undefined>(undefined);
29
30 useEffect(() => () => {
31 if (objectUrlRef.current) {
32 URL.revokeObjectURL(objectUrlRef.current);
33 objectUrlRef.current = undefined;
34 }
35 }, []);
36
37 useEffect(() => {
38 let cancelled = false;
39
40 const clearObjectUrl = () => {
41 if (objectUrlRef.current) {
42 URL.revokeObjectURL(objectUrlRef.current);
43 objectUrlRef.current = undefined;
44 }
45 };
46
47 if (!handleOrDid || !cid) {
48 clearObjectUrl();
49 setState({ loading: false });
50 return () => {
51 cancelled = true;
52 };
53 }
54
55 if (didError) {
56 clearObjectUrl();
57 setState({ loading: false, error: didError });
58 return () => {
59 cancelled = true;
60 };
61 }
62
63 if (endpointError) {
64 clearObjectUrl();
65 setState({ loading: false, error: endpointError });
66 return () => {
67 cancelled = true;
68 };
69 }
70
71 if (didLoading || endpointLoading || !did || !endpoint) {
72 setState(prev => ({ ...prev, loading: true, error: undefined }));
73 return () => {
74 cancelled = true;
75 };
76 }
77
78 const controller = new AbortController();
79
80 (async () => {
81 try {
82 setState(prev => ({ ...prev, loading: true, error: undefined }));
83 const res = await fetch(
84 `${endpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`,
85 { signal: controller.signal }
86 );
87 if (!res.ok) throw new Error(`Blob fetch failed (${res.status})`);
88 const blob = await res.blob();
89 const nextUrl = URL.createObjectURL(blob);
90 const prevUrl = objectUrlRef.current;
91 objectUrlRef.current = nextUrl;
92 if (prevUrl) URL.revokeObjectURL(prevUrl);
93 if (!cancelled) setState({ url: nextUrl, loading: false });
94 } catch (e) {
95 if (controller.signal.aborted) return;
96 clearObjectUrl();
97 if (!cancelled) setState({ loading: false, error: e as Error });
98 }
99 })();
100
101 return () => {
102 cancelled = true;
103 controller.abort();
104 };
105 }, [handleOrDid, cid, did, endpoint, didLoading, endpointLoading, didError, endpointError]);
106
107 return state;
108}