A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
at main 8.0 kB view raw
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 /** Force bypass cache and refetch from network. Useful for auto-refresh scenarios. */ 19 bypassCache?: boolean; 20 /** Internal refresh trigger - changes to this value force a refetch. */ 21 _refreshKey?: number; 22} 23 24/** 25 * Loading state returned by {@link useAtProtoRecord}. 26 */ 27export interface AtProtoRecordState<T = unknown> { 28 /** Resolved record value when fetch succeeds. */ 29 record?: T; 30 /** Error thrown while loading, if any. */ 31 error?: Error; 32 /** Indicates whether the hook is in a loading state. */ 33 loading: boolean; 34} 35 36/** 37 * React hook that fetches a single AT Protocol record and tracks loading/error state. 38 * 39 * For Bluesky collections (app.bsky.*), uses a three-tier fallback strategy: 40 * 1. Try Bluesky appview API first 41 * 2. Fall back to Slingshot getRecord 42 * 3. Finally query the PDS directly 43 * 44 * For other collections, queries the PDS directly (with Slingshot fallback via the client handler). 45 * 46 * @param did - DID (or handle before resolution) that owns the record. 47 * @param collection - NSID collection from which to fetch the record. 48 * @param rkey - Record key identifying the record within the collection. 49 * @param bypassCache - Force bypass cache and refetch from network. Useful for auto-refresh scenarios. 50 * @param _refreshKey - Internal parameter used to trigger refetches. 51 * @returns {AtProtoRecordState<T>} Object containing the resolved record, any error, and a loading flag. 52 */ 53export function useAtProtoRecord<T = unknown>({ 54 did: handleOrDid, 55 collection, 56 rkey, 57 bypassCache = false, 58 _refreshKey = 0, 59}: AtProtoRecordKey): AtProtoRecordState<T> { 60 const { recordCache } = useAtProto(); 61 const isBlueskyCollection = collection?.startsWith("app.bsky."); 62 63 // Always call all hooks (React rules) - conditionally use results 64 const blueskyResult = useBlueskyAppview<T>({ 65 did: isBlueskyCollection ? handleOrDid : undefined, 66 collection: isBlueskyCollection ? collection : undefined, 67 rkey: isBlueskyCollection ? rkey : undefined, 68 }); 69 70 const { 71 did, 72 error: didError, 73 loading: resolvingDid, 74 } = useDidResolution(handleOrDid); 75 const { 76 endpoint, 77 error: endpointError, 78 loading: resolvingEndpoint, 79 } = usePdsEndpoint(did); 80 const [state, setState] = useState<AtProtoRecordState<T>>({ 81 loading: !!(handleOrDid && collection && rkey), 82 }); 83 84 const releaseRef = useRef<(() => void) | undefined>(undefined); 85 86 useEffect(() => { 87 let cancelled = false; 88 89 const assignState = (next: Partial<AtProtoRecordState<T>>) => { 90 if (cancelled) return; 91 setState((prev) => ({ ...prev, ...next })); 92 }; 93 94 if (!handleOrDid || !collection || !rkey) { 95 assignState({ 96 loading: false, 97 record: undefined, 98 error: undefined, 99 }); 100 return () => { 101 cancelled = true; 102 if (releaseRef.current) { 103 releaseRef.current(); 104 releaseRef.current = undefined; 105 } 106 }; 107 } 108 109 if (didError) { 110 assignState({ loading: false, error: didError }); 111 return () => { 112 cancelled = true; 113 if (releaseRef.current) { 114 releaseRef.current(); 115 releaseRef.current = undefined; 116 } 117 }; 118 } 119 120 if (endpointError) { 121 assignState({ loading: false, error: endpointError }); 122 return () => { 123 cancelled = true; 124 if (releaseRef.current) { 125 releaseRef.current(); 126 releaseRef.current = undefined; 127 } 128 }; 129 } 130 131 if (resolvingDid || resolvingEndpoint || !did || !endpoint) { 132 assignState({ loading: true, error: undefined }); 133 return () => { 134 cancelled = true; 135 if (releaseRef.current) { 136 releaseRef.current(); 137 releaseRef.current = undefined; 138 } 139 }; 140 } 141 142 assignState({ loading: true, error: undefined, record: undefined }); 143 144 // Bypass cache if requested (for auto-refresh scenarios) 145 if (bypassCache) { 146 assignState({ loading: true, error: undefined }); 147 148 // Skip cache and fetch directly 149 const controller = new AbortController(); 150 151 const fetchPromise = (async () => { 152 try { 153 const { rpc } = await createAtprotoClient({ 154 service: endpoint, 155 }); 156 const res = await ( 157 rpc as unknown as { 158 get: ( 159 nsid: string, 160 opts: { 161 params: { 162 repo: string; 163 collection: string; 164 rkey: string; 165 }; 166 }, 167 ) => Promise<{ ok: boolean; data: { value: T } }>; 168 } 169 ).get("com.atproto.repo.getRecord", { 170 params: { repo: did, collection, rkey }, 171 }); 172 if (!res.ok) throw new Error("Failed to load record"); 173 return (res.data as { value: T }).value; 174 } catch (err) { 175 // Provide helpful error for banned/unreachable Bluesky PDSes 176 if (endpoint.includes('.bsky.network')) { 177 throw new Error( 178 `Record unavailable. The Bluesky PDS (${endpoint}) may be unreachable or the account may be banned.` 179 ); 180 } 181 throw err; 182 } 183 })(); 184 185 fetchPromise 186 .then((record) => { 187 if (!cancelled) { 188 assignState({ record, loading: false }); 189 } 190 }) 191 .catch((e) => { 192 if (!cancelled) { 193 const err = e instanceof Error ? e : new Error(String(e)); 194 assignState({ error: err, loading: false }); 195 } 196 }); 197 198 return () => { 199 cancelled = true; 200 controller.abort(); 201 }; 202 } 203 204 // Use recordCache.ensure for deduplication and caching 205 const { promise, release } = recordCache.ensure<T>( 206 did, 207 collection, 208 rkey, 209 () => { 210 const controller = new AbortController(); 211 212 const fetchPromise = (async () => { 213 try { 214 const { rpc } = await createAtprotoClient({ 215 service: endpoint, 216 }); 217 const res = await ( 218 rpc as unknown as { 219 get: ( 220 nsid: string, 221 opts: { 222 params: { 223 repo: string; 224 collection: string; 225 rkey: string; 226 }; 227 }, 228 ) => Promise<{ ok: boolean; data: { value: T } }>; 229 } 230 ).get("com.atproto.repo.getRecord", { 231 params: { repo: did, collection, rkey }, 232 }); 233 if (!res.ok) throw new Error("Failed to load record"); 234 return (res.data as { value: T }).value; 235 } catch (err) { 236 // Provide helpful error for banned/unreachable Bluesky PDSes 237 if (endpoint.includes('.bsky.network')) { 238 throw new Error( 239 `Record unavailable. The Bluesky PDS (${endpoint}) may be unreachable or the account may be banned.` 240 ); 241 } 242 throw err; 243 } 244 })(); 245 246 return { 247 promise: fetchPromise, 248 abort: () => controller.abort(), 249 }; 250 } 251 ); 252 253 releaseRef.current = release; 254 255 promise 256 .then((record) => { 257 if (!cancelled) { 258 assignState({ record, loading: false }); 259 } 260 }) 261 .catch((e) => { 262 if (!cancelled) { 263 const err = e instanceof Error ? e : new Error(String(e)); 264 assignState({ error: err, loading: false }); 265 } 266 }); 267 268 return () => { 269 cancelled = true; 270 if (releaseRef.current) { 271 releaseRef.current(); 272 releaseRef.current = undefined; 273 } 274 }; 275 }, [ 276 handleOrDid, 277 did, 278 endpoint, 279 collection, 280 rkey, 281 resolvingDid, 282 resolvingEndpoint, 283 didError, 284 endpointError, 285 recordCache, 286 bypassCache, 287 _refreshKey, 288 ]); 289 290 // Return Bluesky result for app.bsky.* collections 291 if (isBlueskyCollection) { 292 return { 293 record: blueskyResult.record, 294 error: blueskyResult.error, 295 loading: blueskyResult.loading, 296 }; 297 } 298 299 return state; 300}