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