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;