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
···
# atproto-ui
-
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).
+
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).
+
+
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.
## Screenshots
+3 -1
lib/components/BlueskyPostList.tsx
···
import { useDidResolution } from "../hooks/useDidResolution";
import { BlueskyIcon } from "./BlueskyIcon";
import { parseAtUri } from "../utils/at-uri";
+
import { useAtProto } from "../providers/AtProtoProvider";
/**
* Options for rendering a paginated list of Bluesky posts.
···
replyParent,
hasDivider,
}) => {
+
const { blueskyAppBaseUrl } = useAtProto();
const text = record.text?.trim() ?? "";
const relative = record.createdAt
? formatRelativeTime(record.createdAt)
···
const absolute = record.createdAt
? new Date(record.createdAt).toLocaleString()
: undefined;
-
const href = `https://bsky.app/profile/${did}/post/${rkey}`;
+
const href = `${blueskyAppBaseUrl}/profile/${did}/post/${rkey}`;
const repostLabel =
reason?.$type === "app.bsky.feed.defs#reasonRepost"
? `${formatActor(reason.by) ?? "Someone"} reposted`
+7 -4
lib/components/RichText.tsx
···
import React from "react";
import type { AppBskyRichtextFacet } from "@atcute/bluesky";
import { createTextSegments, type TextSegment } from "../utils/richtext";
+
import { useAtProto } from "../providers/AtProtoProvider";
export interface RichTextProps {
text: string;
···
* Properly handles byte offsets and multi-byte characters.
*/
export const RichText: React.FC<RichTextProps> = ({ text, facets, style }) => {
+
const { blueskyAppBaseUrl } = useAtProto();
const segments = createTextSegments(text, facets);
return (
<span style={style}>
{segments.map((segment, idx) => (
-
<RichTextSegment key={idx} segment={segment} />
+
<RichTextSegment key={idx} segment={segment} blueskyAppBaseUrl={blueskyAppBaseUrl} />
))}
</span>
);
···
interface RichTextSegmentProps {
segment: TextSegment;
+
blueskyAppBaseUrl: string;
}
-
const RichTextSegment: React.FC<RichTextSegmentProps> = ({ segment }) => {
+
const RichTextSegment: React.FC<RichTextSegmentProps> = ({ segment, blueskyAppBaseUrl }) => {
if (!segment.facet) {
return <>{segment.text}</>;
}
···
case "app.bsky.richtext.facet#mention": {
const mentionFeature = feature as AppBskyRichtextFacet.Mention;
-
const profileUrl = `https://bsky.app/profile/${mentionFeature.did}`;
+
const profileUrl = `${blueskyAppBaseUrl}/profile/${mentionFeature.did}`;
return (
<a
href={profileUrl}
···
case "app.bsky.richtext.facet#tag": {
const tagFeature = feature as AppBskyRichtextFacet.Tag;
-
const tagUrl = `https://bsky.app/hashtag/${encodeURIComponent(tagFeature.tag)}`;
+
const tagUrl = `${blueskyAppBaseUrl}/hashtag/${encodeURIComponent(tagFeature.tag)}`;
return (
<a
href={tagUrl}
+3 -1
lib/components/TangledString.tsx
···
import { AtProtoRecord } from "../core/AtProtoRecord";
import { TangledStringRenderer } from "../renderers/TangledStringRenderer";
import type { TangledStringRecord } from "../renderers/TangledStringRenderer";
+
import { useAtProto } from "../providers/AtProtoProvider";
/**
* Props for rendering Tangled String records.
···
loadingIndicator,
colorScheme,
}) => {
+
const { tangledBaseUrl } = useAtProto();
const Comp: React.ComponentType<TangledStringRendererInjectedProps> =
renderer ?? ((props) => <TangledStringRenderer {...props} />);
const Wrapped: React.FC<{
···
colorScheme={colorScheme}
did={did}
rkey={rkey}
-
canonicalUrl={`https://tangled.org/strings/${did}/${encodeURIComponent(rkey)}`}
+
canonicalUrl={`${tangledBaseUrl}/strings/${did}/${encodeURIComponent(rkey)}`}
/>
);
+10 -8
lib/hooks/useBlueskyAppview.ts
···
import { useEffect, useReducer, useRef } from "react";
import { useDidResolution } from "./useDidResolution";
import { usePdsEndpoint } from "./usePdsEndpoint";
-
import { createAtprotoClient, SLINGSHOT_BASE_URL } from "../utils/atproto-client";
+
import { createAtprotoClient } from "../utils/atproto-client";
import { useAtProto } from "../providers/AtProtoProvider";
/**
···
/** Source from which the record was successfully fetched. */
source?: "appview" | "slingshot" | "pds";
}
-
-
export const DEFAULT_APPVIEW_SERVICE = "https://public.api.bsky.app";
/**
* Maps Bluesky collection NSIDs to their corresponding appview API endpoints.
···
appviewService,
skipAppview = false,
}: UseBlueskyAppviewOptions): UseBlueskyAppviewResult<T> {
-
const { recordCache } = useAtProto();
+
const { recordCache, blueskyAppviewService, resolver } = useAtProto();
+
const effectiveAppviewService = appviewService ?? blueskyAppviewService;
const {
did,
error: didError,
···
did,
collection,
rkey,
-
appviewService ?? DEFAULT_APPVIEW_SERVICE,
+
effectiveAppviewService,
);
if (result) {
return result;
···
// Tier 2: Try Slingshot getRecord
try {
-
const result = await fetchFromSlingshot<T>(did, collection, rkey);
+
const slingshotUrl = resolver.getSlingshotUrl();
+
const result = await fetchFromSlingshot<T>(did, collection, rkey, slingshotUrl);
if (result) {
return result;
}
···
collection,
rkey,
pdsEndpoint,
-
appviewService,
+
effectiveAppviewService,
skipAppview,
resolvingDid,
resolvingEndpoint,
didError,
endpointError,
recordCache,
+
resolver,
]);
return state;
···
did: string,
collection: string,
rkey: string,
+
slingshotBaseUrl: string,
): Promise<T | undefined> {
-
const res = await callGetRecord<T>(SLINGSHOT_BASE_URL, did, collection, rkey);
+
const res = await callGetRecord<T>(slingshotBaseUrl, did, collection, rkey);
if (!res.ok) throw new Error(`Slingshot getRecord failed for ${did}/${collection}/${rkey}`);
return res.data.value;
}
+4 -6
lib/hooks/usePaginatedRecords.ts
···
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useDidResolution } from "./useDidResolution";
import { usePdsEndpoint } from "./usePdsEndpoint";
-
import {
-
DEFAULT_APPVIEW_SERVICE,
-
callAppviewRpc,
-
callListRecords
-
} from "./useBlueskyAppview";
+
import { callAppviewRpc, callListRecords } from "./useBlueskyAppview";
+
import { useAtProto } from "../providers/AtProtoProvider";
/**
* Record envelope returned by paginated AT Protocol queries.
···
authorFeedService,
authorFeedActor,
}: UsePaginatedRecordsOptions): UsePaginatedRecordsResult<T> {
+
const { blueskyAppviewService } = useAtProto();
const {
did,
handle,
···
}
const res = await callAppviewRpc<AuthorFeedResponse>(
-
authorFeedService ?? DEFAULT_APPVIEW_SERVICE,
+
authorFeedService ?? blueskyAppviewService,
"app.bsky.feed.getAuthorFeed",
{
actor: actorIdentifier,
+78 -5
lib/providers/AtProtoProvider.tsx
···
useMemo,
useRef,
} from "react";
-
import { ServiceResolver, normalizeBaseUrl } from "../utils/atproto-client";
+
import { ServiceResolver, normalizeBaseUrl, DEFAULT_CONFIG } from "../utils/atproto-client";
import { BlobCache, DidCache, RecordCache } from "../utils/cache";
/**
···
children: React.ReactNode;
/** Optional custom PLC directory URL. Defaults to https://plc.directory */
plcDirectory?: string;
+
/** Optional custom identity service URL. Defaults to https://public.api.bsky.app */
+
identityService?: string;
+
/** Optional custom Slingshot service URL. Defaults to https://slingshot.microcosm.blue */
+
slingshotBaseUrl?: string;
+
/** Optional custom Bluesky appview service URL. Defaults to https://public.api.bsky.app */
+
blueskyAppviewService?: string;
+
/** Optional custom Bluesky app base URL for links. Defaults to https://bsky.app */
+
blueskyAppBaseUrl?: string;
+
/** Optional custom Tangled base URL for links. Defaults to https://tangled.org */
+
tangledBaseUrl?: string;
}
/**
···
resolver: ServiceResolver;
/** Normalized PLC directory base URL. */
plcDirectory: string;
+
/** Normalized Bluesky appview service URL. */
+
blueskyAppviewService: string;
+
/** Normalized Bluesky app base URL for links. */
+
blueskyAppBaseUrl: string;
+
/** Normalized Tangled base URL for links. */
+
tangledBaseUrl: string;
/** Cache for DID documents and handle mappings. */
didCache: DidCache;
/** Cache for fetched blob data. */
···
export function AtProtoProvider({
children,
plcDirectory,
+
identityService,
+
slingshotBaseUrl,
+
blueskyAppviewService,
+
blueskyAppBaseUrl,
+
tangledBaseUrl,
}: AtProtoProviderProps) {
const normalizedPlc = useMemo(
() =>
normalizeBaseUrl(
plcDirectory && plcDirectory.trim()
? plcDirectory
-
: "https://plc.directory",
+
: DEFAULT_CONFIG.plcDirectory,
),
[plcDirectory],
);
+
const normalizedIdentity = useMemo(
+
() =>
+
normalizeBaseUrl(
+
identityService && identityService.trim()
+
? identityService
+
: DEFAULT_CONFIG.identityService,
+
),
+
[identityService],
+
);
+
const normalizedSlingshot = useMemo(
+
() =>
+
normalizeBaseUrl(
+
slingshotBaseUrl && slingshotBaseUrl.trim()
+
? slingshotBaseUrl
+
: DEFAULT_CONFIG.slingshotBaseUrl,
+
),
+
[slingshotBaseUrl],
+
);
+
const normalizedAppview = useMemo(
+
() =>
+
normalizeBaseUrl(
+
blueskyAppviewService && blueskyAppviewService.trim()
+
? blueskyAppviewService
+
: DEFAULT_CONFIG.blueskyAppviewService,
+
),
+
[blueskyAppviewService],
+
);
+
const normalizedBlueskyApp = useMemo(
+
() =>
+
normalizeBaseUrl(
+
blueskyAppBaseUrl && blueskyAppBaseUrl.trim()
+
? blueskyAppBaseUrl
+
: DEFAULT_CONFIG.blueskyAppBaseUrl,
+
),
+
[blueskyAppBaseUrl],
+
);
+
const normalizedTangled = useMemo(
+
() =>
+
normalizeBaseUrl(
+
tangledBaseUrl && tangledBaseUrl.trim()
+
? tangledBaseUrl
+
: DEFAULT_CONFIG.tangledBaseUrl,
+
),
+
[tangledBaseUrl],
+
);
const resolver = useMemo(
-
() => new ServiceResolver({ plcDirectory: normalizedPlc }),
-
[normalizedPlc],
+
() => new ServiceResolver({
+
plcDirectory: normalizedPlc,
+
identityService: normalizedIdentity,
+
slingshotBaseUrl: normalizedSlingshot,
+
}),
+
[normalizedPlc, normalizedIdentity, normalizedSlingshot],
);
const cachesRef = useRef<{
didCache: DidCache;
···
() => ({
resolver,
plcDirectory: normalizedPlc,
+
blueskyAppviewService: normalizedAppview,
+
blueskyAppBaseUrl: normalizedBlueskyApp,
+
tangledBaseUrl: normalizedTangled,
didCache: cachesRef.current!.didCache,
blobCache: cachesRef.current!.blobCache,
recordCache: cachesRef.current!.recordCache,
}),
-
[resolver, normalizedPlc],
+
[resolver, normalizedPlc, normalizedAppview, normalizedBlueskyApp, normalizedTangled],
);
return (
+3 -1
lib/renderers/BlueskyProfileRenderer.tsx
···
import React from "react";
import type { ProfileRecord } from "../types/bluesky";
import { BlueskyIcon } from "../components/BlueskyIcon";
+
import { useAtProto } from "../providers/AtProtoProvider";
export interface BlueskyProfileRendererProps {
record: ProfileRecord;
···
handle,
avatarUrl,
}) => {
+
const { blueskyAppBaseUrl } = useAtProto();
if (error)
return (
···
);
if (loading && !record) return <div style={{ padding: 8 }}>Loading…</div>;
-
const profileUrl = `https://bsky.app/profile/${did}`;
+
const profileUrl = `${blueskyAppBaseUrl}/profile/${did}`;
const rawWebsite = record.website?.trim();
const websiteHref = rawWebsite
? rawWebsite.match(/^https?:\/\//i)
+5 -3
lib/renderers/LeafletDocumentRenderer.tsx
···
import React, { useMemo, useRef } from "react";
import { useDidResolution } from "../hooks/useDidResolution";
import { useBlob } from "../hooks/useBlob";
+
import { useAtProto } from "../providers/AtProtoProvider";
import {
parseAtUri,
formatDidForLabel,
···
publicationBaseUrl,
publicationRecord,
}) => {
+
const { blueskyAppBaseUrl } = useAtProto();
const authorDid = record.author?.startsWith("did:")
? record.author
: undefined;
···
: undefined);
const authorLabel = resolvedPublicationLabel ?? fallbackAuthorLabel;
const authorHref = publicationUri
-
? `https://bsky.app/profile/${publicationUri.did}`
+
? `${blueskyAppBaseUrl}/profile/${publicationUri.did}`
: undefined;
if (error)
···
timeStyle: "short",
})
: undefined;
-
const fallbackLeafletUrl = `https://bsky.app/leaflet/${encodeURIComponent(did)}/${encodeURIComponent(rkey)}`;
+
const fallbackLeafletUrl = `${blueskyAppBaseUrl}/leaflet/${encodeURIComponent(did)}/${encodeURIComponent(rkey)}`;
const publicationRoot =
publicationBaseUrl ?? publicationRecord?.base_path ?? undefined;
const resolvedPublicationRoot = publicationRoot
···
publicationLeafletUrl ??
postUrl ??
(publicationUri
-
? `https://bsky.app/profile/${publicationUri.did}`
+
? `${blueskyAppBaseUrl}/profile/${publicationUri.did}`
: undefined) ??
fallbackLeafletUrl;
+3 -1
lib/renderers/TangledStringRenderer.tsx
···
import React from "react";
import type { ShTangledString } from "@atcute/tangled";
+
import { useAtProto } from "../providers/AtProtoProvider";
export type TangledStringRecord = ShTangledString.Main;
···
rkey,
canonicalUrl,
}) => {
+
const { tangledBaseUrl } = useAtProto();
if (error)
return (
···
const viewUrl =
canonicalUrl ??
-
`https://tangled.org/strings/${did}/${encodeURIComponent(rkey)}`;
+
`${tangledBaseUrl}/strings/${did}/${encodeURIComponent(rkey)}`;
const timestamp = new Date(record.createdAt).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
+35 -4
lib/utils/atproto-client.ts
···
export interface ServiceResolverOptions {
plcDirectory?: string;
identityService?: string;
+
slingshotBaseUrl?: string;
fetch?: typeof fetch;
}
const DEFAULT_PLC = "https://plc.directory";
const DEFAULT_IDENTITY_SERVICE = "https://public.api.bsky.app";
+
const DEFAULT_SLINGSHOT = "https://slingshot.microcosm.blue";
+
const DEFAULT_APPVIEW = "https://public.api.bsky.app";
+
const DEFAULT_BLUESKY_APP = "https://bsky.app";
+
const DEFAULT_TANGLED = "https://tangled.org";
+
const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z\d+\-.]*:/;
const SUPPORTED_DID_METHODS = ["plc", "web"] as const;
type SupportedDidMethod = (typeof SUPPORTED_DID_METHODS)[number];
type SupportedDid = Did<SupportedDidMethod>;
-
export const SLINGSHOT_BASE_URL = "https://slingshot.microcosm.blue";
+
/**
+
* Default configuration values for AT Protocol services.
+
* These can be overridden via AtProtoProvider props.
+
*/
+
export const DEFAULT_CONFIG = {
+
plcDirectory: DEFAULT_PLC,
+
identityService: DEFAULT_IDENTITY_SERVICE,
+
slingshotBaseUrl: DEFAULT_SLINGSHOT,
+
blueskyAppviewService: DEFAULT_APPVIEW,
+
blueskyAppBaseUrl: DEFAULT_BLUESKY_APP,
+
tangledBaseUrl: DEFAULT_TANGLED,
+
} as const;
+
+
export const SLINGSHOT_BASE_URL = DEFAULT_SLINGSHOT;
export const normalizeBaseUrl = (input: string): string => {
const trimmed = input.trim();
···
export class ServiceResolver {
private plc: string;
+
private slingshot: string;
private didResolver: CompositeDidDocumentResolver<SupportedDidMethod>;
private handleResolver: XrpcHandleResolver;
private fetchImpl: typeof fetch;
···
opts.identityService && opts.identityService.trim()
? opts.identityService
: DEFAULT_IDENTITY_SERVICE;
+
const slingshotSource =
+
opts.slingshotBaseUrl && opts.slingshotBaseUrl.trim()
+
? opts.slingshotBaseUrl
+
: DEFAULT_SLINGSHOT;
this.plc = normalizeBaseUrl(plcSource);
const identityBase = normalizeBaseUrl(identitySource);
+
this.slingshot = normalizeBaseUrl(slingshotSource);
this.fetchImpl = bindFetch(opts.fetch);
const plcResolver = new PlcDidDocumentResolver({
apiUrl: this.plc,
···
return svc.serviceEndpoint.replace(/\/$/, "");
}
+
getSlingshotUrl(): string {
+
return this.slingshot;
+
}
+
async resolveHandle(handle: string): Promise<string> {
const normalized = handle.trim().toLowerCase();
if (!normalized) throw new Error("Handle cannot be empty");
···
try {
const url = new URL(
"/xrpc/com.atproto.identity.resolveHandle",
-
SLINGSHOT_BASE_URL,
+
this.slingshot,
);
url.searchParams.set("handle", normalized);
const response = await this.fetchImpl(url);
···
}
if (!service) throw new Error("service or did required");
const normalizedService = normalizeBaseUrl(service);
-
const handler = createSlingshotAwareHandler(normalizedService, fetchImpl);
+
const slingshotUrl = resolver.getSlingshotUrl();
+
const handler = createSlingshotAwareHandler(normalizedService, slingshotUrl, fetchImpl);
const rpc = new Client({ handler });
return { rpc, service: normalizedService, resolver };
}
···
function createSlingshotAwareHandler(
service: string,
+
slingshotBaseUrl: string,
fetchImpl: typeof fetch,
): FetchHandler {
const primary = simpleFetchHandler({ service, fetch: fetchImpl });
const slingshot = simpleFetchHandler({
-
service: SLINGSHOT_BASE_URL,
+
service: slingshotBaseUrl,
fetch: fetchImpl,
});
return async (pathname, init) => {
+8 -1
src/App.tsx
···
export const App: React.FC = () => {
return (
-
<AtProtoProvider>
+
<AtProtoProvider
+
plcDirectory="https://plc.wtf/"
+
identityService="https://api.blacksky.community"
+
slingshotBaseUrl="https://slingshot.microcosm.blue"
+
blueskyAppviewService="https://api.blacksky.community"
+
blueskyAppBaseUrl="https://reddwarf.app/"
+
tangledBaseUrl="https://tangled.org"
+
>
<div
style={{
maxWidth: 860,