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} 34 35/** 36 * Result returned from {@link usePaginatedRecords} describing records and pagination state. 37 */ 38export interface UsePaginatedRecordsResult<T> { 39 /** Records for the active page. */ 40 records: PaginatedRecord<T>[]; 41 /** Indicates whether a page load is in progress. */ 42 loading: boolean; 43 /** Error produced during the latest fetch, if any. */ 44 error?: Error; 45 /** `true` when another page can be fetched forward. */ 46 hasNext: boolean; 47 /** `true` when a previous page exists in memory. */ 48 hasPrev: boolean; 49 /** Requests the next page (if available). */ 50 loadNext: () => void; 51 /** Returns to the previous page when possible. */ 52 loadPrev: () => void; 53 /** Index of the currently displayed page. */ 54 pageIndex: number; 55 /** Number of pages fetched so far (or inferred total when known). */ 56 pagesCount: number; 57} 58 59/** 60 * React hook that fetches a repository collection with cursor-based pagination and prefetching. 61 * 62 * @param did - Handle or DID whose repository should be queried. 63 * @param collection - NSID collection to read from. 64 * @param limit - Maximum number of records to request per page. Defaults to `5`. 65 * @returns {UsePaginatedRecordsResult<T>} Object containing the current page, pagination metadata, and navigation callbacks. 66 */ 67export function usePaginatedRecords<T>({ did: handleOrDid, collection, limit = 5 }: UsePaginatedRecordsOptions): UsePaginatedRecordsResult<T> { 68 const { did, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid); 69 const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did); 70 const [pages, setPages] = useState<PageData<T>[]>([]); 71 const [pageIndex, setPageIndex] = useState(0); 72 const [loading, setLoading] = useState(false); 73 const [error, setError] = useState<Error | undefined>(undefined); 74 const inFlight = useRef<Set<string>>(new Set()); 75 const requestSeq = useRef(0); 76 77 const resetState = useCallback(() => { 78 setPages([]); 79 setPageIndex(0); 80 setError(undefined); 81 inFlight.current.clear(); 82 requestSeq.current += 1; 83 }, []); 84 85 const fetchPage = useCallback(async (cursor: string | undefined, targetIndex: number, mode: 'active' | 'prefetch') => { 86 if (!did || !endpoint) return; 87 const token = requestSeq.current; 88 const key = `${targetIndex}:${cursor ?? 'start'}`; 89 if (inFlight.current.has(key)) return; 90 inFlight.current.add(key); 91 if (mode === 'active') { 92 setLoading(true); 93 setError(undefined); 94 } 95 try { 96 const { rpc } = await createAtprotoClient({ service: endpoint }); 97 const res = await (rpc as unknown as { 98 get: ( 99 nsid: string, 100 opts: { params: Record<string, string | number | boolean | undefined> } 101 ) => Promise<{ ok: boolean; data: { records: Array<{ uri: string; rkey?: string; value: T }>; cursor?: string } }>; 102 }).get('com.atproto.repo.listRecords', { 103 params: { 104 repo: did, 105 collection, 106 limit, 107 cursor, 108 reverse: false 109 } 110 }); 111 if (!res.ok) throw new Error('Failed to list records'); 112 const { records, cursor: nextCursor } = res.data; 113 const mapped: PaginatedRecord<T>[] = records.map((item) => ({ 114 uri: item.uri, 115 rkey: item.rkey ?? extractRkey(item.uri), 116 value: item.value 117 })); 118 if (token !== requestSeq.current) { 119 return nextCursor; 120 } 121 if (mode === 'active') setPageIndex(targetIndex); 122 setPages(prev => { 123 const next = [...prev]; 124 next[targetIndex] = { records: mapped, cursor: nextCursor }; 125 return next; 126 }); 127 return nextCursor; 128 } catch (e) { 129 if (mode === 'active') setError(e as Error); 130 } finally { 131 if (mode === 'active') setLoading(false); 132 inFlight.current.delete(key); 133 } 134 return undefined; 135 }, [did, endpoint, collection, limit]); 136 137 useEffect(() => { 138 if (!handleOrDid) { 139 resetState(); 140 setLoading(false); 141 setError(undefined); 142 return; 143 } 144 145 if (didError) { 146 resetState(); 147 setLoading(false); 148 setError(didError); 149 return; 150 } 151 152 if (endpointError) { 153 resetState(); 154 setLoading(false); 155 setError(endpointError); 156 return; 157 } 158 159 if (resolvingDid || resolvingEndpoint || !did || !endpoint) { 160 resetState(); 161 setLoading(true); 162 setError(undefined); 163 return; 164 } 165 166 resetState(); 167 fetchPage(undefined, 0, 'active').catch(() => { 168 /* error handled in state */ 169 }); 170 }, [handleOrDid, did, endpoint, fetchPage, resetState, resolvingDid, resolvingEndpoint, didError, endpointError]); 171 172 const currentPage = pages[pageIndex]; 173 const hasNext = !!currentPage?.cursor || !!pages[pageIndex + 1]; 174 const hasPrev = pageIndex > 0; 175 176 const loadNext = useCallback(() => { 177 const page = pages[pageIndex]; 178 if (!page?.cursor && !pages[pageIndex + 1]) return; 179 if (pages[pageIndex + 1]) { 180 setPageIndex(pageIndex + 1); 181 return; 182 } 183 fetchPage(page.cursor, pageIndex + 1, 'active').catch(() => { 184 /* handled via error state */ 185 }); 186 }, [fetchPage, pageIndex, pages]); 187 188 const loadPrev = useCallback(() => { 189 if (pageIndex === 0) return; 190 setPageIndex(pageIndex - 1); 191 }, [pageIndex]); 192 193 const records = useMemo(() => currentPage?.records ?? [], [currentPage]); 194 195 const effectiveError = error ?? (endpointError as Error | undefined) ?? (didError as Error | undefined); 196 197 useEffect(() => { 198 const cursor = pages[pageIndex]?.cursor; 199 if (!cursor) return; 200 if (pages[pageIndex + 1]) return; 201 fetchPage(cursor, pageIndex + 1, 'prefetch').catch(() => { 202 /* ignore prefetch errors */ 203 }); 204 }, [fetchPage, pageIndex, pages]); 205 206 return { 207 records, 208 loading, 209 error: effectiveError, 210 hasNext, 211 hasPrev, 212 loadNext, 213 loadPrev, 214 pageIndex, 215 pagesCount: pages.length || (currentPage ? pageIndex + 1 : 0) 216 }; 217} 218 219function extractRkey(uri: string): string { 220 const parts = uri.split('/'); 221 return parts[parts.length - 1]; 222}