this repo has no description
at main 856 lines 23 kB view raw
1/** 2 * Sequoia Comments - A Bluesky-powered comments component 3 * 4 * A self-contained Web Component that displays comments from Bluesky posts 5 * linked to documents via the AT Protocol. 6 * 7 * Usage: 8 * <sequoia-comments></sequoia-comments> 9 * 10 * The component looks for a document URI in two places: 11 * 1. The `document-uri` attribute on the element 12 * 2. A <link rel="site.standard.document" href="at://..."> tag in the document head 13 * 14 * Attributes: 15 * - document-uri: AT Protocol URI for the document (optional if link tag exists) 16 * - depth: Maximum depth of nested replies to fetch (default: 6) 17 * - hide: Set to "auto" to hide if no document link is detected 18 * 19 * CSS Custom Properties: 20 * - --sequoia-fg-color: Text color (default: #1f2937) 21 * - --sequoia-bg-color: Background color (default: #ffffff) 22 * - --sequoia-border-color: Border color (default: #e5e7eb) 23 * - --sequoia-accent-color: Accent/link color (default: #2563eb) 24 * - --sequoia-secondary-color: Secondary text color (default: #6b7280) 25 * - --sequoia-border-radius: Border radius (default: 8px) 26 */ 27 28// ============================================================================ 29// Styles 30// ============================================================================ 31 32const styles = ` 33:host { 34 display: block; 35 font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 36 color: var(--sequoia-fg-color, #1f2937); 37 line-height: 1.5; 38} 39 40* { 41 box-sizing: border-box; 42} 43 44.sequoia-comments-container { 45 max-width: 100%; 46} 47 48.sequoia-loading, 49.sequoia-error, 50.sequoia-empty, 51.sequoia-warning { 52 padding: 1rem; 53 border-radius: var(--sequoia-border-radius, 8px); 54 text-align: center; 55} 56 57.sequoia-loading { 58 background: var(--sequoia-bg-color, #ffffff); 59 border: 1px solid var(--sequoia-border-color, #e5e7eb); 60 color: var(--sequoia-secondary-color, #6b7280); 61} 62 63.sequoia-loading-spinner { 64 display: inline-block; 65 width: 1.25rem; 66 height: 1.25rem; 67 border: 2px solid var(--sequoia-border-color, #e5e7eb); 68 border-top-color: var(--sequoia-accent-color, #2563eb); 69 border-radius: 50%; 70 animation: sequoia-spin 0.8s linear infinite; 71 margin-right: 0.5rem; 72 vertical-align: middle; 73} 74 75@keyframes sequoia-spin { 76 to { transform: rotate(360deg); } 77} 78 79.sequoia-error { 80 background: #fef2f2; 81 border: 1px solid #fecaca; 82 color: #dc2626; 83} 84 85.sequoia-warning { 86 background: #fffbeb; 87 border: 1px solid #fde68a; 88 color: #d97706; 89} 90 91.sequoia-empty { 92 background: var(--sequoia-bg-color, #ffffff); 93 border: 1px solid var(--sequoia-border-color, #e5e7eb); 94 color: var(--sequoia-secondary-color, #6b7280); 95} 96 97.sequoia-comments-header { 98 display: flex; 99 justify-content: space-between; 100 align-items: center; 101 margin-bottom: 1rem; 102 padding-bottom: 0.75rem; 103} 104 105.sequoia-comments-title { 106 font-size: 1.125rem; 107 font-weight: 600; 108 margin: 0; 109} 110 111.sequoia-reply-button { 112 display: inline-flex; 113 align-items: center; 114 gap: 0.375rem; 115 padding: 0.5rem 1rem; 116 background: var(--sequoia-accent-color, #2563eb); 117 color: #ffffff; 118 border: none; 119 border-radius: var(--sequoia-border-radius, 8px); 120 font-size: 0.875rem; 121 font-weight: 500; 122 cursor: pointer; 123 text-decoration: none; 124 transition: background-color 0.15s ease; 125} 126 127.sequoia-reply-button:hover { 128 background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); 129} 130 131.sequoia-reply-button svg { 132 width: 1rem; 133 height: 1rem; 134} 135 136.sequoia-comments-list { 137 display: flex; 138 flex-direction: column; 139} 140 141.sequoia-thread { 142 border-top: 1px solid var(--sequoia-border-color, #e5e7eb); 143 padding-bottom: 1rem; 144} 145 146.sequoia-thread + .sequoia-thread { 147 margin-top: 0.5rem; 148} 149 150.sequoia-thread:last-child { 151 border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb); 152} 153 154.sequoia-comment { 155 display: flex; 156 gap: 0.75rem; 157 padding-top: 1rem; 158} 159 160.sequoia-comment-avatar-column { 161 display: flex; 162 flex-direction: column; 163 align-items: center; 164 flex-shrink: 0; 165 width: 2.5rem; 166 position: relative; 167} 168 169.sequoia-comment-avatar { 170 width: 2.5rem; 171 height: 2.5rem; 172 border-radius: 50%; 173 background: var(--sequoia-border-color, #e5e7eb); 174 object-fit: cover; 175 flex-shrink: 0; 176 position: relative; 177 z-index: 1; 178} 179 180.sequoia-comment-avatar-placeholder { 181 width: 2.5rem; 182 height: 2.5rem; 183 border-radius: 50%; 184 background: var(--sequoia-border-color, #e5e7eb); 185 display: flex; 186 align-items: center; 187 justify-content: center; 188 flex-shrink: 0; 189 color: var(--sequoia-secondary-color, #6b7280); 190 font-weight: 600; 191 font-size: 1rem; 192 position: relative; 193 z-index: 1; 194} 195 196.sequoia-thread-line { 197 position: absolute; 198 top: 2.5rem; 199 bottom: calc(-1rem - 0.5rem); 200 left: 50%; 201 transform: translateX(-50%); 202 width: 2px; 203 background: var(--sequoia-border-color, #e5e7eb); 204} 205 206.sequoia-comment-content { 207 flex: 1; 208 min-width: 0; 209} 210 211.sequoia-comment-header { 212 display: flex; 213 align-items: baseline; 214 gap: 0.5rem; 215 margin-bottom: 0.25rem; 216 flex-wrap: wrap; 217} 218 219.sequoia-comment-author { 220 font-weight: 600; 221 color: var(--sequoia-fg-color, #1f2937); 222 text-decoration: none; 223 overflow: hidden; 224 text-overflow: ellipsis; 225 white-space: nowrap; 226} 227 228.sequoia-comment-author:hover { 229 color: var(--sequoia-accent-color, #2563eb); 230} 231 232.sequoia-comment-handle { 233 font-size: 0.875rem; 234 color: var(--sequoia-secondary-color, #6b7280); 235 overflow: hidden; 236 text-overflow: ellipsis; 237 white-space: nowrap; 238} 239 240.sequoia-comment-time { 241 font-size: 0.875rem; 242 color: var(--sequoia-secondary-color, #6b7280); 243 flex-shrink: 0; 244} 245 246.sequoia-comment-time::before { 247 content: "·"; 248 margin-right: 0.5rem; 249} 250 251.sequoia-comment-text { 252 margin: 0; 253 white-space: pre-wrap; 254 word-wrap: break-word; 255} 256 257.sequoia-comment-text a { 258 color: var(--sequoia-accent-color, #2563eb); 259 text-decoration: none; 260} 261 262.sequoia-comment-text a:hover { 263 text-decoration: underline; 264} 265 266.sequoia-bsky-logo { 267 width: 1rem; 268 height: 1rem; 269} 270`; 271 272// ============================================================================ 273// Utility Functions 274// ============================================================================ 275 276/** 277 * Format a relative time string (e.g., "2 hours ago") 278 * @param {string} dateString - ISO date string 279 * @returns {string} Formatted relative time 280 */ 281function formatRelativeTime(dateString) { 282 const date = new Date(dateString); 283 const now = new Date(); 284 const diffMs = now.getTime() - date.getTime(); 285 const diffSeconds = Math.floor(diffMs / 1000); 286 const diffMinutes = Math.floor(diffSeconds / 60); 287 const diffHours = Math.floor(diffMinutes / 60); 288 const diffDays = Math.floor(diffHours / 24); 289 const diffWeeks = Math.floor(diffDays / 7); 290 const diffMonths = Math.floor(diffDays / 30); 291 const diffYears = Math.floor(diffDays / 365); 292 293 if (diffSeconds < 60) { 294 return "just now"; 295 } 296 if (diffMinutes < 60) { 297 return `${diffMinutes}m ago`; 298 } 299 if (diffHours < 24) { 300 return `${diffHours}h ago`; 301 } 302 if (diffDays < 7) { 303 return `${diffDays}d ago`; 304 } 305 if (diffWeeks < 4) { 306 return `${diffWeeks}w ago`; 307 } 308 if (diffMonths < 12) { 309 return `${diffMonths}mo ago`; 310 } 311 return `${diffYears}y ago`; 312} 313 314/** 315 * Escape HTML special characters 316 * @param {string} text - Text to escape 317 * @returns {string} Escaped HTML 318 */ 319function escapeHtml(text) { 320 const div = document.createElement("div"); 321 div.textContent = text; 322 return div.innerHTML; 323} 324 325/** 326 * Convert post text with facets to HTML 327 * @param {string} text - Post text 328 * @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets 329 * @returns {string} HTML string with links 330 */ 331function renderTextWithFacets(text, facets) { 332 if (!facets || facets.length === 0) { 333 return escapeHtml(text); 334 } 335 336 // Convert text to bytes for proper indexing 337 const encoder = new TextEncoder(); 338 const decoder = new TextDecoder(); 339 const textBytes = encoder.encode(text); 340 341 // Sort facets by start index 342 const sortedFacets = [...facets].sort( 343 (a, b) => a.index.byteStart - b.index.byteStart, 344 ); 345 346 let result = ""; 347 let lastEnd = 0; 348 349 for (const facet of sortedFacets) { 350 const { byteStart, byteEnd } = facet.index; 351 352 // Add text before this facet 353 if (byteStart > lastEnd) { 354 const beforeBytes = textBytes.slice(lastEnd, byteStart); 355 result += escapeHtml(decoder.decode(beforeBytes)); 356 } 357 358 // Get the facet text 359 const facetBytes = textBytes.slice(byteStart, byteEnd); 360 const facetText = decoder.decode(facetBytes); 361 362 // Find the first renderable feature 363 const feature = facet.features[0]; 364 if (feature) { 365 if (feature.$type === "app.bsky.richtext.facet#link") { 366 result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 367 } else if (feature.$type === "app.bsky.richtext.facet#mention") { 368 result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 369 } else if (feature.$type === "app.bsky.richtext.facet#tag") { 370 result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 371 } else { 372 result += escapeHtml(facetText); 373 } 374 } else { 375 result += escapeHtml(facetText); 376 } 377 378 lastEnd = byteEnd; 379 } 380 381 // Add remaining text 382 if (lastEnd < textBytes.length) { 383 const remainingBytes = textBytes.slice(lastEnd); 384 result += escapeHtml(decoder.decode(remainingBytes)); 385 } 386 387 return result; 388} 389 390/** 391 * Get initials from a name for avatar placeholder 392 * @param {string} name - Display name 393 * @returns {string} Initials (1-2 characters) 394 */ 395function getInitials(name) { 396 const parts = name.trim().split(/\s+/); 397 if (parts.length >= 2) { 398 return (parts[0][0] + parts[1][0]).toUpperCase(); 399 } 400 return name.substring(0, 2).toUpperCase(); 401} 402 403// ============================================================================ 404// AT Protocol Client Functions 405// ============================================================================ 406 407/** 408 * Parse an AT URI into its components 409 * Format: at://did/collection/rkey 410 * @param {string} atUri - AT Protocol URI 411 * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null 412 */ 413function parseAtUri(atUri) { 414 const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 415 if (!match) return null; 416 return { 417 did: match[1], 418 collection: match[2], 419 rkey: match[3], 420 }; 421} 422 423/** 424 * Resolve a DID to its PDS URL 425 * Supports did:plc and did:web methods 426 * @param {string} did - Decentralized Identifier 427 * @returns {Promise<string>} PDS URL 428 */ 429async function resolvePDS(did) { 430 let pdsUrl; 431 432 if (did.startsWith("did:plc:")) { 433 // Fetch DID document from plc.directory 434 const didDocUrl = `https://plc.directory/${did}`; 435 const didDocResponse = await fetch(didDocUrl); 436 if (!didDocResponse.ok) { 437 throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 438 } 439 const didDoc = await didDocResponse.json(); 440 441 // Find the PDS service endpoint 442 const pdsService = didDoc.service?.find( 443 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 444 ); 445 pdsUrl = pdsService?.serviceEndpoint; 446 } else if (did.startsWith("did:web:")) { 447 // For did:web, fetch the DID document from the domain 448 const domain = did.replace("did:web:", ""); 449 const didDocUrl = `https://${domain}/.well-known/did.json`; 450 const didDocResponse = await fetch(didDocUrl); 451 if (!didDocResponse.ok) { 452 throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 453 } 454 const didDoc = await didDocResponse.json(); 455 456 const pdsService = didDoc.service?.find( 457 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 458 ); 459 pdsUrl = pdsService?.serviceEndpoint; 460 } else { 461 throw new Error(`Unsupported DID method: ${did}`); 462 } 463 464 if (!pdsUrl) { 465 throw new Error("Could not find PDS URL for user"); 466 } 467 468 return pdsUrl; 469} 470 471/** 472 * Fetch a record from a PDS using the public API 473 * @param {string} did - DID of the repository owner 474 * @param {string} collection - Collection name 475 * @param {string} rkey - Record key 476 * @returns {Promise<any>} Record value 477 */ 478async function getRecord(did, collection, rkey) { 479 const pdsUrl = await resolvePDS(did); 480 481 const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`); 482 url.searchParams.set("repo", did); 483 url.searchParams.set("collection", collection); 484 url.searchParams.set("rkey", rkey); 485 486 const response = await fetch(url.toString()); 487 if (!response.ok) { 488 throw new Error(`Failed to fetch record: ${response.status}`); 489 } 490 491 const data = await response.json(); 492 return data.value; 493} 494 495/** 496 * Fetch a document record from its AT URI 497 * @param {string} atUri - AT Protocol URI for the document 498 * @returns {Promise<{$type: string, title: string, site: string, path: string, textContent: string, publishedAt: string, canonicalUrl?: string, description?: string, tags?: string[], bskyPostRef?: {uri: string, cid: string}}>} Document record 499 */ 500async function getDocument(atUri) { 501 const parsed = parseAtUri(atUri); 502 if (!parsed) { 503 throw new Error(`Invalid AT URI: ${atUri}`); 504 } 505 506 return getRecord(parsed.did, parsed.collection, parsed.rkey); 507} 508 509/** 510 * Fetch a post thread from the public Bluesky API 511 * @param {string} postUri - AT Protocol URI for the post 512 * @param {number} [depth=6] - Maximum depth of replies to fetch 513 * @returns {Promise<ThreadViewPost>} Thread view post 514 */ 515async function getPostThread(postUri, depth = 6) { 516 const url = new URL( 517 "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread", 518 ); 519 url.searchParams.set("uri", postUri); 520 url.searchParams.set("depth", depth.toString()); 521 522 const response = await fetch(url.toString()); 523 if (!response.ok) { 524 throw new Error(`Failed to fetch post thread: ${response.status}`); 525 } 526 527 const data = await response.json(); 528 529 if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { 530 throw new Error("Post not found or blocked"); 531 } 532 533 return data.thread; 534} 535 536/** 537 * Build a Bluesky app URL for a post 538 * @param {string} postUri - AT Protocol URI for the post 539 * @returns {string} Bluesky app URL 540 */ 541function buildBskyAppUrl(postUri) { 542 const parsed = parseAtUri(postUri); 543 if (!parsed) { 544 throw new Error(`Invalid post URI: ${postUri}`); 545 } 546 547 return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; 548} 549 550/** 551 * Type guard for ThreadViewPost 552 * @param {any} post - Post to check 553 * @returns {boolean} True if post is a ThreadViewPost 554 */ 555function isThreadViewPost(post) { 556 return post?.$type === "app.bsky.feed.defs#threadViewPost"; 557} 558 559// ============================================================================ 560// Bluesky Icon 561// ============================================================================ 562 563const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 564 <path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/> 565</svg>`; 566 567// ============================================================================ 568// Web Component 569// ============================================================================ 570 571// SSR-safe base class - use HTMLElement in browser, empty class in Node.js 572const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 573 574class SequoiaComments extends BaseElement { 575 constructor() { 576 super(); 577 const shadow = this.attachShadow({ mode: "open" }); 578 579 const styleTag = document.createElement("style"); 580 shadow.appendChild(styleTag); 581 styleTag.innerText = styles; 582 583 const container = document.createElement("div"); 584 shadow.appendChild(container); 585 container.className = "sequoia-comments-container"; 586 container.part = "container"; 587 588 this.commentsContainer = container; 589 this.state = { type: "loading" }; 590 this.abortController = null; 591 } 592 593 static get observedAttributes() { 594 return ["document-uri", "depth", "hide"]; 595 } 596 597 connectedCallback() { 598 this.render(); 599 this.loadComments(); 600 } 601 602 disconnectedCallback() { 603 this.abortController?.abort(); 604 } 605 606 attributeChangedCallback() { 607 if (this.isConnected) { 608 this.loadComments(); 609 } 610 } 611 612 get documentUri() { 613 // First check attribute 614 const attrUri = this.getAttribute("document-uri"); 615 if (attrUri) { 616 return attrUri; 617 } 618 619 // Then scan for link tag in document head 620 const linkTag = document.querySelector( 621 'link[rel="site.standard.document"]', 622 ); 623 return linkTag?.href ?? null; 624 } 625 626 get depth() { 627 const depthAttr = this.getAttribute("depth"); 628 return depthAttr ? parseInt(depthAttr, 10) : 6; 629 } 630 631 get hide() { 632 const hideAttr = this.getAttribute("hide"); 633 return hideAttr === "auto"; 634 } 635 636 async loadComments() { 637 // Cancel any in-flight request 638 this.abortController?.abort(); 639 this.abortController = new AbortController(); 640 641 this.state = { type: "loading" }; 642 this.render(); 643 644 const docUri = this.documentUri; 645 if (!docUri) { 646 this.state = { type: "no-document" }; 647 this.render(); 648 return; 649 } 650 651 try { 652 // Fetch the document record 653 const document = await getDocument(docUri); 654 655 // Check if document has a Bluesky post reference 656 if (!document.bskyPostRef) { 657 this.state = { type: "no-comments-enabled" }; 658 this.render(); 659 return; 660 } 661 662 const postUrl = buildBskyAppUrl(document.bskyPostRef.uri); 663 664 // Fetch the post thread 665 const thread = await getPostThread(document.bskyPostRef.uri, this.depth); 666 667 // Check if there are any replies 668 const replies = thread.replies?.filter(isThreadViewPost) ?? []; 669 if (replies.length === 0) { 670 this.state = { type: "empty", postUrl }; 671 this.render(); 672 return; 673 } 674 675 this.state = { type: "loaded", thread, postUrl }; 676 this.render(); 677 } catch (error) { 678 const message = 679 error instanceof Error ? error.message : "Failed to load comments"; 680 this.state = { type: "error", message }; 681 this.render(); 682 } 683 } 684 685 render() { 686 switch (this.state.type) { 687 case "loading": 688 this.commentsContainer.innerHTML = ` 689 <div class="sequoia-loading"> 690 <span class="sequoia-loading-spinner"></span> 691 Loading comments... 692 </div> 693 `; 694 break; 695 696 case "no-document": 697 this.commentsContainer.innerHTML = ` 698 <div class="sequoia-warning"> 699 No document found. Add a <code>&lt;link rel="site.standard.document" href="at://..."&gt;</code> tag to your page. 700 </div> 701 `; 702 if (this.hide) { 703 this.commentsContainer.style.display = "none"; 704 } 705 break; 706 707 case "no-comments-enabled": 708 this.commentsContainer.innerHTML = ` 709 <div class="sequoia-empty"> 710 Comments are not enabled for this post. 711 </div> 712 `; 713 break; 714 715 case "empty": 716 this.commentsContainer.innerHTML = ` 717 <div class="sequoia-comments-header"> 718 <h3 class="sequoia-comments-title">Comments</h3> 719 <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button"> 720 ${BLUESKY_ICON} 721 Reply on Bluesky 722 </a> 723 </div> 724 <div class="sequoia-empty"> 725 No comments yet. Be the first to reply on Bluesky! 726 </div> 727 `; 728 break; 729 730 case "error": 731 this.commentsContainer.innerHTML = ` 732 <div class="sequoia-error"> 733 Failed to load comments: ${escapeHtml(this.state.message)} 734 </div> 735 `; 736 break; 737 738 case "loaded": { 739 const replies = 740 this.state.thread.replies?.filter(isThreadViewPost) ?? []; 741 const threadsHtml = replies 742 .map((reply) => this.renderThread(reply)) 743 .join(""); 744 const commentCount = this.countComments(replies); 745 746 this.commentsContainer.innerHTML = ` 747 <div class="sequoia-comments-header"> 748 <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3> 749 <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button"> 750 ${BLUESKY_ICON} 751 Reply on Bluesky 752 </a> 753 </div> 754 <div class="sequoia-comments-list"> 755 ${threadsHtml} 756 </div> 757 `; 758 break; 759 } 760 } 761 } 762 763 /** 764 * Flatten a thread into a linear list of comments 765 * @param {ThreadViewPost} thread - Thread to flatten 766 * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments 767 */ 768 flattenThread(thread) { 769 const result = []; 770 const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; 771 772 result.push({ 773 post: thread.post, 774 hasMoreReplies: nestedReplies.length > 0, 775 }); 776 777 // Recursively flatten nested replies 778 for (const reply of nestedReplies) { 779 result.push(...this.flattenThread(reply)); 780 } 781 782 return result; 783 } 784 785 /** 786 * Render a complete thread (top-level comment + all nested replies) 787 */ 788 renderThread(thread) { 789 const flatComments = this.flattenThread(thread); 790 const commentsHtml = flatComments 791 .map((item, index) => 792 this.renderComment(item.post, item.hasMoreReplies, index), 793 ) 794 .join(""); 795 796 return `<div class="sequoia-thread">${commentsHtml}</div>`; 797 } 798 799 /** 800 * Render a single comment 801 * @param {any} post - Post data 802 * @param {boolean} showThreadLine - Whether to show the connecting thread line 803 * @param {number} _index - Index in the flattened thread (0 = top-level) 804 */ 805 renderComment(post, showThreadLine = false, _index = 0) { 806 const author = post.author; 807 const displayName = author.displayName || author.handle; 808 const avatarHtml = author.avatar 809 ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />` 810 : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`; 811 812 const profileUrl = `https://bsky.app/profile/${author.did}`; 813 const textHtml = renderTextWithFacets(post.record.text, post.record.facets); 814 const timeAgo = formatRelativeTime(post.record.createdAt); 815 const threadLineHtml = showThreadLine 816 ? '<div class="sequoia-thread-line"></div>' 817 : ""; 818 819 return ` 820 <div class="sequoia-comment"> 821 <div class="sequoia-comment-avatar-column"> 822 ${avatarHtml} 823 ${threadLineHtml} 824 </div> 825 <div class="sequoia-comment-content"> 826 <div class="sequoia-comment-header"> 827 <a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author"> 828 ${escapeHtml(displayName)} 829 </a> 830 <span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span> 831 <span class="sequoia-comment-time">${timeAgo}</span> 832 </div> 833 <p class="sequoia-comment-text">${textHtml}</p> 834 </div> 835 </div> 836 `; 837 } 838 839 countComments(replies) { 840 let count = 0; 841 for (const reply of replies) { 842 count += 1; 843 const nested = reply.replies?.filter(isThreadViewPost) ?? []; 844 count += this.countComments(nested); 845 } 846 return count; 847 } 848} 849 850// Register the custom element 851if (typeof customElements !== "undefined") { 852 customElements.define("sequoia-comments", SequoiaComments); 853} 854 855// Export for module usage 856export { SequoiaComments };