A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React, {
2 useState,
3 useCallback,
4 useEffect,
5 useMemo,
6 useRef,
7} from "react";
8import { AtProtoProvider } from "../lib/providers/AtProtoProvider";
9import { AtProtoRecord } from "../lib/core/AtProtoRecord";
10import { TangledString } from "../lib/components/TangledString";
11import { LeafletDocument } from "../lib/components/LeafletDocument";
12import { BlueskyProfile } from "../lib/components/BlueskyProfile";
13import {
14 BlueskyPost,
15 BLUESKY_POST_COLLECTION,
16} from "../lib/components/BlueskyPost";
17import { BlueskyPostList } from "../lib/components/BlueskyPostList";
18import { BlueskyQuotePost } from "../lib/components/BlueskyQuotePost";
19import { useDidResolution } from "../lib/hooks/useDidResolution";
20import { useLatestRecord } from "../lib/hooks/useLatestRecord";
21import { ColorSchemeToggle } from "../lib/components/ColorSchemeToggle.tsx";
22import {
23 useColorScheme,
24 type ColorSchemePreference,
25} from "../lib/hooks/useColorScheme";
26import type { FeedPostRecord } from "../lib/types/bluesky";
27
28const COLOR_SCHEME_STORAGE_KEY = "atproto-ui-color-scheme";
29
30const basicUsageSnippet = `import { AtProtoProvider, BlueskyPost } from 'atproto-ui';
31
32export function App() {
33 return (
34 <AtProtoProvider>
35 <BlueskyPost did="did:plc:example" rkey="3k2aexample" />
36 </AtProtoProvider>
37 );
38}`;
39
40const customComponentSnippet = `import { useLatestRecord, useColorScheme, AtProtoRecord } from 'atproto-ui';
41import type { FeedPostRecord } from 'atproto-ui';
42
43const LatestPostSummary: React.FC<{ did: string }> = ({ did }) => {
44 const scheme = useColorScheme('system');
45 const { rkey, loading, error } = useLatestRecord<FeedPostRecord>(did, 'app.bsky.feed.post');
46
47 if (loading) return <span>Loading…</span>;
48 if (error || !rkey) return <span>No post yet.</span>;
49
50 return (
51 <AtProtoRecord<FeedPostRecord>
52 did={did}
53 collection="app.bsky.feed.post"
54 rkey={rkey}
55 renderer={({ record }) => (
56 <article data-color-scheme={scheme}>
57 <strong>{record?.text ?? 'Empty post'}</strong>
58 </article>
59 )}
60 />
61 );
62};`;
63
64const codeBlockBase: React.CSSProperties = {
65 fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace, monospace',
66 fontSize: 12,
67 whiteSpace: "pre",
68 overflowX: "auto",
69 borderRadius: 10,
70 padding: "12px 14px",
71 lineHeight: 1.6,
72};
73
74const FullDemo: React.FC = () => {
75 const handleInputRef = useRef<HTMLInputElement | null>(null);
76 const [submitted, setSubmitted] = useState<string | null>(null);
77 const [colorSchemePreference, setColorSchemePreference] =
78 useState<ColorSchemePreference>(() => {
79 if (typeof window === "undefined") return "system";
80 try {
81 const stored = window.localStorage.getItem(
82 COLOR_SCHEME_STORAGE_KEY,
83 );
84 if (
85 stored === "light" ||
86 stored === "dark" ||
87 stored === "system"
88 )
89 return stored;
90 } catch {
91 /* ignore */
92 }
93 return "system";
94 });
95 const scheme = useColorScheme(colorSchemePreference);
96 const { did, loading: resolvingDid } = useDidResolution(
97 submitted ?? undefined,
98 );
99 const onSubmit = useCallback<React.FormEventHandler>((e) => {
100 e.preventDefault();
101 const rawValue = handleInputRef.current?.value;
102 const nextValue = rawValue?.trim();
103 if (!nextValue) return;
104 if (handleInputRef.current) {
105 handleInputRef.current.value = nextValue;
106 }
107 setSubmitted(nextValue);
108 }, []);
109
110 useEffect(() => {
111 if (typeof window === "undefined") return;
112 try {
113 window.localStorage.setItem(
114 COLOR_SCHEME_STORAGE_KEY,
115 colorSchemePreference,
116 );
117 } catch {
118 /* ignore */
119 }
120 }, [colorSchemePreference]);
121
122 useEffect(() => {
123 if (typeof document === "undefined") return;
124 const root = document.documentElement;
125 const body = document.body;
126 const prevScheme = root.dataset.colorScheme;
127 const prevBg = body.style.backgroundColor;
128 const prevColor = body.style.color;
129 root.dataset.colorScheme = scheme;
130 body.style.backgroundColor = scheme === "dark" ? "#020617" : "#f8fafc";
131 body.style.color = scheme === "dark" ? "#e2e8f0" : "#0f172a";
132 return () => {
133 root.dataset.colorScheme = prevScheme ?? "";
134 body.style.backgroundColor = prevBg;
135 body.style.color = prevColor;
136 };
137 }, [scheme]);
138
139 const showHandle =
140 submitted && !submitted.startsWith("did:") ? submitted : undefined;
141
142 const mutedTextColor = useMemo(
143 () => (scheme === "dark" ? "#94a3b8" : "#555"),
144 [scheme],
145 );
146 const panelStyle = useMemo<React.CSSProperties>(
147 () => ({
148 display: "flex",
149 flexDirection: "column",
150 gap: 8,
151 padding: 10,
152 borderRadius: 12,
153 borderColor: scheme === "dark" ? "#1e293b" : "#e2e8f0",
154 }),
155 [scheme],
156 );
157 const baseTextColor = useMemo(
158 () => (scheme === "dark" ? "#e2e8f0" : "#0f172a"),
159 [scheme],
160 );
161 const gistPanelStyle = useMemo<React.CSSProperties>(
162 () => ({
163 ...panelStyle,
164 padding: 0,
165 border: "none",
166 background: "transparent",
167 backdropFilter: "none",
168 marginTop: 32,
169 }),
170 [panelStyle],
171 );
172 const leafletPanelStyle = useMemo<React.CSSProperties>(
173 () => ({
174 ...panelStyle,
175 padding: 0,
176 border: "none",
177 background: "transparent",
178 backdropFilter: "none",
179 marginTop: 32,
180 alignItems: "center",
181 }),
182 [panelStyle],
183 );
184 const primaryGridStyle = useMemo<React.CSSProperties>(
185 () => ({
186 display: "grid",
187 gap: 32,
188 gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))",
189 }),
190 [],
191 );
192 const columnStackStyle = useMemo<React.CSSProperties>(
193 () => ({
194 display: "flex",
195 flexDirection: "column",
196 gap: 32,
197 }),
198 [],
199 );
200 const codeBlockStyle = useMemo<React.CSSProperties>(
201 () => ({
202 ...codeBlockBase,
203 background: scheme === "dark" ? "#0b1120" : "#f1f5f9",
204 border: `1px solid ${scheme === "dark" ? "#1e293b" : "#e2e8f0"}`,
205 }),
206 [scheme],
207 );
208 const codeTextStyle = useMemo<React.CSSProperties>(
209 () => ({
210 margin: 0,
211 display: "block",
212 fontFamily: codeBlockBase.fontFamily,
213 fontSize: 12,
214 lineHeight: 1.6,
215 whiteSpace: "pre",
216 }),
217 [],
218 );
219 const basicCodeRef = useRef<HTMLElement | null>(null);
220 const customCodeRef = useRef<HTMLElement | null>(null);
221
222 // Latest Bluesky post
223 const {
224 rkey: latestPostRkey,
225 loading: loadingLatestPost,
226 empty: noPosts,
227 error: latestPostError,
228 } = useLatestRecord<unknown>(did, BLUESKY_POST_COLLECTION);
229
230 const quoteSampleDid = "did:plc:ttdrpj45ibqunmfhdsb4zdwq";
231 const quoteSampleRkey = "3m2prlq6xxc2v";
232
233 return (
234 <div
235 style={{
236 display: "flex",
237 flexDirection: "column",
238 gap: 20,
239 color: baseTextColor,
240 }}
241 >
242 <div
243 style={{
244 display: "flex",
245 flexWrap: "wrap",
246 gap: 12,
247 alignItems: "center",
248 justifyContent: "space-between",
249 }}
250 >
251 <form
252 onSubmit={onSubmit}
253 style={{
254 display: "flex",
255 gap: 8,
256 flexWrap: "wrap",
257 flex: "1 1 320px",
258 }}
259 >
260 <input
261 placeholder="Handle or DID (e.g. alice.bsky.social or did:plc:...)"
262 ref={handleInputRef}
263 style={{
264 flex: "1 1 260px",
265 padding: "6px 8px",
266 borderRadius: 8,
267 border: "1px solid",
268 borderColor:
269 scheme === "dark" ? "#1e293b" : "#cbd5f5",
270 background: scheme === "dark" ? "#0b1120" : "#fff",
271 color: scheme === "dark" ? "#e2e8f0" : "#0f172a",
272 }}
273 />
274 <button
275 type="submit"
276 style={{
277 padding: "6px 16px",
278 borderRadius: 8,
279 border: "none",
280 background: "#2563eb",
281 color: "#fff",
282 cursor: "pointer",
283 }}
284 >
285 Load
286 </button>
287 </form>
288 <ColorSchemeToggle
289 value={colorSchemePreference}
290 onChange={setColorSchemePreference}
291 scheme={scheme}
292 />
293 </div>
294 {!submitted && (
295 <p style={{ color: mutedTextColor }}>
296 Enter a handle to fetch your profile, latest Bluesky post, a
297 Tangled string, and a Leaflet document.
298 </p>
299 )}
300 {submitted && resolvingDid && (
301 <p style={{ color: mutedTextColor }}>Resolving DID…</p>
302 )}
303 {did && (
304 <>
305 <div style={primaryGridStyle}>
306 <div style={columnStackStyle}>
307 <section style={panelStyle}>
308 <h3 style={sectionHeaderStyle}>Profile</h3>
309 <BlueskyProfile
310 did={did}
311 handle={showHandle}
312 colorScheme={colorSchemePreference}
313 />
314 </section>
315 <section style={panelStyle}>
316 <h3 style={sectionHeaderStyle}>Recent Posts</h3>
317 <BlueskyPostList
318 did={did}
319 colorScheme={colorSchemePreference}
320 />
321 </section>
322 </div>
323 <div style={columnStackStyle}>
324 <section style={panelStyle}>
325 <h3 style={sectionHeaderStyle}>
326 Latest Bluesky Post
327 </h3>
328 {loadingLatestPost && (
329 <div style={loadingBox}>
330 Loading latest post…
331 </div>
332 )}
333 {latestPostError && (
334 <div style={errorBox}>
335 Failed to load latest post.
336 </div>
337 )}
338 {noPosts && (
339 <div
340 style={{
341 ...infoBox,
342 color: mutedTextColor,
343 }}
344 >
345 No posts found.
346 </div>
347 )}
348 {!loadingLatestPost && latestPostRkey && (
349 <BlueskyPost
350 did={did}
351 rkey={latestPostRkey}
352 colorScheme={colorSchemePreference}
353 />
354 )}
355 </section>
356 <section style={panelStyle}>
357 <h3 style={sectionHeaderStyle}>
358 Quote Post Demo
359 </h3>
360 <BlueskyQuotePost
361 did={quoteSampleDid}
362 rkey={quoteSampleRkey}
363 colorScheme={colorSchemePreference}
364 />
365 </section>
366 </div>
367 </div>
368 <section style={gistPanelStyle}>
369 <h3 style={sectionHeaderStyle}>A Tangled String</h3>
370 <TangledString
371 did="nekomimi.pet"
372 rkey="3m2p4gjptg522"
373 colorScheme={colorSchemePreference}
374 />
375 </section>
376 <section style={leafletPanelStyle}>
377 <h3 style={sectionHeaderStyle}>A Leaflet Document.</h3>
378 <div
379 style={{
380 width: "100%",
381 display: "flex",
382 justifyContent: "center",
383 }}
384 >
385 <LeafletDocument
386 did={"did:plc:ttdrpj45ibqunmfhdsb4zdwq"}
387 rkey={"3m2seagm2222c"}
388 colorScheme={colorSchemePreference}
389 />
390 </div>
391 </section>
392 </>
393 )}
394 <section style={{ ...panelStyle, marginTop: 32 }}>
395 <h3 style={sectionHeaderStyle}>Build your own component</h3>
396 <p style={{ color: mutedTextColor, margin: "4px 0 8px" }}>
397 Wrap your app with the provider once and drop the ready-made
398 components wherever you need them.
399 </p>
400 <pre style={codeBlockStyle}>
401 <code
402 ref={basicCodeRef}
403 className="language-tsx"
404 style={codeTextStyle}
405 >
406 {basicUsageSnippet}
407 </code>
408 </pre>
409 <p style={{ color: mutedTextColor, margin: "16px 0 8px" }}>
410 Need to make your own component? Compose your own renderer
411 with the hooks and utilities that ship with the library.
412 </p>
413 <pre style={codeBlockStyle}>
414 <code
415 ref={customCodeRef}
416 className="language-tsx"
417 style={codeTextStyle}
418 >
419 {customComponentSnippet}
420 </code>
421 </pre>
422 {did && (
423 <div
424 style={{
425 marginTop: 16,
426 display: "flex",
427 flexDirection: "column",
428 gap: 12,
429 }}
430 >
431 <p style={{ color: mutedTextColor, margin: 0 }}>
432 Live example with your handle:
433 </p>
434 <LatestPostSummary
435 did={did}
436 handle={showHandle}
437 colorScheme={colorSchemePreference}
438 />
439 </div>
440 )}
441 </section>
442 </div>
443 );
444};
445
446const LatestPostSummary: React.FC<{
447 did: string;
448 handle?: string;
449 colorScheme: ColorSchemePreference;
450}> = ({ did, colorScheme }) => {
451 const { record, rkey, loading, error } = useLatestRecord<FeedPostRecord>(
452 did,
453 BLUESKY_POST_COLLECTION,
454 );
455 const scheme = useColorScheme(colorScheme);
456 const palette =
457 scheme === "dark"
458 ? latestSummaryPalette.dark
459 : latestSummaryPalette.light;
460
461 if (loading) return <div style={palette.muted}>Loading summary…</div>;
462 if (error)
463 return <div style={palette.error}>Failed to load the latest post.</div>;
464 if (!rkey) return <div style={palette.muted}>No posts published yet.</div>;
465
466 const atProtoProps = record
467 ? { record }
468 : { did, collection: "app.bsky.feed.post", rkey };
469
470 return (
471 <AtProtoRecord<FeedPostRecord>
472 {...atProtoProps}
473 renderer={({ record: resolvedRecord }) => (
474 <article data-color-scheme={scheme}>
475 <strong>{resolvedRecord?.text ?? "Empty post"}</strong>
476 </article>
477 )}
478 />
479 );
480};
481
482const sectionHeaderStyle: React.CSSProperties = {
483 margin: "4px 0",
484 fontSize: 16,
485};
486const loadingBox: React.CSSProperties = { padding: 8 };
487const errorBox: React.CSSProperties = { padding: 8, color: "crimson" };
488const infoBox: React.CSSProperties = { padding: 8, color: "#555" };
489
490const latestSummaryPalette = {
491 light: {
492 card: {
493 border: "1px solid #e2e8f0",
494 background: "#ffffff",
495 borderRadius: 12,
496 padding: 12,
497 display: "flex",
498 flexDirection: "column",
499 gap: 8,
500 } satisfies React.CSSProperties,
501 header: {
502 display: "flex",
503 alignItems: "baseline",
504 justifyContent: "space-between",
505 gap: 12,
506 color: "#0f172a",
507 } satisfies React.CSSProperties,
508 time: {
509 fontSize: 12,
510 color: "#64748b",
511 } satisfies React.CSSProperties,
512 text: {
513 margin: 0,
514 color: "#1f2937",
515 whiteSpace: "pre-wrap",
516 } satisfies React.CSSProperties,
517 link: {
518 color: "#2563eb",
519 fontWeight: 600,
520 fontSize: 12,
521 textDecoration: "none",
522 } satisfies React.CSSProperties,
523 muted: {
524 color: "#64748b",
525 } satisfies React.CSSProperties,
526 error: {
527 color: "crimson",
528 } satisfies React.CSSProperties,
529 },
530 dark: {
531 card: {
532 border: "1px solid #1e293b",
533 background: "#0f172a",
534 borderRadius: 12,
535 padding: 12,
536 display: "flex",
537 flexDirection: "column",
538 gap: 8,
539 } satisfies React.CSSProperties,
540 header: {
541 display: "flex",
542 alignItems: "baseline",
543 justifyContent: "space-between",
544 gap: 12,
545 color: "#e2e8f0",
546 } satisfies React.CSSProperties,
547 time: {
548 fontSize: 12,
549 color: "#cbd5f5",
550 } satisfies React.CSSProperties,
551 text: {
552 margin: 0,
553 color: "#e2e8f0",
554 whiteSpace: "pre-wrap",
555 } satisfies React.CSSProperties,
556 link: {
557 color: "#38bdf8",
558 fontWeight: 600,
559 fontSize: 12,
560 textDecoration: "none",
561 } satisfies React.CSSProperties,
562 muted: {
563 color: "#94a3b8",
564 } satisfies React.CSSProperties,
565 error: {
566 color: "#f472b6",
567 } satisfies React.CSSProperties,
568 },
569} as const;
570
571export const App: React.FC = () => {
572 return (
573 <AtProtoProvider>
574 <div
575 style={{
576 maxWidth: 860,
577 margin: "40px auto",
578 padding: "0 20px",
579 fontFamily: "system-ui, sans-serif",
580 }}
581 >
582 <h1 style={{ marginTop: 0 }}>atproto-ui Demo</h1>
583 <p style={{ lineHeight: 1.4 }}>
584 A component library for rendering common AT Protocol records
585 for applications such as Bluesky and Tangled.
586 </p>
587 <hr style={{ margin: "32px 0" }} />
588 <FullDemo />
589 </div>
590 </AtProtoProvider>
591 );
592};
593
594export default App;