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