A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import { useEffect, useState, useRef } from "react"; 2import { useDidResolution } from "./useDidResolution"; 3import { usePdsEndpoint } from "./usePdsEndpoint"; 4import { createAtprotoClient } from "../utils/atproto-client"; 5import { useBlueskyAppview } from "./useBlueskyAppview"; 6import { useAtProto } from "../providers/AtProtoProvider"; 7 8/** 9 * Identifier trio required to address an AT Protocol record. 10 */ 11export interface AtProtoRecordKey { 12 /** Repository DID (or handle prior to resolution) containing the record. */ 13 did?: string; 14 /** NSID collection in which the record resides. */ 15 collection?: string; 16 /** Record key string uniquely identifying the record within the collection. */ 17 rkey?: string; 18} 19 20/** 21 * Loading state returned by {@link useAtProtoRecord}. 22 */ 23export interface AtProtoRecordState<T = unknown> { 24 /** Resolved record value when fetch succeeds. */ 25 record?: T; 26 /** Error thrown while loading, if any. */ 27 error?: Error; 28 /** Indicates whether the hook is in a loading state. */ 29 loading: boolean; 30} 31 32/** 33 * React hook that fetches a single AT Protocol record and tracks loading/error state. 34 * 35 * For Bluesky collections (app.bsky.*), uses a three-tier fallback strategy: 36 * 1. Try Bluesky appview API first 37 * 2. Fall back to Slingshot getRecord 38 * 3. Finally query the PDS directly 39 * 40 * For other collections, queries the PDS directly (with Slingshot fallback via the client handler). 41 * 42 * @param did - DID (or handle before resolution) that owns the record. 43 * @param collection - NSID collection from which to fetch the record. 44 * @param rkey - Record key identifying the record within the collection. 45 * @returns {AtProtoRecordState<T>} Object containing the resolved record, any error, and a loading flag. 46 */ 47export function useAtProtoRecord<T = unknown>({ 48 did: handleOrDid, 49 collection, 50 rkey, 51}: AtProtoRecordKey): AtProtoRecordState<T> { 52 const { recordCache } = useAtProto(); 53 const isBlueskyCollection = collection?.startsWith("app.bsky."); 54 55 // Always call all hooks (React rules) - conditionally use results 56 const blueskyResult = useBlueskyAppview<T>({ 57 did: isBlueskyCollection ? handleOrDid : undefined, 58 collection: isBlueskyCollection ? collection : undefined, 59 rkey: isBlueskyCollection ? rkey : undefined, 60 }); 61 62 const { 63 did, 64 error: didError, 65 loading: resolvingDid, 66 } = useDidResolution(handleOrDid); 67 const { 68 endpoint, 69 error: endpointError, 70 loading: resolvingEndpoint, 71 } = usePdsEndpoint(did); 72 const [state, setState] = useState<AtProtoRecordState<T>>({ 73 loading: !!(handleOrDid && collection && rkey), 74 }); 75 76 const releaseRef = useRef<(() => void) | undefined>(undefined); 77 78 useEffect(() => { 79 let cancelled = false; 80 81 const assignState = (next: Partial<AtProtoRecordState<T>>) => { 82 if (cancelled) return; 83 setState((prev) => ({ ...prev, ...next })); 84 }; 85 86 if (!handleOrDid || !collection || !rkey) { 87 assignState({ 88 loading: false, 89 record: undefined, 90 error: undefined, 91 }); 92 return () => { 93 cancelled = true; 94 if (releaseRef.current) { 95 releaseRef.current(); 96 releaseRef.current = undefined; 97 } 98 }; 99 } 100 101 if (didError) { 102 assignState({ loading: false, error: didError }); 103 return () => { 104 cancelled = true; 105 if (releaseRef.current) { 106 releaseRef.current(); 107 releaseRef.current = undefined; 108 } 109 }; 110 } 111 112 if (endpointError) { 113 assignState({ loading: false, error: endpointError }); 114 return () => { 115 cancelled = true; 116 if (releaseRef.current) { 117 releaseRef.current(); 118 releaseRef.current = undefined; 119 } 120 }; 121 } 122 123 if (resolvingDid || resolvingEndpoint || !did || !endpoint) { 124 assignState({ loading: true, error: undefined }); 125 return () => { 126 cancelled = true; 127 if (releaseRef.current) { 128 releaseRef.current(); 129 releaseRef.current = undefined; 130 } 131 }; 132 } 133 134 assignState({ loading: true, error: undefined, record: undefined }); 135 136 // Use recordCache.ensure for deduplication and caching 137 const { promise, release } = recordCache.ensure<T>( 138 did, 139 collection, 140 rkey, 141 () => { 142 const controller = new AbortController(); 143 144 const fetchPromise = (async () => { 145 try { 146 const { rpc } = await createAtprotoClient({ 147 service: endpoint, 148 }); 149 const res = await ( 150 rpc as unknown as { 151 get: ( 152 nsid: string, 153 opts: { 154 params: { 155 repo: string; 156 collection: string; 157 rkey: string; 158 }; 159 }, 160 ) => Promise<{ ok: boolean; data: { value: T } }>; 161 } 162 ).get("com.atproto.repo.getRecord", { 163 params: { repo: did, collection, rkey }, 164 }); 165 if (!res.ok) throw new Error("Failed to load record"); 166 return (res.data as { value: T }).value; 167 } catch (err) { 168 // Provide helpful error for banned/unreachable Bluesky PDSes 169 if (endpoint.includes('.bsky.network')) { 170 throw new Error( 171 `Record unavailable. The Bluesky PDS (${endpoint}) may be unreachable or the account may be banned.` 172 ); 173 } 174 throw err; 175 } 176 })(); 177 178 return { 179 promise: fetchPromise, 180 abort: () => controller.abort(), 181 }; 182 } 183 ); 184 185 releaseRef.current = release; 186 187 promise 188 .then((record) => { 189 if (!cancelled) { 190 assignState({ record, loading: false }); 191 } 192 }) 193 .catch((e) => { 194 if (!cancelled) { 195 const err = e instanceof Error ? e : new Error(String(e)); 196 assignState({ error: err, loading: false }); 197 } 198 }); 199 200 return () => { 201 cancelled = true; 202 if (releaseRef.current) { 203 releaseRef.current(); 204 releaseRef.current = undefined; 205 } 206 }; 207 }, [ 208 handleOrDid, 209 did, 210 endpoint, 211 collection, 212 rkey, 213 resolvingDid, 214 resolvingEndpoint, 215 didError, 216 endpointError, 217 recordCache, 218 ]); 219 220 // Return Bluesky result for app.bsky.* collections 221 if (isBlueskyCollection) { 222 return { 223 record: blueskyResult.record, 224 error: blueskyResult.error, 225 loading: blueskyResult.loading, 226 }; 227 } 228 229 return state; 230}