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

Configure Feed

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

Only listen for changes after init

Was loading the script + component in an IntersectionObserver and it
triggered twice.

authored by

Pascal Hertleif and committed by
Tangled
41f1f90e a0ddd6f9

+433 -429
+433 -429
packages/cli/src/components/sequoia-comments.js
··· 326 326 * @returns {string} Formatted relative time 327 327 */ 328 328 function 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); 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 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`; 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 359 } 360 360 361 361 /** ··· 364 364 * @returns {string} Escaped HTML 365 365 */ 366 366 function escapeHtml(text) { 367 - const div = document.createElement("div"); 368 - div.textContent = text; 369 - return div.innerHTML; 367 + const div = document.createElement("div"); 368 + div.textContent = text; 369 + return div.innerHTML; 370 370 } 371 371 372 372 /** ··· 376 376 * @returns {string} HTML string with links 377 377 */ 378 378 function renderTextWithFacets(text, facets) { 379 - if (!facets || facets.length === 0) { 380 - return escapeHtml(text); 381 - } 379 + if (!facets || facets.length === 0) { 380 + return escapeHtml(text); 381 + } 382 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); 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 387 388 - // Sort facets by start index 389 - const sortedFacets = [...facets].sort( 390 - (a, b) => a.index.byteStart - b.index.byteStart, 391 - ); 388 + // Sort facets by start index 389 + const sortedFacets = [...facets].sort( 390 + (a, b) => a.index.byteStart - b.index.byteStart, 391 + ); 392 392 393 - let result = ""; 394 - let lastEnd = 0; 393 + let result = ""; 394 + let lastEnd = 0; 395 395 396 - for (const facet of sortedFacets) { 397 - const { byteStart, byteEnd } = facet.index; 396 + for (const facet of sortedFacets) { 397 + const { byteStart, byteEnd } = facet.index; 398 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 - } 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 404 405 - // Get the facet text 406 - const facetBytes = textBytes.slice(byteStart, byteEnd); 407 - const facetText = decoder.decode(facetBytes); 405 + // Get the facet text 406 + const facetBytes = textBytes.slice(byteStart, byteEnd); 407 + const facetText = decoder.decode(facetBytes); 408 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 - } 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 424 425 - lastEnd = byteEnd; 426 - } 425 + lastEnd = byteEnd; 426 + } 427 427 428 - // Add remaining text 429 - if (lastEnd < textBytes.length) { 430 - const remainingBytes = textBytes.slice(lastEnd); 431 - result += escapeHtml(decoder.decode(remainingBytes)); 432 - } 428 + // Add remaining text 429 + if (lastEnd < textBytes.length) { 430 + const remainingBytes = textBytes.slice(lastEnd); 431 + result += escapeHtml(decoder.decode(remainingBytes)); 432 + } 433 433 434 - return result; 434 + return result; 435 435 } 436 436 437 437 /** ··· 440 440 * @returns {string} Initials (1-2 characters) 441 441 */ 442 442 function 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(); 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 448 } 449 449 450 450 // ============================================================================ ··· 458 458 * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null 459 459 */ 460 460 function 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 - }; 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 468 } 469 469 470 470 /** ··· 474 474 * @returns {Promise<string>} PDS URL 475 475 */ 476 476 async function resolvePDS(did) { 477 - let pdsUrl; 477 + let pdsUrl; 478 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(); 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 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(); 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 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 - } 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 510 511 - if (!pdsUrl) { 512 - throw new Error("Could not find PDS URL for user"); 513 - } 511 + if (!pdsUrl) { 512 + throw new Error("Could not find PDS URL for user"); 513 + } 514 514 515 - return pdsUrl; 515 + return pdsUrl; 516 516 } 517 517 518 518 /** ··· 523 523 * @returns {Promise<any>} Record value 524 524 */ 525 525 async function getRecord(did, collection, rkey) { 526 - const pdsUrl = await resolvePDS(did); 526 + const pdsUrl = await resolvePDS(did); 527 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); 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 532 533 - const response = await fetch(url.toString()); 534 - if (!response.ok) { 535 - throw new Error(`Failed to fetch record: ${response.status}`); 536 - } 533 + const response = await fetch(url.toString()); 534 + if (!response.ok) { 535 + throw new Error(`Failed to fetch record: ${response.status}`); 536 + } 537 537 538 - const data = await response.json(); 539 - return data.value; 538 + const data = await response.json(); 539 + return data.value; 540 540 } 541 541 542 542 /** ··· 545 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 546 */ 547 547 async function getDocument(atUri) { 548 - const parsed = parseAtUri(atUri); 549 - if (!parsed) { 550 - throw new Error(`Invalid AT URI: ${atUri}`); 551 - } 548 + const parsed = parseAtUri(atUri); 549 + if (!parsed) { 550 + throw new Error(`Invalid AT URI: ${atUri}`); 551 + } 552 552 553 - return getRecord(parsed.did, parsed.collection, parsed.rkey); 553 + return getRecord(parsed.did, parsed.collection, parsed.rkey); 554 554 } 555 555 556 556 /** ··· 560 560 * @returns {Promise<ThreadViewPost>} Thread view post 561 561 */ 562 562 async 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()); 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 568 569 - const response = await fetch(url.toString()); 570 - if (!response.ok) { 571 - throw new Error(`Failed to fetch post thread: ${response.status}`); 572 - } 569 + const response = await fetch(url.toString()); 570 + if (!response.ok) { 571 + throw new Error(`Failed to fetch post thread: ${response.status}`); 572 + } 573 573 574 - const data = await response.json(); 574 + const data = await response.json(); 575 575 576 - if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { 577 - throw new Error("Post not found or blocked"); 578 - } 576 + if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { 577 + throw new Error("Post not found or blocked"); 578 + } 579 579 580 - return data.thread; 580 + return data.thread; 581 581 } 582 582 583 583 /** ··· 586 586 * @returns {string} Bluesky app URL 587 587 */ 588 588 function buildBskyAppUrl(postUri) { 589 - const parsed = parseAtUri(postUri); 590 - if (!parsed) { 591 - throw new Error(`Invalid post URI: ${postUri}`); 592 - } 589 + const parsed = parseAtUri(postUri); 590 + if (!parsed) { 591 + throw new Error(`Invalid post URI: ${postUri}`); 592 + } 593 593 594 - return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; 594 + return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; 595 595 } 596 596 597 597 /** ··· 600 600 * @returns {string} Blacksky app URL 601 601 */ 602 602 function buildBlackskyAppUrl(postUri) { 603 - const parsed = parseAtUri(postUri); 604 - if (!parsed) { 605 - throw new Error(`Invalid post URI: ${postUri}`); 606 - } 603 + const parsed = parseAtUri(postUri); 604 + if (!parsed) { 605 + throw new Error(`Invalid post URI: ${postUri}`); 606 + } 607 607 608 - return `https://blacksky.community/profile/${parsed.did}/post/${parsed.rkey}`; 608 + return `https://blacksky.community/profile/${parsed.did}/post/${parsed.rkey}`; 609 609 } 610 610 611 611 /** ··· 614 614 * @returns {boolean} True if post is a ThreadViewPost 615 615 */ 616 616 function isThreadViewPost(post) { 617 - return post?.$type === "app.bsky.feed.defs#threadViewPost"; 617 + return post?.$type === "app.bsky.feed.defs#threadViewPost"; 618 618 } 619 619 620 620 /** ··· 634 634 * @returns {Promise<string>} AT-URI 635 635 */ 636 636 async function resolvePostUri(uriOrUrl) { 637 - if (uriOrUrl.startsWith("at://")) return uriOrUrl; 637 + if (uriOrUrl.startsWith("at://")) return uriOrUrl; 638 638 639 - const match = uriOrUrl.match( 640 - /bsky\.app\/profile\/([^/?#]+)\/post\/([^/?#]+)/, 641 - ); 642 - if (!match) throw new Error(`Cannot parse Bluesky URL: ${uriOrUrl}`); 639 + const match = uriOrUrl.match( 640 + /bsky\.app\/profile\/([^/?#]+)\/post\/([^/?#]+)/, 641 + ); 642 + if (!match) throw new Error(`Cannot parse Bluesky URL: ${uriOrUrl}`); 643 643 644 - const [, handleOrDid, rkey] = match; 644 + const [, handleOrDid, rkey] = match; 645 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 - } 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 657 658 - return `at://${did}/app.bsky.feed.post/${rkey}`; 658 + return `at://${did}/app.bsky.feed.post/${rkey}`; 659 659 } 660 660 661 661 async function getQuotes(postUri) { 662 - const quotes = []; 663 - let cursor; 662 + const quotes = []; 663 + let cursor; 664 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); 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 672 673 - const response = await fetch(url.toString()); 674 - if (!response.ok) { 675 - throw new Error(`Failed to fetch quotes: ${response.status}`); 676 - } 673 + const response = await fetch(url.toString()); 674 + if (!response.ok) { 675 + throw new Error(`Failed to fetch quotes: ${response.status}`); 676 + } 677 677 678 - const data = await response.json(); 679 - quotes.push(...(data.posts ?? [])); 680 - cursor = data.cursor; 681 - } while (cursor); 678 + const data = await response.json(); 679 + quotes.push(...(data.posts ?? [])); 680 + cursor = data.cursor; 681 + } while (cursor); 682 682 683 - return quotes; 683 + return quotes; 684 684 } 685 685 686 686 // ============================================================================ ··· 691 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 692 </svg>`; 693 693 const 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>'; 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 695 696 696 // ============================================================================ 697 697 // Web Component ··· 701 701 const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 702 702 703 703 class SequoiaComments extends BaseElement { 704 - constructor() { 705 - super(); 706 - const shadow = this.attachShadow({ mode: "open" }); 704 + constructor() { 705 + super(); 706 + const shadow = this.attachShadow({ mode: "open" }); 707 707 708 - const styleTag = document.createElement("style"); 709 - shadow.appendChild(styleTag); 710 - styleTag.innerText = styles; 708 + const styleTag = document.createElement("style"); 709 + shadow.appendChild(styleTag); 710 + styleTag.innerText = styles; 711 711 712 - const container = document.createElement("div"); 713 - shadow.appendChild(container); 714 - container.className = "sequoia-comments-container"; 715 - container.part = "container"; 712 + const container = document.createElement("div"); 713 + shadow.appendChild(container); 714 + container.className = "sequoia-comments-container"; 715 + container.part = "container"; 716 716 717 - this.commentsContainer = container; 718 - this.state = { type: "loading" }; 719 - this.abortController = null; 720 - } 717 + this.commentsContainer = container; 718 + this.state = { type: "loading" }; 719 + this.abortController = null; 720 + } 721 721 722 - static get observedAttributes() { 723 - return ["post-uri", "document-uri", "depth", "hide"]; 724 - } 722 + static get observedAttributes() { 723 + return ["post-uri", "document-uri", "depth", "hide"]; 724 + } 725 725 726 - connectedCallback() { 727 - this.render(); 728 - this.loadComments(); 729 - } 726 + connectedCallback() { 727 + this.initialized = true; 728 + this.render(); 729 + this.loadComments(); 730 + } 730 731 731 - disconnectedCallback() { 732 - this.abortController?.abort(); 733 - } 732 + disconnectedCallback() { 733 + this.abortController?.abort(); 734 + } 734 735 735 - attributeChangedCallback() { 736 - if (this.isConnected) { 737 - this.loadComments(); 738 - } 739 - } 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 + } 740 744 741 - get documentUri() { 742 - // First check attribute 743 - const attrUri = this.getAttribute("document-uri"); 744 - if (attrUri) { 745 - return attrUri; 746 - } 745 + get documentUri() { 746 + // First check attribute 747 + const attrUri = this.getAttribute("document-uri"); 748 + if (attrUri) { 749 + return attrUri; 750 + } 747 751 748 - // Then scan for link tag in document head 749 - const linkTag = document.querySelector( 750 - 'link[rel="site.standard.document"]', 751 - ); 752 - return linkTag?.href ?? null; 753 - } 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 + } 754 758 755 - get depth() { 756 - const depthAttr = this.getAttribute("depth"); 757 - return depthAttr ? parseInt(depthAttr, 10) : 6; 758 - } 759 + get depth() { 760 + const depthAttr = this.getAttribute("depth"); 761 + return depthAttr ? parseInt(depthAttr, 10) : 6; 762 + } 759 763 760 - get hide() { 761 - const hideAttr = this.getAttribute("hide"); 762 - return hideAttr === "auto"; 763 - } 764 + get hide() { 765 + const hideAttr = this.getAttribute("hide"); 766 + return hideAttr === "auto"; 767 + } 764 768 765 - async loadComments() { 766 - // Cancel any in-flight request 767 - this.abortController?.abort(); 768 - this.abortController = new AbortController(); 769 + async loadComments() { 770 + // Cancel any in-flight request 771 + this.abortController?.abort(); 772 + this.abortController = new AbortController(); 769 773 770 - this.state = { type: "loading" }; 771 - this.render(); 774 + this.state = { type: "loading" }; 775 + this.render(); 772 776 773 - try { 774 - // Resolve the post URI — either directly from the attribute or via the 775 - // document record (which requires a PDS roundtrip) 776 - const rawPostUri = this.getAttribute("post-uri"); 777 - let postUri = rawPostUri ? await resolvePostUri(rawPostUri) : null; 778 - if (!postUri) { 779 - const docUri = this.documentUri; 780 - if (!docUri) { 781 - this.state = { type: "no-document" }; 782 - this.render(); 783 - return; 784 - } 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 + } 785 789 786 - const document = await getDocument(docUri); 787 - if (!document.bskyPostRef) { 788 - this.state = { type: "no-comments-enabled" }; 789 - this.render(); 790 - return; 791 - } 790 + const document = await getDocument(docUri); 791 + if (!document.bskyPostRef) { 792 + this.state = { type: "no-comments-enabled" }; 793 + this.render(); 794 + return; 795 + } 792 796 793 - postUri = document.bskyPostRef.uri; 794 - } 797 + postUri = document.bskyPostRef.uri; 798 + } 795 799 796 - const postUrl = buildBskyAppUrl(postUri); 797 - const blackskyPostUrl = buildBlackskyAppUrl(postUri); 800 + const postUrl = buildBskyAppUrl(postUri); 801 + const blackskyPostUrl = buildBlackskyAppUrl(postUri); 798 802 799 - // Fetch thread and quotes in parallel; quote failures degrade gracefully 800 - const [threadResult, quotesResult] = await Promise.allSettled([ 801 - getPostThread(postUri, this.depth), 802 - getQuotes(postUri), 803 - ]); 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 + ]); 804 808 805 - if (threadResult.status === "rejected") { 806 - throw threadResult.reason; 807 - } 809 + if (threadResult.status === "rejected") { 810 + throw threadResult.reason; 811 + } 808 812 809 - const thread = threadResult.value; 810 - const quotes = 811 - quotesResult.status === "fulfilled" ? quotesResult.value : []; 813 + const thread = threadResult.value; 814 + const quotes = 815 + quotesResult.status === "fulfilled" ? quotesResult.value : []; 812 816 813 - const replies = thread.replies?.filter(isThreadViewPost) ?? []; 814 - if (replies.length === 0 && quotes.length === 0) { 815 - this.state = { type: "empty", postUrl, blackskyPostUrl }; 816 - this.render(); 817 - return; 818 - } 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 + } 819 823 820 - this.state = { type: "loaded", thread, quotes, postUrl, blackskyPostUrl }; 821 - this.render(); 822 - } catch (error) { 823 - const message = 824 - error instanceof Error ? error.message : "Failed to load comments"; 825 - this.state = { type: "error", message }; 826 - this.render(); 827 - } 828 - } 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 + } 829 833 830 - render() { 831 - switch (this.state.type) { 832 - case "loading": 833 - this.commentsContainer.innerHTML = ` 834 + render() { 835 + switch (this.state.type) { 836 + case "loading": 837 + this.commentsContainer.innerHTML = ` 834 838 <div class="sequoia-loading"> 835 839 <span class="sequoia-loading-spinner"></span> 836 840 Loading comments... 837 841 </div> 838 842 `; 839 - break; 843 + break; 840 844 841 - case "no-document": 842 - this.commentsContainer.innerHTML = ` 845 + case "no-document": 846 + this.commentsContainer.innerHTML = ` 843 847 <div class="sequoia-warning"> 844 848 No document found. Add a <code>&lt;link rel="site.standard.document" href="at://..."&gt;</code> tag to your page. 845 849 </div> 846 850 `; 847 - if (this.hide) { 848 - this.commentsContainer.style.display = "none"; 849 - } 850 - break; 851 + if (this.hide) { 852 + this.commentsContainer.style.display = "none"; 853 + } 854 + break; 851 855 852 - case "no-comments-enabled": 853 - this.commentsContainer.innerHTML = ` 856 + case "no-comments-enabled": 857 + this.commentsContainer.innerHTML = ` 854 858 <div class="sequoia-empty"> 855 859 Comments are not enabled for this post. 856 860 </div> 857 861 `; 858 - break; 862 + break; 859 863 860 - case "empty": 861 - this.commentsContainer.innerHTML = ` 864 + case "empty": 865 + this.commentsContainer.innerHTML = ` 862 866 <div class="sequoia-comments-header"> 863 867 <h3 class="sequoia-comments-title">Comments</h3> 864 868 <div>${this.renderReplyButtons(this.state.postUrl, this.state.blackskyPostUrl)}</div> ··· 867 871 No comments yet. Be the first to reply on Bluesky! 868 872 </div> 869 873 `; 870 - break; 874 + break; 871 875 872 - case "error": 873 - this.commentsContainer.innerHTML = ` 876 + case "error": 877 + this.commentsContainer.innerHTML = ` 874 878 <div class="sequoia-error"> 875 879 Failed to load comments: ${escapeHtml(this.state.message)} 876 880 </div> 877 881 `; 878 - break; 882 + break; 879 883 880 - case "loaded": { 881 - const replies = 882 - this.state.thread.replies?.filter(isThreadViewPost) ?? []; 883 - const quotes = this.state.quotes ?? []; 884 - const threadsHtml = replies 885 - .map((reply) => this.renderThread(reply)) 886 - .join(""); 887 - const commentCount = this.countComments(replies); 888 - const titleText = 889 - commentCount > 0 890 - ? `${commentCount} Comment${commentCount !== 1 ? "s" : ""}` 891 - : "Comments"; 892 - const quotesHtml = this.renderQuotesSection(quotes); 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); 893 897 894 - this.commentsContainer.innerHTML = ` 898 + this.commentsContainer.innerHTML = ` 895 899 <div class="sequoia-comments-header"> 896 900 <h3 class="sequoia-comments-title">${titleText}</h3> 897 901 <div>${this.renderReplyButtons(this.state.postUrl, this.state.blackskyPostUrl)}</div> ··· 901 905 </div> 902 906 ${quotesHtml} 903 907 `; 904 - break; 905 - } 906 - } 907 - } 908 + break; 909 + } 910 + } 911 + } 908 912 909 - /** 910 - * Flatten a thread into a linear list of comments 911 - * @param {ThreadViewPost} thread - Thread to flatten 912 - * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments 913 - */ 914 - flattenThread(thread) { 915 - const result = []; 916 - const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; 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) ?? []; 917 921 918 - result.push({ 919 - post: thread.post, 920 - hasMoreReplies: nestedReplies.length > 0, 921 - }); 922 + result.push({ 923 + post: thread.post, 924 + hasMoreReplies: nestedReplies.length > 0, 925 + }); 922 926 923 - // Recursively flatten nested replies 924 - for (const reply of nestedReplies) { 925 - result.push(...this.flattenThread(reply)); 926 - } 927 + // Recursively flatten nested replies 928 + for (const reply of nestedReplies) { 929 + result.push(...this.flattenThread(reply)); 930 + } 927 931 928 - return result; 929 - } 932 + return result; 933 + } 930 934 931 - /** 932 - * Render the reply-button slot. Any element with slot="reply-button" in the 933 - * light DOM is projected here and remains styleable by external CSS. 934 - * The default Bluesky/Blacksky buttons are used as fallback content. 935 - */ 936 - renderReplyButtons(postUrl, blackskyPostUrl) { 937 - return ` 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 ` 938 942 <slot name="reply-button"> 939 943 <a href="${escapeHtml(postUrl)}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-bluesky"> 940 944 ${BLUESKY_ICON} ··· 944 948 </a> 945 949 </slot> 946 950 `; 947 - } 951 + } 948 952 949 - /** 950 - * Render a complete thread (top-level comment + all nested replies) 951 - */ 952 - renderThread(thread) { 953 - const flatComments = this.flattenThread(thread); 954 - const commentsHtml = flatComments 955 - .map((item, index) => 956 - this.renderComment(item.post, item.hasMoreReplies, index), 957 - ) 958 - .join(""); 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(""); 959 963 960 - return `<div class="sequoia-thread">${commentsHtml}</div>`; 961 - } 964 + return `<div class="sequoia-thread">${commentsHtml}</div>`; 965 + } 962 966 963 - /** 964 - * Render a section of quote posts below the replies 965 - * @param {Array} quotes - Array of PostView objects from getQuotes 966 - */ 967 - renderQuotesSection(quotes) { 968 - if (quotes.length === 0) return ""; 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 ""; 969 973 970 - const quotesHtml = quotes 971 - .map((post) => { 972 - const quotePostUrl = buildBskyAppUrl(post.uri); 973 - return `<div class="sequoia-thread">${this.renderComment(post, false, 0, quotePostUrl)}</div>`; 974 - }) 975 - .join(""); 974 + const quotesHtml = quotes 975 + .map((post) => { 976 + const quotePostUrl = buildBskyAppUrl(post.uri); 977 + return `<div class="sequoia-thread">${this.renderComment(post, false, 0, quotePostUrl)}</div>`; 978 + }) 979 + .join(""); 976 980 977 - return ` 981 + return ` 978 982 <div class="sequoia-quotes-section"> 979 983 <h4 class="sequoia-quotes-header">Quotes (${quotes.length})</h4> 980 984 <div class="sequoia-comments-list"> ··· 982 986 </div> 983 987 </div> 984 988 `; 985 - } 989 + } 986 990 987 - /** 988 - * Render a single comment 989 - * @param {any} post - Post data 990 - * @param {boolean} showThreadLine - Whether to show the connecting thread line 991 - * @param {number} _index - Index in the flattened thread (0 = top-level) 992 - * @param {string|null} postUrl - Optional URL to link the timestamp to (used for quote posts) 993 - */ 994 - renderComment(post, showThreadLine = false, _index = 0, postUrl = null) { 995 - const author = post.author; 996 - const displayName = author.displayName || author.handle; 997 - const avatarHtml = author.avatar 998 - ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />` 999 - : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`; 991 + /** 992 + * Render a single comment 993 + * @param {any} post - Post data 994 + * @param {boolean} showThreadLine - Whether to show the connecting thread line 995 + * @param {number} _index - Index in the flattened thread (0 = top-level) 996 + * @param {string|null} postUrl - Optional URL to link the timestamp to (used for quote posts) 997 + */ 998 + renderComment(post, showThreadLine = false, _index = 0, postUrl = null) { 999 + const author = post.author; 1000 + const displayName = author.displayName || author.handle; 1001 + const avatarHtml = author.avatar 1002 + ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />` 1003 + : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`; 1000 1004 1001 - const profileUrl = `https://bsky.app/profile/${author.did}`; 1002 - const textHtml = renderTextWithFacets(post.record.text, post.record.facets); 1003 - const timeAgo = formatRelativeTime(post.record.createdAt); 1004 - const timeHtml = postUrl 1005 - ? `<a href="${escapeHtml(postUrl)}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-time">${timeAgo}</a>` 1006 - : `<span class="sequoia-comment-time">${timeAgo}</span>`; 1007 - const threadLineHtml = showThreadLine 1008 - ? '<div class="sequoia-thread-line"></div>' 1009 - : ""; 1005 + const profileUrl = `https://bsky.app/profile/${author.did}`; 1006 + const textHtml = renderTextWithFacets(post.record.text, post.record.facets); 1007 + const timeAgo = formatRelativeTime(post.record.createdAt); 1008 + const timeHtml = postUrl 1009 + ? `<a href="${escapeHtml(postUrl)}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-time">${timeAgo}</a>` 1010 + : `<span class="sequoia-comment-time">${timeAgo}</span>`; 1011 + const threadLineHtml = showThreadLine 1012 + ? '<div class="sequoia-thread-line"></div>' 1013 + : ""; 1010 1014 1011 - return ` 1015 + return ` 1012 1016 <div class="sequoia-comment"> 1013 1017 <div class="sequoia-comment-avatar-column"> 1014 1018 ${avatarHtml} ··· 1026 1030 </div> 1027 1031 </div> 1028 1032 `; 1029 - } 1033 + } 1030 1034 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 - } 1035 + countComments(replies) { 1036 + let count = 0; 1037 + for (const reply of replies) { 1038 + count += 1; 1039 + const nested = reply.replies?.filter(isThreadViewPost) ?? []; 1040 + count += this.countComments(nested); 1041 + } 1042 + return count; 1043 + } 1040 1044 } 1041 1045 1042 1046 // Register the custom element 1043 1047 if (typeof customElements !== "undefined") { 1044 - customElements.define("sequoia-comments", SequoiaComments); 1048 + customElements.define("sequoia-comments", SequoiaComments); 1045 1049 } 1046 1050 1047 1051 // Export for module usage