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