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 type { FeedPostRecord, ProfileRecord } from "../types/bluesky"; 8import { useDidResolution } from "../hooks/useDidResolution"; 9import { BlueskyIcon } from "./BlueskyIcon"; 10import { parseAtUri } from "../utils/at-uri"; 11import { useAtProto } from "../providers/AtProtoProvider"; 12import { useAtProtoRecord } from "../hooks/useAtProtoRecord"; 13import { useBlob } from "../hooks/useBlob"; 14import { getAvatarCid } from "../utils/profile"; 15import { isBlobWithCdn } from "../utils/blob"; 16import { BLUESKY_PROFILE_COLLECTION } from "./BlueskyProfile"; 17import { RichText as BlueskyRichText } from "./RichText"; 18 19/** 20 * Options for rendering a paginated list of Bluesky posts. 21 */ 22export interface BlueskyPostListProps { 23 /** 24 * DID whose feed posts should be fetched. 25 */ 26 did: string; 27 /** 28 * Maximum number of records to list per page. Defaults to `5`. 29 */ 30 limit?: number; 31 /** 32 * Enables pagination controls when `true`. Defaults to `true`. 33 */ 34 enablePagination?: boolean; 35} 36 37/** 38 * Fetches a DID's feed posts and renders them with rich pagination and theming. 39 * 40 * @param did - DID whose posts should be displayed. 41 * @param limit - Maximum number of posts per page. Default `5`. 42 * @param enablePagination - Whether pagination controls should render. Default `true`. 43 * @returns A card-like list element with loading, empty, and error handling. 44 */ 45export const BlueskyPostList: React.FC<BlueskyPostListProps> = React.memo(({ 46 did, 47 limit = 5, 48 enablePagination = true, 49}) => { 50 const { handle: resolvedHandle, did: resolvedDid } = useDidResolution(did); 51 const actorLabel = resolvedHandle ?? formatDid(did); 52 const actorPath = resolvedHandle ?? resolvedDid ?? did; 53 54 const { 55 records, 56 loading, 57 error, 58 hasNext, 59 hasPrev, 60 loadNext, 61 loadPrev, 62 pageIndex, 63 pagesCount, 64 } = usePaginatedRecords<FeedPostRecord>({ 65 did, 66 collection: "app.bsky.feed.post", 67 limit, 68 preferAuthorFeed: true, 69 authorFeedActor: actorPath, 70 }); 71 72 const pageLabel = useMemo(() => { 73 const knownTotal = Math.max(pageIndex + 1, pagesCount); 74 if (!enablePagination) return undefined; 75 if (hasNext && knownTotal === pageIndex + 1) 76 return `${pageIndex + 1}/…`; 77 return `${pageIndex + 1}/${knownTotal}`; 78 }, [enablePagination, hasNext, pageIndex, pagesCount]); 79 80 if (error) 81 return ( 82 <div role="alert" style={{ padding: 8, color: "crimson" }}> 83 Failed to load posts. 84 </div> 85 ); 86 87 return ( 88 <div style={{ ...listStyles.card, background: `var(--atproto-color-bg)`, borderWidth: "1px", borderStyle: "solid", borderColor: `var(--atproto-color-border)` }}> 89 <div style={{ ...listStyles.header, background: `var(--atproto-color-bg-elevated)`, color: `var(--atproto-color-text)` }}> 90 <div style={listStyles.headerInfo}> 91 <div style={listStyles.headerIcon}> 92 <BlueskyIcon size={20} /> 93 </div> 94 <div style={listStyles.headerText}> 95 <span style={listStyles.title}>Latest Posts</span> 96 <span 97 style={{ 98 ...listStyles.subtitle, 99 color: `var(--atproto-color-text-secondary)`, 100 }} 101 > 102 @{actorLabel} 103 </span> 104 </div> 105 </div> 106 {pageLabel && ( 107 <span 108 style={{ ...listStyles.pageMeta, color: `var(--atproto-color-text-secondary)` }} 109 > 110 {pageLabel} 111 </span> 112 )} 113 </div> 114 <div style={listStyles.items}> 115 {loading && records.length === 0 && ( 116 <div style={{ ...listStyles.empty, color: `var(--atproto-color-text-secondary)` }}> 117 Loading posts 118 </div> 119 )} 120 {records.map((record, idx) => ( 121 <ListRow 122 key={record.rkey} 123 record={record.value} 124 rkey={record.rkey} 125 did={actorPath} 126 uri={record.uri} 127 reason={record.reason} 128 replyParent={record.replyParent} 129 hasDivider={idx < records.length - 1} 130 /> 131 ))} 132 {!loading && records.length === 0 && ( 133 <div style={{ ...listStyles.empty, color: `var(--atproto-color-text-secondary)` }}> 134 No posts found. 135 </div> 136 )} 137 </div> 138 {enablePagination && ( 139 <div style={{ ...listStyles.footer, borderTopColor: `var(--atproto-color-border)`, color: `var(--atproto-color-text)` }}> 140 <button 141 type="button" 142 style={{ 143 ...listStyles.pageButton, 144 background: `var(--atproto-color-button-bg)`, 145 color: `var(--atproto-color-button-text)`, 146 cursor: hasPrev ? "pointer" : "not-allowed", 147 opacity: hasPrev ? 1 : 0.5, 148 }} 149 onClick={loadPrev} 150 disabled={!hasPrev} 151 > 152 Prev 153 </button> 154 <div style={listStyles.pageChips}> 155 <span 156 style={{ 157 ...listStyles.pageChipActive, 158 color: `var(--atproto-color-button-text)`, 159 background: `var(--atproto-color-button-bg)`, 160 borderWidth: "1px", 161 borderStyle: "solid", 162 borderColor: `var(--atproto-color-button-bg)`, 163 }} 164 > 165 {pageIndex + 1} 166 </span> 167 {(hasNext || pagesCount > pageIndex + 1) && ( 168 <span 169 style={{ 170 ...listStyles.pageChip, 171 color: `var(--atproto-color-text-secondary)`, 172 borderWidth: "1px", 173 borderStyle: "solid", 174 borderColor: `var(--atproto-color-border)`, 175 background: `var(--atproto-color-bg)`, 176 }} 177 > 178 {pageIndex + 2} 179 </span> 180 )} 181 </div> 182 <button 183 type="button" 184 style={{ 185 ...listStyles.pageButton, 186 background: `var(--atproto-color-button-bg)`, 187 color: `var(--atproto-color-button-text)`, 188 cursor: hasNext ? "pointer" : "not-allowed", 189 opacity: hasNext ? 1 : 0.5, 190 }} 191 onClick={loadNext} 192 disabled={!hasNext} 193 > 194 Next 195 </button> 196 </div> 197 )} 198 {loading && records.length > 0 && ( 199 <div 200 style={{ ...listStyles.loadingBar, background: `var(--atproto-color-bg-elevated)`, color: `var(--atproto-color-text-secondary)` }} 201 > 202 Updating 203 </div> 204 )} 205 </div> 206 ); 207}); 208 209interface ListRowProps { 210 record: FeedPostRecord; 211 rkey: string; 212 did: string; 213 uri?: string; 214 reason?: AuthorFeedReason; 215 replyParent?: ReplyParentInfo; 216 hasDivider: boolean; 217} 218 219const ListRow: React.FC<ListRowProps> = ({ 220 record, 221 rkey, 222 did, 223 uri, 224 reason, 225 replyParent, 226 hasDivider, 227}) => { 228 const { blueskyAppBaseUrl } = useAtProto(); 229 const text = record.text?.trim() ?? ""; 230 const relative = record.createdAt 231 ? formatRelativeTime(record.createdAt) 232 : undefined; 233 const absolute = record.createdAt 234 ? new Date(record.createdAt).toLocaleString() 235 : undefined; 236 237 // Parse the URI to get the actual post's DID and rkey 238 const parsedUri = uri ? parseAtUri(uri) : undefined; 239 const postDid = parsedUri?.did ?? did; 240 const postRkey = parsedUri?.rkey ?? rkey; 241 const href = `${blueskyAppBaseUrl}/profile/${postDid}/post/${postRkey}`; 242 243 // Author profile and avatar 244 const { handle: authorHandle } = useDidResolution(postDid); 245 const { record: authorProfile } = useAtProtoRecord<ProfileRecord>({ 246 did: postDid, 247 collection: BLUESKY_PROFILE_COLLECTION, 248 rkey: "self", 249 }); 250 const authorDisplayName = authorProfile?.displayName; 251 const authorAvatar = authorProfile?.avatar; 252 const authorAvatarCdnUrl = isBlobWithCdn(authorAvatar) ? authorAvatar.cdnUrl : undefined; 253 const authorAvatarCid = authorAvatarCdnUrl ? undefined : getAvatarCid(authorProfile); 254 const { url: authorAvatarUrl } = useBlob( 255 postDid, 256 authorAvatarCid, 257 ); 258 const finalAuthorAvatarUrl = authorAvatarCdnUrl ?? authorAvatarUrl; 259 260 // Repost metadata 261 const isRepost = reason?.$type === "app.bsky.feed.defs#reasonRepost"; 262 const reposterDid = reason?.by?.did; 263 const { handle: reposterHandle } = useDidResolution(reposterDid); 264 const { record: reposterProfile } = useAtProtoRecord<ProfileRecord>({ 265 did: reposterDid, 266 collection: BLUESKY_PROFILE_COLLECTION, 267 rkey: "self", 268 }); 269 const reposterDisplayName = reposterProfile?.displayName; 270 const reposterAvatar = reposterProfile?.avatar; 271 const reposterAvatarCdnUrl = isBlobWithCdn(reposterAvatar) ? reposterAvatar.cdnUrl : undefined; 272 const reposterAvatarCid = reposterAvatarCdnUrl ? undefined : getAvatarCid(reposterProfile); 273 const { url: reposterAvatarUrl } = useBlob( 274 reposterDid, 275 reposterAvatarCid, 276 ); 277 const finalReposterAvatarUrl = reposterAvatarCdnUrl ?? reposterAvatarUrl; 278 279 // Reply metadata 280 const parentUri = replyParent?.uri ?? record.reply?.parent?.uri; 281 const parentDid = replyParent?.author?.did ?? (parentUri ? parseAtUri(parentUri)?.did : undefined); 282 const { handle: parentHandle } = useDidResolution( 283 replyParent?.author?.handle ? undefined : parentDid, 284 ); 285 const { record: parentProfile } = useAtProtoRecord<ProfileRecord>({ 286 did: parentDid, 287 collection: BLUESKY_PROFILE_COLLECTION, 288 rkey: "self", 289 }); 290 const parentDisplayName = parentProfile?.displayName; 291 const parentAvatar = parentProfile?.avatar; 292 const parentAvatarCdnUrl = isBlobWithCdn(parentAvatar) ? parentAvatar.cdnUrl : undefined; 293 const parentAvatarCid = parentAvatarCdnUrl ? undefined : getAvatarCid(parentProfile); 294 const { url: parentAvatarUrl } = useBlob( 295 parentDid, 296 parentAvatarCid, 297 ); 298 const finalParentAvatarUrl = parentAvatarCdnUrl ?? parentAvatarUrl; 299 300 const isReply = !!parentUri; 301 const replyTargetHandle = replyParent?.author?.handle ?? parentHandle; 302 303 const postPreview = text.slice(0, 100); 304 const ariaLabel = text 305 ? `Post by ${authorDisplayName ?? authorHandle ?? did}: ${postPreview}${text.length > 100 ? "..." : ""}` 306 : `Post by ${authorDisplayName ?? authorHandle ?? did}`; 307 308 return ( 309 <div 310 style={{ 311 ...listStyles.rowContainer, 312 borderBottom: hasDivider ? `1px solid var(--atproto-color-border)` : "none", 313 }} 314 > 315 {isRepost && ( 316 <div style={listStyles.repostIndicator}> 317 {finalReposterAvatarUrl && ( 318 <img 319 src={finalReposterAvatarUrl} 320 alt="" 321 style={listStyles.repostAvatar} 322 /> 323 )} 324 <svg 325 width="16" 326 height="16" 327 viewBox="0 0 16 16" 328 fill="none" 329 style={{ flexShrink: 0 }} 330 > 331 <path 332 d="M5.5 3.5L3 6L5.5 8.5M3 6H10C11.1046 6 12 6.89543 12 8V8.5M10.5 12.5L13 10L10.5 7.5M13 10H6C4.89543 10 4 9.10457 4 8V7.5" 333 stroke="var(--atproto-color-text-secondary)" 334 strokeWidth="1.5" 335 strokeLinecap="round" 336 strokeLinejoin="round" 337 /> 338 </svg> 339 <span style={{ ...listStyles.repostText, color: "var(--atproto-color-text-secondary)" }}> 340 {reposterDisplayName ?? reposterHandle ?? "Someone"} reposted 341 </span> 342 </div> 343 )} 344 345 {isReply && ( 346 <div style={listStyles.replyIndicator}> 347 <svg 348 width="14" 349 height="14" 350 viewBox="0 0 14 14" 351 fill="none" 352 style={{ flexShrink: 0 }} 353 > 354 <path 355 d="M11 7H3M3 7L7 3M3 7L7 11" 356 stroke="#1185FE" 357 strokeWidth="1.5" 358 strokeLinecap="round" 359 strokeLinejoin="round" 360 /> 361 </svg> 362 <span style={{ ...listStyles.replyText, color: "var(--atproto-color-text-secondary)" }}> 363 Replying to 364 </span> 365 {finalParentAvatarUrl && ( 366 <img 367 src={finalParentAvatarUrl} 368 alt="" 369 style={listStyles.replyAvatar} 370 /> 371 )} 372 <span style={{ color: "#1185FE", fontWeight: 600 }}> 373 @{replyTargetHandle ?? formatDid(parentDid ?? "")} 374 </span> 375 </div> 376 )} 377 378 <div style={listStyles.postContent}> 379 <div style={listStyles.avatarContainer}> 380 {finalAuthorAvatarUrl ? ( 381 <img 382 src={finalAuthorAvatarUrl} 383 alt={authorDisplayName ?? authorHandle ?? "User avatar"} 384 style={listStyles.avatar} 385 /> 386 ) : ( 387 <div style={listStyles.avatarPlaceholder}> 388 {(authorDisplayName ?? authorHandle ?? "?")[0].toUpperCase()} 389 </div> 390 )} 391 </div> 392 393 <div style={listStyles.postMain}> 394 <div style={listStyles.postHeader}> 395 <a 396 href={`${blueskyAppBaseUrl}/profile/${postDid}`} 397 target="_blank" 398 rel="noopener noreferrer" 399 style={{ ...listStyles.authorName, color: "var(--atproto-color-text)" }} 400 onClick={(e) => e.stopPropagation()} 401 > 402 {authorDisplayName ?? authorHandle ?? formatDid(postDid)} 403 </a> 404 <span style={{ ...listStyles.authorHandle, color: "var(--atproto-color-text-secondary)" }}> 405 @{authorHandle ?? formatDid(postDid)} 406 </span> 407 <span style={{ ...listStyles.separator, color: "var(--atproto-color-text-secondary)" }}>·</span> 408 <span 409 style={{ ...listStyles.timestamp, color: "var(--atproto-color-text-secondary)" }} 410 title={absolute} 411 > 412 {relative} 413 </span> 414 </div> 415 416 <a 417 href={href} 418 target="_blank" 419 rel="noopener noreferrer" 420 aria-label={ariaLabel} 421 style={{ ...listStyles.postLink, color: "var(--atproto-color-text)" }} 422 > 423 {text && ( 424 <p style={listStyles.postText}> 425 <BlueskyRichText text={text} facets={record.facets} /> 426 </p> 427 )} 428 {!text && ( 429 <p style={{ ...listStyles.postText, fontStyle: "italic", color: "var(--atproto-color-text-secondary)" }}> 430 No text content 431 </p> 432 )} 433 </a> 434 </div> 435 </div> 436 </div> 437 ); 438}; 439 440function formatDid(did: string) { 441 return did.replace(/^did:(plc:)?/, ""); 442} 443 444function formatRelativeTime(iso: string): string { 445 const date = new Date(iso); 446 const diffSeconds = (date.getTime() - Date.now()) / 1000; 447 const absSeconds = Math.abs(diffSeconds); 448 const thresholds: Array<{ 449 limit: number; 450 unit: Intl.RelativeTimeFormatUnit; 451 divisor: number; 452 }> = [ 453 { limit: 60, unit: "second", divisor: 1 }, 454 { limit: 3600, unit: "minute", divisor: 60 }, 455 { limit: 86400, unit: "hour", divisor: 3600 }, 456 { limit: 604800, unit: "day", divisor: 86400 }, 457 { limit: 2629800, unit: "week", divisor: 604800 }, 458 { limit: 31557600, unit: "month", divisor: 2629800 }, 459 { limit: Infinity, unit: "year", divisor: 31557600 }, 460 ]; 461 const threshold = 462 thresholds.find((t) => absSeconds < t.limit) ?? 463 thresholds[thresholds.length - 1]; 464 const value = diffSeconds / threshold.divisor; 465 const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); 466 return rtf.format(Math.round(value), threshold.unit); 467} 468 469 470const listStyles = { 471 card: { 472 borderRadius: 16, 473 borderWidth: "1px", 474 borderStyle: "solid", 475 borderColor: "transparent", 476 boxShadow: "0 8px 18px -12px rgba(15, 23, 42, 0.25)", 477 overflow: "hidden", 478 display: "flex", 479 flexDirection: "column", 480 } satisfies React.CSSProperties, 481 header: { 482 display: "flex", 483 alignItems: "center", 484 justifyContent: "space-between", 485 padding: "14px 18px", 486 fontSize: 14, 487 fontWeight: 500, 488 borderBottom: "1px solid transparent", 489 } satisfies React.CSSProperties, 490 headerInfo: { 491 display: "flex", 492 alignItems: "center", 493 gap: 12, 494 } satisfies React.CSSProperties, 495 headerIcon: { 496 width: 28, 497 height: 28, 498 display: "flex", 499 alignItems: "center", 500 justifyContent: "center", 501 borderRadius: "50%", 502 } satisfies React.CSSProperties, 503 headerText: { 504 display: "flex", 505 flexDirection: "column", 506 gap: 2, 507 } satisfies React.CSSProperties, 508 title: { 509 fontSize: 15, 510 fontWeight: 600, 511 } satisfies React.CSSProperties, 512 subtitle: { 513 fontSize: 12, 514 fontWeight: 500, 515 } satisfies React.CSSProperties, 516 pageMeta: { 517 fontSize: 12, 518 } satisfies React.CSSProperties, 519 items: { 520 display: "flex", 521 flexDirection: "column", 522 } satisfies React.CSSProperties, 523 empty: { 524 padding: "24px 18px", 525 fontSize: 13, 526 textAlign: "center", 527 } satisfies React.CSSProperties, 528 rowContainer: { 529 padding: "16px", 530 display: "flex", 531 flexDirection: "column", 532 gap: 8, 533 transition: "background-color 120ms ease", 534 position: "relative", 535 } satisfies React.CSSProperties, 536 repostIndicator: { 537 display: "flex", 538 alignItems: "center", 539 gap: 8, 540 fontSize: 13, 541 fontWeight: 500, 542 paddingLeft: 8, 543 marginBottom: 4, 544 } satisfies React.CSSProperties, 545 repostAvatar: { 546 width: 16, 547 height: 16, 548 borderRadius: "50%", 549 objectFit: "cover", 550 } satisfies React.CSSProperties, 551 repostText: { 552 fontSize: 13, 553 fontWeight: 500, 554 } satisfies React.CSSProperties, 555 replyIndicator: { 556 display: "flex", 557 alignItems: "center", 558 gap: 8, 559 fontSize: 13, 560 fontWeight: 500, 561 paddingLeft: 8, 562 marginBottom: 4, 563 } satisfies React.CSSProperties, 564 replyAvatar: { 565 width: 16, 566 height: 16, 567 borderRadius: "50%", 568 objectFit: "cover", 569 } satisfies React.CSSProperties, 570 replyText: { 571 fontSize: 13, 572 fontWeight: 500, 573 } satisfies React.CSSProperties, 574 postContent: { 575 display: "flex", 576 gap: 12, 577 } satisfies React.CSSProperties, 578 avatarContainer: { 579 flexShrink: 0, 580 } satisfies React.CSSProperties, 581 avatar: { 582 width: 48, 583 height: 48, 584 borderRadius: "50%", 585 objectFit: "cover", 586 } satisfies React.CSSProperties, 587 avatarPlaceholder: { 588 width: 48, 589 height: 48, 590 borderRadius: "50%", 591 background: "var(--atproto-color-bg-elevated)", 592 color: "var(--atproto-color-text-secondary)", 593 display: "flex", 594 alignItems: "center", 595 justifyContent: "center", 596 fontSize: 18, 597 fontWeight: 600, 598 } satisfies React.CSSProperties, 599 postMain: { 600 flex: 1, 601 minWidth: 0, 602 display: "flex", 603 flexDirection: "column", 604 gap: 6, 605 } satisfies React.CSSProperties, 606 postHeader: { 607 display: "flex", 608 alignItems: "baseline", 609 gap: 6, 610 flexWrap: "wrap", 611 } satisfies React.CSSProperties, 612 authorName: { 613 fontWeight: 700, 614 fontSize: 15, 615 textDecoration: "none", 616 maxWidth: "200px", 617 overflow: "hidden", 618 textOverflow: "ellipsis", 619 whiteSpace: "nowrap", 620 } satisfies React.CSSProperties, 621 authorHandle: { 622 fontSize: 15, 623 fontWeight: 400, 624 maxWidth: "150px", 625 overflow: "hidden", 626 textOverflow: "ellipsis", 627 whiteSpace: "nowrap", 628 } satisfies React.CSSProperties, 629 separator: { 630 fontSize: 15, 631 fontWeight: 400, 632 } satisfies React.CSSProperties, 633 timestamp: { 634 fontSize: 15, 635 fontWeight: 400, 636 } satisfies React.CSSProperties, 637 postLink: { 638 textDecoration: "none", 639 display: "block", 640 } satisfies React.CSSProperties, 641 postText: { 642 margin: 0, 643 whiteSpace: "pre-wrap", 644 fontSize: 15, 645 lineHeight: 1.5, 646 wordBreak: "break-word", 647 } satisfies React.CSSProperties, 648 footer: { 649 display: "flex", 650 alignItems: "center", 651 justifyContent: "space-between", 652 padding: "12px 18px", 653 borderTop: "1px solid transparent", 654 fontSize: 13, 655 } satisfies React.CSSProperties, 656 pageChips: { 657 display: "flex", 658 gap: 6, 659 alignItems: "center", 660 } satisfies React.CSSProperties, 661 pageChip: { 662 padding: "4px 10px", 663 borderRadius: 999, 664 fontSize: 13, 665 borderWidth: "1px", 666 borderStyle: "solid", 667 borderColor: "transparent", 668 } satisfies React.CSSProperties, 669 pageChipActive: { 670 padding: "4px 10px", 671 borderRadius: 999, 672 fontSize: 13, 673 fontWeight: 600, 674 borderWidth: "1px", 675 borderStyle: "solid", 676 borderColor: "transparent", 677 } satisfies React.CSSProperties, 678 pageButton: { 679 border: "none", 680 borderRadius: 999, 681 padding: "6px 12px", 682 fontSize: 13, 683 fontWeight: 500, 684 background: "transparent", 685 display: "flex", 686 alignItems: "center", 687 gap: 4, 688 transition: "background-color 120ms ease", 689 } satisfies React.CSSProperties, 690 loadingBar: { 691 padding: "4px 18px 14px", 692 fontSize: 12, 693 textAlign: "right", 694 color: "#64748b", 695 } satisfies React.CSSProperties, 696}; 697 698export default BlueskyPostList;