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