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}