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
71export type AuthorFeedFilter =
72 | 'posts_with_replies'
73 | 'posts_no_replies'
74 | 'posts_with_media'
75 | 'posts_and_author_threads'
76 | 'posts_with_video';
77
78/**
79 * React hook that fetches a repository collection with cursor-based pagination and prefetching.
80 *
81 * @param did - Handle or DID whose repository should be queried.
82 * @param collection - NSID collection to read from.
83 * @param limit - Maximum number of records to request per page. Defaults to `5`.
84 * @returns {UsePaginatedRecordsResult<T>} Object containing the current page, pagination metadata, and navigation callbacks.
85 */
86export function usePaginatedRecords<T>({
87 did: handleOrDid,
88 collection,
89 limit = 5,
90 preferAuthorFeed = false,
91 authorFeedFilter,
92 authorFeedIncludePins,
93 authorFeedService,
94 authorFeedActor
95}: UsePaginatedRecordsOptions): UsePaginatedRecordsResult<T> {
96 const { did, handle, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid);
97 const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did);
98 const [pages, setPages] = useState<PageData<T>[]>([]);
99 const [pageIndex, setPageIndex] = useState(0);
100 const [loading, setLoading] = useState(false);
101 const [error, setError] = useState<Error | undefined>(undefined);
102 const inFlight = useRef<Set<string>>(new Set());
103 const requestSeq = useRef(0);
104 const identityRef = useRef<string | undefined>(undefined);
105 const feedDisabledRef = useRef(false);
106
107 const identity = did && endpoint ? `${did}::${endpoint}` : undefined;
108 const normalizedInput = useMemo(() => {
109 if (!handleOrDid) return undefined;
110 const trimmed = handleOrDid.trim();
111 return trimmed || undefined;
112 }, [handleOrDid]);
113
114 const actorIdentifier = useMemo(() => {
115 const explicit = authorFeedActor?.trim();
116 if (explicit) return explicit;
117 if (handle) return handle;
118 if (normalizedInput) return normalizedInput;
119 if (did) return did;
120 return undefined;
121 }, [authorFeedActor, handle, normalizedInput, did]);
122
123 const resetState = useCallback(() => {
124 setPages([]);
125 setPageIndex(0);
126 setError(undefined);
127 inFlight.current.clear();
128 requestSeq.current += 1;
129 feedDisabledRef.current = false;
130 }, []);
131
132 const fetchPage = useCallback(async (identityKey: string, cursor: string | undefined, targetIndex: number, mode: 'active' | 'prefetch') => {
133 if (!did || !endpoint) return;
134 const currentIdentity = `${did}::${endpoint}`;
135 if (identityKey !== currentIdentity) return;
136 const token = requestSeq.current;
137 const key = `${identityKey}:${targetIndex}:${cursor ?? 'start'}`;
138 if (inFlight.current.has(key)) return;
139 inFlight.current.add(key);
140 if (mode === 'active') {
141 setLoading(true);
142 setError(undefined);
143 }
144 try {
145 let nextCursor: string | undefined;
146 let mapped: PaginatedRecord<T>[] | undefined;
147
148 const shouldUseAuthorFeed = preferAuthorFeed && collection === 'app.bsky.feed.post' && !feedDisabledRef.current && !!actorIdentifier;
149 if (shouldUseAuthorFeed) {
150 try {
151 const { rpc } = await createAtprotoClient({ service: authorFeedService ?? DEFAULT_APPVIEW_SERVICE });
152 const res = await (rpc as unknown as {
153 get: (
154 nsid: string,
155 opts: { params: Record<string, string | number | boolean | undefined> }
156 ) => Promise<{ ok: boolean; data: { feed?: Array<{ post?: { uri?: string; record?: T } }>; cursor?: string } }>;
157 }).get('app.bsky.feed.getAuthorFeed', {
158 params: {
159 actor: actorIdentifier,
160 limit,
161 cursor,
162 filter: authorFeedFilter,
163 includePins: authorFeedIncludePins
164 }
165 });
166 if (!res.ok) throw new Error('Failed to fetch author feed');
167 const { feed, cursor: feedCursor } = res.data;
168 mapped = (feed ?? []).reduce<PaginatedRecord<T>[]>((acc, item) => {
169 const post = item?.post;
170 if (!post || typeof post.uri !== 'string' || !post.record) return acc;
171 acc.push({
172 uri: post.uri,
173 rkey: extractRkey(post.uri),
174 value: post.record as T
175 });
176 return acc;
177 }, []);
178 nextCursor = feedCursor;
179 } catch (err) {
180 feedDisabledRef.current = true;
181 if (process.env.NODE_ENV !== 'production') {
182 console.warn('[usePaginatedRecords] Author feed unavailable, falling back to PDS', err);
183 }
184 }
185 }
186
187 if (!mapped) {
188 const { rpc } = await createAtprotoClient({ service: endpoint });
189 const res = await (rpc as unknown as {
190 get: (
191 nsid: string,
192 opts: { params: Record<string, string | number | boolean | undefined> }
193 ) => Promise<{ ok: boolean; data: { records: Array<{ uri: string; rkey?: string; value: T }>; cursor?: string } }>;
194 }).get('com.atproto.repo.listRecords', {
195 params: {
196 repo: did,
197 collection,
198 limit,
199 cursor,
200 reverse: false
201 }
202 });
203 if (!res.ok) throw new Error('Failed to list records');
204 const { records, cursor: repoCursor } = res.data;
205 mapped = records.map((item) => ({
206 uri: item.uri,
207 rkey: item.rkey ?? extractRkey(item.uri),
208 value: item.value
209 }));
210 nextCursor = repoCursor;
211 }
212
213 if (token !== requestSeq.current || identityKey !== identityRef.current) {
214 return nextCursor;
215 }
216 if (mode === 'active') setPageIndex(targetIndex);
217 setPages(prev => {
218 const next = [...prev];
219 next[targetIndex] = { records: mapped!, cursor: nextCursor };
220 return next;
221 });
222 return nextCursor;
223 } catch (e) {
224 if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) {
225 setError(e as Error);
226 }
227 } finally {
228 if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) {
229 setLoading(false);
230 }
231 inFlight.current.delete(key);
232 }
233 return undefined;
234 }, [
235 did,
236 endpoint,
237 collection,
238 limit,
239 preferAuthorFeed,
240 actorIdentifier,
241 authorFeedService,
242 authorFeedFilter,
243 authorFeedIncludePins
244 ]);
245
246 useEffect(() => {
247 if (!handleOrDid) {
248 identityRef.current = undefined;
249 resetState();
250 setLoading(false);
251 setError(undefined);
252 return;
253 }
254
255 if (didError) {
256 identityRef.current = undefined;
257 resetState();
258 setLoading(false);
259 setError(didError);
260 return;
261 }
262
263 if (endpointError) {
264 identityRef.current = undefined;
265 resetState();
266 setLoading(false);
267 setError(endpointError);
268 return;
269 }
270
271 if (resolvingDid || resolvingEndpoint || !identity) {
272 if (identityRef.current !== identity) {
273 identityRef.current = identity;
274 resetState();
275 }
276 setLoading(!!handleOrDid);
277 setError(undefined);
278 return;
279 }
280
281 if (identityRef.current !== identity) {
282 identityRef.current = identity;
283 resetState();
284 }
285
286 fetchPage(identity, undefined, 0, 'active').catch(() => {
287 /* error handled in state */
288 });
289 }, [handleOrDid, identity, fetchPage, resetState, resolvingDid, resolvingEndpoint, didError, endpointError]);
290
291 const currentPage = pages[pageIndex];
292 const hasNext = !!currentPage?.cursor || !!pages[pageIndex + 1];
293 const hasPrev = pageIndex > 0;
294
295 const loadNext = useCallback(() => {
296 const identityKey = identityRef.current;
297 if (!identityKey) return;
298 const page = pages[pageIndex];
299 if (!page?.cursor && !pages[pageIndex + 1]) return;
300 if (pages[pageIndex + 1]) {
301 setPageIndex(pageIndex + 1);
302 return;
303 }
304 fetchPage(identityKey, page.cursor, pageIndex + 1, 'active').catch(() => {
305 /* handled via error state */
306 });
307 }, [fetchPage, pageIndex, pages]);
308
309 const loadPrev = useCallback(() => {
310 if (pageIndex === 0) return;
311 setPageIndex(pageIndex - 1);
312 }, [pageIndex]);
313
314 const records = useMemo(() => currentPage?.records ?? [], [currentPage]);
315
316 const effectiveError = error ?? (endpointError as Error | undefined) ?? (didError as Error | undefined);
317
318 useEffect(() => {
319 const cursor = pages[pageIndex]?.cursor;
320 if (!cursor) return;
321 if (pages[pageIndex + 1]) return;
322 const identityKey = identityRef.current;
323 if (!identityKey) return;
324 fetchPage(identityKey, cursor, pageIndex + 1, 'prefetch').catch(() => {
325 /* ignore prefetch errors */
326 });
327 }, [fetchPage, pageIndex, pages]);
328
329 return {
330 records,
331 loading,
332 error: effectiveError,
333 hasNext,
334 hasPrev,
335 loadNext,
336 loadPrev,
337 pageIndex,
338 pagesCount: pages.length || (currentPage ? pageIndex + 1 : 0)
339 };
340}
341
342function extractRkey(uri: string): string {
343 const parts = uri.split('/');
344 return parts[parts.length - 1];
345}