Fork of Chiri for Astro for my blog
0
fork

Configure Feed

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

Adding comments

+862
+3
sequoia.json
··· 12 12 }, 13 13 "bluesky": { 14 14 "enabled": true 15 + }, 16 + "ui": { 17 + "components": "src/components/widgets" 15 18 } 16 19 }
+856
src/components/widgets/sequoia-comments.js
··· 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 + 32 + const 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 + */ 281 + function 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 + */ 319 + function 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 + */ 331 + function 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 + */ 395 + function 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 + */ 413 + function 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 + */ 429 + async 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 + */ 478 + async 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 + */ 500 + async 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 + */ 515 + async 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 + */ 541 + function 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 + */ 555 + function isThreadViewPost(post) { 556 + return post?.$type === "app.bsky.feed.defs#threadViewPost"; 557 + } 558 + 559 + // ============================================================================ 560 + // Bluesky Icon 561 + // ============================================================================ 562 + 563 + const 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 572 + const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 573 + 574 + class 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 851 + if (typeof customElements !== "undefined") { 852 + customElements.define("sequoia-comments", SequoiaComments); 853 + } 854 + 855 + // Export for module usage 856 + export { SequoiaComments };
+3
src/layouts/PostLayout.astro
··· 55 55 <article class="content"> 56 56 <slot /> 57 57 </article> 58 + <h2>Comments</h2> 59 + <sequoia-comments /> 60 + <script>import '@/components/widgets/sequoia-comments.js'</script> 58 61 </div> 59 62 </main> 60 63 <ImageOptimizer />