+3
-1
README.md
+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.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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+8
-1
src/App.tsx
···
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,