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