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}