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 } 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 <h3 style={sectionHeaderStyle}> 388 Custom Themed Post 389 </h3> 390 <p 391 style={{ 392 fontSize: 12, 393 color: `var(--demo-text-secondary)`, 394 margin: "0 0 8px", 395 }} 396 > 397 Wrapping a component in a div with custom 398 CSS variables to override the theme! 399 </p> 400 <div 401 style={ 402 { 403 "--atproto-color-bg": 404 "var(--demo-secondary-bg)", 405 "--atproto-color-bg-elevated": 406 "var(--demo-input-bg)", 407 "--atproto-color-bg-secondary": 408 "var(--demo-code-bg)", 409 "--atproto-color-text": 410 "var(--demo-text)", 411 "--atproto-color-text-secondary": 412 "var(--demo-text-secondary)", 413 "--atproto-color-text-muted": 414 "var(--demo-text-secondary)", 415 "--atproto-color-border": 416 "var(--demo-border)", 417 "--atproto-color-border-subtle": 418 "var(--demo-border)", 419 "--atproto-color-link": 420 "var(--demo-button-bg)", 421 } as React.CSSProperties 422 } 423 > 424 <BlueskyPost 425 did="nekomimi.pet" 426 rkey="3m2dgvyws7k27" 427 /> 428 </div> 429 </section> 430 </div> 431 </div> 432 <section style={gistPanelStyle}> 433 <h3 style={sectionHeaderStyle}>A Tangled String</h3> 434 <TangledString 435 did="nekomimi.pet" 436 rkey="3m2p4gjptg522" 437 /> 438 </section> 439 <section style={leafletPanelStyle}> 440 <h3 style={sectionHeaderStyle}>A Leaflet Document.</h3> 441 <div 442 style={{ 443 width: "100%", 444 display: "flex", 445 justifyContent: "center", 446 }} 447 > 448 <LeafletDocument 449 did={"did:plc:ttdrpj45ibqunmfhdsb4zdwq"} 450 rkey={"3m2seagm2222c"} 451 /> 452 </div> 453 </section> 454 </> 455 )} 456 <section style={{ ...panelStyle, marginTop: 32 }}> 457 <h3 style={sectionHeaderStyle}>Code Examples</h3> 458 <p 459 style={{ 460 color: `var(--demo-text-secondary)`, 461 margin: "4px 0 8px", 462 }} 463 > 464 Wrap your app with the provider once and drop the ready-made 465 components wherever you need them. 466 </p> 467 <pre style={codeBlockStyle}> 468 <code 469 ref={basicCodeRef} 470 className="language-tsx" 471 style={codeTextStyle} 472 > 473 {basicUsageSnippet} 474 </code> 475 </pre> 476 <p 477 style={{ 478 color: `var(--demo-text-secondary)`, 479 margin: "16px 0 8px", 480 }} 481 > 482 Pass prefetched data to components to skip API callsperfect 483 for SSR or caching. 484 </p> 485 <pre style={codeBlockStyle}> 486 <code 487 ref={customCodeRef} 488 className="language-tsx" 489 style={codeTextStyle} 490 > 491 {prefetchedDataSnippet} 492 </code> 493 </pre> 494 <p 495 style={{ 496 color: `var(--demo-text-secondary)`, 497 margin: "16px 0 8px", 498 }} 499 > 500 Use atcute directly to construct records and pass them to 501 componentsfully compatible! 502 </p> 503 <pre style={codeBlockStyle}> 504 <code className="language-tsx" style={codeTextStyle}> 505 {atcuteUsageSnippet} 506 </code> 507 </pre> 508 </section> 509 </div> 510 ); 511}; 512 513const sectionHeaderStyle: React.CSSProperties = { 514 margin: "4px 0", 515 fontSize: 16, 516 color: "var(--demo-text)", 517}; 518const loadingBox: React.CSSProperties = { padding: 8 }; 519const errorBox: React.CSSProperties = { padding: 8, color: "crimson" }; 520const infoBox: React.CSSProperties = { 521 padding: 8, 522 color: "var(--demo-text-secondary)", 523}; 524 525export const App: React.FC = () => { 526 return ( 527 <AtProtoProvider 528 plcDirectory="https://plc.wtf/" 529 identityService="https://api.blacksky.community" 530 slingshotBaseUrl="https://slingshot.microcosm.blue" 531 blueskyAppviewService="https://api.blacksky.community" 532 blueskyAppBaseUrl="https://reddwarf.app/" 533 tangledBaseUrl="https://tangled.org" 534 > 535 <div 536 style={{ 537 maxWidth: 860, 538 margin: "40px auto", 539 padding: "0 20px", 540 fontFamily: "system-ui, sans-serif", 541 minHeight: "100vh", 542 }} 543 > 544 <h1 style={{ marginTop: 0, color: "var(--demo-text)" }}> 545 atproto-ui Demo 546 </h1> 547 <p 548 style={{ 549 lineHeight: 1.4, 550 color: "var(--demo-text-secondary)", 551 }} 552 > 553 A component library for rendering common AT Protocol records 554 for applications such as Bluesky and Tangled. 555 </p> 556 <hr 557 style={{ margin: "32px 0", borderColor: "var(--demo-hr)" }} 558 /> 559 <FullDemo /> 560 </div> 561 </AtProtoProvider> 562 ); 563}; 564 565export default App;