A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.

feat: improve record fetching, use slingshot and bluesky appview. ---

+7 -1
lib/components/BlueskyPostList.tsx
··· 44 44 const actorLabel = resolvedHandle ?? formatDid(did); 45 45 const actorPath = resolvedHandle ?? resolvedDid ?? did; 46 46 47 - const { records, loading, error, hasNext, hasPrev, loadNext, loadPrev, pageIndex, pagesCount } = usePaginatedRecords<FeedPostRecord>({ did, collection: 'app.bsky.feed.post', limit }); 47 + const { records, loading, error, hasNext, hasPrev, loadNext, loadPrev, pageIndex, pagesCount } = usePaginatedRecords<FeedPostRecord>({ 48 + did, 49 + collection: 'app.bsky.feed.post', 50 + limit, 51 + preferAuthorFeed: true, 52 + authorFeedActor: actorPath 53 + }); 48 54 49 55 const pageLabel = useMemo(() => { 50 56 const knownTotal = Math.max(pageIndex + 1, pagesCount);
+1 -1
lib/components/LeafletDocument.tsx
··· 111 111 const href = parsed ? toBlueskyPostUrl(parsed) : undefined; 112 112 if (href) return href; 113 113 } 114 - return `https://bsky.app/leaflet/${encodeURIComponent(did)}/${encodeURIComponent(rkey)}`; 114 + return `at://${encodeURIComponent(did)}/${LEAFLET_DOCUMENT_COLLECTION}/${encodeURIComponent(rkey)}`; 115 115 } 116 116 117 117 export default LeafletDocument;
+33 -9
lib/core/AtProtoRecord.tsx
··· 1 1 import React from 'react'; 2 2 import { useAtProtoRecord } from '../hooks/useAtProtoRecord'; 3 3 4 - export interface AtProtoRecordProps<T = unknown> { 5 - did: string; 6 - collection: string; 7 - rkey: string; 4 + interface AtProtoRecordRenderProps<T> { 8 5 renderer?: React.ComponentType<{ record: T; loading: boolean; error?: Error }>; 9 6 fallback?: React.ReactNode; 10 7 loadingIndicator?: React.ReactNode; 11 8 } 12 9 13 - export function AtProtoRecord<T = unknown>({ did, collection, rkey, renderer: Renderer, fallback = null, loadingIndicator = 'Loading…' }: AtProtoRecordProps<T>) { 14 - const { record, error, loading } = useAtProtoRecord<T>({ did, collection, rkey }); 10 + type AtProtoRecordFetchProps<T> = AtProtoRecordRenderProps<T> & { 11 + did: string; 12 + collection: string; 13 + rkey: string; 14 + record?: undefined; 15 + }; 15 16 16 - if (error) return <>{fallback}</>; 17 - if (!record) return <>{loading ? loadingIndicator : fallback}</>; 18 - if (Renderer) return <Renderer record={record} loading={loading} error={error} />; 17 + type AtProtoRecordProvidedRecordProps<T> = AtProtoRecordRenderProps<T> & { 18 + record: T; 19 + did?: string; 20 + collection?: string; 21 + rkey?: string; 22 + }; 23 + 24 + export type AtProtoRecordProps<T = unknown> = AtProtoRecordFetchProps<T> | AtProtoRecordProvidedRecordProps<T>; 25 + 26 + export function AtProtoRecord<T = unknown>(props: AtProtoRecordProps<T>) { 27 + const { renderer: Renderer, fallback = null, loadingIndicator = 'Loading…' } = props; 28 + const hasProvidedRecord = 'record' in props; 29 + const providedRecord = hasProvidedRecord ? props.record : undefined; 30 + 31 + const { record: fetchedRecord, error, loading } = useAtProtoRecord<T>({ 32 + did: hasProvidedRecord ? undefined : props.did, 33 + collection: hasProvidedRecord ? undefined : props.collection, 34 + rkey: hasProvidedRecord ? undefined : props.rkey, 35 + }); 36 + 37 + const record = providedRecord ?? fetchedRecord; 38 + const isLoading = loading && !providedRecord; 39 + 40 + if (error && !record) return <>{fallback}</>; 41 + if (!record) return <>{isLoading ? loadingIndicator : fallback}</>; 42 + if (Renderer) return <Renderer record={record} loading={isLoading} error={error} />; 19 43 return <pre style={{ fontSize: 12, padding: 8, background: '#f5f5f5', overflow: 'auto' }}>{JSON.stringify(record, null, 2)}</pre>; 20 44 }
+5 -4
lib/hooks/useAtProtoRecord.ts
··· 10 10 /** Repository DID (or handle prior to resolution) containing the record. */ 11 11 did?: string; 12 12 /** NSID collection in which the record resides. */ 13 - collection: string; 13 + collection?: string; 14 14 /** Record key string uniquely identifying the record within the collection. */ 15 - rkey: string; 15 + rkey?: string; 16 16 } 17 17 18 18 /** ··· 48 48 setState(prev => ({ ...prev, ...next })); 49 49 }; 50 50 51 - if (!handleOrDid) { 51 + if (!handleOrDid || !collection || !rkey) { 52 52 assignState({ loading: false, record: undefined, error: undefined }); 53 53 return () => { cancelled = true; }; 54 54 } ··· 85 85 const record = (res.data as { value: T }).value; 86 86 assignState({ record, loading: false }); 87 87 } catch (e) { 88 - assignState({ error: e as Error, loading: false }); 88 + const err = e instanceof Error ? e : new Error(String(e)); 89 + assignState({ error: err, loading: false }); 89 90 } 90 91 })(); 91 92
+161 -38
lib/hooks/usePaginatedRecords.ts
··· 30 30 collection: string; 31 31 /** Maximum page size to request; defaults to `5`. */ 32 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; 33 43 } 34 44 35 45 /** ··· 56 66 pagesCount: number; 57 67 } 58 68 69 + const DEFAULT_APPVIEW_SERVICE = 'https://public.api.bsky.app'; 70 + 71 + export type AuthorFeedFilter = 72 + | 'posts_with_replies' 73 + | 'posts_no_replies' 74 + | 'posts_with_media' 75 + | 'posts_and_author_threads' 76 + | 'posts_with_video'; 77 + 59 78 /** 60 79 * React hook that fetches a repository collection with cursor-based pagination and prefetching. 61 80 * ··· 64 83 * @param limit - Maximum number of records to request per page. Defaults to `5`. 65 84 * @returns {UsePaginatedRecordsResult<T>} Object containing the current page, pagination metadata, and navigation callbacks. 66 85 */ 67 - export function usePaginatedRecords<T>({ did: handleOrDid, collection, limit = 5 }: UsePaginatedRecordsOptions): UsePaginatedRecordsResult<T> { 68 - const { did, error: didError, loading: resolvingDid } = useDidResolution(handleOrDid); 86 + export 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); 69 97 const { endpoint, error: endpointError, loading: resolvingEndpoint } = usePdsEndpoint(did); 70 98 const [pages, setPages] = useState<PageData<T>[]>([]); 71 99 const [pageIndex, setPageIndex] = useState(0); ··· 73 101 const [error, setError] = useState<Error | undefined>(undefined); 74 102 const inFlight = useRef<Set<string>>(new Set()); 75 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]); 76 122 77 123 const resetState = useCallback(() => { 78 124 setPages([]); ··· 80 126 setError(undefined); 81 127 inFlight.current.clear(); 82 128 requestSeq.current += 1; 129 + feedDisabledRef.current = false; 83 130 }, []); 84 131 85 - const fetchPage = useCallback(async (cursor: string | undefined, targetIndex: number, mode: 'active' | 'prefetch') => { 132 + const fetchPage = useCallback(async (identityKey: string, cursor: string | undefined, targetIndex: number, mode: 'active' | 'prefetch') => { 86 133 if (!did || !endpoint) return; 134 + const currentIdentity = `${did}::${endpoint}`; 135 + if (identityKey !== currentIdentity) return; 87 136 const token = requestSeq.current; 88 - const key = `${targetIndex}:${cursor ?? 'start'}`; 137 + const key = `${identityKey}:${targetIndex}:${cursor ?? 'start'}`; 89 138 if (inFlight.current.has(key)) return; 90 139 inFlight.current.add(key); 91 140 if (mode === 'active') { ··· 93 142 setError(undefined); 94 143 } 95 144 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 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 + } 109 184 } 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) { 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) { 119 214 return nextCursor; 120 215 } 121 216 if (mode === 'active') setPageIndex(targetIndex); 122 217 setPages(prev => { 123 218 const next = [...prev]; 124 - next[targetIndex] = { records: mapped, cursor: nextCursor }; 219 + next[targetIndex] = { records: mapped!, cursor: nextCursor }; 125 220 return next; 126 221 }); 127 222 return nextCursor; 128 223 } catch (e) { 129 - if (mode === 'active') setError(e as Error); 224 + if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) { 225 + setError(e as Error); 226 + } 130 227 } finally { 131 - if (mode === 'active') setLoading(false); 228 + if (mode === 'active' && token === requestSeq.current && identityKey === identityRef.current) { 229 + setLoading(false); 230 + } 132 231 inFlight.current.delete(key); 133 232 } 134 233 return undefined; 135 - }, [did, endpoint, collection, limit]); 234 + }, [ 235 + did, 236 + endpoint, 237 + collection, 238 + limit, 239 + preferAuthorFeed, 240 + actorIdentifier, 241 + authorFeedService, 242 + authorFeedFilter, 243 + authorFeedIncludePins 244 + ]); 136 245 137 246 useEffect(() => { 138 247 if (!handleOrDid) { 248 + identityRef.current = undefined; 139 249 resetState(); 140 250 setLoading(false); 141 251 setError(undefined); ··· 143 253 } 144 254 145 255 if (didError) { 256 + identityRef.current = undefined; 146 257 resetState(); 147 258 setLoading(false); 148 259 setError(didError); ··· 150 261 } 151 262 152 263 if (endpointError) { 264 + identityRef.current = undefined; 153 265 resetState(); 154 266 setLoading(false); 155 267 setError(endpointError); 156 268 return; 157 269 } 158 270 159 - if (resolvingDid || resolvingEndpoint || !did || !endpoint) { 160 - resetState(); 161 - setLoading(true); 271 + if (resolvingDid || resolvingEndpoint || !identity) { 272 + if (identityRef.current !== identity) { 273 + identityRef.current = identity; 274 + resetState(); 275 + } 276 + setLoading(!!handleOrDid); 162 277 setError(undefined); 163 278 return; 164 279 } 165 280 166 - resetState(); 167 - fetchPage(undefined, 0, 'active').catch(() => { 281 + if (identityRef.current !== identity) { 282 + identityRef.current = identity; 283 + resetState(); 284 + } 285 + 286 + fetchPage(identity, undefined, 0, 'active').catch(() => { 168 287 /* error handled in state */ 169 288 }); 170 - }, [handleOrDid, did, endpoint, fetchPage, resetState, resolvingDid, resolvingEndpoint, didError, endpointError]); 289 + }, [handleOrDid, identity, fetchPage, resetState, resolvingDid, resolvingEndpoint, didError, endpointError]); 171 290 172 291 const currentPage = pages[pageIndex]; 173 292 const hasNext = !!currentPage?.cursor || !!pages[pageIndex + 1]; 174 293 const hasPrev = pageIndex > 0; 175 294 176 295 const loadNext = useCallback(() => { 296 + const identityKey = identityRef.current; 297 + if (!identityKey) return; 177 298 const page = pages[pageIndex]; 178 299 if (!page?.cursor && !pages[pageIndex + 1]) return; 179 300 if (pages[pageIndex + 1]) { 180 301 setPageIndex(pageIndex + 1); 181 302 return; 182 303 } 183 - fetchPage(page.cursor, pageIndex + 1, 'active').catch(() => { 304 + fetchPage(identityKey, page.cursor, pageIndex + 1, 'active').catch(() => { 184 305 /* handled via error state */ 185 306 }); 186 307 }, [fetchPage, pageIndex, pages]); ··· 198 319 const cursor = pages[pageIndex]?.cursor; 199 320 if (!cursor) return; 200 321 if (pages[pageIndex + 1]) return; 201 - fetchPage(cursor, pageIndex + 1, 'prefetch').catch(() => { 322 + const identityKey = identityRef.current; 323 + if (!identityKey) return; 324 + fetchPage(identityKey, cursor, pageIndex + 1, 'prefetch').catch(() => { 202 325 /* ignore prefetch errors */ 203 326 }); 204 327 }, [fetchPage, pageIndex, pages]);
+2 -1
lib/renderers/BlueskyPostRenderer.tsx
··· 167 167 }, 168 168 text: { 169 169 margin: 0, 170 - whiteSpace: 'pre-wrap' 170 + whiteSpace: 'pre-wrap', 171 + overflowWrap: 'anywhere' 171 172 }, 172 173 facets: { 173 174 marginTop: 8,
+104 -16
lib/utils/atproto-client.ts
··· 1 - import { Client, simpleFetchHandler } from '@atcute/client'; 1 + import { Client, simpleFetchHandler, type FetchHandler } from '@atcute/client'; 2 2 import { CompositeDidDocumentResolver, PlcDidDocumentResolver, WebDidDocumentResolver, XrpcHandleResolver } from '@atcute/identity-resolver'; 3 3 import type { DidDocument } from '@atcute/identity'; 4 4 import type { Did, Handle } from '@atcute/lexicons/syntax'; ··· 17 17 const SUPPORTED_DID_METHODS = ['plc', 'web'] as const; 18 18 type SupportedDidMethod = (typeof SUPPORTED_DID_METHODS)[number]; 19 19 type SupportedDid = Did<SupportedDidMethod>; 20 + 21 + export const SLINGSHOT_BASE_URL = 'https://slingshot.microcosm.blue'; 20 22 21 23 export const normalizeBaseUrl = (input: string): string => { 22 24 const trimmed = input.trim(); ··· 31 33 private plc: string; 32 34 private didResolver: CompositeDidDocumentResolver<SupportedDidMethod>; 33 35 private handleResolver: XrpcHandleResolver; 36 + private fetchImpl: typeof fetch; 34 37 constructor(opts: ServiceResolverOptions = {}) { 35 38 const plcSource = opts.plcDirectory && opts.plcDirectory.trim() ? opts.plcDirectory : DEFAULT_PLC; 36 39 const identitySource = opts.identityService && opts.identityService.trim() ? opts.identityService : DEFAULT_IDENTITY_SERVICE; 37 40 this.plc = normalizeBaseUrl(plcSource); 38 41 const identityBase = normalizeBaseUrl(identitySource); 39 - const fetchImpl = opts.fetch ?? fetch; 40 - const plcResolver = new PlcDidDocumentResolver({ apiUrl: this.plc, fetch: fetchImpl }); 41 - const webResolver = new WebDidDocumentResolver({ fetch: fetchImpl }); 42 + this.fetchImpl = bindFetch(opts.fetch); 43 + const plcResolver = new PlcDidDocumentResolver({ apiUrl: this.plc, fetch: this.fetchImpl }); 44 + const webResolver = new WebDidDocumentResolver({ fetch: this.fetchImpl }); 42 45 this.didResolver = new CompositeDidDocumentResolver({ methods: { plc: plcResolver, web: webResolver } }); 43 - this.handleResolver = new XrpcHandleResolver({ serviceUrl: identityBase, fetch: fetchImpl }); 46 + this.handleResolver = new XrpcHandleResolver({ serviceUrl: identityBase, fetch: this.fetchImpl }); 44 47 } 45 48 46 49 async resolveDidDoc(did: string): Promise<DidDocument> { ··· 66 69 async resolveHandle(handle: string): Promise<string> { 67 70 const normalized = handle.trim().toLowerCase(); 68 71 if (!normalized) throw new Error('Handle cannot be empty'); 69 - return this.handleResolver.resolve(normalized as Handle); 72 + let slingshotError: Error | undefined; 73 + try { 74 + const url = new URL('/xrpc/com.atproto.identity.resolveHandle', SLINGSHOT_BASE_URL); 75 + url.searchParams.set('handle', normalized); 76 + const response = await this.fetchImpl(url); 77 + if (response.ok) { 78 + const payload = await response.json() as { did?: string } | null; 79 + if (payload?.did) { 80 + console.info('[slingshot] resolveHandle cache hit', { handle: normalized }); 81 + return payload.did; 82 + } 83 + slingshotError = new Error('Slingshot resolveHandle response missing DID'); 84 + console.warn('[slingshot] resolveHandle payload missing DID; falling back', { handle: normalized }); 85 + } else { 86 + slingshotError = new Error(`Slingshot resolveHandle failed with status ${response.status}`); 87 + const body = response.body; 88 + if (body) { 89 + body.cancel().catch(() => {}); 90 + } 91 + console.info('[slingshot] resolveHandle cache miss', { handle: normalized, status: response.status }); 92 + } 93 + } catch (err) { 94 + if (err instanceof DOMException && err.name === 'AbortError') throw err; 95 + slingshotError = err instanceof Error ? err : new Error(String(err)); 96 + console.warn('[slingshot] resolveHandle error; falling back to identity service', { handle: normalized, error: slingshotError }); 97 + } 98 + 99 + try { 100 + const did = await this.handleResolver.resolve(normalized as Handle); 101 + if (slingshotError) { 102 + console.info('[slingshot] resolveHandle fallback succeeded', { handle: normalized }); 103 + } 104 + return did; 105 + } catch (err) { 106 + if (slingshotError && err instanceof Error) { 107 + const prior = err.message; 108 + err.message = `${prior}; Slingshot resolveHandle failed: ${slingshotError.message}`; 109 + if (slingshotError) { 110 + console.warn('[slingshot] resolveHandle fallback failed', { handle: normalized, error: slingshotError }); 111 + } 112 + } 113 + throw err; 114 + } 70 115 } 71 116 } 72 117 73 118 export interface CreateClientOptions extends ServiceResolverOptions { 74 - did?: string; // optional to create a DID-scoped client 75 - service?: string; // override service base url 119 + did?: string; // optional to create a DID-scoped client 120 + service?: string; // override service base url 76 121 } 77 122 78 123 export async function createAtprotoClient(opts: CreateClientOptions = {}) { 79 - let service = opts.service; 80 - const resolver = new ServiceResolver(opts); 81 - if (!service && opts.did) { 82 - service = await resolver.pdsEndpointForDid(opts.did); 83 - } 124 + const fetchImpl = bindFetch(opts.fetch); 125 + let service = opts.service; 126 + const resolver = new ServiceResolver({ ...opts, fetch: fetchImpl }); 127 + if (!service && opts.did) { 128 + service = await resolver.pdsEndpointForDid(opts.did); 129 + } 84 130 if (!service) throw new Error('service or did required'); 85 - const handler = simpleFetchHandler({ service: normalizeBaseUrl(service) }); 86 - const rpc = new Client({ handler }); 87 - return { rpc, service, resolver }; 131 + const normalizedService = normalizeBaseUrl(service); 132 + const handler = createSlingshotAwareHandler(normalizedService, fetchImpl); 133 + const rpc = new Client({ handler }); 134 + return { rpc, service: normalizedService, resolver }; 88 135 } 89 136 90 137 export type AtprotoClient = Awaited<ReturnType<typeof createAtprotoClient>>['rpc']; 138 + 139 + const SLINGSHOT_RETRY_PATHS = [ 140 + '/xrpc/com.atproto.repo.getRecord', 141 + '/xrpc/com.atproto.identity.resolveHandle', 142 + ]; 143 + 144 + function createSlingshotAwareHandler(service: string, fetchImpl: typeof fetch): FetchHandler { 145 + const primary = simpleFetchHandler({ service, fetch: fetchImpl }); 146 + const slingshot = simpleFetchHandler({ service: SLINGSHOT_BASE_URL, fetch: fetchImpl }); 147 + return async (pathname, init) => { 148 + const matched = SLINGSHOT_RETRY_PATHS.find(candidate => pathname === candidate || pathname.startsWith(`${candidate}?`)); 149 + if (matched) { 150 + try { 151 + const slingshotResponse = await slingshot(pathname, init); 152 + if (slingshotResponse.ok) { 153 + console.info(`[slingshot] cache hit for ${matched}`); 154 + return slingshotResponse; 155 + } 156 + const body = slingshotResponse.body; 157 + if (body) { 158 + body.cancel().catch(() => {}); 159 + } 160 + console.info(`[slingshot] cache miss ${slingshotResponse.status} for ${matched}, falling back to ${service}`); 161 + } catch (err) { 162 + if (err instanceof DOMException && err.name === 'AbortError') { 163 + throw err; 164 + } 165 + console.warn(`[slingshot] fetch error for ${matched}, falling back to ${service}`, err); 166 + } 167 + } 168 + return primary(pathname, init); 169 + }; 170 + } 171 + 172 + function bindFetch(fetchImpl?: typeof fetch): typeof fetch { 173 + const impl = fetchImpl ?? globalThis.fetch; 174 + if (typeof impl !== 'function') { 175 + throw new Error('fetch implementation not available'); 176 + } 177 + return impl.bind(globalThis); 178 + }
+1 -1
package.json
··· 1 1 { 2 2 "name": "atproto-ui", 3 - "version": "0.2.1", 3 + "version": "0.3.0", 4 4 "type": "module", 5 5 "description": "React components and hooks for rendering AT Protocol records.", 6 6 "main": "./lib-dist/index.js",
+9 -7
src/App.tsx
··· 261 261 }; 262 262 263 263 const LatestPostSummary: React.FC<{ did: string; handle?: string; colorScheme: ColorSchemePreference }> = ({ did, colorScheme }) => { 264 - const { rkey, loading, error } = useLatestRecord<FeedPostRecord>(did, BLUESKY_POST_COLLECTION); 264 + const { record, rkey, loading, error } = useLatestRecord<FeedPostRecord>(did, BLUESKY_POST_COLLECTION); 265 265 const scheme = useColorScheme(colorScheme); 266 266 const palette = scheme === 'dark' ? latestSummaryPalette.dark : latestSummaryPalette.light; 267 267 ··· 269 269 if (error) return <div style={palette.error}>Failed to load the latest post.</div>; 270 270 if (!rkey) return <div style={palette.muted}>No posts published yet.</div>; 271 271 272 + const atProtoProps = record 273 + ? { record } 274 + : { did, collection: 'app.bsky.feed.post', rkey }; 275 + 272 276 return ( 273 - <AtProtoRecord<FeedPostRecord> 274 - did={did} 275 - collection="app.bsky.feed.post" 276 - rkey={rkey} 277 - renderer={({ record }) => ( 277 + <AtProtoRecord<FeedPostRecord> 278 + {...atProtoProps} 279 + renderer={({ record: resolvedRecord }) => ( 278 280 <article data-color-scheme={scheme}> 279 - <strong>{record?.text ?? 'Empty post'}</strong> 281 + <strong>{resolvedRecord?.text ?? 'Empty post'}</strong> 280 282 </article> 281 283 )} 282 284 />