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