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 75type MaybeNodeEnv = { process?: { env?: Record<string, string | undefined> } }; 76 77const isNonProductionEnv = (): boolean => { 78 if (typeof globalThis === 'undefined') return false; 79 const env = (globalThis as MaybeNodeEnv).process?.env?.NODE_ENV; 80 return env ? env !== 'production' : false; 81}; 82 83export type AuthorFeedFilter = 84 | 'posts_with_replies' 85 | 'posts_no_replies' 86 | 'posts_with_media' 87 | 'posts_and_author_threads' 88 | 'posts_with_video'; 89 90export interface AuthorFeedReason { 91 $type?: string; 92 by?: { 93 handle?: string; 94 did?: string; 95 }; 96 indexedAt?: string; 97} 98 99export interface ReplyParentInfo { 100 uri?: string; 101 author?: { 102 handle?: string; 103 did?: string; 104 }; 105} 106 107/** 108 * React hook that fetches a repository collection with cursor-based pagination and prefetching. 109 * 110 * @param did - Handle or DID whose repository should be queried. 111 * @param collection - NSID collection to read from. 112 * @param limit - Maximum number of records to request per page. Defaults to `5`. 113 * @returns {UsePaginatedRecordsResult<T>} Object containing the current page, pagination metadata, and navigation callbacks. 114 */ 115export function usePaginatedRecords<T>({ 116 did: handleOrDid, 117 collection, 118 limit = 5, 119 preferAuthorFeed = false, 120 authorFeedFilter, 121 authorFeedIncludePins, 122 authorFeedService, 123 authorFeedActor 124}: UsePaginatedRecordsOptions): UsePaginatedRecordsResult<T> { 125 const { did, handle, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid); 126 const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did); 127 const [pages, setPages] = useState<PageData<T>[]>([]); 128 const [pageIndex, setPageIndex] = useState(0); 129 const [loading, setLoading] = useState(false); 130 const [error, setError] = useState<Error | undefined>(undefined); 131 const inFlight = useRef<Set<string>>(new Set()); 132 const requestSeq = useRef(0); 133 const identityRef = useRef<string | undefined>(undefined); 134 const feedDisabledRef = useRef(false); 135 136 const identity = did && endpoint ? `${did}::${endpoint}` : undefined; 137 const normalizedInput = useMemo(() => { 138 if (!handleOrDid) return undefined; 139 const trimmed = handleOrDid.trim(); 140 return trimmed || undefined; 141 }, [handleOrDid]); 142 143 const actorIdentifier = useMemo(() => { 144 const explicit = authorFeedActor?.trim(); 145 if (explicit) return explicit; 146 if (handle) return handle; 147 if (normalizedInput) return normalizedInput; 148 if (did) return did; 149 return undefined; 150 }, [authorFeedActor, handle, normalizedInput, did]); 151 152 const resetState = useCallback(() => { 153 setPages([]); 154 setPageIndex(0); 155 setError(undefined); 156 inFlight.current.clear(); 157 requestSeq.current += 1; 158 feedDisabledRef.current = false; 159 }, []); 160 161 const fetchPage = useCallback(async (identityKey: string, cursor: string | undefined, targetIndex: number, mode: 'active' | 'prefetch') => { 162 if (!did || !endpoint) return; 163 const currentIdentity = `${did}::${endpoint}`; 164 if (identityKey !== currentIdentity) return; 165 const token = requestSeq.current; 166 const key = `${identityKey}:${targetIndex}:${cursor ?? 'start'}`; 167 if (inFlight.current.has(key)) return; 168 inFlight.current.add(key); 169 if (mode === 'active') { 170 setLoading(true); 171 setError(undefined); 172 } 173 try { 174 let nextCursor: string | undefined; 175 let mapped: PaginatedRecord<T>[] | undefined; 176 177 const shouldUseAuthorFeed = preferAuthorFeed && collection === 'app.bsky.feed.post' && !feedDisabledRef.current && !!actorIdentifier; 178 if (shouldUseAuthorFeed) { 179 try { 180 const { rpc } = await createAtprotoClient({ service: authorFeedService ?? DEFAULT_APPVIEW_SERVICE }); 181 const res = await (rpc as unknown as { 182 get: ( 183 nsid: string, 184 opts: { params: Record<string, string | number | boolean | undefined> } 185 ) => Promise<{ 186 ok: boolean; 187 data: { 188 feed?: Array<{ 189 post?: { 190 uri?: string; 191 record?: T; 192 reply?: { 193 parent?: { 194 uri?: string; 195 author?: { handle?: string; did?: string }; 196 }; 197 }; 198 }; 199 reason?: AuthorFeedReason; 200 }>; 201 cursor?: string; 202 }; 203 }>; 204 }).get('app.bsky.feed.getAuthorFeed', { 205 params: { 206 actor: actorIdentifier, 207 limit, 208 cursor, 209 filter: authorFeedFilter, 210 includePins: authorFeedIncludePins 211 } 212 }); 213 if (!res.ok) throw new Error('Failed to fetch author feed'); 214 const { feed, cursor: feedCursor } = res.data; 215 mapped = (feed ?? []).reduce<PaginatedRecord<T>[]>((acc, item) => { 216 const post = item?.post; 217 if (!post || typeof post.uri !== 'string' || !post.record) return acc; 218 acc.push({ 219 uri: post.uri, 220 rkey: extractRkey(post.uri), 221 value: post.record as T, 222 reason: item?.reason, 223 replyParent: post.reply?.parent 224 }); 225 return acc; 226 }, []); 227 nextCursor = feedCursor; 228 } catch (err) { 229 feedDisabledRef.current = true; 230 if (isNonProductionEnv()) { 231 console.warn('[usePaginatedRecords] Author feed unavailable, falling back to PDS', err); 232 } 233 } 234 } 235 236 if (!mapped) { 237 const { rpc } = await createAtprotoClient({ service: endpoint }); 238 const res = await (rpc as unknown as { 239 get: ( 240 nsid: string, 241 opts: { params: Record<string, string | number | boolean | undefined> } 242 ) => Promise<{ ok: boolean; data: { records: Array<{ uri: string; rkey?: string; value: T }>; cursor?: string } }>; 243 }).get('com.atproto.repo.listRecords', { 244 params: { 245 repo: did, 246 collection, 247 limit, 248 cursor, 249 reverse: false 250 } 251 }); 252 if (!res.ok) throw new Error('Failed to list records'); 253 const { records, cursor: repoCursor } = res.data; 254 mapped = records.map((item) => ({ 255 uri: item.uri, 256 rkey: item.rkey ?? extractRkey(item.uri), 257 value: item.value 258 })); 259 nextCursor = repoCursor; 260 } 261 262 if (token !== requestSeq.current || identityKey !== identityRef.current) { 263 return nextCursor; 264 } 265 if (mode === 'active') setPageIndex(targetIndex); 266 setPages(prev => { 267 const next = [...prev]; 268 next[targetIndex] = { records: mapped!, cursor: nextCursor }; 269 return next; 270 }); 271 return nextCursor; 272 } catch (e) { 273 if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) { 274 setError(e as Error); 275 } 276 } finally { 277 if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) { 278 setLoading(false); 279 } 280 inFlight.current.delete(key); 281 } 282 return undefined; 283 }, [ 284 did, 285 endpoint, 286 collection, 287 limit, 288 preferAuthorFeed, 289 actorIdentifier, 290 authorFeedService, 291 authorFeedFilter, 292 authorFeedIncludePins 293 ]); 294 295 useEffect(() => { 296 if (!handleOrDid) { 297 identityRef.current = undefined; 298 resetState(); 299 setLoading(false); 300 setError(undefined); 301 return; 302 } 303 304 if (didError) { 305 identityRef.current = undefined; 306 resetState(); 307 setLoading(false); 308 setError(didError); 309 return; 310 } 311 312 if (endpointError) { 313 identityRef.current = undefined; 314 resetState(); 315 setLoading(false); 316 setError(endpointError); 317 return; 318 } 319 320 if (resolvingDid || resolvingEndpoint || !identity) { 321 if (identityRef.current !== identity) { 322 identityRef.current = identity; 323 resetState(); 324 } 325 setLoading(!!handleOrDid); 326 setError(undefined); 327 return; 328 } 329 330 if (identityRef.current !== identity) { 331 identityRef.current = identity; 332 resetState(); 333 } 334 335 fetchPage(identity, undefined, 0, 'active').catch(() => { 336 /* error handled in state */ 337 }); 338 }, [handleOrDid, identity, fetchPage, resetState, resolvingDid, resolvingEndpoint, didError, endpointError]); 339 340 const currentPage = pages[pageIndex]; 341 const hasNext = !!currentPage?.cursor || !!pages[pageIndex + 1]; 342 const hasPrev = pageIndex > 0; 343 344 const loadNext = useCallback(() => { 345 const identityKey = identityRef.current; 346 if (!identityKey) return; 347 const page = pages[pageIndex]; 348 if (!page?.cursor && !pages[pageIndex + 1]) return; 349 if (pages[pageIndex + 1]) { 350 setPageIndex(pageIndex + 1); 351 return; 352 } 353 fetchPage(identityKey, page.cursor, pageIndex + 1, 'active').catch(() => { 354 /* handled via error state */ 355 }); 356 }, [fetchPage, pageIndex, pages]); 357 358 const loadPrev = useCallback(() => { 359 if (pageIndex === 0) return; 360 setPageIndex(pageIndex - 1); 361 }, [pageIndex]); 362 363 const records = useMemo(() => currentPage?.records ?? [], [currentPage]); 364 365 const effectiveError = error ?? (endpointError as Error | undefined) ?? (didError as Error | undefined); 366 367 useEffect(() => { 368 const cursor = pages[pageIndex]?.cursor; 369 if (!cursor) return; 370 if (pages[pageIndex + 1]) return; 371 const identityKey = identityRef.current; 372 if (!identityKey) return; 373 fetchPage(identityKey, cursor, pageIndex + 1, 'prefetch').catch(() => { 374 /* ignore prefetch errors */ 375 }); 376 }, [fetchPage, pageIndex, pages]); 377 378 return { 379 records, 380 loading, 381 error: effectiveError, 382 hasNext, 383 hasPrev, 384 loadNext, 385 loadPrev, 386 pageIndex, 387 pagesCount: pages.length || (currentPage ? pageIndex + 1 : 0) 388 }; 389} 390 391function extractRkey(uri: string): string { 392 const parts = uri.split('/'); 393 return parts[parts.length - 1]; 394}