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 propno 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 callsperfect 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 componentsfully 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;