A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import React, { useMemo } from "react"; 2import { 3 usePaginatedRecords, 4 type AuthorFeedReason, 5 type ReplyParentInfo, 6} from "../hooks/usePaginatedRecords"; 7import { useColorScheme } from "../hooks/useColorScheme"; 8import type { FeedPostRecord } from "../types/bluesky"; 9import { useDidResolution } from "../hooks/useDidResolution"; 10import { BlueskyIcon } from "./BlueskyIcon"; 11import { parseAtUri } from "../utils/at-uri"; 12 13/** 14 * Options for rendering a paginated list of Bluesky posts. 15 */ 16export interface BlueskyPostListProps { 17 /** 18 * DID whose feed posts should be fetched. 19 */ 20 did: string; 21 /** 22 * Maximum number of records to list per page. Defaults to `5`. 23 */ 24 limit?: number; 25 /** 26 * Enables pagination controls when `true`. Defaults to `true`. 27 */ 28 enablePagination?: boolean; 29 /** 30 * Preferred color scheme passed through to styling helpers. 31 * Defaults to `'system'` which follows the OS preference. 32 */ 33 colorScheme?: "light" | "dark" | "system"; 34} 35 36/** 37 * Fetches a DID's feed posts and renders them with rich pagination and theming. 38 * 39 * @param did - DID whose posts should be displayed. 40 * @param limit - Maximum number of posts per page. Default `5`. 41 * @param enablePagination - Whether pagination controls should render. Default `true`. 42 * @param colorScheme - Preferred color scheme used for styling. Default `'system'`. 43 * @returns A card-like list element with loading, empty, and error handling. 44 */ 45export const BlueskyPostList: React.FC<BlueskyPostListProps> = ({ 46 did, 47 limit = 5, 48 enablePagination = true, 49 colorScheme = "system", 50}) => { 51 const scheme = useColorScheme(colorScheme); 52 const palette: ListPalette = scheme === "dark" ? darkPalette : lightPalette; 53 const { handle: resolvedHandle, did: resolvedDid } = useDidResolution(did); 54 const actorLabel = resolvedHandle ?? formatDid(did); 55 const actorPath = resolvedHandle ?? resolvedDid ?? did; 56 57 const { 58 records, 59 loading, 60 error, 61 hasNext, 62 hasPrev, 63 loadNext, 64 loadPrev, 65 pageIndex, 66 pagesCount, 67 } = usePaginatedRecords<FeedPostRecord>({ 68 did, 69 collection: "app.bsky.feed.post", 70 limit, 71 preferAuthorFeed: true, 72 authorFeedActor: actorPath, 73 }); 74 75 const pageLabel = useMemo(() => { 76 const knownTotal = Math.max(pageIndex + 1, pagesCount); 77 if (!enablePagination) return undefined; 78 if (hasNext && knownTotal === pageIndex + 1) 79 return `${pageIndex + 1}/…`; 80 return `${pageIndex + 1}/${knownTotal}`; 81 }, [enablePagination, hasNext, pageIndex, pagesCount]); 82 83 if (error) 84 return ( 85 <div style={{ padding: 8, color: "crimson" }}> 86 Failed to load posts. 87 </div> 88 ); 89 90 return ( 91 <div style={{ ...listStyles.card, ...palette.card }}> 92 <div style={{ ...listStyles.header, ...palette.header }}> 93 <div style={listStyles.headerInfo}> 94 <div style={listStyles.headerIcon}> 95 <BlueskyIcon size={20} /> 96 </div> 97 <div style={listStyles.headerText}> 98 <span style={listStyles.title}>Latest Posts</span> 99 <span 100 style={{ 101 ...listStyles.subtitle, 102 ...palette.subtitle, 103 }} 104 > 105 @{actorLabel} 106 </span> 107 </div> 108 </div> 109 {pageLabel && ( 110 <span 111 style={{ ...listStyles.pageMeta, ...palette.pageMeta }} 112 > 113 {pageLabel} 114 </span> 115 )} 116 </div> 117 <div style={listStyles.items}> 118 {loading && records.length === 0 && ( 119 <div style={{ ...listStyles.empty, ...palette.empty }}> 120 Loading posts 121 </div> 122 )} 123 {records.map((record, idx) => ( 124 <ListRow 125 key={record.rkey} 126 record={record.value} 127 rkey={record.rkey} 128 did={actorPath} 129 reason={record.reason} 130 replyParent={record.replyParent} 131 palette={palette} 132 hasDivider={idx < records.length - 1} 133 /> 134 ))} 135 {!loading && records.length === 0 && ( 136 <div style={{ ...listStyles.empty, ...palette.empty }}> 137 No posts found. 138 </div> 139 )} 140 </div> 141 {enablePagination && ( 142 <div style={{ ...listStyles.footer, ...palette.footer }}> 143 <button 144 type="button" 145 style={{ 146 ...listStyles.navButton, 147 ...palette.navButton, 148 cursor: hasPrev ? "pointer" : "not-allowed", 149 opacity: hasPrev ? 1 : 0.5, 150 }} 151 onClick={loadPrev} 152 disabled={!hasPrev} 153 > 154 Prev 155 </button> 156 <div style={listStyles.pageChips}> 157 <span 158 style={{ 159 ...listStyles.pageChipActive, 160 ...palette.pageChipActive, 161 }} 162 > 163 {pageIndex + 1} 164 </span> 165 {(hasNext || pagesCount > pageIndex + 1) && ( 166 <span 167 style={{ 168 ...listStyles.pageChip, 169 ...palette.pageChip, 170 }} 171 > 172 {pageIndex + 2} 173 </span> 174 )} 175 </div> 176 <button 177 type="button" 178 style={{ 179 ...listStyles.navButton, 180 ...palette.navButton, 181 cursor: hasNext ? "pointer" : "not-allowed", 182 opacity: hasNext ? 1 : 0.5, 183 }} 184 onClick={loadNext} 185 disabled={!hasNext} 186 > 187 Next 188 </button> 189 </div> 190 )} 191 {loading && records.length > 0 && ( 192 <div 193 style={{ ...listStyles.loadingBar, ...palette.loadingBar }} 194 > 195 Updating 196 </div> 197 )} 198 </div> 199 ); 200}; 201 202interface ListRowProps { 203 record: FeedPostRecord; 204 rkey: string; 205 did: string; 206 reason?: AuthorFeedReason; 207 replyParent?: ReplyParentInfo; 208 palette: ListPalette; 209 hasDivider: boolean; 210} 211 212const ListRow: React.FC<ListRowProps> = ({ 213 record, 214 rkey, 215 did, 216 reason, 217 replyParent, 218 palette, 219 hasDivider, 220}) => { 221 const text = record.text?.trim() ?? ""; 222 const relative = record.createdAt 223 ? formatRelativeTime(record.createdAt) 224 : undefined; 225 const absolute = record.createdAt 226 ? new Date(record.createdAt).toLocaleString() 227 : undefined; 228 const href = `https://bsky.app/profile/${did}/post/${rkey}`; 229 const repostLabel = 230 reason?.$type === "app.bsky.feed.defs#reasonRepost" 231 ? `${formatActor(reason.by) ?? "Someone"} reposted` 232 : undefined; 233 const parentUri = replyParent?.uri ?? record.reply?.parent?.uri; 234 const parentDid = 235 replyParent?.author?.did ?? 236 (parentUri ? parseAtUri(parentUri)?.did : undefined); 237 const { handle: resolvedReplyHandle } = useDidResolution( 238 replyParent?.author?.handle ? undefined : parentDid, 239 ); 240 const replyLabel = formatReplyTarget( 241 parentUri, 242 replyParent, 243 resolvedReplyHandle, 244 ); 245 246 return ( 247 <a 248 href={href} 249 target="_blank" 250 rel="noopener noreferrer" 251 style={{ 252 ...listStyles.row, 253 ...palette.row, 254 borderBottom: hasDivider 255 ? `1px solid ${palette.divider}` 256 : "none", 257 }} 258 > 259 {repostLabel && ( 260 <span style={{ ...listStyles.rowMeta, ...palette.rowMeta }}> 261 {repostLabel} 262 </span> 263 )} 264 {replyLabel && ( 265 <span style={{ ...listStyles.rowMeta, ...palette.rowMeta }}> 266 {replyLabel} 267 </span> 268 )} 269 {relative && ( 270 <span 271 style={{ ...listStyles.rowTime, ...palette.rowTime }} 272 title={absolute} 273 > 274 {relative} 275 </span> 276 )} 277 {text && ( 278 <p style={{ ...listStyles.rowBody, ...palette.rowBody }}> 279 {text} 280 </p> 281 )} 282 {!text && ( 283 <p 284 style={{ 285 ...listStyles.rowBody, 286 ...palette.rowBody, 287 fontStyle: "italic", 288 }} 289 > 290 No text content. 291 </p> 292 )} 293 </a> 294 ); 295}; 296 297function formatDid(did: string) { 298 return did.replace(/^did:(plc:)?/, ""); 299} 300 301function formatRelativeTime(iso: string): string { 302 const date = new Date(iso); 303 const diffSeconds = (date.getTime() - Date.now()) / 1000; 304 const absSeconds = Math.abs(diffSeconds); 305 const thresholds: Array<{ 306 limit: number; 307 unit: Intl.RelativeTimeFormatUnit; 308 divisor: number; 309 }> = [ 310 { limit: 60, unit: "second", divisor: 1 }, 311 { limit: 3600, unit: "minute", divisor: 60 }, 312 { limit: 86400, unit: "hour", divisor: 3600 }, 313 { limit: 604800, unit: "day", divisor: 86400 }, 314 { limit: 2629800, unit: "week", divisor: 604800 }, 315 { limit: 31557600, unit: "month", divisor: 2629800 }, 316 { limit: Infinity, unit: "year", divisor: 31557600 }, 317 ]; 318 const threshold = 319 thresholds.find((t) => absSeconds < t.limit) ?? 320 thresholds[thresholds.length - 1]; 321 const value = diffSeconds / threshold.divisor; 322 const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); 323 return rtf.format(Math.round(value), threshold.unit); 324} 325 326interface ListPalette { 327 card: { background: string; borderColor: string }; 328 header: { borderBottomColor: string; color: string }; 329 pageMeta: { color: string }; 330 subtitle: { color: string }; 331 empty: { color: string }; 332 row: { color: string }; 333 rowTime: { color: string }; 334 rowBody: { color: string }; 335 rowMeta: { color: string }; 336 divider: string; 337 footer: { borderTopColor: string; color: string }; 338 navButton: { color: string; background: string }; 339 pageChip: { color: string; borderColor: string; background: string }; 340 pageChipActive: { color: string; background: string; borderColor: string }; 341 loadingBar: { color: string }; 342} 343 344const listStyles = { 345 card: { 346 borderRadius: 16, 347 border: "1px solid transparent", 348 boxShadow: "0 8px 18px -12px rgba(15, 23, 42, 0.25)", 349 overflow: "hidden", 350 display: "flex", 351 flexDirection: "column", 352 } satisfies React.CSSProperties, 353 header: { 354 display: "flex", 355 alignItems: "center", 356 justifyContent: "space-between", 357 padding: "14px 18px", 358 fontSize: 14, 359 fontWeight: 500, 360 borderBottom: "1px solid transparent", 361 } satisfies React.CSSProperties, 362 headerInfo: { 363 display: "flex", 364 alignItems: "center", 365 gap: 12, 366 } satisfies React.CSSProperties, 367 headerIcon: { 368 width: 28, 369 height: 28, 370 display: "flex", 371 alignItems: "center", 372 justifyContent: "center", 373 //background: 'rgba(17, 133, 254, 0.14)', 374 borderRadius: "50%", 375 } satisfies React.CSSProperties, 376 headerText: { 377 display: "flex", 378 flexDirection: "column", 379 gap: 2, 380 } satisfies React.CSSProperties, 381 title: { 382 fontSize: 15, 383 fontWeight: 600, 384 } satisfies React.CSSProperties, 385 subtitle: { 386 fontSize: 12, 387 fontWeight: 500, 388 } satisfies React.CSSProperties, 389 pageMeta: { 390 fontSize: 12, 391 } satisfies React.CSSProperties, 392 items: { 393 display: "flex", 394 flexDirection: "column", 395 } satisfies React.CSSProperties, 396 empty: { 397 padding: "24px 18px", 398 fontSize: 13, 399 textAlign: "center", 400 } satisfies React.CSSProperties, 401 row: { 402 padding: "18px", 403 textDecoration: "none", 404 display: "flex", 405 flexDirection: "column", 406 gap: 6, 407 transition: "background-color 120ms ease", 408 } satisfies React.CSSProperties, 409 rowHeader: { 410 display: "flex", 411 gap: 6, 412 alignItems: "baseline", 413 fontSize: 13, 414 } satisfies React.CSSProperties, 415 rowTime: { 416 fontSize: 12, 417 fontWeight: 500, 418 } satisfies React.CSSProperties, 419 rowMeta: { 420 fontSize: 12, 421 fontWeight: 500, 422 letterSpacing: "0.6px", 423 } satisfies React.CSSProperties, 424 rowBody: { 425 margin: 0, 426 whiteSpace: "pre-wrap", 427 fontSize: 14, 428 lineHeight: 1.45, 429 } satisfies React.CSSProperties, 430 footer: { 431 display: "flex", 432 alignItems: "center", 433 justifyContent: "space-between", 434 padding: "12px 18px", 435 borderTop: "1px solid transparent", 436 fontSize: 13, 437 } satisfies React.CSSProperties, 438 navButton: { 439 border: "none", 440 borderRadius: 999, 441 padding: "6px 12px", 442 fontSize: 13, 443 fontWeight: 500, 444 background: "transparent", 445 display: "flex", 446 alignItems: "center", 447 gap: 4, 448 transition: "background-color 120ms ease", 449 } satisfies React.CSSProperties, 450 pageChips: { 451 display: "flex", 452 gap: 6, 453 alignItems: "center", 454 } satisfies React.CSSProperties, 455 pageChip: { 456 padding: "4px 10px", 457 borderRadius: 999, 458 fontSize: 13, 459 border: "1px solid transparent", 460 } satisfies React.CSSProperties, 461 pageChipActive: { 462 padding: "4px 10px", 463 borderRadius: 999, 464 fontSize: 13, 465 fontWeight: 600, 466 border: "1px solid transparent", 467 } satisfies React.CSSProperties, 468 loadingBar: { 469 padding: "4px 18px 14px", 470 fontSize: 12, 471 textAlign: "right", 472 color: "#64748b", 473 } satisfies React.CSSProperties, 474}; 475 476const lightPalette: ListPalette = { 477 card: { 478 background: "#ffffff", 479 borderColor: "#e2e8f0", 480 }, 481 header: { 482 borderBottomColor: "#e2e8f0", 483 color: "#0f172a", 484 }, 485 pageMeta: { 486 color: "#64748b", 487 }, 488 subtitle: { 489 color: "#475569", 490 }, 491 empty: { 492 color: "#64748b", 493 }, 494 row: { 495 color: "#0f172a", 496 }, 497 rowTime: { 498 color: "#94a3b8", 499 }, 500 rowBody: { 501 color: "#0f172a", 502 }, 503 rowMeta: { 504 color: "#64748b", 505 }, 506 divider: "#e2e8f0", 507 footer: { 508 borderTopColor: "#e2e8f0", 509 color: "#0f172a", 510 }, 511 navButton: { 512 color: "#0f172a", 513 background: "#f1f5f9", 514 }, 515 pageChip: { 516 color: "#475569", 517 borderColor: "#e2e8f0", 518 background: "#ffffff", 519 }, 520 pageChipActive: { 521 color: "#ffffff", 522 background: "#0f172a", 523 borderColor: "#0f172a", 524 }, 525 loadingBar: { 526 color: "#64748b", 527 }, 528}; 529 530const darkPalette: ListPalette = { 531 card: { 532 background: "#0f172a", 533 borderColor: "#1e293b", 534 }, 535 header: { 536 borderBottomColor: "#1e293b", 537 color: "#e2e8f0", 538 }, 539 pageMeta: { 540 color: "#94a3b8", 541 }, 542 subtitle: { 543 color: "#94a3b8", 544 }, 545 empty: { 546 color: "#94a3b8", 547 }, 548 row: { 549 color: "#e2e8f0", 550 }, 551 rowTime: { 552 color: "#94a3b8", 553 }, 554 rowBody: { 555 color: "#e2e8f0", 556 }, 557 rowMeta: { 558 color: "#94a3b8", 559 }, 560 divider: "#1e293b", 561 footer: { 562 borderTopColor: "#1e293b", 563 color: "#e2e8f0", 564 }, 565 navButton: { 566 color: "#e2e8f0", 567 background: "#111c31", 568 }, 569 pageChip: { 570 color: "#cbd5f5", 571 borderColor: "#1e293b", 572 background: "#0f172a", 573 }, 574 pageChipActive: { 575 color: "#0f172a", 576 background: "#38bdf8", 577 borderColor: "#38bdf8", 578 }, 579 loadingBar: { 580 color: "#94a3b8", 581 }, 582}; 583 584export default BlueskyPostList; 585 586function formatActor(actor?: { handle?: string; did?: string }) { 587 if (!actor) return undefined; 588 if (actor.handle) return `@${actor.handle}`; 589 if (actor.did) return `@${formatDid(actor.did)}`; 590 return undefined; 591} 592 593function formatReplyTarget( 594 parentUri?: string, 595 feedParent?: ReplyParentInfo, 596 resolvedHandle?: string, 597) { 598 const directHandle = feedParent?.author?.handle; 599 const handle = directHandle ?? resolvedHandle; 600 if (handle) { 601 return `Replying to @${handle}`; 602 } 603 const parentDid = feedParent?.author?.did; 604 const targetUri = feedParent?.uri ?? parentUri; 605 if (!targetUri) return undefined; 606 const parsed = parseAtUri(targetUri); 607 const did = parentDid ?? parsed?.did; 608 if (!did) return undefined; 609 return `Replying to @${formatDid(did)}`; 610}