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