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