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 // Determine if this is a Bluesky collection that should use the appview 52 const isBlueskyCollection = collection?.startsWith("app.bsky."); 53 54 // Use the three-tier fallback for Bluesky collections 55 const blueskyResult = useBlueskyAppview<T>({ 56 did: isBlueskyCollection ? handleOrDid : undefined, 57 collection: isBlueskyCollection ? collection : undefined, 58 rkey: isBlueskyCollection ? rkey : undefined, 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 using Bluesky appview, skip the manual fetch logic 83 if (isBlueskyCollection) { 84 return () => { 85 cancelled = true; 86 }; 87 } 88 89 if (!handleOrDid || !collection || !rkey) { 90 assignState({ 91 loading: false, 92 record: undefined, 93 error: undefined, 94 }); 95 return () => { 96 cancelled = true; 97 }; 98 } 99 100 if (didError) { 101 assignState({ loading: false, error: didError }); 102 return () => { 103 cancelled = true; 104 }; 105 } 106 107 if (endpointError) { 108 assignState({ loading: false, error: endpointError }); 109 return () => { 110 cancelled = true; 111 }; 112 } 113 114 if (resolvingDid || resolvingEndpoint || !did || !endpoint) { 115 assignState({ loading: true, error: undefined }); 116 return () => { 117 cancelled = true; 118 }; 119 } 120 121 assignState({ loading: true, error: undefined, record: undefined }); 122 123 (async () => { 124 try { 125 const { rpc } = await createAtprotoClient({ 126 service: endpoint, 127 }); 128 const res = await ( 129 rpc as unknown as { 130 get: ( 131 nsid: string, 132 opts: { 133 params: { 134 repo: string; 135 collection: string; 136 rkey: string; 137 }; 138 }, 139 ) => Promise<{ ok: boolean; data: { value: T } }>; 140 } 141 ).get("com.atproto.repo.getRecord", { 142 params: { repo: did, collection, rkey }, 143 }); 144 if (!res.ok) throw new Error("Failed to load record"); 145 const record = (res.data as { value: T }).value; 146 assignState({ record, loading: false }); 147 } catch (e) { 148 const err = e instanceof Error ? e : new Error(String(e)); 149 assignState({ error: err, loading: false }); 150 } 151 })(); 152 153 return () => { 154 cancelled = true; 155 }; 156 }, [ 157 handleOrDid, 158 did, 159 endpoint, 160 collection, 161 rkey, 162 resolvingDid, 163 resolvingEndpoint, 164 didError, 165 endpointError, 166 isBlueskyCollection, 167 ]); 168 169 // Return Bluesky appview result if it's a Bluesky collection 170 if (isBlueskyCollection) { 171 return { 172 record: blueskyResult.record, 173 error: blueskyResult.error, 174 loading: blueskyResult.loading, 175 }; 176 } 177 178 return state; 179}