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 const { rpc } = await createAtprotoClient({ 146 service: endpoint, 147 }); 148 const res = await ( 149 rpc as unknown as { 150 get: ( 151 nsid: string, 152 opts: { 153 params: { 154 repo: string; 155 collection: string; 156 rkey: string; 157 }; 158 }, 159 ) => Promise<{ ok: boolean; data: { value: T } }>; 160 } 161 ).get("com.atproto.repo.getRecord", { 162 params: { repo: did, collection, rkey }, 163 }); 164 if (!res.ok) throw new Error("Failed to load record"); 165 return (res.data as { value: T }).value; 166 })(); 167 168 return { 169 promise: fetchPromise, 170 abort: () => controller.abort(), 171 }; 172 } 173 ); 174 175 releaseRef.current = release; 176 177 promise 178 .then((record) => { 179 if (!cancelled) { 180 assignState({ record, loading: false }); 181 } 182 }) 183 .catch((e) => { 184 if (!cancelled) { 185 const err = e instanceof Error ? e : new Error(String(e)); 186 assignState({ error: err, loading: false }); 187 } 188 }); 189 190 return () => { 191 cancelled = true; 192 if (releaseRef.current) { 193 releaseRef.current(); 194 releaseRef.current = undefined; 195 } 196 }; 197 }, [ 198 handleOrDid, 199 did, 200 endpoint, 201 collection, 202 rkey, 203 resolvingDid, 204 resolvingEndpoint, 205 didError, 206 endpointError, 207 recordCache, 208 ]); 209 210 // Return Bluesky result for app.bsky.* collections 211 if (isBlueskyCollection) { 212 return { 213 record: blueskyResult.record, 214 error: blueskyResult.error, 215 loading: blueskyResult.loading, 216 }; 217 } 218 219 return state; 220}