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}