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 } 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 <h3 style={sectionHeaderStyle}>
388 Custom Themed Post
389 </h3>
390 <p
391 style={{
392 fontSize: 12,
393 color: `var(--demo-text-secondary)`,
394 margin: "0 0 8px",
395 }}
396 >
397 Wrapping a component in a div with custom
398 CSS variables to override the theme!
399 </p>
400 <div
401 style={
402 {
403 "--atproto-color-bg":
404 "var(--demo-secondary-bg)",
405 "--atproto-color-bg-elevated":
406 "var(--demo-input-bg)",
407 "--atproto-color-bg-secondary":
408 "var(--demo-code-bg)",
409 "--atproto-color-text":
410 "var(--demo-text)",
411 "--atproto-color-text-secondary":
412 "var(--demo-text-secondary)",
413 "--atproto-color-text-muted":
414 "var(--demo-text-secondary)",
415 "--atproto-color-border":
416 "var(--demo-border)",
417 "--atproto-color-border-subtle":
418 "var(--demo-border)",
419 "--atproto-color-link":
420 "var(--demo-button-bg)",
421 } as React.CSSProperties
422 }
423 >
424 <BlueskyPost
425 did="nekomimi.pet"
426 rkey="3m2dgvyws7k27"
427 />
428 </div>
429 </section>
430 </div>
431 </div>
432 <section style={gistPanelStyle}>
433 <h3 style={sectionHeaderStyle}>A Tangled String</h3>
434 <TangledString
435 did="nekomimi.pet"
436 rkey="3m2p4gjptg522"
437 />
438 </section>
439 <section style={leafletPanelStyle}>
440 <h3 style={sectionHeaderStyle}>A Leaflet Document.</h3>
441 <div
442 style={{
443 width: "100%",
444 display: "flex",
445 justifyContent: "center",
446 }}
447 >
448 <LeafletDocument
449 did={"did:plc:ttdrpj45ibqunmfhdsb4zdwq"}
450 rkey={"3m2seagm2222c"}
451 />
452 </div>
453 </section>
454 </>
455 )}
456 <section style={{ ...panelStyle, marginTop: 32 }}>
457 <h3 style={sectionHeaderStyle}>Code Examples</h3>
458 <p
459 style={{
460 color: `var(--demo-text-secondary)`,
461 margin: "4px 0 8px",
462 }}
463 >
464 Wrap your app with the provider once and drop the ready-made
465 components wherever you need them.
466 </p>
467 <pre style={codeBlockStyle}>
468 <code
469 ref={basicCodeRef}
470 className="language-tsx"
471 style={codeTextStyle}
472 >
473 {basicUsageSnippet}
474 </code>
475 </pre>
476 <p
477 style={{
478 color: `var(--demo-text-secondary)`,
479 margin: "16px 0 8px",
480 }}
481 >
482 Pass prefetched data to components to skip API calls—perfect
483 for SSR or caching.
484 </p>
485 <pre style={codeBlockStyle}>
486 <code
487 ref={customCodeRef}
488 className="language-tsx"
489 style={codeTextStyle}
490 >
491 {prefetchedDataSnippet}
492 </code>
493 </pre>
494 <p
495 style={{
496 color: `var(--demo-text-secondary)`,
497 margin: "16px 0 8px",
498 }}
499 >
500 Use atcute directly to construct records and pass them to
501 components—fully compatible!
502 </p>
503 <pre style={codeBlockStyle}>
504 <code className="language-tsx" style={codeTextStyle}>
505 {atcuteUsageSnippet}
506 </code>
507 </pre>
508 </section>
509 </div>
510 );
511};
512
513const sectionHeaderStyle: React.CSSProperties = {
514 margin: "4px 0",
515 fontSize: 16,
516 color: "var(--demo-text)",
517};
518const loadingBox: React.CSSProperties = { padding: 8 };
519const errorBox: React.CSSProperties = { padding: 8, color: "crimson" };
520const infoBox: React.CSSProperties = {
521 padding: 8,
522 color: "var(--demo-text-secondary)",
523};
524
525export const App: React.FC = () => {
526 return (
527 <AtProtoProvider>
528 <div
529 style={{
530 maxWidth: 860,
531 margin: "40px auto",
532 padding: "0 20px",
533 fontFamily: "system-ui, sans-serif",
534 minHeight: "100vh",
535 }}
536 >
537 <h1 style={{ marginTop: 0, color: "var(--demo-text)" }}>
538 atproto-ui Demo
539 </h1>
540 <p
541 style={{
542 lineHeight: 1.4,
543 color: "var(--demo-text-secondary)",
544 }}
545 >
546 A component library for rendering common AT Protocol records
547 for applications such as Bluesky and Tangled.
548 </p>
549 <hr
550 style={{ margin: "32px 0", borderColor: "var(--demo-hr)" }}
551 />
552 <FullDemo />
553 </div>
554 </AtProtoProvider>
555 );
556};
557
558export default App;