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 const replyTargetName = parentDisplayName ?? replyTargetHandle ?? formatDid(parentDid ?? ""); 303 304 const postPreview = text.slice(0, 100); 305 const ariaLabel = text 306 ? `Post by ${authorDisplayName ?? authorHandle ?? did}: ${postPreview}${text.length > 100 ? "..." : ""}` 307 : `Post by ${authorDisplayName ?? authorHandle ?? did}`; 308 309 return ( 310 <div 311 style={{ 312 ...listStyles.rowContainer, 313 borderBottom: hasDivider ? `1px solid var(--atproto-color-border)` : "none", 314 }} 315 > 316 {isRepost && ( 317 <div style={listStyles.repostIndicator}> 318 {finalReposterAvatarUrl && ( 319 <img 320 src={finalReposterAvatarUrl} 321 alt="" 322 style={listStyles.repostAvatar} 323 /> 324 )} 325 <svg 326 width="16" 327 height="16" 328 viewBox="0 0 16 16" 329 fill="none" 330 style={{ flexShrink: 0 }} 331 > 332 <path 333 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" 334 stroke="var(--atproto-color-text-secondary)" 335 strokeWidth="1.5" 336 strokeLinecap="round" 337 strokeLinejoin="round" 338 /> 339 </svg> 340 <span style={{ ...listStyles.repostText, color: "var(--atproto-color-text-secondary)" }}> 341 {reposterDisplayName ?? reposterHandle ?? "Someone"} reposted 342 </span> 343 </div> 344 )} 345 346 {isReply && ( 347 <div style={listStyles.replyIndicator}> 348 <svg 349 width="14" 350 height="14" 351 viewBox="0 0 14 14" 352 fill="none" 353 style={{ flexShrink: 0 }} 354 > 355 <path 356 d="M11 7H3M3 7L7 3M3 7L7 11" 357 stroke="#1185FE" 358 strokeWidth="1.5" 359 strokeLinecap="round" 360 strokeLinejoin="round" 361 /> 362 </svg> 363 <span style={{ ...listStyles.replyText, color: "var(--atproto-color-text-secondary)" }}> 364 Replying to 365 </span> 366 {finalParentAvatarUrl && ( 367 <img 368 src={finalParentAvatarUrl} 369 alt="" 370 style={listStyles.replyAvatar} 371 /> 372 )} 373 <span style={{ color: "#1185FE", fontWeight: 600 }}> 374 @{replyTargetHandle ?? formatDid(parentDid ?? "")} 375 </span> 376 </div> 377 )} 378 379 <div style={listStyles.postContent}> 380 <div style={listStyles.avatarContainer}> 381 {finalAuthorAvatarUrl ? ( 382 <img 383 src={finalAuthorAvatarUrl} 384 alt={authorDisplayName ?? authorHandle ?? "User avatar"} 385 style={listStyles.avatar} 386 /> 387 ) : ( 388 <div style={listStyles.avatarPlaceholder}> 389 {(authorDisplayName ?? authorHandle ?? "?")[0].toUpperCase()} 390 </div> 391 )} 392 </div> 393 394 <div style={listStyles.postMain}> 395 <div style={listStyles.postHeader}> 396 <a 397 href={`${blueskyAppBaseUrl}/profile/${postDid}`} 398 target="_blank" 399 rel="noopener noreferrer" 400 style={{ ...listStyles.authorName, color: "var(--atproto-color-text)" }} 401 onClick={(e) => e.stopPropagation()} 402 > 403 {authorDisplayName ?? authorHandle ?? formatDid(postDid)} 404 </a> 405 <span style={{ ...listStyles.authorHandle, color: "var(--atproto-color-text-secondary)" }}> 406 @{authorHandle ?? formatDid(postDid)} 407 </span> 408 <span style={{ ...listStyles.separator, color: "var(--atproto-color-text-secondary)" }}>·</span> 409 <span 410 style={{ ...listStyles.timestamp, color: "var(--atproto-color-text-secondary)" }} 411 title={absolute} 412 > 413 {relative} 414 </span> 415 </div> 416 417 <a 418 href={href} 419 target="_blank" 420 rel="noopener noreferrer" 421 aria-label={ariaLabel} 422 style={{ ...listStyles.postLink, color: "var(--atproto-color-text)" }} 423 > 424 {text && ( 425 <p style={listStyles.postText}> 426 <BlueskyRichText text={text} facets={record.facets} /> 427 </p> 428 )} 429 {!text && ( 430 <p style={{ ...listStyles.postText, fontStyle: "italic", color: "var(--atproto-color-text-secondary)" }}> 431 No text content 432 </p> 433 )} 434 </a> 435 </div> 436 </div> 437 </div> 438 ); 439}; 440 441function formatDid(did: string) { 442 return did.replace(/^did:(plc:)?/, ""); 443} 444 445function formatRelativeTime(iso: string): string { 446 const date = new Date(iso); 447 const diffSeconds = (date.getTime() - Date.now()) / 1000; 448 const absSeconds = Math.abs(diffSeconds); 449 const thresholds: Array<{ 450 limit: number; 451 unit: Intl.RelativeTimeFormatUnit; 452 divisor: number; 453 }> = [ 454 { limit: 60, unit: "second", divisor: 1 }, 455 { limit: 3600, unit: "minute", divisor: 60 }, 456 { limit: 86400, unit: "hour", divisor: 3600 }, 457 { limit: 604800, unit: "day", divisor: 86400 }, 458 { limit: 2629800, unit: "week", divisor: 604800 }, 459 { limit: 31557600, unit: "month", divisor: 2629800 }, 460 { limit: Infinity, unit: "year", divisor: 31557600 }, 461 ]; 462 const threshold = 463 thresholds.find((t) => absSeconds < t.limit) ?? 464 thresholds[thresholds.length - 1]; 465 const value = diffSeconds / threshold.divisor; 466 const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); 467 return rtf.format(Math.round(value), threshold.unit); 468} 469 470 471const listStyles = { 472 card: { 473 borderRadius: 16, 474 borderWidth: "1px", 475 borderStyle: "solid", 476 borderColor: "transparent", 477 boxShadow: "0 8px 18px -12px rgba(15, 23, 42, 0.25)", 478 overflow: "hidden", 479 display: "flex", 480 flexDirection: "column", 481 } satisfies React.CSSProperties, 482 header: { 483 display: "flex", 484 alignItems: "center", 485 justifyContent: "space-between", 486 padding: "14px 18px", 487 fontSize: 14, 488 fontWeight: 500, 489 borderBottom: "1px solid transparent", 490 } satisfies React.CSSProperties, 491 headerInfo: { 492 display: "flex", 493 alignItems: "center", 494 gap: 12, 495 } satisfies React.CSSProperties, 496 headerIcon: { 497 width: 28, 498 height: 28, 499 display: "flex", 500 alignItems: "center", 501 justifyContent: "center", 502 borderRadius: "50%", 503 } satisfies React.CSSProperties, 504 headerText: { 505 display: "flex", 506 flexDirection: "column", 507 gap: 2, 508 } satisfies React.CSSProperties, 509 title: { 510 fontSize: 15, 511 fontWeight: 600, 512 } satisfies React.CSSProperties, 513 subtitle: { 514 fontSize: 12, 515 fontWeight: 500, 516 } satisfies React.CSSProperties, 517 pageMeta: { 518 fontSize: 12, 519 } satisfies React.CSSProperties, 520 items: { 521 display: "flex", 522 flexDirection: "column", 523 } satisfies React.CSSProperties, 524 empty: { 525 padding: "24px 18px", 526 fontSize: 13, 527 textAlign: "center", 528 } satisfies React.CSSProperties, 529 rowContainer: { 530 padding: "16px", 531 display: "flex", 532 flexDirection: "column", 533 gap: 8, 534 transition: "background-color 120ms ease", 535 position: "relative", 536 } satisfies React.CSSProperties, 537 repostIndicator: { 538 display: "flex", 539 alignItems: "center", 540 gap: 8, 541 fontSize: 13, 542 fontWeight: 500, 543 paddingLeft: 8, 544 marginBottom: 4, 545 } satisfies React.CSSProperties, 546 repostAvatar: { 547 width: 16, 548 height: 16, 549 borderRadius: "50%", 550 objectFit: "cover", 551 } satisfies React.CSSProperties, 552 repostText: { 553 fontSize: 13, 554 fontWeight: 500, 555 } satisfies React.CSSProperties, 556 replyIndicator: { 557 display: "flex", 558 alignItems: "center", 559 gap: 8, 560 fontSize: 13, 561 fontWeight: 500, 562 paddingLeft: 8, 563 marginBottom: 4, 564 } satisfies React.CSSProperties, 565 replyAvatar: { 566 width: 16, 567 height: 16, 568 borderRadius: "50%", 569 objectFit: "cover", 570 } satisfies React.CSSProperties, 571 replyText: { 572 fontSize: 13, 573 fontWeight: 500, 574 } satisfies React.CSSProperties, 575 postContent: { 576 display: "flex", 577 gap: 12, 578 } satisfies React.CSSProperties, 579 avatarContainer: { 580 flexShrink: 0, 581 } satisfies React.CSSProperties, 582 avatar: { 583 width: 48, 584 height: 48, 585 borderRadius: "50%", 586 objectFit: "cover", 587 } satisfies React.CSSProperties, 588 avatarPlaceholder: { 589 width: 48, 590 height: 48, 591 borderRadius: "50%", 592 background: "var(--atproto-color-bg-elevated)", 593 color: "var(--atproto-color-text-secondary)", 594 display: "flex", 595 alignItems: "center", 596 justifyContent: "center", 597 fontSize: 18, 598 fontWeight: 600, 599 } satisfies React.CSSProperties, 600 postMain: { 601 flex: 1, 602 minWidth: 0, 603 display: "flex", 604 flexDirection: "column", 605 gap: 6, 606 } satisfies React.CSSProperties, 607 postHeader: { 608 display: "flex", 609 alignItems: "baseline", 610 gap: 6, 611 flexWrap: "wrap", 612 } satisfies React.CSSProperties, 613 authorName: { 614 fontWeight: 700, 615 fontSize: 15, 616 textDecoration: "none", 617 maxWidth: "200px", 618 overflow: "hidden", 619 textOverflow: "ellipsis", 620 whiteSpace: "nowrap", 621 } satisfies React.CSSProperties, 622 authorHandle: { 623 fontSize: 15, 624 fontWeight: 400, 625 maxWidth: "150px", 626 overflow: "hidden", 627 textOverflow: "ellipsis", 628 whiteSpace: "nowrap", 629 } satisfies React.CSSProperties, 630 separator: { 631 fontSize: 15, 632 fontWeight: 400, 633 } satisfies React.CSSProperties, 634 timestamp: { 635 fontSize: 15, 636 fontWeight: 400, 637 } satisfies React.CSSProperties, 638 postLink: { 639 textDecoration: "none", 640 display: "block", 641 } satisfies React.CSSProperties, 642 postText: { 643 margin: 0, 644 whiteSpace: "pre-wrap", 645 fontSize: 15, 646 lineHeight: 1.5, 647 wordBreak: "break-word", 648 } satisfies React.CSSProperties, 649 footer: { 650 display: "flex", 651 alignItems: "center", 652 justifyContent: "space-between", 653 padding: "12px 18px", 654 borderTop: "1px solid transparent", 655 fontSize: 13, 656 } satisfies React.CSSProperties, 657 pageChips: { 658 display: "flex", 659 gap: 6, 660 alignItems: "center", 661 } satisfies React.CSSProperties, 662 pageChip: { 663 padding: "4px 10px", 664 borderRadius: 999, 665 fontSize: 13, 666 borderWidth: "1px", 667 borderStyle: "solid", 668 borderColor: "transparent", 669 } satisfies React.CSSProperties, 670 pageChipActive: { 671 padding: "4px 10px", 672 borderRadius: 999, 673 fontSize: 13, 674 fontWeight: 600, 675 borderWidth: "1px", 676 borderStyle: "solid", 677 borderColor: "transparent", 678 } satisfies React.CSSProperties, 679 pageButton: { 680 border: "none", 681 borderRadius: 999, 682 padding: "6px 12px", 683 fontSize: 13, 684 fontWeight: 500, 685 background: "transparent", 686 display: "flex", 687 alignItems: "center", 688 gap: 4, 689 transition: "background-color 120ms ease", 690 } satisfies React.CSSProperties, 691 loadingBar: { 692 padding: "4px 18px 14px", 693 fontSize: 12, 694 textAlign: "right", 695 color: "#64748b", 696 } satisfies React.CSSProperties, 697}; 698 699export default BlueskyPostList;