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