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