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}