A CLI for publishing standard.site documents to ATProto
at main 894 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 = 594 '<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>'; 595 596// ============================================================================ 597// Web Component 598// ============================================================================ 599 600// SSR-safe base class - use HTMLElement in browser, empty class in Node.js 601const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 602 603class SequoiaComments extends BaseElement { 604 constructor() { 605 super(); 606 const shadow = this.attachShadow({ mode: "open" }); 607 608 const styleTag = document.createElement("style"); 609 shadow.appendChild(styleTag); 610 styleTag.innerText = styles; 611 612 const container = document.createElement("div"); 613 shadow.appendChild(container); 614 container.className = "sequoia-comments-container"; 615 container.part = "container"; 616 617 this.commentsContainer = container; 618 this.state = { type: "loading" }; 619 this.abortController = null; 620 } 621 622 static get observedAttributes() { 623 return ["document-uri", "depth", "hide"]; 624 } 625 626 connectedCallback() { 627 this.render(); 628 this.loadComments(); 629 } 630 631 disconnectedCallback() { 632 this.abortController?.abort(); 633 } 634 635 attributeChangedCallback() { 636 if (this.isConnected) { 637 this.loadComments(); 638 } 639 } 640 641 get documentUri() { 642 // First check attribute 643 const attrUri = this.getAttribute("document-uri"); 644 if (attrUri) { 645 return attrUri; 646 } 647 648 // Then scan for link tag in document head 649 const linkTag = document.querySelector( 650 'link[rel="site.standard.document"]', 651 ); 652 return linkTag?.href ?? null; 653 } 654 655 get depth() { 656 const depthAttr = this.getAttribute("depth"); 657 return depthAttr ? parseInt(depthAttr, 10) : 6; 658 } 659 660 get hide() { 661 const hideAttr = this.getAttribute("hide"); 662 return hideAttr === "auto"; 663 } 664 665 async loadComments() { 666 // Cancel any in-flight request 667 this.abortController?.abort(); 668 this.abortController = new AbortController(); 669 670 this.state = { type: "loading" }; 671 this.render(); 672 673 const docUri = this.documentUri; 674 if (!docUri) { 675 this.state = { type: "no-document" }; 676 this.render(); 677 return; 678 } 679 680 try { 681 // Fetch the document record 682 const document = await getDocument(docUri); 683 684 // Check if document has a Bluesky post reference 685 if (!document.bskyPostRef) { 686 this.state = { type: "no-comments-enabled" }; 687 this.render(); 688 return; 689 } 690 691 const postUrl = buildBskyAppUrl(document.bskyPostRef.uri); 692 const blackskyPostUrl = buildBlackskyAppUrl(document.bskyPostRef.uri); 693 694 // Fetch the post thread 695 const thread = await getPostThread(document.bskyPostRef.uri, this.depth); 696 697 // Check if there are any replies 698 const replies = thread.replies?.filter(isThreadViewPost) ?? []; 699 if (replies.length === 0) { 700 this.state = { type: "empty", postUrl, blackskyPostUrl }; 701 this.render(); 702 return; 703 } 704 705 this.state = { type: "loaded", thread, postUrl, blackskyPostUrl }; 706 this.render(); 707 } catch (error) { 708 const message = 709 error instanceof Error ? error.message : "Failed to load comments"; 710 this.state = { type: "error", message }; 711 this.render(); 712 } 713 } 714 715 render() { 716 switch (this.state.type) { 717 case "loading": 718 this.commentsContainer.innerHTML = ` 719 <div class="sequoia-loading"> 720 <span class="sequoia-loading-spinner"></span> 721 Loading comments... 722 </div> 723 `; 724 break; 725 726 case "no-document": 727 this.commentsContainer.innerHTML = ` 728 <div class="sequoia-warning"> 729 No document found. Add a <code>&lt;link rel="site.standard.document" href="at://..."&gt;</code> tag to your page. 730 </div> 731 `; 732 if (this.hide) { 733 this.commentsContainer.style.display = "none"; 734 } 735 break; 736 737 case "no-comments-enabled": 738 this.commentsContainer.innerHTML = ` 739 <div class="sequoia-empty"> 740 Comments are not enabled for this post. 741 </div> 742 `; 743 break; 744 745 case "empty": 746 this.commentsContainer.innerHTML = ` 747 <div class="sequoia-comments-header"> 748 <h3 class="sequoia-comments-title">Comments</h3> 749 <div> 750 <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-bluesky"> 751 ${BLUESKY_ICON} 752 </a> 753 <a href="${this.state.blackskyPostUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-blacksky"> 754 ${BLACKSKY_ICON} 755 </a> 756 </div> 757 </div> 758 <div class="sequoia-empty"> 759 No comments yet. Be the first to reply on Bluesky! 760 </div> 761 `; 762 break; 763 764 case "error": 765 this.commentsContainer.innerHTML = ` 766 <div class="sequoia-error"> 767 Failed to load comments: ${escapeHtml(this.state.message)} 768 </div> 769 `; 770 break; 771 772 case "loaded": { 773 const replies = 774 this.state.thread.replies?.filter(isThreadViewPost) ?? []; 775 const threadsHtml = replies 776 .map((reply) => this.renderThread(reply)) 777 .join(""); 778 const commentCount = this.countComments(replies); 779 780 this.commentsContainer.innerHTML = ` 781 <div class="sequoia-comments-header"> 782 <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3> 783 <div> 784 <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-bluesky"> 785 ${BLUESKY_ICON} 786 </a> 787 <a href="${this.state.blackskyPostUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-blacksky"> 788 ${BLACKSKY_ICON} 789 </a> 790 </div> 791 </div> 792 <div class="sequoia-comments-list"> 793 ${threadsHtml} 794 </div> 795 `; 796 break; 797 } 798 } 799 } 800 801 /** 802 * Flatten a thread into a linear list of comments 803 * @param {ThreadViewPost} thread - Thread to flatten 804 * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments 805 */ 806 flattenThread(thread) { 807 const result = []; 808 const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; 809 810 result.push({ 811 post: thread.post, 812 hasMoreReplies: nestedReplies.length > 0, 813 }); 814 815 // Recursively flatten nested replies 816 for (const reply of nestedReplies) { 817 result.push(...this.flattenThread(reply)); 818 } 819 820 return result; 821 } 822 823 /** 824 * Render a complete thread (top-level comment + all nested replies) 825 */ 826 renderThread(thread) { 827 const flatComments = this.flattenThread(thread); 828 const commentsHtml = flatComments 829 .map((item, index) => 830 this.renderComment(item.post, item.hasMoreReplies, index), 831 ) 832 .join(""); 833 834 return `<div class="sequoia-thread">${commentsHtml}</div>`; 835 } 836 837 /** 838 * Render a single comment 839 * @param {any} post - Post data 840 * @param {boolean} showThreadLine - Whether to show the connecting thread line 841 * @param {number} _index - Index in the flattened thread (0 = top-level) 842 */ 843 renderComment(post, showThreadLine = false, _index = 0) { 844 const author = post.author; 845 const displayName = author.displayName || author.handle; 846 const avatarHtml = author.avatar 847 ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />` 848 : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`; 849 850 const profileUrl = `https://bsky.app/profile/${author.did}`; 851 const textHtml = renderTextWithFacets(post.record.text, post.record.facets); 852 const timeAgo = formatRelativeTime(post.record.createdAt); 853 const threadLineHtml = showThreadLine 854 ? '<div class="sequoia-thread-line"></div>' 855 : ""; 856 857 return ` 858 <div class="sequoia-comment"> 859 <div class="sequoia-comment-avatar-column"> 860 ${avatarHtml} 861 ${threadLineHtml} 862 </div> 863 <div class="sequoia-comment-content"> 864 <div class="sequoia-comment-header"> 865 <a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author"> 866 ${escapeHtml(displayName)} 867 </a> 868 <span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span> 869 <span class="sequoia-comment-time">${timeAgo}</span> 870 </div> 871 <p class="sequoia-comment-text">${textHtml}</p> 872 </div> 873 </div> 874 `; 875 } 876 877 countComments(replies) { 878 let count = 0; 879 for (const reply of replies) { 880 count += 1; 881 const nested = reply.replies?.filter(isThreadViewPost) ?? []; 882 count += this.countComments(nested); 883 } 884 return count; 885 } 886} 887 888// Register the custom element 889if (typeof customElements !== "undefined") { 890 customElements.define("sequoia-comments", SequoiaComments); 891} 892 893// Export for module usage 894export { SequoiaComments };