A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2import { useDidResolution } from './useDidResolution';
3import { usePdsEndpoint } from './usePdsEndpoint';
4import { createAtprotoClient } from '../utils/atproto-client';
5
6/**
7 * Record envelope returned by paginated AT Protocol queries.
8 */
9export interface PaginatedRecord<T> {
10 /** Fully qualified AT URI for the record. */
11 uri: string;
12 /** Record key extracted from the URI or provided by the API. */
13 rkey: string;
14 /** Raw record value. */
15 value: T;
16}
17
18interface PageData<T> {
19 records: PaginatedRecord<T>[];
20 cursor?: string;
21}
22
23/**
24 * Options accepted by {@link usePaginatedRecords}.
25 */
26export interface UsePaginatedRecordsOptions {
27 /** DID or handle whose repository should be queried. */
28 did?: string;
29 /** NSID collection containing the target records. */
30 collection: string;
31 /** Maximum page size to request; defaults to `5`. */
32 limit?: number;
33 /** Prefer the Bluesky appview author feed endpoint before falling back to the PDS. */
34 preferAuthorFeed?: boolean;
35 /** Optional filter applied when fetching from the appview author feed. */
36 authorFeedFilter?: AuthorFeedFilter;
37 /** Whether to include pinned posts when fetching from the author feed. */
38 authorFeedIncludePins?: boolean;
39 /** Override for the appview service base URL used to query the author feed. */
40 authorFeedService?: string;
41 /** Optional explicit actor identifier for the author feed request. */
42 authorFeedActor?: string;
43}
44
45/**
46 * Result returned from {@link usePaginatedRecords} describing records and pagination state.
47 */
48export interface UsePaginatedRecordsResult<T> {
49 /** Records for the active page. */
50 records: PaginatedRecord<T>[];
51 /** Indicates whether a page load is in progress. */
52 loading: boolean;
53 /** Error produced during the latest fetch, if any. */
54 error?: Error;
55 /** `true` when another page can be fetched forward. */
56 hasNext: boolean;
57 /** `true` when a previous page exists in memory. */
58 hasPrev: boolean;
59 /** Requests the next page (if available). */
60 loadNext: () => void;
61 /** Returns to the previous page when possible. */
62 loadPrev: () => void;
63 /** Index of the currently displayed page. */
64 pageIndex: number;
65 /** Number of pages fetched so far (or inferred total when known). */
66 pagesCount: number;
67}
68
69const DEFAULT_APPVIEW_SERVICE = 'https://public.api.bsky.app';
70
71type MaybeNodeEnv = { process?: { env?: Record<string, string | undefined> } };
72
73const isNonProductionEnv = (): boolean => {
74 if (typeof globalThis === 'undefined') return false;
75 const env = (globalThis as MaybeNodeEnv).process?.env?.NODE_ENV;
76 return env ? env !== 'production' : false;
77};
78
79export type AuthorFeedFilter =
80 | 'posts_with_replies'
81 | 'posts_no_replies'
82 | 'posts_with_media'
83 | 'posts_and_author_threads'
84 | 'posts_with_video';
85
86/**
87 * React hook that fetches a repository collection with cursor-based pagination and prefetching.
88 *
89 * @param did - Handle or DID whose repository should be queried.
90 * @param collection - NSID collection to read from.
91 * @param limit - Maximum number of records to request per page. Defaults to `5`.
92 * @returns {UsePaginatedRecordsResult<T>} Object containing the current page, pagination metadata, and navigation callbacks.
93 */
94export function usePaginatedRecords<T>({
95 did: handleOrDid,
96 collection,
97 limit = 5,
98 preferAuthorFeed = false,
99 authorFeedFilter,
100 authorFeedIncludePins,
101 authorFeedService,
102 authorFeedActor
103}: UsePaginatedRecordsOptions): UsePaginatedRecordsResult<T> {
104 const { did, handle, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid);
105 const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did);
106 const [pages, setPages] = useState<PageData<T>[]>([]);
107 const [pageIndex, setPageIndex] = useState(0);
108 const [loading, setLoading] = useState(false);
109 const [error, setError] = useState<Error | undefined>(undefined);
110 const inFlight = useRef<Set<string>>(new Set());
111 const requestSeq = useRef(0);
112 const identityRef = useRef<string | undefined>(undefined);
113 const feedDisabledRef = useRef(false);
114
115 const identity = did && endpoint ? `${did}::${endpoint}` : undefined;
116 const normalizedInput = useMemo(() => {
117 if (!handleOrDid) return undefined;
118 const trimmed = handleOrDid.trim();
119 return trimmed || undefined;
120 }, [handleOrDid]);
121
122 const actorIdentifier = useMemo(() => {
123 const explicit = authorFeedActor?.trim();
124 if (explicit) return explicit;
125 if (handle) return handle;
126 if (normalizedInput) return normalizedInput;
127 if (did) return did;
128 return undefined;
129 }, [authorFeedActor, handle, normalizedInput, did]);
130
131 const resetState = useCallback(() => {
132 setPages([]);
133 setPageIndex(0);
134 setError(undefined);
135 inFlight.current.clear();
136 requestSeq.current += 1;
137 feedDisabledRef.current = false;
138 }, []);
139
140 const fetchPage = useCallback(async (identityKey: string, cursor: string | undefined, targetIndex: number, mode: 'active' | 'prefetch') => {
141 if (!did || !endpoint) return;
142 const currentIdentity = `${did}::${endpoint}`;
143 if (identityKey !== currentIdentity) return;
144 const token = requestSeq.current;
145 const key = `${identityKey}:${targetIndex}:${cursor ?? 'start'}`;
146 if (inFlight.current.has(key)) return;
147 inFlight.current.add(key);
148 if (mode === 'active') {
149 setLoading(true);
150 setError(undefined);
151 }
152 try {
153 let nextCursor: string | undefined;
154 let mapped: PaginatedRecord<T>[] | undefined;
155
156 const shouldUseAuthorFeed = preferAuthorFeed && collection === 'app.bsky.feed.post' && !feedDisabledRef.current && !!actorIdentifier;
157 if (shouldUseAuthorFeed) {
158 try {
159 const { rpc } = await createAtprotoClient({ service: authorFeedService ?? DEFAULT_APPVIEW_SERVICE });
160 const res = await (rpc as unknown as {
161 get: (
162 nsid: string,
163 opts: { params: Record<string, string | number | boolean | undefined> }
164 ) => Promise<{ ok: boolean; data: { feed?: Array<{ post?: { uri?: string; record?: T } }>; cursor?: string } }>;
165 }).get('app.bsky.feed.getAuthorFeed', {
166 params: {
167 actor: actorIdentifier,
168 limit,
169 cursor,
170 filter: authorFeedFilter,
171 includePins: authorFeedIncludePins
172 }
173 });
174 if (!res.ok) throw new Error('Failed to fetch author feed');
175 const { feed, cursor: feedCursor } = res.data;
176 mapped = (feed ?? []).reduce<PaginatedRecord<T>[]>((acc, item) => {
177 const post = item?.post;
178 if (!post || typeof post.uri !== 'string' || !post.record) return acc;
179 acc.push({
180 uri: post.uri,
181 rkey: extractRkey(post.uri),
182 value: post.record as T
183 });
184 return acc;
185 }, []);
186 nextCursor = feedCursor;
187 } catch (err) {
188 feedDisabledRef.current = true;
189 if (isNonProductionEnv()) {
190 console.warn('[usePaginatedRecords] Author feed unavailable, falling back to PDS', err);
191 }
192 }
193 }
194
195 if (!mapped) {
196 const { rpc } = await createAtprotoClient({ service: endpoint });
197 const res = await (rpc as unknown as {
198 get: (
199 nsid: string,
200 opts: { params: Record<string, string | number | boolean | undefined> }
201 ) => Promise<{ ok: boolean; data: { records: Array<{ uri: string; rkey?: string; value: T }>; cursor?: string } }>;
202 }).get('com.atproto.repo.listRecords', {
203 params: {
204 repo: did,
205 collection,
206 limit,
207 cursor,
208 reverse: false
209 }
210 });
211 if (!res.ok) throw new Error('Failed to list records');
212 const { records, cursor: repoCursor } = res.data;
213 mapped = records.map((item) => ({
214 uri: item.uri,
215 rkey: item.rkey ?? extractRkey(item.uri),
216 value: item.value
217 }));
218 nextCursor = repoCursor;
219 }
220
221 if (token !== requestSeq.current || identityKey !== identityRef.current) {
222 return nextCursor;
223 }
224 if (mode === 'active') setPageIndex(targetIndex);
225 setPages(prev => {
226 const next = [...prev];
227 next[targetIndex] = { records: mapped!, cursor: nextCursor };
228 return next;
229 });
230 return nextCursor;
231 } catch (e) {
232 if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) {
233 setError(e as Error);
234 }
235 } finally {
236 if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) {
237 setLoading(false);
238 }
239 inFlight.current.delete(key);
240 }
241 return undefined;
242 }, [
243 did,
244 endpoint,
245 collection,
246 limit,
247 preferAuthorFeed,
248 actorIdentifier,
249 authorFeedService,
250 authorFeedFilter,
251 authorFeedIncludePins
252 ]);
253
254 useEffect(() => {
255 if (!handleOrDid) {
256 identityRef.current = undefined;
257 resetState();
258 setLoading(false);
259 setError(undefined);
260 return;
261 }
262
263 if (didError) {
264 identityRef.current = undefined;
265 resetState();
266 setLoading(false);
267 setError(didError);
268 return;
269 }
270
271 if (endpointError) {
272 identityRef.current = undefined;
273 resetState();
274 setLoading(false);
275 setError(endpointError);
276 return;
277 }
278
279 if (resolvingDid || resolvingEndpoint || !identity) {
280 if (identityRef.current !== identity) {
281 identityRef.current = identity;
282 resetState();
283 }
284 setLoading(!!handleOrDid);
285 setError(undefined);
286 return;
287 }
288
289 if (identityRef.current !== identity) {
290 identityRef.current = identity;
291 resetState();
292 }
293
294 fetchPage(identity, undefined, 0, 'active').catch(() => {
295 /* error handled in state */
296 });
297 }, [handleOrDid, identity, fetchPage, resetState, resolvingDid, resolvingEndpoint, didError, endpointError]);
298
299 const currentPage = pages[pageIndex];
300 const hasNext = !!currentPage?.cursor || !!pages[pageIndex + 1];
301 const hasPrev = pageIndex > 0;
302
303 const loadNext = useCallback(() => {
304 const identityKey = identityRef.current;
305 if (!identityKey) return;
306 const page = pages[pageIndex];
307 if (!page?.cursor && !pages[pageIndex + 1]) return;
308 if (pages[pageIndex + 1]) {
309 setPageIndex(pageIndex + 1);
310 return;
311 }
312 fetchPage(identityKey, page.cursor, pageIndex + 1, 'active').catch(() => {
313 /* handled via error state */
314 });
315 }, [fetchPage, pageIndex, pages]);
316
317 const loadPrev = useCallback(() => {
318 if (pageIndex === 0) return;
319 setPageIndex(pageIndex - 1);
320 }, [pageIndex]);
321
322 const records = useMemo(() => currentPage?.records ?? [], [currentPage]);
323
324 const effectiveError = error ?? (endpointError as Error | undefined) ?? (didError as Error | undefined);
325
326 useEffect(() => {
327 const cursor = pages[pageIndex]?.cursor;
328 if (!cursor) return;
329 if (pages[pageIndex + 1]) return;
330 const identityKey = identityRef.current;
331 if (!identityKey) return;
332 fetchPage(identityKey, cursor, pageIndex + 1, 'prefetch').catch(() => {
333 /* ignore prefetch errors */
334 });
335 }, [fetchPage, pageIndex, pages]);
336
337 return {
338 records,
339 loading,
340 error: effectiveError,
341 hasNext,
342 hasPrev,
343 loadNext,
344 loadPrev,
345 pageIndex,
346 pagesCount: pages.length || (currentPage ? pageIndex + 1 : 0)
347 };
348}
349
350function extractRkey(uri: string): string {
351 const parts = uri.split('/');
352 return parts[parts.length - 1];
353}