Fork of Chiri for Astro for my blog
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 893 lines 26 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 border: none; 117 border-radius: var(--sequoia-border-radius, 15px); 118 font-size: 0.875rem; 119 font-weight: 500; 120 cursor: pointer; 121 text-decoration: none; 122 transition: background-color 0.15s ease; 123 margin-left:10px; 124} 125 126.sequoia-reply-bluesky { 127 background: var(--sequoia-accent-color, #2563eb); 128 color: #ffffff; 129} 130 131.sequoia-reply-blacksky { 132 background: var(--sequoia-accent-color, #6060E9); 133 color: #ffffff; 134} 135 136.sequoia-reply-bluesky:hover { 137 background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); 138} 139 140.sequoia-reply-blacksky:hover { 141 background: color-mix(in srgb, var(--sequoia-accent-color, #5252c3) 85%, black); 142} 143 144.sequoia-reply-button svg { 145 width: 1rem; 146 height: 1rem; 147} 148 149.sequoia-comments-list { 150 display: flex; 151 flex-direction: column; 152} 153 154.sequoia-thread { 155 border-top: 1px solid var(--sequoia-border-color, #e5e7eb); 156 padding-bottom: 1rem; 157} 158 159.sequoia-thread + .sequoia-thread { 160 margin-top: 0.5rem; 161} 162 163.sequoia-thread:last-child { 164 border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb); 165} 166 167.sequoia-comment { 168 display: flex; 169 gap: 0.75rem; 170 padding-top: 1rem; 171} 172 173.sequoia-comment-avatar-column { 174 display: flex; 175 flex-direction: column; 176 align-items: center; 177 flex-shrink: 0; 178 width: 2.5rem; 179 position: relative; 180} 181 182.sequoia-comment-avatar { 183 width: 2.5rem; 184 height: 2.5rem; 185 border-radius: 50%; 186 background: var(--sequoia-border-color, #e5e7eb); 187 object-fit: cover; 188 flex-shrink: 0; 189 position: relative; 190 z-index: 1; 191} 192 193.sequoia-comment-avatar-placeholder { 194 width: 2.5rem; 195 height: 2.5rem; 196 border-radius: 50%; 197 background: var(--sequoia-border-color, #e5e7eb); 198 display: flex; 199 align-items: center; 200 justify-content: center; 201 flex-shrink: 0; 202 color: var(--sequoia-secondary-color, #6b7280); 203 font-weight: 600; 204 font-size: 1rem; 205 position: relative; 206 z-index: 1; 207} 208 209.sequoia-thread-line { 210 position: absolute; 211 top: 2.5rem; 212 bottom: calc(-1rem - 0.5rem); 213 left: 50%; 214 transform: translateX(-50%); 215 width: 2px; 216 background: var(--sequoia-border-color, #e5e7eb); 217} 218 219.sequoia-comment-content { 220 flex: 1; 221 min-width: 0; 222} 223 224.sequoia-comment-header { 225 display: flex; 226 align-items: baseline; 227 gap: 0.5rem; 228 margin-bottom: 0.25rem; 229 flex-wrap: wrap; 230} 231 232.sequoia-comment-author { 233 font-weight: 600; 234 color: var(--sequoia-fg-color, #1f2937); 235 text-decoration: none; 236 overflow: hidden; 237 text-overflow: ellipsis; 238 white-space: nowrap; 239} 240 241.sequoia-comment-author:hover { 242 color: var(--sequoia-accent-color, #2563eb); 243} 244 245.sequoia-comment-handle { 246 font-size: 0.875rem; 247 color: var(--sequoia-secondary-color, #6b7280); 248 overflow: hidden; 249 text-overflow: ellipsis; 250 white-space: nowrap; 251} 252 253.sequoia-comment-time { 254 font-size: 0.875rem; 255 color: var(--sequoia-secondary-color, #6b7280); 256 flex-shrink: 0; 257} 258 259.sequoia-comment-time::before { 260 content: "·"; 261 margin-right: 0.5rem; 262} 263 264.sequoia-comment-text { 265 margin: 0; 266 white-space: pre-wrap; 267 word-wrap: break-word; 268} 269 270.sequoia-comment-text a { 271 color: var(--sequoia-accent-color, #2563eb); 272 text-decoration: none; 273} 274 275.sequoia-comment-text a:hover { 276 text-decoration: underline; 277} 278 279.sequoia-bsky-logo { 280 width: 1rem; 281 height: 1rem; 282} 283`; 284 285// ============================================================================ 286// Utility Functions 287// ============================================================================ 288 289/** 290 * Format a relative time string (e.g., "2 hours ago") 291 * @param {string} dateString - ISO date string 292 * @returns {string} Formatted relative time 293 */ 294function formatRelativeTime(dateString) { 295 const date = new Date(dateString); 296 const now = new Date(); 297 const diffMs = now.getTime() - date.getTime(); 298 const diffSeconds = Math.floor(diffMs / 1000); 299 const diffMinutes = Math.floor(diffSeconds / 60); 300 const diffHours = Math.floor(diffMinutes / 60); 301 const diffDays = Math.floor(diffHours / 24); 302 const diffWeeks = Math.floor(diffDays / 7); 303 const diffMonths = Math.floor(diffDays / 30); 304 const diffYears = Math.floor(diffDays / 365); 305 306 if (diffSeconds < 60) { 307 return "just now"; 308 } 309 if (diffMinutes < 60) { 310 return `${diffMinutes}m ago`; 311 } 312 if (diffHours < 24) { 313 return `${diffHours}h ago`; 314 } 315 if (diffDays < 7) { 316 return `${diffDays}d ago`; 317 } 318 if (diffWeeks < 4) { 319 return `${diffWeeks}w ago`; 320 } 321 if (diffMonths < 12) { 322 return `${diffMonths}mo ago`; 323 } 324 return `${diffYears}y ago`; 325} 326 327/** 328 * Escape HTML special characters 329 * @param {string} text - Text to escape 330 * @returns {string} Escaped HTML 331 */ 332function escapeHtml(text) { 333 const div = document.createElement("div"); 334 div.textContent = text; 335 return div.innerHTML; 336} 337 338/** 339 * Convert post text with facets to HTML 340 * @param {string} text - Post text 341 * @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets 342 * @returns {string} HTML string with links 343 */ 344function renderTextWithFacets(text, facets) { 345 if (!facets || facets.length === 0) { 346 return escapeHtml(text); 347 } 348 349 // Convert text to bytes for proper indexing 350 const encoder = new TextEncoder(); 351 const decoder = new TextDecoder(); 352 const textBytes = encoder.encode(text); 353 354 // Sort facets by start index 355 const sortedFacets = [...facets].sort( 356 (a, b) => a.index.byteStart - b.index.byteStart, 357 ); 358 359 let result = ""; 360 let lastEnd = 0; 361 362 for (const facet of sortedFacets) { 363 const { byteStart, byteEnd } = facet.index; 364 365 // Add text before this facet 366 if (byteStart > lastEnd) { 367 const beforeBytes = textBytes.slice(lastEnd, byteStart); 368 result += escapeHtml(decoder.decode(beforeBytes)); 369 } 370 371 // Get the facet text 372 const facetBytes = textBytes.slice(byteStart, byteEnd); 373 const facetText = decoder.decode(facetBytes); 374 375 // Find the first renderable feature 376 const feature = facet.features[0]; 377 if (feature) { 378 if (feature.$type === "app.bsky.richtext.facet#link") { 379 result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 380 } else if (feature.$type === "app.bsky.richtext.facet#mention") { 381 result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 382 } else if (feature.$type === "app.bsky.richtext.facet#tag") { 383 result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 384 } else { 385 result += escapeHtml(facetText); 386 } 387 } else { 388 result += escapeHtml(facetText); 389 } 390 391 lastEnd = byteEnd; 392 } 393 394 // Add remaining text 395 if (lastEnd < textBytes.length) { 396 const remainingBytes = textBytes.slice(lastEnd); 397 result += escapeHtml(decoder.decode(remainingBytes)); 398 } 399 400 return result; 401} 402 403/** 404 * Get initials from a name for avatar placeholder 405 * @param {string} name - Display name 406 * @returns {string} Initials (1-2 characters) 407 */ 408function getInitials(name) { 409 const parts = name.trim().split(/\s+/); 410 if (parts.length >= 2) { 411 return (parts[0][0] + parts[1][0]).toUpperCase(); 412 } 413 return name.substring(0, 2).toUpperCase(); 414} 415 416// ============================================================================ 417// AT Protocol Client Functions 418// ============================================================================ 419 420/** 421 * Parse an AT URI into its components 422 * Format: at://did/collection/rkey 423 * @param {string} atUri - AT Protocol URI 424 * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null 425 */ 426function parseAtUri(atUri) { 427 const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 428 if (!match) return null; 429 return { 430 did: match[1], 431 collection: match[2], 432 rkey: match[3], 433 }; 434} 435 436/** 437 * Resolve a DID to its PDS URL 438 * Supports did:plc and did:web methods 439 * @param {string} did - Decentralized Identifier 440 * @returns {Promise<string>} PDS URL 441 */ 442async function resolvePDS(did) { 443 let pdsUrl; 444 445 if (did.startsWith("did:plc:")) { 446 // Fetch DID document from plc.directory 447 const didDocUrl = `https://plc.directory/${did}`; 448 const didDocResponse = await fetch(didDocUrl); 449 if (!didDocResponse.ok) { 450 throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 451 } 452 const didDoc = await didDocResponse.json(); 453 454 // Find the PDS service endpoint 455 const pdsService = didDoc.service?.find( 456 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 457 ); 458 pdsUrl = pdsService?.serviceEndpoint; 459 } else if (did.startsWith("did:web:")) { 460 // For did:web, fetch the DID document from the domain 461 const domain = did.replace("did:web:", ""); 462 const didDocUrl = `https://${domain}/.well-known/did.json`; 463 const didDocResponse = await fetch(didDocUrl); 464 if (!didDocResponse.ok) { 465 throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 466 } 467 const didDoc = await didDocResponse.json(); 468 469 const pdsService = didDoc.service?.find( 470 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 471 ); 472 pdsUrl = pdsService?.serviceEndpoint; 473 } else { 474 throw new Error(`Unsupported DID method: ${did}`); 475 } 476 477 if (!pdsUrl) { 478 throw new Error("Could not find PDS URL for user"); 479 } 480 481 return pdsUrl; 482} 483 484/** 485 * Fetch a record from a PDS using the public API 486 * @param {string} did - DID of the repository owner 487 * @param {string} collection - Collection name 488 * @param {string} rkey - Record key 489 * @returns {Promise<any>} Record value 490 */ 491async function getRecord(did, collection, rkey) { 492 const pdsUrl = await resolvePDS(did); 493 494 const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`); 495 url.searchParams.set("repo", did); 496 url.searchParams.set("collection", collection); 497 url.searchParams.set("rkey", rkey); 498 499 const response = await fetch(url.toString()); 500 if (!response.ok) { 501 throw new Error(`Failed to fetch record: ${response.status}`); 502 } 503 504 const data = await response.json(); 505 return data.value; 506} 507 508/** 509 * Fetch a document record from its AT URI 510 * @param {string} atUri - AT Protocol URI for the document 511 * @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 512 */ 513async function getDocument(atUri) { 514 const parsed = parseAtUri(atUri); 515 if (!parsed) { 516 throw new Error(`Invalid AT URI: ${atUri}`); 517 } 518 519 return getRecord(parsed.did, parsed.collection, parsed.rkey); 520} 521 522/** 523 * Fetch a post thread from the public Bluesky API 524 * @param {string} postUri - AT Protocol URI for the post 525 * @param {number} [depth=6] - Maximum depth of replies to fetch 526 * @returns {Promise<ThreadViewPost>} Thread view post 527 */ 528async function getPostThread(postUri, depth = 6) { 529 const url = new URL( 530 "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread", 531 ); 532 url.searchParams.set("uri", postUri); 533 url.searchParams.set("depth", depth.toString()); 534 535 const response = await fetch(url.toString()); 536 if (!response.ok) { 537 throw new Error(`Failed to fetch post thread: ${response.status}`); 538 } 539 540 const data = await response.json(); 541 542 if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { 543 throw new Error("Post not found or blocked"); 544 } 545 546 return data.thread; 547} 548 549/** 550 * Build a Bluesky app URL for a post 551 * @param {string} postUri - AT Protocol URI for the post 552 * @returns {string} Bluesky app URL 553 */ 554function buildBskyAppUrl(postUri) { 555 const parsed = parseAtUri(postUri); 556 if (!parsed) { 557 throw new Error(`Invalid post URI: ${postUri}`); 558 } 559 560 return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; 561} 562 563/** 564 * Build a Blacksky app URL for a post 565 * @param {string} postUri - AT Protocol URI for the post 566 * @returns {string} Blacksky app URL 567 */ 568function buildBlackskyAppUrl(postUri) { 569 const parsed = parseAtUri(postUri); 570 if (!parsed) { 571 throw new Error(`Invalid post URI: ${postUri}`); 572 } 573 574 return `https://blacksky.community/profile/${parsed.did}/post/${parsed.rkey}`; 575} 576 577/** 578 * Type guard for ThreadViewPost 579 * @param {any} post - Post to check 580 * @returns {boolean} True if post is a ThreadViewPost 581 */ 582function isThreadViewPost(post) { 583 return post?.$type === "app.bsky.feed.defs#threadViewPost"; 584} 585 586// ============================================================================ 587// Bluesky Icon 588// ============================================================================ 589 590const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 591 <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"/> 592</svg>`; 593const BLACKSKY_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.0620117 0.348442 87.9941 74.9653"><path d="M41.9565 74.9643L24.0161 74.9653L41.9565 74.9643ZM63.8511 74.9653H45.9097L63.8501 74.9643V57.3286H63.8511V74.9653ZM45.9097 44.5893C45.9099 49.2737 49.7077 53.0707 54.3921 53.0707H63.8501V57.3286H54.3921C49.7077 57.3286 45.9099 61.1257 45.9097 65.81V74.9643H41.9565V65.81C41.9563 61.1258 38.1593 57.3287 33.4751 57.3286H24.0161V53.0707H33.4741C38.1587 53.0707 41.9565 49.2729 41.9565 44.5883V35.1303H45.9097V44.5893ZM63.8511 53.0707H63.8501V35.1303H63.8511V53.0707Z" fill="white"></path><path d="M52.7272 9.83198C49.4148 13.1445 49.4148 18.5151 52.7272 21.8275L59.4155 28.5158L56.4051 31.5262L49.7169 24.8379C46.4044 21.5254 41.0338 21.5254 37.7213 24.8379L31.2482 31.3111L28.4527 28.5156L34.9259 22.0424C38.2383 18.7299 38.2383 13.3594 34.9259 10.0469L28.2378 3.35883L31.2482 0.348442L37.9365 7.03672C41.2489 10.3492 46.6195 10.3492 49.932 7.03672L56.6203 0.348442L59.4155 3.14371L52.7272 9.83198Z" fill="white"/><path d="M24.3831 23.2335C23.1706 27.7584 25.8559 32.4095 30.3808 33.6219L39.5172 36.07L38.4154 40.182L29.2793 37.734C24.7544 36.5215 20.1033 39.2068 18.8909 43.7317L16.5215 52.5745L12.7028 51.5513L15.0721 42.7088C16.2846 38.1839 13.5993 33.5328 9.07434 32.3204L-0.0620117 29.8723L1.03987 25.76L10.1762 28.2081C14.7011 29.4206 19.3522 26.7352 20.5647 22.2103L23.0127 13.074L26.8311 14.0971L24.3831 23.2335Z" fill="white"/><path d="M67.3676 22.0297C68.5801 26.5546 73.2311 29.2399 77.756 28.0275L86.8923 25.5794L87.9941 29.6914L78.8578 32.1394C74.3329 33.3519 71.6476 38.003 72.86 42.5279L75.2294 51.3707L71.411 52.3938L69.0417 43.5513C67.8293 39.0264 63.1782 36.3411 58.6533 37.5535L49.5169 40.0016L48.415 35.8894L57.5514 33.4413C62.0763 32.2288 64.7616 27.5778 63.5492 23.0528L61.1011 13.9165L64.9195 12.8934L67.3676 22.0297Z" fill="white"/></svg>'; 594 595// ============================================================================ 596// Web Component 597// ============================================================================ 598 599// SSR-safe base class - use HTMLElement in browser, empty class in Node.js 600const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 601 602class SequoiaComments extends BaseElement { 603 constructor() { 604 super(); 605 const shadow = this.attachShadow({ mode: "open" }); 606 607 const styleTag = document.createElement("style"); 608 shadow.appendChild(styleTag); 609 styleTag.innerText = styles; 610 611 const container = document.createElement("div"); 612 shadow.appendChild(container); 613 container.className = "sequoia-comments-container"; 614 container.part = "container"; 615 616 this.commentsContainer = container; 617 this.state = { type: "loading" }; 618 this.abortController = null; 619 } 620 621 static get observedAttributes() { 622 return ["document-uri", "depth", "hide"]; 623 } 624 625 connectedCallback() { 626 this.render(); 627 this.loadComments(); 628 } 629 630 disconnectedCallback() { 631 this.abortController?.abort(); 632 } 633 634 attributeChangedCallback() { 635 if (this.isConnected) { 636 this.loadComments(); 637 } 638 } 639 640 get documentUri() { 641 // First check attribute 642 const attrUri = this.getAttribute("document-uri"); 643 if (attrUri) { 644 return attrUri; 645 } 646 647 // Then scan for link tag in document head 648 const linkTag = document.querySelector( 649 'link[rel="site.standard.document"]', 650 ); 651 return linkTag?.href ?? null; 652 } 653 654 get depth() { 655 const depthAttr = this.getAttribute("depth"); 656 return depthAttr ? parseInt(depthAttr, 10) : 6; 657 } 658 659 get hide() { 660 const hideAttr = this.getAttribute("hide"); 661 return hideAttr === "auto"; 662 } 663 664 async loadComments() { 665 // Cancel any in-flight request 666 this.abortController?.abort(); 667 this.abortController = new AbortController(); 668 669 this.state = { type: "loading" }; 670 this.render(); 671 672 const docUri = this.documentUri; 673 if (!docUri) { 674 this.state = { type: "no-document" }; 675 this.render(); 676 return; 677 } 678 679 try { 680 // Fetch the document record 681 const document = await getDocument(docUri); 682 683 // Check if document has a Bluesky post reference 684 if (!document.bskyPostRef) { 685 this.state = { type: "no-comments-enabled" }; 686 this.render(); 687 return; 688 } 689 690 const postUrl = buildBskyAppUrl(document.bskyPostRef.uri); 691 const blackskyPostUrl = buildBlackskyAppUrl(document.bskyPostRef.uri); 692 693 // Fetch the post thread 694 const thread = await getPostThread(document.bskyPostRef.uri, this.depth); 695 696 // Check if there are any replies 697 const replies = thread.replies?.filter(isThreadViewPost) ?? []; 698 if (replies.length === 0) { 699 this.state = { type: "empty", postUrl, blackskyPostUrl }; 700 this.render(); 701 return; 702 } 703 704 this.state = { type: "loaded", thread, postUrl, blackskyPostUrl }; 705 this.render(); 706 } catch (error) { 707 const message = 708 error instanceof Error ? error.message : "Failed to load comments"; 709 this.state = { type: "error", message }; 710 this.render(); 711 } 712 } 713 714 render() { 715 switch (this.state.type) { 716 case "loading": 717 this.commentsContainer.innerHTML = ` 718 <div class="sequoia-loading"> 719 <span class="sequoia-loading-spinner"></span> 720 Loading comments... 721 </div> 722 `; 723 break; 724 725 case "no-document": 726 this.commentsContainer.innerHTML = ` 727 <div class="sequoia-warning"> 728 No document found. Add a <code>&lt;link rel="site.standard.document" href="at://..."&gt;</code> tag to your page. 729 </div> 730 `; 731 if (this.hide) { 732 this.commentsContainer.style.display = "none"; 733 } 734 break; 735 736 case "no-comments-enabled": 737 this.commentsContainer.innerHTML = ` 738 <div class="sequoia-empty"> 739 Comments are not enabled for this post. 740 </div> 741 `; 742 break; 743 744 case "empty": 745 this.commentsContainer.innerHTML = ` 746 <div class="sequoia-comments-header"> 747 <h3 class="sequoia-comments-title">Comments</h3> 748 <div> 749 <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-bluesky"> 750 ${BLUESKY_ICON} 751 </a> 752 <a href="${this.state.blackskyPostUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-blacksky"> 753 ${BLACKSKY_ICON} 754 </a> 755 </div> 756 </div> 757 <div class="sequoia-empty"> 758 No comments yet. Be the first to reply on Bluesky! 759 </div> 760 `; 761 break; 762 763 case "error": 764 this.commentsContainer.innerHTML = ` 765 <div class="sequoia-error"> 766 Failed to load comments: ${escapeHtml(this.state.message)} 767 </div> 768 `; 769 break; 770 771 case "loaded": { 772 const replies = 773 this.state.thread.replies?.filter(isThreadViewPost) ?? []; 774 const threadsHtml = replies 775 .map((reply) => this.renderThread(reply)) 776 .join(""); 777 const commentCount = this.countComments(replies); 778 779 this.commentsContainer.innerHTML = ` 780 <div class="sequoia-comments-header"> 781 <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3> 782 <div> 783 <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-bluesky"> 784 ${BLUESKY_ICON} 785 </a> 786 <a href="${this.state.blackskyPostUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-blacksky"> 787 ${BLACKSKY_ICON} 788 </a> 789 </div> 790 </div> 791 <div class="sequoia-comments-list"> 792 ${threadsHtml} 793 </div> 794 `; 795 break; 796 } 797 } 798 } 799 800 /** 801 * Flatten a thread into a linear list of comments 802 * @param {ThreadViewPost} thread - Thread to flatten 803 * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments 804 */ 805 flattenThread(thread) { 806 const result = []; 807 const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; 808 809 result.push({ 810 post: thread.post, 811 hasMoreReplies: nestedReplies.length > 0, 812 }); 813 814 // Recursively flatten nested replies 815 for (const reply of nestedReplies) { 816 result.push(...this.flattenThread(reply)); 817 } 818 819 return result; 820 } 821 822 /** 823 * Render a complete thread (top-level comment + all nested replies) 824 */ 825 renderThread(thread) { 826 const flatComments = this.flattenThread(thread); 827 const commentsHtml = flatComments 828 .map((item, index) => 829 this.renderComment(item.post, item.hasMoreReplies, index), 830 ) 831 .join(""); 832 833 return `<div class="sequoia-thread">${commentsHtml}</div>`; 834 } 835 836 /** 837 * Render a single comment 838 * @param {any} post - Post data 839 * @param {boolean} showThreadLine - Whether to show the connecting thread line 840 * @param {number} _index - Index in the flattened thread (0 = top-level) 841 */ 842 renderComment(post, showThreadLine = false, _index = 0) { 843 const author = post.author; 844 const displayName = author.displayName || author.handle; 845 const avatarHtml = author.avatar 846 ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />` 847 : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`; 848 849 const profileUrl = `https://bsky.app/profile/${author.did}`; 850 const textHtml = renderTextWithFacets(post.record.text, post.record.facets); 851 const timeAgo = formatRelativeTime(post.record.createdAt); 852 const threadLineHtml = showThreadLine 853 ? '<div class="sequoia-thread-line"></div>' 854 : ""; 855 856 return ` 857 <div class="sequoia-comment"> 858 <div class="sequoia-comment-avatar-column"> 859 ${avatarHtml} 860 ${threadLineHtml} 861 </div> 862 <div class="sequoia-comment-content"> 863 <div class="sequoia-comment-header"> 864 <a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author"> 865 ${escapeHtml(displayName)} 866 </a> 867 <span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span> 868 <span class="sequoia-comment-time">${timeAgo}</span> 869 </div> 870 <p class="sequoia-comment-text">${textHtml}</p> 871 </div> 872 </div> 873 `; 874 } 875 876 countComments(replies) { 877 let count = 0; 878 for (const reply of replies) { 879 count += 1; 880 const nested = reply.replies?.filter(isThreadViewPost) ?? []; 881 count += this.countComments(nested); 882 } 883 return count; 884 } 885} 886 887// Register the custom element 888if (typeof customElements !== "undefined") { 889 customElements.define("sequoia-comments", SequoiaComments); 890} 891 892// Export for module usage 893export { SequoiaComments };