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

Configure Feed

Select the types of activity you want to include in your feed.

update readme with credits, make constants configurable

+162 -36
+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,