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 { did, handle, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid); 118 const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did); 119 const [pages, setPages] = useState<PageData<T>[]>([]); 120 const [pageIndex, setPageIndex] = useState(0); 121 const [loading, setLoading] = useState(false); 122 const [error, setError] = useState<Error | undefined>(undefined); 123 const inFlight = useRef<Set<string>>(new Set()); 124 const requestSeq = useRef(0); 125 const identityRef = useRef<string | undefined>(undefined); 126 const feedDisabledRef = useRef(false); 127 128 const identity = did && endpoint ? `${did}::${endpoint}` : undefined; 129 const normalizedInput = useMemo(() => { 130 if (!handleOrDid) return undefined; 131 const trimmed = handleOrDid.trim(); 132 return trimmed || undefined; 133 }, [handleOrDid]); 134 135 const actorIdentifier = useMemo(() => { 136 const explicit = authorFeedActor?.trim(); 137 if (explicit) return explicit; 138 if (handle) return handle; 139 if (normalizedInput) return normalizedInput; 140 if (did) return did; 141 return undefined; 142 }, [authorFeedActor, handle, normalizedInput, did]); 143 144 const resetState = useCallback(() => { 145 setPages([]); 146 setPageIndex(0); 147 setError(undefined); 148 inFlight.current.clear(); 149 requestSeq.current += 1; 150 feedDisabledRef.current = false; 151 }, []); 152 153 const fetchPage = useCallback(async (identityKey: string, cursor: string | undefined, targetIndex: number, mode: 'active' | 'prefetch') => { 154 if (!did || !endpoint) return; 155 const currentIdentity = `${did}::${endpoint}`; 156 if (identityKey !== currentIdentity) return; 157 const token = requestSeq.current; 158 const key = `${identityKey}:${targetIndex}:${cursor ?? 'start'}`; 159 if (inFlight.current.has(key)) return; 160 inFlight.current.add(key); 161 if (mode === 'active') { 162 setLoading(true); 163 setError(undefined); 164 } 165 try { 166 let nextCursor: string | undefined; 167 let mapped: PaginatedRecord<T>[] | undefined; 168 169 const shouldUseAuthorFeed = preferAuthorFeed && collection === 'app.bsky.feed.post' && !feedDisabledRef.current && !!actorIdentifier; 170 if (shouldUseAuthorFeed) { 171 try { 172 const { rpc } = await createAtprotoClient({ service: authorFeedService ?? DEFAULT_APPVIEW_SERVICE }); 173 const res = await (rpc as unknown as { 174 get: ( 175 nsid: string, 176 opts: { params: Record<string, string | number | boolean | undefined> } 177 ) => Promise<{ 178 ok: boolean; 179 data: { 180 feed?: Array<{ 181 post?: { 182 uri?: string; 183 record?: T; 184 reply?: { 185 parent?: { 186 uri?: string; 187 author?: { handle?: string; did?: string }; 188 }; 189 }; 190 }; 191 reason?: AuthorFeedReason; 192 }>; 193 cursor?: string; 194 }; 195 }>; 196 }).get('app.bsky.feed.getAuthorFeed', { 197 params: { 198 actor: actorIdentifier, 199 limit, 200 cursor, 201 filter: authorFeedFilter, 202 includePins: authorFeedIncludePins 203 } 204 }); 205 if (!res.ok) throw new Error('Failed to fetch author feed'); 206 const { feed, cursor: feedCursor } = res.data; 207 mapped = (feed ?? []).reduce<PaginatedRecord<T>[]>((acc, item) => { 208 const post = item?.post; 209 if (!post || typeof post.uri !== 'string' || !post.record) return acc; 210 acc.push({ 211 uri: post.uri, 212 rkey: extractRkey(post.uri), 213 value: post.record as T, 214 reason: item?.reason, 215 replyParent: post.reply?.parent 216 }); 217 return acc; 218 }, []); 219 nextCursor = feedCursor; 220 } catch (err) { 221 feedDisabledRef.current = true; 222 } 223 } 224 225 if (!mapped) { 226 const { rpc } = await createAtprotoClient({ service: endpoint }); 227 const res = await (rpc as unknown as { 228 get: ( 229 nsid: string, 230 opts: { params: Record<string, string | number | boolean | undefined> } 231 ) => Promise<{ ok: boolean; data: { records: Array<{ uri: string; rkey?: string; value: T }>; cursor?: string } }>; 232 }).get('com.atproto.repo.listRecords', { 233 params: { 234 repo: did, 235 collection, 236 limit, 237 cursor, 238 reverse: false 239 } 240 }); 241 if (!res.ok) throw new Error('Failed to list records'); 242 const { records, cursor: repoCursor } = res.data; 243 mapped = records.map((item) => ({ 244 uri: item.uri, 245 rkey: item.rkey ?? extractRkey(item.uri), 246 value: item.value 247 })); 248 nextCursor = repoCursor; 249 } 250 251 if (token !== requestSeq.current || identityKey !== identityRef.current) { 252 return nextCursor; 253 } 254 if (mode === 'active') setPageIndex(targetIndex); 255 setPages(prev => { 256 const next = [...prev]; 257 next[targetIndex] = { records: mapped!, cursor: nextCursor }; 258 return next; 259 }); 260 return nextCursor; 261 } catch (e) { 262 if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) { 263 setError(e as Error); 264 } 265 } finally { 266 if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) { 267 setLoading(false); 268 } 269 inFlight.current.delete(key); 270 } 271 return undefined; 272 }, [ 273 did, 274 endpoint, 275 collection, 276 limit, 277 preferAuthorFeed, 278 actorIdentifier, 279 authorFeedService, 280 authorFeedFilter, 281 authorFeedIncludePins 282 ]); 283 284 useEffect(() => { 285 if (!handleOrDid) { 286 identityRef.current = undefined; 287 resetState(); 288 setLoading(false); 289 setError(undefined); 290 return; 291 } 292 293 if (didError) { 294 identityRef.current = undefined; 295 resetState(); 296 setLoading(false); 297 setError(didError); 298 return; 299 } 300 301 if (endpointError) { 302 identityRef.current = undefined; 303 resetState(); 304 setLoading(false); 305 setError(endpointError); 306 return; 307 } 308 309 if (resolvingDid || resolvingEndpoint || !identity) { 310 if (identityRef.current !== identity) { 311 identityRef.current = identity; 312 resetState(); 313 } 314 setLoading(!!handleOrDid); 315 setError(undefined); 316 return; 317 } 318 319 if (identityRef.current !== identity) { 320 identityRef.current = identity; 321 resetState(); 322 } 323 324 fetchPage(identity, undefined, 0, 'active').catch(() => { 325 /* error handled in state */ 326 }); 327 }, [handleOrDid, identity, fetchPage, resetState, resolvingDid, resolvingEndpoint, didError, endpointError]); 328 329 const currentPage = pages[pageIndex]; 330 const hasNext = !!currentPage?.cursor || !!pages[pageIndex + 1]; 331 const hasPrev = pageIndex > 0; 332 333 const loadNext = useCallback(() => { 334 const identityKey = identityRef.current; 335 if (!identityKey) return; 336 const page = pages[pageIndex]; 337 if (!page?.cursor && !pages[pageIndex + 1]) return; 338 if (pages[pageIndex + 1]) { 339 setPageIndex(pageIndex + 1); 340 return; 341 } 342 fetchPage(identityKey, page.cursor, pageIndex + 1, 'active').catch(() => { 343 /* handled via error state */ 344 }); 345 }, [fetchPage, pageIndex, pages]); 346 347 const loadPrev = useCallback(() => { 348 if (pageIndex === 0) return; 349 setPageIndex(pageIndex - 1); 350 }, [pageIndex]); 351 352 const records = useMemo(() => currentPage?.records ?? [], [currentPage]); 353 354 const effectiveError = error ?? (endpointError as Error | undefined) ?? (didError as Error | undefined); 355 356 useEffect(() => { 357 const cursor = pages[pageIndex]?.cursor; 358 if (!cursor) return; 359 if (pages[pageIndex + 1]) return; 360 const identityKey = identityRef.current; 361 if (!identityKey) return; 362 fetchPage(identityKey, cursor, pageIndex + 1, 'prefetch').catch(() => { 363 /* ignore prefetch errors */ 364 }); 365 }, [fetchPage, pageIndex, pages]); 366 367 return { 368 records, 369 loading, 370 error: effectiveError, 371 hasNext, 372 hasPrev, 373 loadNext, 374 loadPrev, 375 pageIndex, 376 pagesCount: pages.length || (currentPage ? pageIndex + 1 : 0) 377 }; 378} 379 380function extractRkey(uri: string): string { 381 const parts = uri.split('/'); 382 return parts[parts.length - 1]; 383}