A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
at main 12 kB view raw
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}