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}