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