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