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 { did, handle, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid);
118 const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did);
119 const [pages, setPages] = useState<PageData<T>[]>([]);
120 const [pageIndex, setPageIndex] = useState(0);
121 const [loading, setLoading] = useState(false);
122 const [error, setError] = useState<Error | undefined>(undefined);
123 const inFlight = useRef<Set<string>>(new Set());
124 const requestSeq = useRef(0);
125 const identityRef = useRef<string | undefined>(undefined);
126 const feedDisabledRef = useRef(false);
127
128 const identity = did && endpoint ? `${did}::${endpoint}` : undefined;
129 const normalizedInput = useMemo(() => {
130 if (!handleOrDid) return undefined;
131 const trimmed = handleOrDid.trim();
132 return trimmed || undefined;
133 }, [handleOrDid]);
134
135 const actorIdentifier = useMemo(() => {
136 const explicit = authorFeedActor?.trim();
137 if (explicit) return explicit;
138 if (handle) return handle;
139 if (normalizedInput) return normalizedInput;
140 if (did) return did;
141 return undefined;
142 }, [authorFeedActor, handle, normalizedInput, did]);
143
144 const resetState = useCallback(() => {
145 setPages([]);
146 setPageIndex(0);
147 setError(undefined);
148 inFlight.current.clear();
149 requestSeq.current += 1;
150 feedDisabledRef.current = false;
151 }, []);
152
153 const fetchPage = useCallback(async (identityKey: string, cursor: string | undefined, targetIndex: number, mode: 'active' | 'prefetch') => {
154 if (!did || !endpoint) return;
155 const currentIdentity = `${did}::${endpoint}`;
156 if (identityKey !== currentIdentity) return;
157 const token = requestSeq.current;
158 const key = `${identityKey}:${targetIndex}:${cursor ?? 'start'}`;
159 if (inFlight.current.has(key)) return;
160 inFlight.current.add(key);
161 if (mode === 'active') {
162 setLoading(true);
163 setError(undefined);
164 }
165 try {
166 let nextCursor: string | undefined;
167 let mapped: PaginatedRecord<T>[] | undefined;
168
169 const shouldUseAuthorFeed = preferAuthorFeed && collection === 'app.bsky.feed.post' && !feedDisabledRef.current && !!actorIdentifier;
170 if (shouldUseAuthorFeed) {
171 try {
172 const { rpc } = await createAtprotoClient({ service: authorFeedService ?? DEFAULT_APPVIEW_SERVICE });
173 const res = await (rpc as unknown as {
174 get: (
175 nsid: string,
176 opts: { params: Record<string, string | number | boolean | undefined> }
177 ) => Promise<{
178 ok: boolean;
179 data: {
180 feed?: Array<{
181 post?: {
182 uri?: string;
183 record?: T;
184 reply?: {
185 parent?: {
186 uri?: string;
187 author?: { handle?: string; did?: string };
188 };
189 };
190 };
191 reason?: AuthorFeedReason;
192 }>;
193 cursor?: string;
194 };
195 }>;
196 }).get('app.bsky.feed.getAuthorFeed', {
197 params: {
198 actor: actorIdentifier,
199 limit,
200 cursor,
201 filter: authorFeedFilter,
202 includePins: authorFeedIncludePins
203 }
204 });
205 if (!res.ok) throw new Error('Failed to fetch author feed');
206 const { feed, cursor: feedCursor } = res.data;
207 mapped = (feed ?? []).reduce<PaginatedRecord<T>[]>((acc, item) => {
208 const post = item?.post;
209 if (!post || typeof post.uri !== 'string' || !post.record) return acc;
210 acc.push({
211 uri: post.uri,
212 rkey: extractRkey(post.uri),
213 value: post.record as T,
214 reason: item?.reason,
215 replyParent: post.reply?.parent
216 });
217 return acc;
218 }, []);
219 nextCursor = feedCursor;
220 } catch (err) {
221 feedDisabledRef.current = true;
222 }
223 }
224
225 if (!mapped) {
226 const { rpc } = await createAtprotoClient({ service: endpoint });
227 const res = await (rpc as unknown as {
228 get: (
229 nsid: string,
230 opts: { params: Record<string, string | number | boolean | undefined> }
231 ) => Promise<{ ok: boolean; data: { records: Array<{ uri: string; rkey?: string; value: T }>; cursor?: string } }>;
232 }).get('com.atproto.repo.listRecords', {
233 params: {
234 repo: did,
235 collection,
236 limit,
237 cursor,
238 reverse: false
239 }
240 });
241 if (!res.ok) throw new Error('Failed to list records');
242 const { records, cursor: repoCursor } = res.data;
243 mapped = records.map((item) => ({
244 uri: item.uri,
245 rkey: item.rkey ?? extractRkey(item.uri),
246 value: item.value
247 }));
248 nextCursor = repoCursor;
249 }
250
251 if (token !== requestSeq.current || identityKey !== identityRef.current) {
252 return nextCursor;
253 }
254 if (mode === 'active') setPageIndex(targetIndex);
255 setPages(prev => {
256 const next = [...prev];
257 next[targetIndex] = { records: mapped!, cursor: nextCursor };
258 return next;
259 });
260 return nextCursor;
261 } catch (e) {
262 if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) {
263 setError(e as Error);
264 }
265 } finally {
266 if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) {
267 setLoading(false);
268 }
269 inFlight.current.delete(key);
270 }
271 return undefined;
272 }, [
273 did,
274 endpoint,
275 collection,
276 limit,
277 preferAuthorFeed,
278 actorIdentifier,
279 authorFeedService,
280 authorFeedFilter,
281 authorFeedIncludePins
282 ]);
283
284 useEffect(() => {
285 if (!handleOrDid) {
286 identityRef.current = undefined;
287 resetState();
288 setLoading(false);
289 setError(undefined);
290 return;
291 }
292
293 if (didError) {
294 identityRef.current = undefined;
295 resetState();
296 setLoading(false);
297 setError(didError);
298 return;
299 }
300
301 if (endpointError) {
302 identityRef.current = undefined;
303 resetState();
304 setLoading(false);
305 setError(endpointError);
306 return;
307 }
308
309 if (resolvingDid || resolvingEndpoint || !identity) {
310 if (identityRef.current !== identity) {
311 identityRef.current = identity;
312 resetState();
313 }
314 setLoading(!!handleOrDid);
315 setError(undefined);
316 return;
317 }
318
319 if (identityRef.current !== identity) {
320 identityRef.current = identity;
321 resetState();
322 }
323
324 fetchPage(identity, undefined, 0, 'active').catch(() => {
325 /* error handled in state */
326 });
327 }, [handleOrDid, identity, fetchPage, resetState, resolvingDid, resolvingEndpoint, didError, endpointError]);
328
329 const currentPage = pages[pageIndex];
330 const hasNext = !!currentPage?.cursor || !!pages[pageIndex + 1];
331 const hasPrev = pageIndex > 0;
332
333 const loadNext = useCallback(() => {
334 const identityKey = identityRef.current;
335 if (!identityKey) return;
336 const page = pages[pageIndex];
337 if (!page?.cursor && !pages[pageIndex + 1]) return;
338 if (pages[pageIndex + 1]) {
339 setPageIndex(pageIndex + 1);
340 return;
341 }
342 fetchPage(identityKey, page.cursor, pageIndex + 1, 'active').catch(() => {
343 /* handled via error state */
344 });
345 }, [fetchPage, pageIndex, pages]);
346
347 const loadPrev = useCallback(() => {
348 if (pageIndex === 0) return;
349 setPageIndex(pageIndex - 1);
350 }, [pageIndex]);
351
352 const records = useMemo(() => currentPage?.records ?? [], [currentPage]);
353
354 const effectiveError = error ?? (endpointError as Error | undefined) ?? (didError as Error | undefined);
355
356 useEffect(() => {
357 const cursor = pages[pageIndex]?.cursor;
358 if (!cursor) return;
359 if (pages[pageIndex + 1]) return;
360 const identityKey = identityRef.current;
361 if (!identityKey) return;
362 fetchPage(identityKey, cursor, pageIndex + 1, 'prefetch').catch(() => {
363 /* ignore prefetch errors */
364 });
365 }, [fetchPage, pageIndex, pages]);
366
367 return {
368 records,
369 loading,
370 error: effectiveError,
371 hasNext,
372 hasPrev,
373 loadNext,
374 loadPrev,
375 pageIndex,
376 pagesCount: pages.length || (currentPage ? pageIndex + 1 : 0)
377 };
378}
379
380function extractRkey(uri: string): string {
381 const parts = uri.split('/');
382 return parts[parts.length - 1];
383}