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

update readme with credits, make constants configurable

+3 -1
README.md
··· 1 1 # atproto-ui 2 2 3 - A React component library for rendering AT Protocol records (Bluesky, Leaflet, Tangled, and more). Handles DID resolution, PDS discovery, and record fetching automatically. [Live demo](https://atproto-ui.wisp.place). 3 + A React component library for rendering AT Protocol records (Bluesky, Leaflet, Tangled, and more). Handles DID resolution, PDS discovery, and record fetching automatically. [Live demo](https://atproto-ui.netlify.app). 4 + 5 + This project is mostly a wrapper on the extremely amazing work [Mary](https://mary.my.id/) has done with [atcute](https://tangled.org/@mary.my.id/atcute), please support it. I have to give thanks to [phil](https://bsky.app/profile/bad-example.com) for microcosm and slingshot. Incredible services being given for free that is responsible for why the components fetch data so quickly. 4 6 5 7 ## Screenshots 6 8
+3 -1
lib/components/BlueskyPostList.tsx
··· 8 8 import { useDidResolution } from "../hooks/useDidResolution"; 9 9 import { BlueskyIcon } from "./BlueskyIcon"; 10 10 import { parseAtUri } from "../utils/at-uri"; 11 + import { useAtProto } from "../providers/AtProtoProvider"; 11 12 12 13 /** 13 14 * Options for rendering a paginated list of Bluesky posts. ··· 215 216 replyParent, 216 217 hasDivider, 217 218 }) => { 219 + const { blueskyAppBaseUrl } = useAtProto(); 218 220 const text = record.text?.trim() ?? ""; 219 221 const relative = record.createdAt 220 222 ? formatRelativeTime(record.createdAt) ··· 222 224 const absolute = record.createdAt 223 225 ? new Date(record.createdAt).toLocaleString() 224 226 : undefined; 225 - const href = `https://bsky.app/profile/${did}/post/${rkey}`; 227 + const href = `${blueskyAppBaseUrl}/profile/${did}/post/${rkey}`; 226 228 const repostLabel = 227 229 reason?.$type === "app.bsky.feed.defs#reasonRepost" 228 230 ? `${formatActor(reason.by) ?? "Someone"} reposted`
+7 -4
lib/components/RichText.tsx
··· 1 1 import React from "react"; 2 2 import type { AppBskyRichtextFacet } from "@atcute/bluesky"; 3 3 import { createTextSegments, type TextSegment } from "../utils/richtext"; 4 + import { useAtProto } from "../providers/AtProtoProvider"; 4 5 5 6 export interface RichTextProps { 6 7 text: string; ··· 13 14 * Properly handles byte offsets and multi-byte characters. 14 15 */ 15 16 export const RichText: React.FC<RichTextProps> = ({ text, facets, style }) => { 17 + const { blueskyAppBaseUrl } = useAtProto(); 16 18 const segments = createTextSegments(text, facets); 17 19 18 20 return ( 19 21 <span style={style}> 20 22 {segments.map((segment, idx) => ( 21 - <RichTextSegment key={idx} segment={segment} /> 23 + <RichTextSegment key={idx} segment={segment} blueskyAppBaseUrl={blueskyAppBaseUrl} /> 22 24 ))} 23 25 </span> 24 26 ); ··· 26 28 27 29 interface RichTextSegmentProps { 28 30 segment: TextSegment; 31 + blueskyAppBaseUrl: string; 29 32 } 30 33 31 - const RichTextSegment: React.FC<RichTextSegmentProps> = ({ segment }) => { 34 + const RichTextSegment: React.FC<RichTextSegmentProps> = ({ segment, blueskyAppBaseUrl }) => { 32 35 if (!segment.facet) { 33 36 return <>{segment.text}</>; 34 37 } ··· 68 71 69 72 case "app.bsky.richtext.facet#mention": { 70 73 const mentionFeature = feature as AppBskyRichtextFacet.Mention; 71 - const profileUrl = `https://bsky.app/profile/${mentionFeature.did}`; 74 + const profileUrl = `${blueskyAppBaseUrl}/profile/${mentionFeature.did}`; 72 75 return ( 73 76 <a 74 77 href={profileUrl} ··· 92 95 93 96 case "app.bsky.richtext.facet#tag": { 94 97 const tagFeature = feature as AppBskyRichtextFacet.Tag; 95 - const tagUrl = `https://bsky.app/hashtag/${encodeURIComponent(tagFeature.tag)}`; 98 + const tagUrl = `${blueskyAppBaseUrl}/hashtag/${encodeURIComponent(tagFeature.tag)}`; 96 99 return ( 97 100 <a 98 101 href={tagUrl}
+3 -1
lib/components/TangledString.tsx
··· 2 2 import { AtProtoRecord } from "../core/AtProtoRecord"; 3 3 import { TangledStringRenderer } from "../renderers/TangledStringRenderer"; 4 4 import type { TangledStringRecord } from "../renderers/TangledStringRenderer"; 5 + import { useAtProto } from "../providers/AtProtoProvider"; 5 6 6 7 /** 7 8 * Props for rendering Tangled String records. ··· 66 67 loadingIndicator, 67 68 colorScheme, 68 69 }) => { 70 + const { tangledBaseUrl } = useAtProto(); 69 71 const Comp: React.ComponentType<TangledStringRendererInjectedProps> = 70 72 renderer ?? ((props) => <TangledStringRenderer {...props} />); 71 73 const Wrapped: React.FC<{ ··· 78 80 colorScheme={colorScheme} 79 81 did={did} 80 82 rkey={rkey} 81 - canonicalUrl={`https://tangled.org/strings/${did}/${encodeURIComponent(rkey)}`} 83 + canonicalUrl={`${tangledBaseUrl}/strings/${did}/${encodeURIComponent(rkey)}`} 82 84 /> 83 85 ); 84 86
+10 -8
lib/hooks/useBlueskyAppview.ts
··· 1 1 import { useEffect, useReducer, useRef } from "react"; 2 2 import { useDidResolution } from "./useDidResolution"; 3 3 import { usePdsEndpoint } from "./usePdsEndpoint"; 4 - import { createAtprotoClient, SLINGSHOT_BASE_URL } from "../utils/atproto-client"; 4 + import { createAtprotoClient } from "../utils/atproto-client"; 5 5 import { useAtProto } from "../providers/AtProtoProvider"; 6 6 7 7 /** ··· 91 91 /** Source from which the record was successfully fetched. */ 92 92 source?: "appview" | "slingshot" | "pds"; 93 93 } 94 - 95 - export const DEFAULT_APPVIEW_SERVICE = "https://public.api.bsky.app"; 96 94 97 95 /** 98 96 * Maps Bluesky collection NSIDs to their corresponding appview API endpoints. ··· 236 234 appviewService, 237 235 skipAppview = false, 238 236 }: UseBlueskyAppviewOptions): UseBlueskyAppviewResult<T> { 239 - const { recordCache } = useAtProto(); 237 + const { recordCache, blueskyAppviewService, resolver } = useAtProto(); 238 + const effectiveAppviewService = appviewService ?? blueskyAppviewService; 240 239 const { 241 240 did, 242 241 error: didError, ··· 326 325 did, 327 326 collection, 328 327 rkey, 329 - appviewService ?? DEFAULT_APPVIEW_SERVICE, 328 + effectiveAppviewService, 330 329 ); 331 330 if (result) { 332 331 return result; ··· 339 338 340 339 // Tier 2: Try Slingshot getRecord 341 340 try { 342 - const result = await fetchFromSlingshot<T>(did, collection, rkey); 341 + const slingshotUrl = resolver.getSlingshotUrl(); 342 + const result = await fetchFromSlingshot<T>(did, collection, rkey, slingshotUrl); 343 343 if (result) { 344 344 return result; 345 345 } ··· 408 408 collection, 409 409 rkey, 410 410 pdsEndpoint, 411 - appviewService, 411 + effectiveAppviewService, 412 412 skipAppview, 413 413 resolvingDid, 414 414 resolvingEndpoint, 415 415 didError, 416 416 endpointError, 417 417 recordCache, 418 + resolver, 418 419 ]); 419 420 420 421 return state; ··· 575 576 did: string, 576 577 collection: string, 577 578 rkey: string, 579 + slingshotBaseUrl: string, 578 580 ): Promise<T | undefined> { 579 - const res = await callGetRecord<T>(SLINGSHOT_BASE_URL, did, collection, rkey); 581 + const res = await callGetRecord<T>(slingshotBaseUrl, did, collection, rkey); 580 582 if (!res.ok) throw new Error(`Slingshot getRecord failed for ${did}/${collection}/${rkey}`); 581 583 return res.data.value; 582 584 }
+4 -6
lib/hooks/usePaginatedRecords.ts
··· 1 1 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 2 import { useDidResolution } from "./useDidResolution"; 3 3 import { usePdsEndpoint } from "./usePdsEndpoint"; 4 - import { 5 - DEFAULT_APPVIEW_SERVICE, 6 - callAppviewRpc, 7 - callListRecords 8 - } from "./useBlueskyAppview"; 4 + import { callAppviewRpc, callListRecords } from "./useBlueskyAppview"; 5 + import { useAtProto } from "../providers/AtProtoProvider"; 9 6 10 7 /** 11 8 * Record envelope returned by paginated AT Protocol queries. ··· 118 115 authorFeedService, 119 116 authorFeedActor, 120 117 }: UsePaginatedRecordsOptions): UsePaginatedRecordsResult<T> { 118 + const { blueskyAppviewService } = useAtProto(); 121 119 const { 122 120 did, 123 121 handle, ··· 213 211 } 214 212 215 213 const res = await callAppviewRpc<AuthorFeedResponse>( 216 - authorFeedService ?? DEFAULT_APPVIEW_SERVICE, 214 + authorFeedService ?? blueskyAppviewService, 217 215 "app.bsky.feed.getAuthorFeed", 218 216 { 219 217 actor: actorIdentifier,
+78 -5
lib/providers/AtProtoProvider.tsx
··· 5 5 useMemo, 6 6 useRef, 7 7 } from "react"; 8 - import { ServiceResolver, normalizeBaseUrl } from "../utils/atproto-client"; 8 + import { ServiceResolver, normalizeBaseUrl, DEFAULT_CONFIG } from "../utils/atproto-client"; 9 9 import { BlobCache, DidCache, RecordCache } from "../utils/cache"; 10 10 11 11 /** ··· 16 16 children: React.ReactNode; 17 17 /** Optional custom PLC directory URL. Defaults to https://plc.directory */ 18 18 plcDirectory?: string; 19 + /** Optional custom identity service URL. Defaults to https://public.api.bsky.app */ 20 + identityService?: string; 21 + /** Optional custom Slingshot service URL. Defaults to https://slingshot.microcosm.blue */ 22 + slingshotBaseUrl?: string; 23 + /** Optional custom Bluesky appview service URL. Defaults to https://public.api.bsky.app */ 24 + blueskyAppviewService?: string; 25 + /** Optional custom Bluesky app base URL for links. Defaults to https://bsky.app */ 26 + blueskyAppBaseUrl?: string; 27 + /** Optional custom Tangled base URL for links. Defaults to https://tangled.org */ 28 + tangledBaseUrl?: string; 19 29 } 20 30 21 31 /** ··· 26 36 resolver: ServiceResolver; 27 37 /** Normalized PLC directory base URL. */ 28 38 plcDirectory: string; 39 + /** Normalized Bluesky appview service URL. */ 40 + blueskyAppviewService: string; 41 + /** Normalized Bluesky app base URL for links. */ 42 + blueskyAppBaseUrl: string; 43 + /** Normalized Tangled base URL for links. */ 44 + tangledBaseUrl: string; 29 45 /** Cache for DID documents and handle mappings. */ 30 46 didCache: DidCache; 31 47 /** Cache for fetched blob data. */ ··· 77 93 export function AtProtoProvider({ 78 94 children, 79 95 plcDirectory, 96 + identityService, 97 + slingshotBaseUrl, 98 + blueskyAppviewService, 99 + blueskyAppBaseUrl, 100 + tangledBaseUrl, 80 101 }: AtProtoProviderProps) { 81 102 const normalizedPlc = useMemo( 82 103 () => 83 104 normalizeBaseUrl( 84 105 plcDirectory && plcDirectory.trim() 85 106 ? plcDirectory 86 - : "https://plc.directory", 107 + : DEFAULT_CONFIG.plcDirectory, 87 108 ), 88 109 [plcDirectory], 89 110 ); 111 + const normalizedIdentity = useMemo( 112 + () => 113 + normalizeBaseUrl( 114 + identityService && identityService.trim() 115 + ? identityService 116 + : DEFAULT_CONFIG.identityService, 117 + ), 118 + [identityService], 119 + ); 120 + const normalizedSlingshot = useMemo( 121 + () => 122 + normalizeBaseUrl( 123 + slingshotBaseUrl && slingshotBaseUrl.trim() 124 + ? slingshotBaseUrl 125 + : DEFAULT_CONFIG.slingshotBaseUrl, 126 + ), 127 + [slingshotBaseUrl], 128 + ); 129 + const normalizedAppview = useMemo( 130 + () => 131 + normalizeBaseUrl( 132 + blueskyAppviewService && blueskyAppviewService.trim() 133 + ? blueskyAppviewService 134 + : DEFAULT_CONFIG.blueskyAppviewService, 135 + ), 136 + [blueskyAppviewService], 137 + ); 138 + const normalizedBlueskyApp = useMemo( 139 + () => 140 + normalizeBaseUrl( 141 + blueskyAppBaseUrl && blueskyAppBaseUrl.trim() 142 + ? blueskyAppBaseUrl 143 + : DEFAULT_CONFIG.blueskyAppBaseUrl, 144 + ), 145 + [blueskyAppBaseUrl], 146 + ); 147 + const normalizedTangled = useMemo( 148 + () => 149 + normalizeBaseUrl( 150 + tangledBaseUrl && tangledBaseUrl.trim() 151 + ? tangledBaseUrl 152 + : DEFAULT_CONFIG.tangledBaseUrl, 153 + ), 154 + [tangledBaseUrl], 155 + ); 90 156 const resolver = useMemo( 91 - () => new ServiceResolver({ plcDirectory: normalizedPlc }), 92 - [normalizedPlc], 157 + () => new ServiceResolver({ 158 + plcDirectory: normalizedPlc, 159 + identityService: normalizedIdentity, 160 + slingshotBaseUrl: normalizedSlingshot, 161 + }), 162 + [normalizedPlc, normalizedIdentity, normalizedSlingshot], 93 163 ); 94 164 const cachesRef = useRef<{ 95 165 didCache: DidCache; ··· 108 178 () => ({ 109 179 resolver, 110 180 plcDirectory: normalizedPlc, 181 + blueskyAppviewService: normalizedAppview, 182 + blueskyAppBaseUrl: normalizedBlueskyApp, 183 + tangledBaseUrl: normalizedTangled, 111 184 didCache: cachesRef.current!.didCache, 112 185 blobCache: cachesRef.current!.blobCache, 113 186 recordCache: cachesRef.current!.recordCache, 114 187 }), 115 - [resolver, normalizedPlc], 188 + [resolver, normalizedPlc, normalizedAppview, normalizedBlueskyApp, normalizedTangled], 116 189 ); 117 190 118 191 return (
+3 -1
lib/renderers/BlueskyProfileRenderer.tsx
··· 1 1 import React from "react"; 2 2 import type { ProfileRecord } from "../types/bluesky"; 3 3 import { BlueskyIcon } from "../components/BlueskyIcon"; 4 + import { useAtProto } from "../providers/AtProtoProvider"; 4 5 5 6 export interface BlueskyProfileRendererProps { 6 7 record: ProfileRecord; ··· 19 20 handle, 20 21 avatarUrl, 21 22 }) => { 23 + const { blueskyAppBaseUrl } = useAtProto(); 22 24 23 25 if (error) 24 26 return ( ··· 28 30 ); 29 31 if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>; 30 32 31 - const profileUrl = `https://bsky.app/profile/${did}`; 33 + const profileUrl = `${blueskyAppBaseUrl}/profile/${did}`; 32 34 const rawWebsite = record.website?.trim(); 33 35 const websiteHref = rawWebsite 34 36 ? rawWebsite.match(/^https?:\/\//i)
+5 -3
lib/renderers/LeafletDocumentRenderer.tsx
··· 1 1 import React, { useMemo, useRef } from "react"; 2 2 import { useDidResolution } from "../hooks/useDidResolution"; 3 3 import { useBlob } from "../hooks/useBlob"; 4 + import { useAtProto } from "../providers/AtProtoProvider"; 4 5 import { 5 6 parseAtUri, 6 7 formatDidForLabel, ··· 54 55 publicationBaseUrl, 55 56 publicationRecord, 56 57 }) => { 58 + const { blueskyAppBaseUrl } = useAtProto(); 57 59 const authorDid = record.author?.startsWith("did:") 58 60 ? record.author 59 61 : undefined; ··· 78 80 : undefined); 79 81 const authorLabel = resolvedPublicationLabel ?? fallbackAuthorLabel; 80 82 const authorHref = publicationUri 81 - ? `https://bsky.app/profile/${publicationUri.did}` 83 + ? `${blueskyAppBaseUrl}/profile/${publicationUri.did}` 82 84 : undefined; 83 85 84 86 if (error) ··· 105 107 timeStyle: "short", 106 108 }) 107 109 : undefined; 108 - const fallbackLeafletUrl = `https://bsky.app/leaflet/${encodeURIComponent(did)}/${encodeURIComponent(rkey)}`; 110 + const fallbackLeafletUrl = `${blueskyAppBaseUrl}/leaflet/${encodeURIComponent(did)}/${encodeURIComponent(rkey)}`; 109 111 const publicationRoot = 110 112 publicationBaseUrl ?? publicationRecord?.base_path ?? undefined; 111 113 const resolvedPublicationRoot = publicationRoot ··· 117 119 publicationLeafletUrl ?? 118 120 postUrl ?? 119 121 (publicationUri 120 - ? `https://bsky.app/profile/${publicationUri.did}` 122 + ? `${blueskyAppBaseUrl}/profile/${publicationUri.did}` 121 123 : undefined) ?? 122 124 fallbackLeafletUrl; 123 125
+3 -1
lib/renderers/TangledStringRenderer.tsx
··· 1 1 import React from "react"; 2 2 import type { ShTangledString } from "@atcute/tangled"; 3 + import { useAtProto } from "../providers/AtProtoProvider"; 3 4 4 5 export type TangledStringRecord = ShTangledString.Main; 5 6 ··· 20 21 rkey, 21 22 canonicalUrl, 22 23 }) => { 24 + const { tangledBaseUrl } = useAtProto(); 23 25 24 26 if (error) 25 27 return ( ··· 31 33 32 34 const viewUrl = 33 35 canonicalUrl ?? 34 - `https://tangled.org/strings/${did}/${encodeURIComponent(rkey)}`; 36 + `${tangledBaseUrl}/strings/${did}/${encodeURIComponent(rkey)}`; 35 37 const timestamp = new Date(record.createdAt).toLocaleString(undefined, { 36 38 dateStyle: "medium", 37 39 timeStyle: "short",
+35 -4
lib/utils/atproto-client.ts
··· 13 13 export interface ServiceResolverOptions { 14 14 plcDirectory?: string; 15 15 identityService?: string; 16 + slingshotBaseUrl?: string; 16 17 fetch?: typeof fetch; 17 18 } 18 19 19 20 const DEFAULT_PLC = "https://plc.directory"; 20 21 const DEFAULT_IDENTITY_SERVICE = "https://public.api.bsky.app"; 22 + const DEFAULT_SLINGSHOT = "https://slingshot.microcosm.blue"; 23 + const DEFAULT_APPVIEW = "https://public.api.bsky.app"; 24 + const DEFAULT_BLUESKY_APP = "https://bsky.app"; 25 + const DEFAULT_TANGLED = "https://tangled.org"; 26 + 21 27 const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; 22 28 const SUPPORTED_DID_METHODS = ["plc", "web"] as const; 23 29 type SupportedDidMethod = (typeof SUPPORTED_DID_METHODS)[number]; 24 30 type SupportedDid = Did<SupportedDidMethod>; 25 31 26 - export const SLINGSHOT_BASE_URL = "https://slingshot.microcosm.blue"; 32 + /** 33 + * Default configuration values for AT Protocol services. 34 + * These can be overridden via AtProtoProvider props. 35 + */ 36 + export const DEFAULT_CONFIG = { 37 + plcDirectory: DEFAULT_PLC, 38 + identityService: DEFAULT_IDENTITY_SERVICE, 39 + slingshotBaseUrl: DEFAULT_SLINGSHOT, 40 + blueskyAppviewService: DEFAULT_APPVIEW, 41 + blueskyAppBaseUrl: DEFAULT_BLUESKY_APP, 42 + tangledBaseUrl: DEFAULT_TANGLED, 43 + } as const; 44 + 45 + export const SLINGSHOT_BASE_URL = DEFAULT_SLINGSHOT; 27 46 28 47 export const normalizeBaseUrl = (input: string): string => { 29 48 const trimmed = input.trim(); ··· 38 57 39 58 export class ServiceResolver { 40 59 private plc: string; 60 + private slingshot: string; 41 61 private didResolver: CompositeDidDocumentResolver<SupportedDidMethod>; 42 62 private handleResolver: XrpcHandleResolver; 43 63 private fetchImpl: typeof fetch; ··· 50 70 opts.identityService && opts.identityService.trim() 51 71 ? opts.identityService 52 72 : DEFAULT_IDENTITY_SERVICE; 73 + const slingshotSource = 74 + opts.slingshotBaseUrl && opts.slingshotBaseUrl.trim() 75 + ? opts.slingshotBaseUrl 76 + : DEFAULT_SLINGSHOT; 53 77 this.plc = normalizeBaseUrl(plcSource); 54 78 const identityBase = normalizeBaseUrl(identitySource); 79 + this.slingshot = normalizeBaseUrl(slingshotSource); 55 80 this.fetchImpl = bindFetch(opts.fetch); 56 81 const plcResolver = new PlcDidDocumentResolver({ 57 82 apiUrl: this.plc, ··· 97 122 return svc.serviceEndpoint.replace(/\/$/, ""); 98 123 } 99 124 125 + getSlingshotUrl(): string { 126 + return this.slingshot; 127 + } 128 + 100 129 async resolveHandle(handle: string): Promise<string> { 101 130 const normalized = handle.trim().toLowerCase(); 102 131 if (!normalized) throw new Error("Handle cannot be empty"); ··· 104 133 try { 105 134 const url = new URL( 106 135 "/xrpc/com.atproto.identity.resolveHandle", 107 - SLINGSHOT_BASE_URL, 136 + this.slingshot, 108 137 ); 109 138 url.searchParams.set("handle", normalized); 110 139 const response = await this.fetchImpl(url); ··· 161 190 } 162 191 if (!service) throw new Error("service or did required"); 163 192 const normalizedService = normalizeBaseUrl(service); 164 - const handler = createSlingshotAwareHandler(normalizedService, fetchImpl); 193 + const slingshotUrl = resolver.getSlingshotUrl(); 194 + const handler = createSlingshotAwareHandler(normalizedService, slingshotUrl, fetchImpl); 165 195 const rpc = new Client({ handler }); 166 196 return { rpc, service: normalizedService, resolver }; 167 197 } ··· 177 207 178 208 function createSlingshotAwareHandler( 179 209 service: string, 210 + slingshotBaseUrl: string, 180 211 fetchImpl: typeof fetch, 181 212 ): FetchHandler { 182 213 const primary = simpleFetchHandler({ service, fetch: fetchImpl }); 183 214 const slingshot = simpleFetchHandler({ 184 - service: SLINGSHOT_BASE_URL, 215 + service: slingshotBaseUrl, 185 216 fetch: fetchImpl, 186 217 }); 187 218 return async (pathname, init) => {
+8 -1
src/App.tsx
··· 524 524 525 525 export const App: React.FC = () => { 526 526 return ( 527 - <AtProtoProvider> 527 + <AtProtoProvider 528 + plcDirectory="https://plc.wtf/" 529 + identityService="https://api.blacksky.community" 530 + slingshotBaseUrl="https://slingshot.microcosm.blue" 531 + blueskyAppviewService="https://api.blacksky.community" 532 + blueskyAppBaseUrl="https://reddwarf.app/" 533 + tangledBaseUrl="https://tangled.org" 534 + > 528 535 <div 529 536 style={{ 530 537 maxWidth: 860,