A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
58
fork

Configure Feed

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

at main 1048 lines 31 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 * Custom reply button: 15 * Place any element with slot="reply-button" to replace the default Bluesky/Blacksky buttons. 16 * It stays in the light DOM, so your page CSS applies to it normally. 17 * Only practical with post-uri, since that's the only time the URL is known at authoring time: 18 * <sequoia-comments post-uri="https://bsky.app/profile/.../post/..."> 19 * <a slot="reply-button" href="https://bsky.app/profile/.../post/...">Reply</a> 20 * </sequoia-comments> 21 * 22 * Attributes: 23 * - post-uri: Bluesky post as AT-URI (at://...) or bsky.app URL — skips PDS document lookup 24 * - document-uri: AT Protocol URI for the document (optional if link tag exists) 25 * - depth: Maximum depth of nested replies to fetch (default: 6) 26 * - hide: Set to "auto" to hide if no document link is detected 27 * 28 * CSS Custom Properties: 29 * - --sequoia-fg-color: Text color (default: #1f2937) 30 * - --sequoia-bg-color: Background color (default: #ffffff) 31 * - --sequoia-border-color: Border color (default: #e5e7eb) 32 * - --sequoia-accent-color: Accent/link color (default: #2563eb) 33 * - --sequoia-secondary-color: Secondary text color (default: #6b7280) 34 * - --sequoia-font-family: Font family (default: system-ui stack) 35 * - --sequoia-border-radius: Border radius (default: 8px) 36 */ 37 38// ============================================================================ 39// Styles 40// ============================================================================ 41 42const styles = ` 43:host { 44 display: block; 45 font-family: var(--sequoia-font-family, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); 46 color: var(--sequoia-fg-color, #1f2937); 47 line-height: 1.5; 48} 49 50* { 51 box-sizing: border-box; 52} 53 54.sequoia-comments-container { 55 max-width: 100%; 56} 57 58.sequoia-loading, 59.sequoia-error, 60.sequoia-empty, 61.sequoia-warning { 62 padding: 1rem; 63 border-radius: var(--sequoia-border-radius, 8px); 64 text-align: center; 65} 66 67.sequoia-loading { 68 background: var(--sequoia-bg-color, #ffffff); 69 border: 1px solid var(--sequoia-border-color, #e5e7eb); 70 color: var(--sequoia-secondary-color, #6b7280); 71} 72 73.sequoia-loading-spinner { 74 display: inline-block; 75 width: 1.25rem; 76 height: 1.25rem; 77 border: 2px solid var(--sequoia-border-color, #e5e7eb); 78 border-top-color: var(--sequoia-accent-color, #2563eb); 79 border-radius: 50%; 80 animation: sequoia-spin 0.8s linear infinite; 81 margin-right: 0.5rem; 82 vertical-align: middle; 83} 84 85@keyframes sequoia-spin { 86 to { transform: rotate(360deg); } 87} 88 89.sequoia-error { 90 background: #fef2f2; 91 border: 1px solid #fecaca; 92 color: #dc2626; 93} 94 95.sequoia-warning { 96 background: #fffbeb; 97 border: 1px solid #fde68a; 98 color: #d97706; 99} 100 101.sequoia-empty { 102 background: var(--sequoia-bg-color, #ffffff); 103 border: 1px solid var(--sequoia-border-color, #e5e7eb); 104 color: var(--sequoia-secondary-color, #6b7280); 105} 106 107.sequoia-comments-header { 108 display: flex; 109 justify-content: space-between; 110 align-items: center; 111 margin-bottom: 1rem; 112 padding-bottom: 0.75rem; 113} 114 115.sequoia-comments-title { 116 font-size: 1.125rem; 117 font-weight: 600; 118 margin: 0; 119} 120 121.sequoia-reply-button { 122 display: inline-flex; 123 align-items: center; 124 gap: 0.375rem; 125 padding: 0.5rem 1rem; 126 border: none; 127 border-radius: var(--sequoia-border-radius, 15px); 128 font-size: 0.875rem; 129 font-weight: 500; 130 cursor: pointer; 131 text-decoration: none; 132 transition: background-color 0.15s ease; 133 margin-left:10px; 134} 135 136.sequoia-reply-bluesky { 137 background: var(--sequoia-accent-color, #2563eb); 138 color: #ffffff; 139} 140 141.sequoia-reply-blacksky { 142 background: var(--sequoia-accent-color, #6060E9); 143 color: #ffffff; 144} 145 146.sequoia-reply-bluesky:hover { 147 background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); 148} 149 150.sequoia-reply-blacksky:hover { 151 background: color-mix(in srgb, var(--sequoia-accent-color, #5252c3) 85%, black); 152} 153 154.sequoia-reply-button svg { 155 width: 1rem; 156 height: 1rem; 157} 158 159.sequoia-comments-list { 160 display: flex; 161 flex-direction: column; 162} 163 164.sequoia-thread { 165 border-top: 1px solid var(--sequoia-border-color, #e5e7eb); 166 padding-bottom: 1rem; 167} 168 169.sequoia-thread + .sequoia-thread { 170 margin-top: 0.5rem; 171} 172 173.sequoia-thread:last-child { 174 border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb); 175} 176 177.sequoia-comment { 178 display: flex; 179 gap: 0.75rem; 180 padding-top: 1rem; 181} 182 183.sequoia-comment-avatar-column { 184 display: flex; 185 flex-direction: column; 186 align-items: center; 187 flex-shrink: 0; 188 width: 2.5rem; 189 position: relative; 190} 191 192.sequoia-comment-avatar { 193 width: 2.5rem; 194 height: 2.5rem; 195 border-radius: 50%; 196 background: var(--sequoia-border-color, #e5e7eb); 197 object-fit: cover; 198 flex-shrink: 0; 199 position: relative; 200 z-index: 1; 201} 202 203.sequoia-comment-avatar-placeholder { 204 width: 2.5rem; 205 height: 2.5rem; 206 border-radius: 50%; 207 background: var(--sequoia-border-color, #e5e7eb); 208 display: flex; 209 align-items: center; 210 justify-content: center; 211 flex-shrink: 0; 212 color: var(--sequoia-secondary-color, #6b7280); 213 font-weight: 600; 214 font-size: 1rem; 215 position: relative; 216 z-index: 1; 217} 218 219.sequoia-thread-line { 220 position: absolute; 221 top: 2.5rem; 222 bottom: calc(-1rem - 0.5rem); 223 left: 50%; 224 transform: translateX(-50%); 225 width: 2px; 226 background: var(--sequoia-border-color, #e5e7eb); 227} 228 229.sequoia-comment-content { 230 flex: 1; 231 min-width: 0; 232} 233 234.sequoia-comment-header { 235 display: flex; 236 align-items: baseline; 237 gap: 0.5rem; 238 margin-bottom: 0.25rem; 239 flex-wrap: wrap; 240} 241 242.sequoia-comment-author { 243 font-weight: 600; 244 color: var(--sequoia-fg-color, #1f2937); 245 text-decoration: none; 246 overflow: hidden; 247 text-overflow: ellipsis; 248 white-space: nowrap; 249} 250 251.sequoia-comment-author:hover { 252 color: var(--sequoia-accent-color, #2563eb); 253} 254 255.sequoia-comment-handle { 256 font-size: 0.875rem; 257 color: var(--sequoia-secondary-color, #6b7280); 258 overflow: hidden; 259 text-overflow: ellipsis; 260 white-space: nowrap; 261} 262 263.sequoia-comment-handle::after { 264 content: "·"; 265 margin-left: 0.5rem; 266} 267 268.sequoia-comment-time { 269 font-size: 0.875rem; 270 color: var(--sequoia-secondary-color, #6b7280); 271 flex-shrink: 0; 272} 273 274.sequoia-comment-text { 275 margin: 0; 276 white-space: pre-wrap; 277 word-wrap: break-word; 278} 279 280.sequoia-comment-text a { 281 color: var(--sequoia-accent-color, #2563eb); 282 text-decoration: none; 283} 284 285.sequoia-comment-text a:hover { 286 text-decoration: underline; 287} 288 289.sequoia-bsky-logo { 290 width: 1rem; 291 height: 1rem; 292} 293 294.sequoia-quotes-section { 295 margin-top: 1.75rem; 296} 297 298.sequoia-quotes-header { 299 font-size: 0.75rem; 300 font-weight: 600; 301 color: var(--sequoia-secondary-color, #6b7280); 302 letter-spacing: 0.05em; 303 text-transform: uppercase; 304 margin: 0; 305 padding-bottom: 0.75rem; 306 border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb); 307} 308 309a.sequoia-comment-time { 310 text-decoration: none; 311 color: var(--sequoia-secondary-color, #6b7280); 312} 313 314a.sequoia-comment-time:hover { 315 text-decoration: underline; 316} 317`; 318 319// ============================================================================ 320// Utility Functions 321// ============================================================================ 322 323/** 324 * Format a relative time string (e.g., "2 hours ago") 325 * @param {string} dateString - ISO date string 326 * @returns {string} Formatted relative time 327 */ 328function formatRelativeTime(dateString) { 329 const date = new Date(dateString); 330 const now = new Date(); 331 const diffMs = now.getTime() - date.getTime(); 332 const diffSeconds = Math.floor(diffMs / 1000); 333 const diffMinutes = Math.floor(diffSeconds / 60); 334 const diffHours = Math.floor(diffMinutes / 60); 335 const diffDays = Math.floor(diffHours / 24); 336 const diffWeeks = Math.floor(diffDays / 7); 337 const diffMonths = Math.floor(diffDays / 30); 338 const diffYears = Math.floor(diffDays / 365); 339 340 if (diffSeconds < 60) { 341 return "just now"; 342 } 343 if (diffMinutes < 60) { 344 return `${diffMinutes}m ago`; 345 } 346 if (diffHours < 24) { 347 return `${diffHours}h ago`; 348 } 349 if (diffDays < 7) { 350 return `${diffDays}d ago`; 351 } 352 if (diffWeeks < 4) { 353 return `${diffWeeks}w ago`; 354 } 355 if (diffMonths < 12) { 356 return `${diffMonths}mo ago`; 357 } 358 return `${diffYears}y ago`; 359} 360 361/** 362 * Escape HTML special characters 363 * @param {string} text - Text to escape 364 * @returns {string} Escaped HTML 365 */ 366function escapeHtml(text) { 367 const div = document.createElement("div"); 368 div.textContent = text; 369 return div.innerHTML; 370} 371 372/** 373 * Convert post text with facets to HTML 374 * @param {string} text - Post text 375 * @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets 376 * @returns {string} HTML string with links 377 */ 378function renderTextWithFacets(text, facets) { 379 if (!facets || facets.length === 0) { 380 return escapeHtml(text); 381 } 382 383 // Convert text to bytes for proper indexing 384 const encoder = new TextEncoder(); 385 const decoder = new TextDecoder(); 386 const textBytes = encoder.encode(text); 387 388 // Sort facets by start index 389 const sortedFacets = [...facets].sort( 390 (a, b) => a.index.byteStart - b.index.byteStart, 391 ); 392 393 let result = ""; 394 let lastEnd = 0; 395 396 for (const facet of sortedFacets) { 397 const { byteStart, byteEnd } = facet.index; 398 399 // Add text before this facet 400 if (byteStart > lastEnd) { 401 const beforeBytes = textBytes.slice(lastEnd, byteStart); 402 result += escapeHtml(decoder.decode(beforeBytes)); 403 } 404 405 // Get the facet text 406 const facetBytes = textBytes.slice(byteStart, byteEnd); 407 const facetText = decoder.decode(facetBytes); 408 409 // Find the first renderable feature 410 const feature = facet.features[0]; 411 if (feature) { 412 if (feature.$type === "app.bsky.richtext.facet#link") { 413 result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 414 } else if (feature.$type === "app.bsky.richtext.facet#mention") { 415 result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 416 } else if (feature.$type === "app.bsky.richtext.facet#tag") { 417 result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; 418 } else { 419 result += escapeHtml(facetText); 420 } 421 } else { 422 result += escapeHtml(facetText); 423 } 424 425 lastEnd = byteEnd; 426 } 427 428 // Add remaining text 429 if (lastEnd < textBytes.length) { 430 const remainingBytes = textBytes.slice(lastEnd); 431 result += escapeHtml(decoder.decode(remainingBytes)); 432 } 433 434 return result; 435} 436 437/** 438 * Get initials from a name for avatar placeholder 439 * @param {string} name - Display name 440 * @returns {string} Initials (1-2 characters) 441 */ 442function getInitials(name) { 443 const parts = name.trim().split(/\s+/); 444 if (parts.length >= 2) { 445 return (parts[0][0] + parts[1][0]).toUpperCase(); 446 } 447 return name.substring(0, 2).toUpperCase(); 448} 449 450// ============================================================================ 451// AT Protocol Client Functions 452// ============================================================================ 453 454/** 455 * Parse an AT URI into its components 456 * Format: at://did/collection/rkey 457 * @param {string} atUri - AT Protocol URI 458 * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null 459 */ 460function parseAtUri(atUri) { 461 const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 462 if (!match) return null; 463 return { 464 did: match[1], 465 collection: match[2], 466 rkey: match[3], 467 }; 468} 469 470/** 471 * Resolve a DID to its PDS URL 472 * Supports did:plc and did:web methods 473 * @param {string} did - Decentralized Identifier 474 * @returns {Promise<string>} PDS URL 475 */ 476async function resolvePDS(did) { 477 let pdsUrl; 478 479 if (did.startsWith("did:plc:")) { 480 // Fetch DID document from plc.directory 481 const didDocUrl = `https://plc.directory/${did}`; 482 const didDocResponse = await fetch(didDocUrl); 483 if (!didDocResponse.ok) { 484 throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 485 } 486 const didDoc = await didDocResponse.json(); 487 488 // Find the PDS service endpoint 489 const pdsService = didDoc.service?.find( 490 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 491 ); 492 pdsUrl = pdsService?.serviceEndpoint; 493 } else if (did.startsWith("did:web:")) { 494 // For did:web, fetch the DID document from the domain 495 const domain = did.replace("did:web:", ""); 496 const didDocUrl = `https://${domain}/.well-known/did.json`; 497 const didDocResponse = await fetch(didDocUrl); 498 if (!didDocResponse.ok) { 499 throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 500 } 501 const didDoc = await didDocResponse.json(); 502 503 const pdsService = didDoc.service?.find( 504 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 505 ); 506 pdsUrl = pdsService?.serviceEndpoint; 507 } else { 508 throw new Error(`Unsupported DID method: ${did}`); 509 } 510 511 if (!pdsUrl) { 512 throw new Error("Could not find PDS URL for user"); 513 } 514 515 return pdsUrl; 516} 517 518/** 519 * Fetch a record from a PDS using the public API 520 * @param {string} did - DID of the repository owner 521 * @param {string} collection - Collection name 522 * @param {string} rkey - Record key 523 * @returns {Promise<any>} Record value 524 */ 525async function getRecord(did, collection, rkey) { 526 const pdsUrl = await resolvePDS(did); 527 528 const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`); 529 url.searchParams.set("repo", did); 530 url.searchParams.set("collection", collection); 531 url.searchParams.set("rkey", rkey); 532 533 const response = await fetch(url.toString()); 534 if (!response.ok) { 535 throw new Error(`Failed to fetch record: ${response.status}`); 536 } 537 538 const data = await response.json(); 539 return data.value; 540} 541 542/** 543 * Fetch a document record from its AT URI 544 * @param {string} atUri - AT Protocol URI for the document 545 * @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 546 */ 547async function getDocument(atUri) { 548 const parsed = parseAtUri(atUri); 549 if (!parsed) { 550 throw new Error(`Invalid AT URI: ${atUri}`); 551 } 552 553 return getRecord(parsed.did, parsed.collection, parsed.rkey); 554} 555 556/** 557 * Fetch a post thread from the public Bluesky API 558 * @param {string} postUri - AT Protocol URI for the post 559 * @param {number} [depth=6] - Maximum depth of replies to fetch 560 * @returns {Promise<ThreadViewPost>} Thread view post 561 */ 562async function getPostThread(postUri, depth = 6) { 563 const url = new URL( 564 "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread", 565 ); 566 url.searchParams.set("uri", postUri); 567 url.searchParams.set("depth", depth.toString()); 568 569 const response = await fetch(url.toString()); 570 if (!response.ok) { 571 throw new Error(`Failed to fetch post thread: ${response.status}`); 572 } 573 574 const data = await response.json(); 575 576 if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { 577 throw new Error("Post not found or blocked"); 578 } 579 580 return data.thread; 581} 582 583/** 584 * Build a Bluesky app URL for a post 585 * @param {string} postUri - AT Protocol URI for the post 586 * @returns {string} Bluesky app URL 587 */ 588function buildBskyAppUrl(postUri) { 589 const parsed = parseAtUri(postUri); 590 if (!parsed) { 591 throw new Error(`Invalid post URI: ${postUri}`); 592 } 593 594 return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; 595} 596 597/** 598 * Build a Blacksky app URL for a post 599 * @param {string} postUri - AT Protocol URI for the post 600 * @returns {string} Blacksky app URL 601 */ 602function buildBlackskyAppUrl(postUri) { 603 const parsed = parseAtUri(postUri); 604 if (!parsed) { 605 throw new Error(`Invalid post URI: ${postUri}`); 606 } 607 608 return `https://blacksky.community/profile/${parsed.did}/post/${parsed.rkey}`; 609} 610 611/** 612 * Type guard for ThreadViewPost 613 * @param {any} post - Post to check 614 * @returns {boolean} True if post is a ThreadViewPost 615 */ 616function isThreadViewPost(post) { 617 return post?.$type === "app.bsky.feed.defs#threadViewPost"; 618} 619 620/** 621 * Fetch all quote posts for a given post URI, paginating through all results. 622 * Uses the public Bluesky AppView — gaps are expected for posts from 623 * less-connected PDS instances. 624 * @param {string} postUri - AT Protocol URI for the post 625 * @returns {Promise<Array>} Array of PostView objects 626 */ 627/** 628 * Normalise a user-supplied post reference to an AT-URI. 629 * Accepts: 630 * - AT-URIs as-is: at://did:plc:.../app.bsky.feed.post/rkey 631 * - bsky.app post URLs: https://bsky.app/profile/<handle-or-did>/post/<rkey> 632 * When the profile segment is already a DID no network request is made. 633 * @param {string} uriOrUrl 634 * @returns {Promise<string>} AT-URI 635 */ 636async function resolvePostUri(uriOrUrl) { 637 if (uriOrUrl.startsWith("at://")) return uriOrUrl; 638 639 const match = uriOrUrl.match( 640 /bsky\.app\/profile\/([^/?#]+)\/post\/([^/?#]+)/, 641 ); 642 if (!match) throw new Error(`Cannot parse Bluesky URL: ${uriOrUrl}`); 643 644 const [, handleOrDid, rkey] = match; 645 646 let did = handleOrDid; 647 if (!handleOrDid.startsWith("did:")) { 648 const url = new URL( 649 "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle", 650 ); 651 url.searchParams.set("handle", handleOrDid); 652 const response = await fetch(url.toString()); 653 if (!response.ok) 654 throw new Error(`Failed to resolve handle: ${response.status}`); 655 did = (await response.json()).did; 656 } 657 658 return `at://${did}/app.bsky.feed.post/${rkey}`; 659} 660 661async function getQuotes(postUri) { 662 const quotes = []; 663 let cursor; 664 665 do { 666 const url = new URL( 667 "https://public.api.bsky.app/xrpc/app.bsky.feed.getQuotes", 668 ); 669 url.searchParams.set("uri", postUri); 670 url.searchParams.set("limit", "100"); 671 if (cursor) url.searchParams.set("cursor", cursor); 672 673 const response = await fetch(url.toString()); 674 if (!response.ok) { 675 throw new Error(`Failed to fetch quotes: ${response.status}`); 676 } 677 678 const data = await response.json(); 679 quotes.push(...(data.posts ?? [])); 680 cursor = data.cursor; 681 } while (cursor); 682 683 return quotes; 684} 685 686// ============================================================================ 687// Bluesky Icon 688// ============================================================================ 689 690const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 691 <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"/> 692</svg>`; 693const BLACKSKY_ICON = 694 '<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>'; 695 696// ============================================================================ 697// Web Component 698// ============================================================================ 699 700// SSR-safe base class - use HTMLElement in browser, empty class in Node.js 701const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 702 703class SequoiaComments extends BaseElement { 704 constructor() { 705 super(); 706 const shadow = this.attachShadow({ mode: "open" }); 707 708 const styleTag = document.createElement("style"); 709 shadow.appendChild(styleTag); 710 styleTag.innerText = styles; 711 712 const container = document.createElement("div"); 713 shadow.appendChild(container); 714 container.className = "sequoia-comments-container"; 715 container.part = "container"; 716 717 this.commentsContainer = container; 718 this.state = { type: "loading" }; 719 this.abortController = null; 720 } 721 722 static get observedAttributes() { 723 return ["post-uri", "document-uri", "depth", "hide"]; 724 } 725 726 connectedCallback() { 727 this.initialized = true; 728 this.render(); 729 this.loadComments(); 730 } 731 732 disconnectedCallback() { 733 this.abortController?.abort(); 734 } 735 736 attributeChangedCallback() { 737 // attributeChangedCallback fires for pre-existing attributes during 738 // element upgrade, *before* connectedCallback — skip until we've done 739 // the initial load, otherwise every attribute triggers a duplicate fetch. 740 if (this.initialized) { 741 this.loadComments(); 742 } 743 } 744 745 get documentUri() { 746 // First check attribute 747 const attrUri = this.getAttribute("document-uri"); 748 if (attrUri) { 749 return attrUri; 750 } 751 752 // Then scan for link tag in document head 753 const linkTag = document.querySelector( 754 'link[rel="site.standard.document"]', 755 ); 756 return linkTag?.href ?? null; 757 } 758 759 get depth() { 760 const depthAttr = this.getAttribute("depth"); 761 return depthAttr ? parseInt(depthAttr, 10) : 6; 762 } 763 764 get hide() { 765 const hideAttr = this.getAttribute("hide"); 766 return hideAttr === "auto"; 767 } 768 769 async loadComments() { 770 // Cancel any in-flight request 771 this.abortController?.abort(); 772 this.abortController = new AbortController(); 773 774 this.state = { type: "loading" }; 775 this.render(); 776 777 try { 778 // Resolve the post URI — either directly from the attribute or via the 779 // document record (which requires a PDS roundtrip) 780 const rawPostUri = this.getAttribute("post-uri"); 781 let postUri = rawPostUri ? await resolvePostUri(rawPostUri) : null; 782 if (!postUri) { 783 const docUri = this.documentUri; 784 if (!docUri) { 785 this.state = { type: "no-document" }; 786 this.render(); 787 return; 788 } 789 790 const document = await getDocument(docUri); 791 if (!document.bskyPostRef) { 792 this.state = { type: "no-comments-enabled" }; 793 this.render(); 794 return; 795 } 796 797 postUri = document.bskyPostRef.uri; 798 } 799 800 const postUrl = buildBskyAppUrl(postUri); 801 const blackskyPostUrl = buildBlackskyAppUrl(postUri); 802 803 // Fetch thread and quotes in parallel; quote failures degrade gracefully 804 const [threadResult, quotesResult] = await Promise.allSettled([ 805 getPostThread(postUri, this.depth), 806 getQuotes(postUri), 807 ]); 808 809 if (threadResult.status === "rejected") { 810 throw threadResult.reason; 811 } 812 813 const thread = threadResult.value; 814 const quotes = 815 quotesResult.status === "fulfilled" ? quotesResult.value : []; 816 817 const replies = thread.replies?.filter(isThreadViewPost) ?? []; 818 if (replies.length === 0 && quotes.length === 0) { 819 this.state = { type: "empty", postUrl, blackskyPostUrl }; 820 this.render(); 821 return; 822 } 823 824 this.state = { type: "loaded", thread, quotes, postUrl, blackskyPostUrl }; 825 this.render(); 826 } catch (error) { 827 const message = 828 error instanceof Error ? error.message : "Failed to load comments"; 829 this.state = { type: "error", message }; 830 this.render(); 831 } 832 } 833 834 render() { 835 switch (this.state.type) { 836 case "loading": 837 this.commentsContainer.innerHTML = ` 838 <div class="sequoia-loading"> 839 <span class="sequoia-loading-spinner"></span> 840 Loading comments... 841 </div> 842 `; 843 break; 844 845 case "no-document": 846 this.commentsContainer.innerHTML = ` 847 <div class="sequoia-warning"> 848 No document found. Add a <code>&lt;link rel="site.standard.document" href="at://..."&gt;</code> tag to your page. 849 </div> 850 `; 851 if (this.hide) { 852 this.commentsContainer.style.display = "none"; 853 } 854 break; 855 856 case "no-comments-enabled": 857 this.commentsContainer.innerHTML = ` 858 <div class="sequoia-empty"> 859 Comments are not enabled for this post. 860 </div> 861 `; 862 break; 863 864 case "empty": 865 this.commentsContainer.innerHTML = ` 866 <div class="sequoia-comments-header"> 867 <h3 class="sequoia-comments-title">Comments</h3> 868 <div>${this.renderReplyButtons(this.state.postUrl, this.state.blackskyPostUrl)}</div> 869 </div> 870 <div class="sequoia-empty"> 871 No comments yet. Be the first to reply on Bluesky! 872 </div> 873 `; 874 break; 875 876 case "error": 877 this.commentsContainer.innerHTML = ` 878 <div class="sequoia-error"> 879 Failed to load comments: ${escapeHtml(this.state.message)} 880 </div> 881 `; 882 break; 883 884 case "loaded": { 885 const replies = 886 this.state.thread.replies?.filter(isThreadViewPost) ?? []; 887 const quotes = this.state.quotes ?? []; 888 const threadsHtml = replies 889 .map((reply) => this.renderThread(reply)) 890 .join(""); 891 const commentCount = this.countComments(replies); 892 const titleText = 893 commentCount > 0 894 ? `${commentCount} Comment${commentCount !== 1 ? "s" : ""}` 895 : "Comments"; 896 const quotesHtml = this.renderQuotesSection(quotes); 897 898 this.commentsContainer.innerHTML = ` 899 <div class="sequoia-comments-header"> 900 <h3 class="sequoia-comments-title">${titleText}</h3> 901 <div>${this.renderReplyButtons(this.state.postUrl, this.state.blackskyPostUrl)}</div> 902 </div> 903 <div class="sequoia-comments-list"> 904 ${threadsHtml} 905 </div> 906 ${quotesHtml} 907 `; 908 break; 909 } 910 } 911 } 912 913 /** 914 * Flatten a thread into a linear list of comments 915 * @param {ThreadViewPost} thread - Thread to flatten 916 * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments 917 */ 918 flattenThread(thread) { 919 const result = []; 920 const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; 921 922 result.push({ 923 post: thread.post, 924 hasMoreReplies: nestedReplies.length > 0, 925 }); 926 927 // Recursively flatten nested replies 928 for (const reply of nestedReplies) { 929 result.push(...this.flattenThread(reply)); 930 } 931 932 return result; 933 } 934 935 /** 936 * Render the reply-button slot. Any element with slot="reply-button" in the 937 * light DOM is projected here and remains styleable by external CSS. 938 * The default Bluesky/Blacksky buttons are used as fallback content. 939 */ 940 renderReplyButtons(postUrl, blackskyPostUrl) { 941 return ` 942 <slot name="reply-button"> 943 <a href="${escapeHtml(postUrl)}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-bluesky"> 944 ${BLUESKY_ICON} 945 </a> 946 <a href="${escapeHtml(blackskyPostUrl)}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-blacksky"> 947 ${BLACKSKY_ICON} 948 </a> 949 </slot> 950 `; 951 } 952 953 /** 954 * Render a complete thread (top-level comment + all nested replies) 955 */ 956 renderThread(thread) { 957 const flatComments = this.flattenThread(thread); 958 const commentsHtml = flatComments 959 .map((item, index) => 960 this.renderComment(item.post, item.hasMoreReplies, index), 961 ) 962 .join(""); 963 964 return `<div class="sequoia-thread">${commentsHtml}</div>`; 965 } 966 967 /** 968 * Render a section of quote posts below the replies 969 * @param {Array} quotes - Array of PostView objects from getQuotes 970 */ 971 renderQuotesSection(quotes) { 972 if (quotes.length === 0) return ""; 973 974 const quotesHtml = quotes 975 .map((post) => { 976 return `<div class="sequoia-thread">${this.renderComment(post, false, 0)}</div>`; 977 }) 978 .join(""); 979 980 return ` 981 <div class="sequoia-quotes-section"> 982 <h4 class="sequoia-quotes-header">Quotes (${quotes.length})</h4> 983 <div class="sequoia-comments-list"> 984 ${quotesHtml} 985 </div> 986 </div> 987 `; 988 } 989 990 /** 991 * Render a single comment 992 * @param {any} post - Post data 993 * @param {boolean} showThreadLine - Whether to show the connecting thread line 994 * @param {number} _index - Index in the flattened thread (0 = top-level) 995 */ 996 renderComment(post, showThreadLine = false, _index = 0) { 997 const author = post.author; 998 const displayName = author.displayName || author.handle; 999 const avatarHtml = author.avatar 1000 ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />` 1001 : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`; 1002 1003 const profileUrl = `https://bsky.app/profile/${author.did}`; 1004 const textHtml = renderTextWithFacets(post.record.text, post.record.facets); 1005 const timeAgo = formatRelativeTime(post.record.createdAt); 1006 const timeHtml = `<a href="${escapeHtml(buildBskyAppUrl(post.uri))}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-time">${timeAgo}</a>`; 1007 const threadLineHtml = showThreadLine 1008 ? '<div class="sequoia-thread-line"></div>' 1009 : ""; 1010 1011 return ` 1012 <div class="sequoia-comment"> 1013 <div class="sequoia-comment-avatar-column"> 1014 ${avatarHtml} 1015 ${threadLineHtml} 1016 </div> 1017 <div class="sequoia-comment-content"> 1018 <div class="sequoia-comment-header"> 1019 <a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author"> 1020 ${escapeHtml(displayName)} 1021 </a> 1022 <span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span> 1023 ${timeHtml} 1024 </div> 1025 <p class="sequoia-comment-text">${textHtml}</p> 1026 </div> 1027 </div> 1028 `; 1029 } 1030 1031 countComments(replies) { 1032 let count = 0; 1033 for (const reply of replies) { 1034 count += 1; 1035 const nested = reply.replies?.filter(isThreadViewPost) ?? []; 1036 count += this.countComments(nested); 1037 } 1038 return count; 1039 } 1040} 1041 1042// Register the custom element 1043if (typeof customElements !== "undefined") { 1044 customElements.define("sequoia-comments", SequoiaComments); 1045} 1046 1047// Export for module usage 1048export { SequoiaComments };