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} 17 18interface PageData<T> { 19 records: PaginatedRecord<T>[]; 20 cursor?: string; 21} 22 23/** 24 * Options accepted by {@link usePaginatedRecords}. 25 */ 26export interface UsePaginatedRecordsOptions { 27 /** DID or handle whose repository should be queried. */ 28 did?: string; 29 /** NSID collection containing the target records. */ 30 collection: string; 31 /** Maximum page size to request; defaults to `5`. */ 32 limit?: number; 33 /** Prefer the Bluesky appview author feed endpoint before falling back to the PDS. */ 34 preferAuthorFeed?: boolean; 35 /** Optional filter applied when fetching from the appview author feed. */ 36 authorFeedFilter?: AuthorFeedFilter; 37 /** Whether to include pinned posts when fetching from the author feed. */ 38 authorFeedIncludePins?: boolean; 39 /** Override for the appview service base URL used to query the author feed. */ 40 authorFeedService?: string; 41 /** Optional explicit actor identifier for the author feed request. */ 42 authorFeedActor?: string; 43} 44 45/** 46 * Result returned from {@link usePaginatedRecords} describing records and pagination state. 47 */ 48export interface UsePaginatedRecordsResult<T> { 49 /** Records for the active page. */ 50 records: PaginatedRecord<T>[]; 51 /** Indicates whether a page load is in progress. */ 52 loading: boolean; 53 /** Error produced during the latest fetch, if any. */ 54 error?: Error; 55 /** `true` when another page can be fetched forward. */ 56 hasNext: boolean; 57 /** `true` when a previous page exists in memory. */ 58 hasPrev: boolean; 59 /** Requests the next page (if available). */ 60 loadNext: () => void; 61 /** Returns to the previous page when possible. */ 62 loadPrev: () => void; 63 /** Index of the currently displayed page. */ 64 pageIndex: number; 65 /** Number of pages fetched so far (or inferred total when known). */ 66 pagesCount: number; 67} 68 69const DEFAULT_APPVIEW_SERVICE = 'https://public.api.bsky.app'; 70 71export type AuthorFeedFilter = 72 | 'posts_with_replies' 73 | 'posts_no_replies' 74 | 'posts_with_media' 75 | 'posts_and_author_threads' 76 | 'posts_with_video'; 77 78/** 79 * React hook that fetches a repository collection with cursor-based pagination and prefetching. 80 * 81 * @param did - Handle or DID whose repository should be queried. 82 * @param collection - NSID collection to read from. 83 * @param limit - Maximum number of records to request per page. Defaults to `5`. 84 * @returns {UsePaginatedRecordsResult<T>} Object containing the current page, pagination metadata, and navigation callbacks. 85 */ 86export function usePaginatedRecords<T>({ 87 did: handleOrDid, 88 collection, 89 limit = 5, 90 preferAuthorFeed = false, 91 authorFeedFilter, 92 authorFeedIncludePins, 93 authorFeedService, 94 authorFeedActor 95}: UsePaginatedRecordsOptions): UsePaginatedRecordsResult<T> { 96 const { did, handle, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid); 97 const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did); 98 const [pages, setPages] = useState<PageData<T>[]>([]); 99 const [pageIndex, setPageIndex] = useState(0); 100 const [loading, setLoading] = useState(false); 101 const [error, setError] = useState<Error | undefined>(undefined); 102 const inFlight = useRef<Set<string>>(new Set()); 103 const requestSeq = useRef(0); 104 const identityRef = useRef<string | undefined>(undefined); 105 const feedDisabledRef = useRef(false); 106 107 const identity = did && endpoint ? `${did}::${endpoint}` : undefined; 108 const normalizedInput = useMemo(() => { 109 if (!handleOrDid) return undefined; 110 const trimmed = handleOrDid.trim(); 111 return trimmed || undefined; 112 }, [handleOrDid]); 113 114 const actorIdentifier = useMemo(() => { 115 const explicit = authorFeedActor?.trim(); 116 if (explicit) return explicit; 117 if (handle) return handle; 118 if (normalizedInput) return normalizedInput; 119 if (did) return did; 120 return undefined; 121 }, [authorFeedActor, handle, normalizedInput, did]); 122 123 const resetState = useCallback(() => { 124 setPages([]); 125 setPageIndex(0); 126 setError(undefined); 127 inFlight.current.clear(); 128 requestSeq.current += 1; 129 feedDisabledRef.current = false; 130 }, []); 131 132 const fetchPage = useCallback(async (identityKey: string, cursor: string | undefined, targetIndex: number, mode: 'active' | 'prefetch') => { 133 if (!did || !endpoint) return; 134 const currentIdentity = `${did}::${endpoint}`; 135 if (identityKey !== currentIdentity) return; 136 const token = requestSeq.current; 137 const key = `${identityKey}:${targetIndex}:${cursor ?? 'start'}`; 138 if (inFlight.current.has(key)) return; 139 inFlight.current.add(key); 140 if (mode === 'active') { 141 setLoading(true); 142 setError(undefined); 143 } 144 try { 145 let nextCursor: string | undefined; 146 let mapped: PaginatedRecord<T>[] | undefined; 147 148 const shouldUseAuthorFeed = preferAuthorFeed && collection === 'app.bsky.feed.post' && !feedDisabledRef.current && !!actorIdentifier; 149 if (shouldUseAuthorFeed) { 150 try { 151 const { rpc } = await createAtprotoClient({ service: authorFeedService ?? DEFAULT_APPVIEW_SERVICE }); 152 const res = await (rpc as unknown as { 153 get: ( 154 nsid: string, 155 opts: { params: Record<string, string | number | boolean | undefined> } 156 ) => Promise<{ ok: boolean; data: { feed?: Array<{ post?: { uri?: string; record?: T } }>; cursor?: string } }>; 157 }).get('app.bsky.feed.getAuthorFeed', { 158 params: { 159 actor: actorIdentifier, 160 limit, 161 cursor, 162 filter: authorFeedFilter, 163 includePins: authorFeedIncludePins 164 } 165 }); 166 if (!res.ok) throw new Error('Failed to fetch author feed'); 167 const { feed, cursor: feedCursor } = res.data; 168 mapped = (feed ?? []).reduce<PaginatedRecord<T>[]>((acc, item) => { 169 const post = item?.post; 170 if (!post || typeof post.uri !== 'string' || !post.record) return acc; 171 acc.push({ 172 uri: post.uri, 173 rkey: extractRkey(post.uri), 174 value: post.record as T 175 }); 176 return acc; 177 }, []); 178 nextCursor = feedCursor; 179 } catch (err) { 180 feedDisabledRef.current = true; 181 if (process.env.NODE_ENV !== 'production') { 182 console.warn('[usePaginatedRecords] Author feed unavailable, falling back to PDS', err); 183 } 184 } 185 } 186 187 if (!mapped) { 188 const { rpc } = await createAtprotoClient({ service: endpoint }); 189 const res = await (rpc as unknown as { 190 get: ( 191 nsid: string, 192 opts: { params: Record<string, string | number | boolean | undefined> } 193 ) => Promise<{ ok: boolean; data: { records: Array<{ uri: string; rkey?: string; value: T }>; cursor?: string } }>; 194 }).get('com.atproto.repo.listRecords', { 195 params: { 196 repo: did, 197 collection, 198 limit, 199 cursor, 200 reverse: false 201 } 202 }); 203 if (!res.ok) throw new Error('Failed to list records'); 204 const { records, cursor: repoCursor } = res.data; 205 mapped = records.map((item) => ({ 206 uri: item.uri, 207 rkey: item.rkey ?? extractRkey(item.uri), 208 value: item.value 209 })); 210 nextCursor = repoCursor; 211 } 212 213 if (token !== requestSeq.current || identityKey !== identityRef.current) { 214 return nextCursor; 215 } 216 if (mode === 'active') setPageIndex(targetIndex); 217 setPages(prev => { 218 const next = [...prev]; 219 next[targetIndex] = { records: mapped!, cursor: nextCursor }; 220 return next; 221 }); 222 return nextCursor; 223 } catch (e) { 224 if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) { 225 setError(e as Error); 226 } 227 } finally { 228 if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) { 229 setLoading(false); 230 } 231 inFlight.current.delete(key); 232 } 233 return undefined; 234 }, [ 235 did, 236 endpoint, 237 collection, 238 limit, 239 preferAuthorFeed, 240 actorIdentifier, 241 authorFeedService, 242 authorFeedFilter, 243 authorFeedIncludePins 244 ]); 245 246 useEffect(() => { 247 if (!handleOrDid) { 248 identityRef.current = undefined; 249 resetState(); 250 setLoading(false); 251 setError(undefined); 252 return; 253 } 254 255 if (didError) { 256 identityRef.current = undefined; 257 resetState(); 258 setLoading(false); 259 setError(didError); 260 return; 261 } 262 263 if (endpointError) { 264 identityRef.current = undefined; 265 resetState(); 266 setLoading(false); 267 setError(endpointError); 268 return; 269 } 270 271 if (resolvingDid || resolvingEndpoint || !identity) { 272 if (identityRef.current !== identity) { 273 identityRef.current = identity; 274 resetState(); 275 } 276 setLoading(!!handleOrDid); 277 setError(undefined); 278 return; 279 } 280 281 if (identityRef.current !== identity) { 282 identityRef.current = identity; 283 resetState(); 284 } 285 286 fetchPage(identity, undefined, 0, 'active').catch(() => { 287 /* error handled in state */ 288 }); 289 }, [handleOrDid, identity, fetchPage, resetState, resolvingDid, resolvingEndpoint, didError, endpointError]); 290 291 const currentPage = pages[pageIndex]; 292 const hasNext = !!currentPage?.cursor || !!pages[pageIndex + 1]; 293 const hasPrev = pageIndex > 0; 294 295 const loadNext = useCallback(() => { 296 const identityKey = identityRef.current; 297 if (!identityKey) return; 298 const page = pages[pageIndex]; 299 if (!page?.cursor && !pages[pageIndex + 1]) return; 300 if (pages[pageIndex + 1]) { 301 setPageIndex(pageIndex + 1); 302 return; 303 } 304 fetchPage(identityKey, page.cursor, pageIndex + 1, 'active').catch(() => { 305 /* handled via error state */ 306 }); 307 }, [fetchPage, pageIndex, pages]); 308 309 const loadPrev = useCallback(() => { 310 if (pageIndex === 0) return; 311 setPageIndex(pageIndex - 1); 312 }, [pageIndex]); 313 314 const records = useMemo(() => currentPage?.records ?? [], [currentPage]); 315 316 const effectiveError = error ?? (endpointError as Error | undefined) ?? (didError as Error | undefined); 317 318 useEffect(() => { 319 const cursor = pages[pageIndex]?.cursor; 320 if (!cursor) return; 321 if (pages[pageIndex + 1]) return; 322 const identityKey = identityRef.current; 323 if (!identityKey) return; 324 fetchPage(identityKey, cursor, pageIndex + 1, 'prefetch').catch(() => { 325 /* ignore prefetch errors */ 326 }); 327 }, [fetchPage, pageIndex, pages]); 328 329 return { 330 records, 331 loading, 332 error: effectiveError, 333 hasNext, 334 hasPrev, 335 loadNext, 336 loadPrev, 337 pageIndex, 338 pagesCount: pages.length || (currentPage ? pageIndex + 1 : 0) 339 }; 340} 341 342function extractRkey(uri: string): string { 343 const parts = uri.split('/'); 344 return parts[parts.length - 1]; 345}