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"; 9 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 prefetchedDataSnippet = `import { BlueskyPost, useLatestRecord } from 'atproto-ui'; 41import type { FeedPostRecord } from 'atproto-ui'; 42 43const LatestPostWithPrefetch: React.FC<{ did: string }> = ({ did }) => { 44 // Fetch once with the hook 45 const { record, rkey, loading } = useLatestRecord<FeedPostRecord>( 46 did, 47 'app.bsky.feed.post' 48 ); 49 50 if (loading) return <span>Loading…</span>; 51 if (!record || !rkey) return <span>No posts yet.</span>; 52 53 // Pass prefetched record—BlueskyPost won't re-fetch it 54 return <BlueskyPost did={did} rkey={rkey} record={record} />; 55};`; 56 57const codeBlockBase: React.CSSProperties = { 58 fontFamily: 'Menlo, Consolas, "SFMono-Regular", ui-monospace, monospace', 59 fontSize: 12, 60 whiteSpace: "pre", 61 overflowX: "auto", 62 borderRadius: 10, 63 padding: "12px 14px", 64 lineHeight: 1.6, 65}; 66 67const FullDemo: React.FC = () => { 68 const handleInputRef = useRef<HTMLInputElement | null>(null); 69 const [submitted, setSubmitted] = useState<string | null>(null); 70 const [colorSchemePreference, setColorSchemePreference] = 71 useState<ColorSchemePreference>(() => { 72 if (typeof window === "undefined") return "system"; 73 try { 74 const stored = window.localStorage.getItem( 75 COLOR_SCHEME_STORAGE_KEY, 76 ); 77 if ( 78 stored === "light" || 79 stored === "dark" || 80 stored === "system" 81 ) 82 return stored; 83 } catch { 84 /* ignore */ 85 } 86 return "system"; 87 }); 88 const scheme = useColorScheme(colorSchemePreference); 89 const { did, loading: resolvingDid } = useDidResolution( 90 submitted ?? undefined, 91 ); 92 const onSubmit = useCallback<React.FormEventHandler>((e) => { 93 e.preventDefault(); 94 const rawValue = handleInputRef.current?.value; 95 const nextValue = rawValue?.trim(); 96 if (!nextValue) return; 97 if (handleInputRef.current) { 98 handleInputRef.current.value = nextValue; 99 } 100 setSubmitted(nextValue); 101 }, []); 102 103 useEffect(() => { 104 if (typeof window === "undefined") return; 105 try { 106 window.localStorage.setItem( 107 COLOR_SCHEME_STORAGE_KEY, 108 colorSchemePreference, 109 ); 110 } catch { 111 /* ignore */ 112 } 113 }, [colorSchemePreference]); 114 115 useEffect(() => { 116 if (typeof document === "undefined") return; 117 const root = document.documentElement; 118 const body = document.body; 119 const prevScheme = root.dataset.colorScheme; 120 const prevBg = body.style.backgroundColor; 121 const prevColor = body.style.color; 122 root.dataset.colorScheme = scheme; 123 body.style.backgroundColor = scheme === "dark" ? "#020617" : "#f8fafc"; 124 body.style.color = scheme === "dark" ? "#e2e8f0" : "#0f172a"; 125 return () => { 126 root.dataset.colorScheme = prevScheme ?? ""; 127 body.style.backgroundColor = prevBg; 128 body.style.color = prevColor; 129 }; 130 }, [scheme]); 131 132 const showHandle = 133 submitted && !submitted.startsWith("did:") ? submitted : undefined; 134 135 const mutedTextColor = useMemo( 136 () => (scheme === "dark" ? "#94a3b8" : "#555"), 137 [scheme], 138 ); 139 const panelStyle = useMemo<React.CSSProperties>( 140 () => ({ 141 display: "flex", 142 flexDirection: "column", 143 gap: 8, 144 padding: 10, 145 borderRadius: 12, 146 borderColor: scheme === "dark" ? "#1e293b" : "#e2e8f0", 147 }), 148 [scheme], 149 ); 150 const baseTextColor = useMemo( 151 () => (scheme === "dark" ? "#e2e8f0" : "#0f172a"), 152 [scheme], 153 ); 154 const gistPanelStyle = useMemo<React.CSSProperties>( 155 () => ({ 156 ...panelStyle, 157 padding: 0, 158 border: "none", 159 background: "transparent", 160 backdropFilter: "none", 161 marginTop: 32, 162 }), 163 [panelStyle], 164 ); 165 const leafletPanelStyle = useMemo<React.CSSProperties>( 166 () => ({ 167 ...panelStyle, 168 padding: 0, 169 border: "none", 170 background: "transparent", 171 backdropFilter: "none", 172 marginTop: 32, 173 alignItems: "center", 174 }), 175 [panelStyle], 176 ); 177 const primaryGridStyle = useMemo<React.CSSProperties>( 178 () => ({ 179 display: "grid", 180 gap: 32, 181 gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))", 182 }), 183 [], 184 ); 185 const columnStackStyle = useMemo<React.CSSProperties>( 186 () => ({ 187 display: "flex", 188 flexDirection: "column", 189 gap: 32, 190 }), 191 [], 192 ); 193 const codeBlockStyle = useMemo<React.CSSProperties>( 194 () => ({ 195 ...codeBlockBase, 196 background: scheme === "dark" ? "#0b1120" : "#f1f5f9", 197 border: `1px solid ${scheme === "dark" ? "#1e293b" : "#e2e8f0"}`, 198 }), 199 [scheme], 200 ); 201 const codeTextStyle = useMemo<React.CSSProperties>( 202 () => ({ 203 margin: 0, 204 display: "block", 205 fontFamily: codeBlockBase.fontFamily, 206 fontSize: 12, 207 lineHeight: 1.6, 208 whiteSpace: "pre", 209 }), 210 [], 211 ); 212 const basicCodeRef = useRef<HTMLElement | null>(null); 213 const customCodeRef = useRef<HTMLElement | null>(null); 214 215 // Latest Bluesky post - fetch with record for prefetch demo 216 const { 217 record: latestPostRecord, 218 rkey: latestPostRkey, 219 loading: loadingLatestPost, 220 empty: noPosts, 221 error: latestPostError, 222 } = useLatestRecord<FeedPostRecord>(did, BLUESKY_POST_COLLECTION); 223 224 const quoteSampleDid = "did:plc:ttdrpj45ibqunmfhdsb4zdwq"; 225 const quoteSampleRkey = "3m2prlq6xxc2v"; 226 227 return ( 228 <div 229 style={{ 230 display: "flex", 231 flexDirection: "column", 232 gap: 20, 233 color: baseTextColor, 234 }} 235 > 236 <div 237 style={{ 238 display: "flex", 239 flexWrap: "wrap", 240 gap: 12, 241 alignItems: "center", 242 justifyContent: "space-between", 243 }} 244 > 245 <form 246 onSubmit={onSubmit} 247 style={{ 248 display: "flex", 249 gap: 8, 250 flexWrap: "wrap", 251 flex: "1 1 320px", 252 }} 253 > 254 <input 255 placeholder="Handle or DID (e.g. alice.bsky.social or did:plc:...)" 256 ref={handleInputRef} 257 style={{ 258 flex: "1 1 260px", 259 padding: "6px 8px", 260 borderRadius: 8, 261 border: "1px solid", 262 borderColor: 263 scheme === "dark" ? "#1e293b" : "#cbd5f5", 264 background: scheme === "dark" ? "#0b1120" : "#fff", 265 color: scheme === "dark" ? "#e2e8f0" : "#0f172a", 266 }} 267 /> 268 <button 269 type="submit" 270 style={{ 271 padding: "6px 16px", 272 borderRadius: 8, 273 border: "none", 274 background: "#2563eb", 275 color: "#fff", 276 cursor: "pointer", 277 }} 278 > 279 Load 280 </button> 281 </form> 282 <ColorSchemeToggle 283 value={colorSchemePreference} 284 onChange={setColorSchemePreference} 285 scheme={scheme} 286 /> 287 </div> 288 {!submitted && ( 289 <p style={{ color: mutedTextColor }}> 290 Enter a handle to fetch your profile, latest Bluesky post, a 291 Tangled string, and a Leaflet document. 292 </p> 293 )} 294 {submitted && resolvingDid && ( 295 <p style={{ color: mutedTextColor }}>Resolving DID</p> 296 )} 297 {did && ( 298 <> 299 <div style={primaryGridStyle}> 300 <div style={columnStackStyle}> 301 <section style={panelStyle}> 302 <h3 style={sectionHeaderStyle}>Profile</h3> 303 <BlueskyProfile 304 did={did} 305 handle={showHandle} 306 colorScheme={colorSchemePreference} 307 /> 308 </section> 309 <section style={panelStyle}> 310 <h3 style={sectionHeaderStyle}>Recent Posts</h3> 311 <BlueskyPostList 312 did={did} 313 colorScheme={colorSchemePreference} 314 /> 315 </section> 316 </div> 317 <div style={columnStackStyle}> 318 <section style={panelStyle}> 319 <h3 style={sectionHeaderStyle}> 320 Latest Post (Prefetched Data) 321 </h3> 322 <p style={{ fontSize: 12, color: mutedTextColor, margin: "0 0 8px" }}> 323 Using <code style={{ background: scheme === "dark" ? "#1e293b" : "#e2e8f0", padding: "2px 4px", borderRadius: 3 }}>useLatestRecord</code> to fetch once, then passing <code style={{ background: scheme === "dark" ? "#1e293b" : "#e2e8f0", padding: "2px 4px", borderRadius: 3 }}>record</code> propno re-fetch! 324 </p> 325 {loadingLatestPost && ( 326 <div style={loadingBox}> 327 Loading latest post 328 </div> 329 )} 330 {latestPostError && ( 331 <div style={errorBox}> 332 Failed to load latest post. 333 </div> 334 )} 335 {noPosts && ( 336 <div 337 style={{ 338 ...infoBox, 339 color: mutedTextColor, 340 }} 341 > 342 No posts found. 343 </div> 344 )} 345 {!loadingLatestPost && latestPostRkey && latestPostRecord && ( 346 <BlueskyPost 347 did={did} 348 rkey={latestPostRkey} 349 record={latestPostRecord} 350 colorScheme={colorSchemePreference} 351 /> 352 )} 353 </section> 354 <section style={panelStyle}> 355 <h3 style={sectionHeaderStyle}> 356 Quote Post Demo 357 </h3> 358 <BlueskyQuotePost 359 did={quoteSampleDid} 360 rkey={quoteSampleRkey} 361 colorScheme={colorSchemePreference} 362 /> 363 </section> 364 </div> 365 </div> 366 <section style={gistPanelStyle}> 367 <h3 style={sectionHeaderStyle}>A Tangled String</h3> 368 <TangledString 369 did="nekomimi.pet" 370 rkey="3m2p4gjptg522" 371 colorScheme={colorSchemePreference} 372 /> 373 </section> 374 <section style={leafletPanelStyle}> 375 <h3 style={sectionHeaderStyle}>A Leaflet Document.</h3> 376 <div 377 style={{ 378 width: "100%", 379 display: "flex", 380 justifyContent: "center", 381 }} 382 > 383 <LeafletDocument 384 did={"did:plc:ttdrpj45ibqunmfhdsb4zdwq"} 385 rkey={"3m2seagm2222c"} 386 colorScheme={colorSchemePreference} 387 /> 388 </div> 389 </section> 390 </> 391 )} 392 <section style={{ ...panelStyle, marginTop: 32 }}> 393 <h3 style={sectionHeaderStyle}>Code Examples</h3> 394 <p style={{ color: mutedTextColor, margin: "4px 0 8px" }}> 395 Wrap your app with the provider once and drop the ready-made 396 components wherever you need them. 397 </p> 398 <pre style={codeBlockStyle}> 399 <code 400 ref={basicCodeRef} 401 className="language-tsx" 402 style={codeTextStyle} 403 > 404 {basicUsageSnippet} 405 </code> 406 </pre> 407 <p style={{ color: mutedTextColor, margin: "16px 0 8px" }}> 408 Pass prefetched data to components to skip API callsperfect for SSR or caching. 409 </p> 410 <pre style={codeBlockStyle}> 411 <code 412 ref={customCodeRef} 413 className="language-tsx" 414 style={codeTextStyle} 415 > 416 {prefetchedDataSnippet} 417 </code> 418 </pre> 419 </section> 420 </div> 421 ); 422}; 423 424 425 426const sectionHeaderStyle: React.CSSProperties = { 427 margin: "4px 0", 428 fontSize: 16, 429}; 430const loadingBox: React.CSSProperties = { padding: 8 }; 431const errorBox: React.CSSProperties = { padding: 8, color: "crimson" }; 432const infoBox: React.CSSProperties = { padding: 8, color: "#555" }; 433 434export const App: React.FC = () => { 435 return ( 436 <AtProtoProvider> 437 <div 438 style={{ 439 maxWidth: 860, 440 margin: "40px auto", 441 padding: "0 20px", 442 fontFamily: "system-ui, sans-serif", 443 }} 444 > 445 <h1 style={{ marginTop: 0 }}>atproto-ui Demo</h1> 446 <p style={{ lineHeight: 1.4 }}> 447 A component library for rendering common AT Protocol records 448 for applications such as Bluesky and Tangled. 449 </p> 450 <hr style={{ margin: "32px 0" }} /> 451 <FullDemo /> 452 </div> 453 </AtProtoProvider> 454 ); 455}; 456 457export default App;