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 /** Optional feed metadata (for example, repost context). */
17 reason?: AuthorFeedReason;
18 /** Optional reply context derived from feed metadata. */
19 replyParent?: ReplyParentInfo;
20}
21
22interface PageData<T> {
23 records: PaginatedRecord<T>[];
24 cursor?: string;
25}
26
27/**
28 * Options accepted by {@link usePaginatedRecords}.
29 */
30export interface UsePaginatedRecordsOptions {
31 /** DID or handle whose repository should be queried. */
32 did?: string;
33 /** NSID collection containing the target records. */
34 collection: string;
35 /** Maximum page size to request; defaults to `5`. */
36 limit?: number;
37 /** Prefer the Bluesky appview author feed endpoint before falling back to the PDS. */
38 preferAuthorFeed?: boolean;
39 /** Optional filter applied when fetching from the appview author feed. */
40 authorFeedFilter?: AuthorFeedFilter;
41 /** Whether to include pinned posts when fetching from the author feed. */
42 authorFeedIncludePins?: boolean;
43 /** Override for the appview service base URL used to query the author feed. */
44 authorFeedService?: string;
45 /** Optional explicit actor identifier for the author feed request. */
46 authorFeedActor?: string;
47}
48
49/**
50 * Result returned from {@link usePaginatedRecords} describing records and pagination state.
51 */
52export interface UsePaginatedRecordsResult<T> {
53 /** Records for the active page. */
54 records: PaginatedRecord<T>[];
55 /** Indicates whether a page load is in progress. */
56 loading: boolean;
57 /** Error produced during the latest fetch, if any. */
58 error?: Error;
59 /** `true` when another page can be fetched forward. */
60 hasNext: boolean;
61 /** `true` when a previous page exists in memory. */
62 hasPrev: boolean;
63 /** Requests the next page (if available). */
64 loadNext: () => void;
65 /** Returns to the previous page when possible. */
66 loadPrev: () => void;
67 /** Index of the currently displayed page. */
68 pageIndex: number;
69 /** Number of pages fetched so far (or inferred total when known). */
70 pagesCount: number;
71}
72
73const DEFAULT_APPVIEW_SERVICE = "https://public.api.bsky.app";
74
75export type AuthorFeedFilter =
76 | "posts_with_replies"
77 | "posts_no_replies"
78 | "posts_with_media"
79 | "posts_and_author_threads"
80 | "posts_with_video";
81
82export interface AuthorFeedReason {
83 $type?: string;
84 by?: {
85 handle?: string;
86 did?: string;
87 };
88 indexedAt?: string;
89}
90
91export interface ReplyParentInfo {
92 uri?: string;
93 author?: {
94 handle?: string;
95 did?: string;
96 };
97}
98
99/**
100 * React hook that fetches a repository collection with cursor-based pagination and prefetching.
101 *
102 * @param did - Handle or DID whose repository should be queried.
103 * @param collection - NSID collection to read from.
104 * @param limit - Maximum number of records to request per page. Defaults to `5`.
105 * @returns {UsePaginatedRecordsResult<T>} Object containing the current page, pagination metadata, and navigation callbacks.
106 */
107export function usePaginatedRecords<T>({
108 did: handleOrDid,
109 collection,
110 limit = 5,
111 preferAuthorFeed = false,
112 authorFeedFilter,
113 authorFeedIncludePins,
114 authorFeedService,
115 authorFeedActor,
116}: UsePaginatedRecordsOptions): UsePaginatedRecordsResult<T> {
117 const {
118 did,
119 handle,
120 error: didError,
121 loading: resolvingDid,
122 } = useDidResolution(handleOrDid);
123 const {
124 endpoint,
125 error: endpointError,
126 loading: resolvingEndpoint,
127 } = usePdsEndpoint(did);
128 const [pages, setPages] = useState<PageData<T>[]>([]);
129 const [pageIndex, setPageIndex] = useState(0);
130 const [loading, setLoading] = useState(false);
131 const [error, setError] = useState<Error | undefined>(undefined);
132 const inFlight = useRef<Set<string>>(new Set());
133 const requestSeq = useRef(0);
134 const identityRef = useRef<string | undefined>(undefined);
135 const feedDisabledRef = useRef(false);
136
137 const identity = did && endpoint ? `${did}::${endpoint}` : undefined;
138 const normalizedInput = useMemo(() => {
139 if (!handleOrDid) return undefined;
140 const trimmed = handleOrDid.trim();
141 return trimmed || undefined;
142 }, [handleOrDid]);
143
144 const actorIdentifier = useMemo(() => {
145 const explicit = authorFeedActor?.trim();
146 if (explicit) return explicit;
147 if (handle) return handle;
148 if (normalizedInput) return normalizedInput;
149 if (did) return did;
150 return undefined;
151 }, [authorFeedActor, handle, normalizedInput, did]);
152
153 const resetState = useCallback(() => {
154 setPages([]);
155 setPageIndex(0);
156 setError(undefined);
157 inFlight.current.clear();
158 requestSeq.current += 1;
159 feedDisabledRef.current = false;
160 }, []);
161
162 const fetchPage = useCallback(
163 async (
164 identityKey: string,
165 cursor: string | undefined,
166 targetIndex: number,
167 mode: "active" | "prefetch",
168 ) => {
169 if (!did || !endpoint) return;
170 const currentIdentity = `${did}::${endpoint}`;
171 if (identityKey !== currentIdentity) return;
172 const token = requestSeq.current;
173 const key = `${identityKey}:${targetIndex}:${cursor ?? "start"}`;
174 if (inFlight.current.has(key)) return;
175 inFlight.current.add(key);
176 if (mode === "active") {
177 setLoading(true);
178 setError(undefined);
179 }
180 try {
181 let nextCursor: string | undefined;
182 let mapped: PaginatedRecord<T>[] | undefined;
183
184 const shouldUseAuthorFeed =
185 preferAuthorFeed &&
186 collection === "app.bsky.feed.post" &&
187 !feedDisabledRef.current &&
188 !!actorIdentifier;
189 if (shouldUseAuthorFeed) {
190 try {
191 const { rpc } = await createAtprotoClient({
192 service:
193 authorFeedService ?? DEFAULT_APPVIEW_SERVICE,
194 });
195 const res = await (
196 rpc as unknown as {
197 get: (
198 nsid: string,
199 opts: {
200 params: Record<
201 string,
202 | string
203 | number
204 | boolean
205 | undefined
206 >;
207 },
208 ) => Promise<{
209 ok: boolean;
210 data: {
211 feed?: Array<{
212 post?: {
213 uri?: string;
214 record?: T;
215 reply?: {
216 parent?: {
217 uri?: string;
218 author?: {
219 handle?: string;
220 did?: string;
221 };
222 };
223 };
224 };
225 reason?: AuthorFeedReason;
226 }>;
227 cursor?: string;
228 };
229 }>;
230 }
231 ).get("app.bsky.feed.getAuthorFeed", {
232 params: {
233 actor: actorIdentifier,
234 limit,
235 cursor,
236 filter: authorFeedFilter,
237 includePins: authorFeedIncludePins,
238 },
239 });
240 if (!res.ok)
241 throw new Error("Failed to fetch author feed");
242 const { feed, cursor: feedCursor } = res.data;
243 mapped = (feed ?? []).reduce<PaginatedRecord<T>[]>(
244 (acc, item) => {
245 const post = item?.post;
246 if (
247 !post ||
248 typeof post.uri !== "string" ||
249 !post.record
250 )
251 return acc;
252 acc.push({
253 uri: post.uri,
254 rkey: extractRkey(post.uri),
255 value: post.record as T,
256 reason: item?.reason,
257 replyParent: post.reply?.parent,
258 });
259 return acc;
260 },
261 [],
262 );
263 nextCursor = feedCursor;
264 } catch (err) {
265 console.log(err);
266 feedDisabledRef.current = true;
267 }
268 }
269
270 if (!mapped) {
271 const { rpc } = await createAtprotoClient({
272 service: endpoint,
273 });
274 const res = await (
275 rpc as unknown as {
276 get: (
277 nsid: string,
278 opts: {
279 params: Record<
280 string,
281 string | number | boolean | undefined
282 >;
283 },
284 ) => Promise<{
285 ok: boolean;
286 data: {
287 records: Array<{
288 uri: string;
289 rkey?: string;
290 value: T;
291 }>;
292 cursor?: string;
293 };
294 }>;
295 }
296 ).get("com.atproto.repo.listRecords", {
297 params: {
298 repo: did,
299 collection,
300 limit,
301 cursor,
302 reverse: false,
303 },
304 });
305 if (!res.ok) throw new Error("Failed to list records");
306 const { records, cursor: repoCursor } = res.data;
307 mapped = records.map((item) => ({
308 uri: item.uri,
309 rkey: item.rkey ?? extractRkey(item.uri),
310 value: item.value,
311 }));
312 nextCursor = repoCursor;
313 }
314
315 if (
316 token !== requestSeq.current ||
317 identityKey !== identityRef.current
318 ) {
319 return nextCursor;
320 }
321 if (mode === "active") setPageIndex(targetIndex);
322 setPages((prev) => {
323 const next = [...prev];
324 next[targetIndex] = {
325 records: mapped!,
326 cursor: nextCursor,
327 };
328 return next;
329 });
330 return nextCursor;
331 } catch (e) {
332 if (
333 mode === "active" &&
334 token === requestSeq.current &&
335 identityKey === identityRef.current
336 ) {
337 setError(e as Error);
338 }
339 } finally {
340 if (
341 mode === "active" &&
342 token === requestSeq.current &&
343 identityKey === identityRef.current
344 ) {
345 setLoading(false);
346 }
347 inFlight.current.delete(key);
348 }
349 return undefined;
350 },
351 [
352 did,
353 endpoint,
354 collection,
355 limit,
356 preferAuthorFeed,
357 actorIdentifier,
358 authorFeedService,
359 authorFeedFilter,
360 authorFeedIncludePins,
361 ],
362 );
363
364 useEffect(() => {
365 if (!handleOrDid) {
366 identityRef.current = undefined;
367 resetState();
368 setLoading(false);
369 setError(undefined);
370 return;
371 }
372
373 if (didError) {
374 identityRef.current = undefined;
375 resetState();
376 setLoading(false);
377 setError(didError);
378 return;
379 }
380
381 if (endpointError) {
382 identityRef.current = undefined;
383 resetState();
384 setLoading(false);
385 setError(endpointError);
386 return;
387 }
388
389 if (resolvingDid || resolvingEndpoint || !identity) {
390 if (identityRef.current !== identity) {
391 identityRef.current = identity;
392 resetState();
393 }
394 setLoading(!!handleOrDid);
395 setError(undefined);
396 return;
397 }
398
399 if (identityRef.current !== identity) {
400 identityRef.current = identity;
401 resetState();
402 }
403
404 fetchPage(identity, undefined, 0, "active").catch(() => {
405 /* error handled in state */
406 });
407 }, [
408 handleOrDid,
409 identity,
410 fetchPage,
411 resetState,
412 resolvingDid,
413 resolvingEndpoint,
414 didError,
415 endpointError,
416 ]);
417
418 const currentPage = pages[pageIndex];
419 const hasNext = !!currentPage?.cursor || !!pages[pageIndex + 1];
420 const hasPrev = pageIndex > 0;
421
422 const loadNext = useCallback(() => {
423 const identityKey = identityRef.current;
424 if (!identityKey) return;
425 const page = pages[pageIndex];
426 if (!page?.cursor && !pages[pageIndex + 1]) return;
427 if (pages[pageIndex + 1]) {
428 setPageIndex(pageIndex + 1);
429 return;
430 }
431 fetchPage(identityKey, page.cursor, pageIndex + 1, "active").catch(
432 () => {
433 /* handled via error state */
434 },
435 );
436 }, [fetchPage, pageIndex, pages]);
437
438 const loadPrev = useCallback(() => {
439 if (pageIndex === 0) return;
440 setPageIndex(pageIndex - 1);
441 }, [pageIndex]);
442
443 const records = useMemo(() => currentPage?.records ?? [], [currentPage]);
444
445 const effectiveError =
446 error ??
447 (endpointError as Error | undefined) ??
448 (didError as Error | undefined);
449
450 useEffect(() => {
451 const cursor = pages[pageIndex]?.cursor;
452 if (!cursor) return;
453 if (pages[pageIndex + 1]) return;
454 const identityKey = identityRef.current;
455 if (!identityKey) return;
456 fetchPage(identityKey, cursor, pageIndex + 1, "prefetch").catch(() => {
457 /* ignore prefetch errors */
458 });
459 }, [fetchPage, pageIndex, pages]);
460
461 return {
462 records,
463 loading,
464 error: effectiveError,
465 hasNext,
466 hasPrev,
467 loadNext,
468 loadPrev,
469 pageIndex,
470 pagesCount: pages.length || (currentPage ? pageIndex + 1 : 0),
471 };
472}
473
474function extractRkey(uri: string): string {
475 const parts = uri.split("/");
476 return parts[parts.length - 1];
477}