A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React, { useState, useCallback, useRef } from "react";
2import { AtProtoProvider, TangledRepo } from "../lib";
3import "../lib/styles.css";
4import "./App.css";
5
6import { TangledString } from "../lib/components/TangledString";
7import { LeafletDocument } from "../lib/components/LeafletDocument";
8import { BlueskyProfile } from "../lib/components/BlueskyProfile";
9import {
10 BlueskyPost,
11 BLUESKY_POST_COLLECTION,
12} from "../lib/components/BlueskyPost";
13import { BlueskyPostList } from "../lib/components/BlueskyPostList";
14import { BlueskyQuotePost } from "../lib/components/BlueskyQuotePost";
15import { useDidResolution } from "../lib/hooks/useDidResolution";
16import { useLatestRecord } from "../lib/hooks/useLatestRecord";
17import type { FeedPostRecord } from "../lib/types/bluesky";
18
19const basicUsageSnippet = `import { AtProtoProvider, BlueskyPost } from 'atproto-ui';
20
21export function App() {
22 return (
23 <AtProtoProvider>
24 <BlueskyPost did="did:plc:example" rkey="3k2aexample" />
25 </AtProtoProvider>
26 );
27}`;
28
29const prefetchedDataSnippet = `import { BlueskyPost, useLatestRecord } from 'atproto-ui';
30import type { FeedPostRecord } from 'atproto-ui';
31
32const LatestPostWithPrefetch: React.FC<{ did: string }> = ({ did }) => {
33 // Fetch once with the hook
34 const { record, rkey, loading } = useLatestRecord<FeedPostRecord>(
35 did,
36 'app.bsky.feed.post'
37 );
38
39 if (loading) return <span>Loading…</span>;
40 if (!record || !rkey) return <span>No posts yet.</span>;
41
42 // Pass prefetched record—BlueskyPost won't re-fetch it
43 return <BlueskyPost did={did} rkey={rkey} record={record} />;
44};`;
45
46const atcuteUsageSnippet = `import { Client, simpleFetchHandler, ok } from '@atcute/client';
47import type { AppBskyFeedPost } from '@atcute/bluesky';
48import { BlueskyPost } from 'atproto-ui';
49
50// Create atcute client
51const client = new Client({
52 handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
53});
54
55// Fetch a record
56const data = await ok(
57 client.get('com.atproto.repo.getRecord', {
58 params: {
59 repo: 'did:plc:ttdrpj45ibqunmfhdsb4zdwq',
60 collection: 'app.bsky.feed.post',
61 rkey: '3m45rq4sjes2h'
62 }
63 })
64);
65
66const record = data.value as AppBskyFeedPost.Main;
67
68// Pass atcute record directly to component!
69<BlueskyPost
70 did="did:plc:ttdrpj45ibqunmfhdsb4zdwq"
71 rkey="3m45rq4sjes2h"
72 record={record}
73/>`;
74
75const codeBlockBase: React.CSSProperties = {
76 fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace, monospace',
77 fontSize: 12,
78 whiteSpace: "pre",
79 overflowX: "auto",
80 borderRadius: 10,
81 padding: "12px 14px",
82 lineHeight: 1.6,
83};
84
85const ThemeSwitcher: React.FC = () => {
86 const [theme, setTheme] = useState<"light" | "dark" | "system">("system");
87
88 const toggle = () => {
89 const schemes: ("light" | "dark" | "system")[] = [
90 "light",
91 "dark",
92 "system",
93 ];
94 const currentIndex = schemes.indexOf(theme);
95 const nextIndex = (currentIndex + 1) % schemes.length;
96 const nextTheme = schemes[nextIndex];
97 setTheme(nextTheme);
98
99 // Update the data-theme attribute on the document element
100 if (nextTheme === "system") {
101 document.documentElement.removeAttribute("data-theme");
102 } else {
103 document.documentElement.setAttribute("data-theme", nextTheme);
104 }
105 };
106
107 return (
108 <button
109 onClick={toggle}
110 style={{
111 padding: "8px 12px",
112 borderRadius: 8,
113 border: "1px solid var(--demo-border)",
114 background: "var(--demo-input-bg)",
115 color: "var(--demo-text)",
116 cursor: "pointer",
117 }}
118 >
119 Theme: {theme}
120 </button>
121 );
122};
123
124const FullDemo: React.FC = () => {
125 const handleInputRef = useRef<HTMLInputElement | null>(null);
126 const [submitted, setSubmitted] = useState<string | null>(null);
127
128 const { did, loading: resolvingDid } = useDidResolution(
129 submitted ?? undefined,
130 );
131 const onSubmit = useCallback<React.FormEventHandler>((e) => {
132 e.preventDefault();
133 const rawValue = handleInputRef.current?.value;
134 const nextValue = rawValue?.trim();
135 if (!nextValue) return;
136 if (handleInputRef.current) {
137 handleInputRef.current.value = nextValue;
138 }
139 setSubmitted(nextValue);
140 }, []);
141
142 const showHandle =
143 submitted && !submitted.startsWith("did:") ? submitted : undefined;
144
145 const panelStyle: React.CSSProperties = {
146 display: "flex",
147 flexDirection: "column",
148 gap: 8,
149 padding: 10,
150 borderRadius: 12,
151 border: `1px solid var(--demo-border)`,
152 };
153
154 const gistPanelStyle: React.CSSProperties = {
155 ...panelStyle,
156 padding: 0,
157 border: "none",
158 background: "transparent",
159 backdropFilter: "none",
160 marginTop: 32,
161 };
162 const leafletPanelStyle: React.CSSProperties = {
163 ...panelStyle,
164 padding: 0,
165 border: "none",
166 background: "transparent",
167 backdropFilter: "none",
168 marginTop: 32,
169 alignItems: "center",
170 };
171 const primaryGridStyle: React.CSSProperties = {
172 display: "grid",
173 gap: 32,
174 gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
175 };
176 const columnStackStyle: React.CSSProperties = {
177 display: "flex",
178 flexDirection: "column",
179 gap: 32,
180 };
181 const codeBlockStyle: React.CSSProperties = {
182 ...codeBlockBase,
183 background: `var(--demo-code-bg)`,
184 border: `1px solid var(--demo-code-border)`,
185 color: `var(--demo-text)`,
186 };
187 const codeTextStyle: React.CSSProperties = {
188 margin: 0,
189 display: "block",
190 fontFamily: codeBlockBase.fontFamily,
191 fontSize: 12,
192 lineHeight: 1.6,
193 whiteSpace: "pre",
194 };
195 const basicCodeRef = useRef<HTMLElement | null>(null);
196 const customCodeRef = useRef<HTMLElement | null>(null);
197
198 // Latest Bluesky post - fetch with record for prefetch demo
199 const {
200 record: latestPostRecord,
201 rkey: latestPostRkey,
202 loading: loadingLatestPost,
203 empty: noPosts,
204 error: latestPostError,
205 } = useLatestRecord<FeedPostRecord>(did, BLUESKY_POST_COLLECTION);
206
207 const quoteSampleDid = "did:plc:ttdrpj45ibqunmfhdsb4zdwq";
208 const quoteSampleRkey = "3m2prlq6xxc2v";
209
210 return (
211 <div
212 style={{
213 display: "flex",
214 flexDirection: "column",
215 gap: 20,
216 }}
217 >
218 <div
219 style={{
220 display: "flex",
221 flexWrap: "wrap",
222 gap: 12,
223 alignItems: "center",
224 justifyContent: "space-between",
225 }}
226 >
227 <form
228 onSubmit={onSubmit}
229 style={{
230 display: "flex",
231 gap: 8,
232 flexWrap: "wrap",
233 flex: "1 1 320px",
234 }}
235 >
236 <input
237 placeholder="Handle or DID (e.g. alice.bsky.social or did:plc:...)"
238 ref={handleInputRef}
239 style={{
240 flex: "1 1 260px",
241 padding: "6px 8px",
242 borderRadius: 8,
243 border: `1px solid var(--demo-border)`,
244 background: `var(--demo-input-bg)`,
245 color: `var(--demo-text)`,
246 }}
247 />
248 <button
249 type="submit"
250 style={{
251 padding: "6px 16px",
252 borderRadius: 8,
253 border: "none",
254 background: `var(--demo-button-bg)`,
255 color: `var(--demo-button-text)`,
256 cursor: "pointer",
257 }}
258 >
259 Load
260 </button>
261 </form>
262 <ThemeSwitcher />
263 </div>
264 {!submitted && (
265 <p style={{ color: `var(--demo-text-secondary)` }}>
266 Enter a handle to fetch your profile, latest Bluesky post, a
267 Tangled string, and a Leaflet document.
268 </p>
269 )}
270 {submitted && resolvingDid && (
271 <p style={{ color: `var(--demo-text-secondary)` }}>
272 Resolving DID…
273 </p>
274 )}
275 {did && (
276 <>
277 <div style={primaryGridStyle}>
278 <div style={columnStackStyle}>
279 <section style={panelStyle}>
280 <h3 style={sectionHeaderStyle}>Profile</h3>
281 <BlueskyProfile did={did} handle={showHandle} />
282 </section>
283 <section style={panelStyle}>
284 <h3 style={sectionHeaderStyle}>Recent Posts</h3>
285 <BlueskyPostList did={did} />
286 </section>
287 </div>
288 <div style={columnStackStyle}>
289 <section style={panelStyle}>
290 <h3 style={sectionHeaderStyle}>
291 Latest Post (Prefetched Data)
292 </h3>
293 <p
294 style={{
295 fontSize: 12,
296 color: `var(--demo-text-secondary)`,
297 margin: "0 0 8px",
298 }}
299 >
300 Using{" "}
301 <code
302 style={{
303 background: `var(--demo-code-bg)`,
304 padding: "2px 4px",
305 borderRadius: 3,
306 color: "var(--demo-text)",
307 }}
308 >
309 useLatestRecord
310 </code>{" "}
311 to fetch once, then passing{" "}
312 <code
313 style={{
314 background: `var(--demo-code-bg)`,
315 padding: "2px 4px",
316 borderRadius: 3,
317 color: "var(--demo-text)",
318 }}
319 >
320 record
321 </code>{" "}
322 prop—no re-fetch!
323 </p>
324 {loadingLatestPost && (
325 <div style={loadingBox}>
326 Loading latest post…
327 </div>
328 )}
329 {latestPostError && (
330 <div style={errorBox}>
331 Failed to load latest post.
332 </div>
333 )}
334 {noPosts && (
335 <div style={infoBox}>No posts found.</div>
336 )}
337 {!loadingLatestPost &&
338 latestPostRkey &&
339 latestPostRecord && (
340 <BlueskyPost
341 did={did}
342 rkey={latestPostRkey}
343 record={latestPostRecord}
344 />
345 )}
346 </section>
347 <section style={panelStyle}>
348 <h3 style={sectionHeaderStyle}>
349 Quote Post Demo
350 </h3>
351 <BlueskyQuotePost
352 did={quoteSampleDid}
353 rkey={quoteSampleRkey}
354 />
355 </section>
356 <section style={panelStyle}>
357 <h3 style={sectionHeaderStyle}>
358 Reply Post Demo
359 </h3>
360 <BlueskyPost
361 did="did:plc:xwhsmuozq3mlsp56dyd7copv"
362 rkey="3m3je5ydg4s2o"
363 showParent={true}
364 recursiveParent={true}
365 />
366 </section>
367 <section style={panelStyle}>
368 <h3 style={sectionHeaderStyle}>
369 Rich Text Facets Demo
370 </h3>
371 <p
372 style={{
373 fontSize: 12,
374 color: `var(--demo-text-secondary)`,
375 margin: "0 0 8px",
376 }}
377 >
378 Post with mentions, links, and hashtags
379 </p>
380 <BlueskyPost
381 did="nekomimi.pet"
382 rkey="3m45s553cys22"
383 showParent={false}
384 />
385 </section>
386 <section style={panelStyle}>
387 <TangledRepo
388 did="did:plc:ttdrpj45ibqunmfhdsb4zdwq"
389 rkey="3m2sx5zpxzs22"
390 />
391 </section>
392 <section style={panelStyle}>
393 <h3 style={sectionHeaderStyle}>
394 Custom Themed Post
395 </h3>
396 <p
397 style={{
398 fontSize: 12,
399 color: `var(--demo-text-secondary)`,
400 margin: "0 0 8px",
401 }}
402 >
403 Wrapping a component in a div with custom
404 CSS variables to override the theme!
405 </p>
406 <div
407 style={
408 {
409 "--atproto-color-bg":
410 "var(--demo-secondary-bg)",
411 "--atproto-color-bg-elevated":
412 "var(--demo-input-bg)",
413 "--atproto-color-bg-secondary":
414 "var(--demo-code-bg)",
415 "--atproto-color-text":
416 "var(--demo-text)",
417 "--atproto-color-text-secondary":
418 "var(--demo-text-secondary)",
419 "--atproto-color-text-muted":
420 "var(--demo-text-secondary)",
421 "--atproto-color-border":
422 "var(--demo-border)",
423 "--atproto-color-border-subtle":
424 "var(--demo-border)",
425 "--atproto-color-link":
426 "var(--demo-button-bg)",
427 } as React.CSSProperties
428 }
429 >
430 <BlueskyPost
431 did="nekomimi.pet"
432 rkey="3m2dgvyws7k27"
433 />
434 </div>
435 </section>
436 </div>
437 </div>
438 <section style={gistPanelStyle}>
439 <h3 style={sectionHeaderStyle}>A Tangled String</h3>
440 <TangledString
441 did="nekomimi.pet"
442 rkey="3m2p4gjptg522"
443 />
444 </section>
445 <section style={leafletPanelStyle}>
446 <h3 style={sectionHeaderStyle}>A Leaflet Document.</h3>
447 <div
448 style={{
449 width: "100%",
450 display: "flex",
451 justifyContent: "center",
452 }}
453 >
454 <LeafletDocument
455 did={"did:plc:ttdrpj45ibqunmfhdsb4zdwq"}
456 rkey={"3m2seagm2222c"}
457 />
458 </div>
459 </section>
460 </>
461 )}
462 <section style={{ ...panelStyle, marginTop: 32 }}>
463 <h3 style={sectionHeaderStyle}>Code Examples</h3>
464 <p
465 style={{
466 color: `var(--demo-text-secondary)`,
467 margin: "4px 0 8px",
468 }}
469 >
470 Wrap your app with the provider once and drop the ready-made
471 components wherever you need them.
472 </p>
473 <pre style={codeBlockStyle}>
474 <code
475 ref={basicCodeRef}
476 className="language-tsx"
477 style={codeTextStyle}
478 >
479 {basicUsageSnippet}
480 </code>
481 </pre>
482 <p
483 style={{
484 color: `var(--demo-text-secondary)`,
485 margin: "16px 0 8px",
486 }}
487 >
488 Pass prefetched data to components to skip API calls—perfect
489 for SSR or caching.
490 </p>
491 <pre style={codeBlockStyle}>
492 <code
493 ref={customCodeRef}
494 className="language-tsx"
495 style={codeTextStyle}
496 >
497 {prefetchedDataSnippet}
498 </code>
499 </pre>
500 <p
501 style={{
502 color: `var(--demo-text-secondary)`,
503 margin: "16px 0 8px",
504 }}
505 >
506 Use atcute directly to construct records and pass them to
507 components—fully compatible!
508 </p>
509 <pre style={codeBlockStyle}>
510 <code className="language-tsx" style={codeTextStyle}>
511 {atcuteUsageSnippet}
512 </code>
513 </pre>
514 </section>
515 </div>
516 );
517};
518
519const sectionHeaderStyle: React.CSSProperties = {
520 margin: "4px 0",
521 fontSize: 16,
522 color: "var(--demo-text)",
523};
524const loadingBox: React.CSSProperties = { padding: 8 };
525const errorBox: React.CSSProperties = { padding: 8, color: "crimson" };
526const infoBox: React.CSSProperties = {
527 padding: 8,
528 color: "var(--demo-text-secondary)",
529};
530
531export const App: React.FC = () => {
532 return (
533 <AtProtoProvider>
534 <div
535 style={{
536 maxWidth: 860,
537 margin: "40px auto",
538 padding: "0 20px",
539 fontFamily: "system-ui, sans-serif",
540 minHeight: "100vh",
541 }}
542 >
543 <h1 style={{ marginTop: 0, color: "var(--demo-text)" }}>
544 atproto-ui Demo
545 </h1>
546 <p
547 style={{
548 lineHeight: 1.4,
549 color: "var(--demo-text-secondary)",
550 }}
551 >
552 A component library for rendering common AT Protocol records
553 for applications such as Bluesky and Tangled.
554 </p>
555 <hr
556 style={{ margin: "32px 0", borderColor: "var(--demo-hr)" }}
557 />
558 <FullDemo />
559 </div>
560 </AtProtoProvider>
561 );
562};
563
564export default App;