diff --git a/packages/cli/src/components/sequoia-comments.js b/packages/cli/src/components/sequoia-comments.js index 3b6ea0a..be93a89 100644 --- a/packages/cli/src/components/sequoia-comments.js +++ b/packages/cli/src/components/sequoia-comments.js @@ -326,36 +326,36 @@ a.sequoia-comment-time:hover { * @returns {string} Formatted relative time */ function formatRelativeTime(dateString) { - const date = new Date(dateString); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffSeconds = Math.floor(diffMs / 1000); - const diffMinutes = Math.floor(diffSeconds / 60); - const diffHours = Math.floor(diffMinutes / 60); - const diffDays = Math.floor(diffHours / 24); - const diffWeeks = Math.floor(diffDays / 7); - const diffMonths = Math.floor(diffDays / 30); - const diffYears = Math.floor(diffDays / 365); - - if (diffSeconds < 60) { - return "just now"; - } - if (diffMinutes < 60) { - return `${diffMinutes}m ago`; - } - if (diffHours < 24) { - return `${diffHours}h ago`; - } - if (diffDays < 7) { - return `${diffDays}d ago`; - } - if (diffWeeks < 4) { - return `${diffWeeks}w ago`; - } - if (diffMonths < 12) { - return `${diffMonths}mo ago`; - } - return `${diffYears}y ago`; + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + const diffWeeks = Math.floor(diffDays / 7); + const diffMonths = Math.floor(diffDays / 30); + const diffYears = Math.floor(diffDays / 365); + + if (diffSeconds < 60) { + return "just now"; + } + if (diffMinutes < 60) { + return `${diffMinutes}m ago`; + } + if (diffHours < 24) { + return `${diffHours}h ago`; + } + if (diffDays < 7) { + return `${diffDays}d ago`; + } + if (diffWeeks < 4) { + return `${diffWeeks}w ago`; + } + if (diffMonths < 12) { + return `${diffMonths}mo ago`; + } + return `${diffYears}y ago`; } /** @@ -364,9 +364,9 @@ function formatRelativeTime(dateString) { * @returns {string} Escaped HTML */ function escapeHtml(text) { - const div = document.createElement("div"); - div.textContent = text; - return div.innerHTML; + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; } /** @@ -376,62 +376,62 @@ function escapeHtml(text) { * @returns {string} HTML string with links */ function renderTextWithFacets(text, facets) { - if (!facets || facets.length === 0) { - return escapeHtml(text); - } - - // Convert text to bytes for proper indexing - const encoder = new TextEncoder(); - const decoder = new TextDecoder(); - const textBytes = encoder.encode(text); - - // Sort facets by start index - const sortedFacets = [...facets].sort( - (a, b) => a.index.byteStart - b.index.byteStart, - ); - - let result = ""; - let lastEnd = 0; - - for (const facet of sortedFacets) { - const { byteStart, byteEnd } = facet.index; - - // Add text before this facet - if (byteStart > lastEnd) { - const beforeBytes = textBytes.slice(lastEnd, byteStart); - result += escapeHtml(decoder.decode(beforeBytes)); - } - - // Get the facet text - const facetBytes = textBytes.slice(byteStart, byteEnd); - const facetText = decoder.decode(facetBytes); - - // Find the first renderable feature - const feature = facet.features[0]; - if (feature) { - if (feature.$type === "app.bsky.richtext.facet#link") { - result += `${escapeHtml(facetText)}`; - } else if (feature.$type === "app.bsky.richtext.facet#mention") { - result += `${escapeHtml(facetText)}`; - } else if (feature.$type === "app.bsky.richtext.facet#tag") { - result += `${escapeHtml(facetText)}`; - } else { - result += escapeHtml(facetText); - } - } else { - result += escapeHtml(facetText); - } - - lastEnd = byteEnd; - } - - // Add remaining text - if (lastEnd < textBytes.length) { - const remainingBytes = textBytes.slice(lastEnd); - result += escapeHtml(decoder.decode(remainingBytes)); - } - - return result; + if (!facets || facets.length === 0) { + return escapeHtml(text); + } + + // Convert text to bytes for proper indexing + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const textBytes = encoder.encode(text); + + // Sort facets by start index + const sortedFacets = [...facets].sort( + (a, b) => a.index.byteStart - b.index.byteStart, + ); + + let result = ""; + let lastEnd = 0; + + for (const facet of sortedFacets) { + const { byteStart, byteEnd } = facet.index; + + // Add text before this facet + if (byteStart > lastEnd) { + const beforeBytes = textBytes.slice(lastEnd, byteStart); + result += escapeHtml(decoder.decode(beforeBytes)); + } + + // Get the facet text + const facetBytes = textBytes.slice(byteStart, byteEnd); + const facetText = decoder.decode(facetBytes); + + // Find the first renderable feature + const feature = facet.features[0]; + if (feature) { + if (feature.$type === "app.bsky.richtext.facet#link") { + result += `${escapeHtml(facetText)}`; + } else if (feature.$type === "app.bsky.richtext.facet#mention") { + result += `${escapeHtml(facetText)}`; + } else if (feature.$type === "app.bsky.richtext.facet#tag") { + result += `${escapeHtml(facetText)}`; + } else { + result += escapeHtml(facetText); + } + } else { + result += escapeHtml(facetText); + } + + lastEnd = byteEnd; + } + + // Add remaining text + if (lastEnd < textBytes.length) { + const remainingBytes = textBytes.slice(lastEnd); + result += escapeHtml(decoder.decode(remainingBytes)); + } + + return result; } /** @@ -440,11 +440,11 @@ function renderTextWithFacets(text, facets) { * @returns {string} Initials (1-2 characters) */ function getInitials(name) { - const parts = name.trim().split(/\s+/); - if (parts.length >= 2) { - return (parts[0][0] + parts[1][0]).toUpperCase(); - } - return name.substring(0, 2).toUpperCase(); + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); } // ============================================================================ @@ -458,13 +458,13 @@ function getInitials(name) { * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null */ function parseAtUri(atUri) { - const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!match) return null; - return { - did: match[1], - collection: match[2], - rkey: match[3], - }; + const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); + if (!match) return null; + return { + did: match[1], + collection: match[2], + rkey: match[3], + }; } /** @@ -474,45 +474,45 @@ function parseAtUri(atUri) { * @returns {Promise} PDS URL */ async function resolvePDS(did) { - let pdsUrl; - - if (did.startsWith("did:plc:")) { - // Fetch DID document from plc.directory - const didDocUrl = `https://plc.directory/${did}`; - const didDocResponse = await fetch(didDocUrl); - if (!didDocResponse.ok) { - throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); - } - const didDoc = await didDocResponse.json(); - - // Find the PDS service endpoint - const pdsService = didDoc.service?.find( - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", - ); - pdsUrl = pdsService?.serviceEndpoint; - } else if (did.startsWith("did:web:")) { - // For did:web, fetch the DID document from the domain - const domain = did.replace("did:web:", ""); - const didDocUrl = `https://${domain}/.well-known/did.json`; - const didDocResponse = await fetch(didDocUrl); - if (!didDocResponse.ok) { - throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); - } - const didDoc = await didDocResponse.json(); - - const pdsService = didDoc.service?.find( - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", - ); - pdsUrl = pdsService?.serviceEndpoint; - } else { - throw new Error(`Unsupported DID method: ${did}`); - } - - if (!pdsUrl) { - throw new Error("Could not find PDS URL for user"); - } - - return pdsUrl; + let pdsUrl; + + if (did.startsWith("did:plc:")) { + // Fetch DID document from plc.directory + const didDocUrl = `https://plc.directory/${did}`; + const didDocResponse = await fetch(didDocUrl); + if (!didDocResponse.ok) { + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); + } + const didDoc = await didDocResponse.json(); + + // Find the PDS service endpoint + const pdsService = didDoc.service?.find( + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", + ); + pdsUrl = pdsService?.serviceEndpoint; + } else if (did.startsWith("did:web:")) { + // For did:web, fetch the DID document from the domain + const domain = did.replace("did:web:", ""); + const didDocUrl = `https://${domain}/.well-known/did.json`; + const didDocResponse = await fetch(didDocUrl); + if (!didDocResponse.ok) { + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); + } + const didDoc = await didDocResponse.json(); + + const pdsService = didDoc.service?.find( + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", + ); + pdsUrl = pdsService?.serviceEndpoint; + } else { + throw new Error(`Unsupported DID method: ${did}`); + } + + if (!pdsUrl) { + throw new Error("Could not find PDS URL for user"); + } + + return pdsUrl; } /** @@ -523,20 +523,20 @@ async function resolvePDS(did) { * @returns {Promise} Record value */ async function getRecord(did, collection, rkey) { - const pdsUrl = await resolvePDS(did); + const pdsUrl = await resolvePDS(did); - const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`); - url.searchParams.set("repo", did); - url.searchParams.set("collection", collection); - url.searchParams.set("rkey", rkey); + const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`); + url.searchParams.set("repo", did); + url.searchParams.set("collection", collection); + url.searchParams.set("rkey", rkey); - const response = await fetch(url.toString()); - if (!response.ok) { - throw new Error(`Failed to fetch record: ${response.status}`); - } + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Failed to fetch record: ${response.status}`); + } - const data = await response.json(); - return data.value; + const data = await response.json(); + return data.value; } /** @@ -545,12 +545,12 @@ async function getRecord(did, collection, rkey) { * @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 */ async function getDocument(atUri) { - const parsed = parseAtUri(atUri); - if (!parsed) { - throw new Error(`Invalid AT URI: ${atUri}`); - } + const parsed = parseAtUri(atUri); + if (!parsed) { + throw new Error(`Invalid AT URI: ${atUri}`); + } - return getRecord(parsed.did, parsed.collection, parsed.rkey); + return getRecord(parsed.did, parsed.collection, parsed.rkey); } /** @@ -560,24 +560,24 @@ async function getDocument(atUri) { * @returns {Promise} Thread view post */ async function getPostThread(postUri, depth = 6) { - const url = new URL( - "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread", - ); - url.searchParams.set("uri", postUri); - url.searchParams.set("depth", depth.toString()); + const url = new URL( + "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread", + ); + url.searchParams.set("uri", postUri); + url.searchParams.set("depth", depth.toString()); - const response = await fetch(url.toString()); - if (!response.ok) { - throw new Error(`Failed to fetch post thread: ${response.status}`); - } + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Failed to fetch post thread: ${response.status}`); + } - const data = await response.json(); + const data = await response.json(); - if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { - throw new Error("Post not found or blocked"); - } + if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { + throw new Error("Post not found or blocked"); + } - return data.thread; + return data.thread; } /** @@ -586,12 +586,12 @@ async function getPostThread(postUri, depth = 6) { * @returns {string} Bluesky app URL */ function buildBskyAppUrl(postUri) { - const parsed = parseAtUri(postUri); - if (!parsed) { - throw new Error(`Invalid post URI: ${postUri}`); - } + const parsed = parseAtUri(postUri); + if (!parsed) { + throw new Error(`Invalid post URI: ${postUri}`); + } - return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; + return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; } /** @@ -600,12 +600,12 @@ function buildBskyAppUrl(postUri) { * @returns {string} Blacksky app URL */ function buildBlackskyAppUrl(postUri) { - const parsed = parseAtUri(postUri); - if (!parsed) { - throw new Error(`Invalid post URI: ${postUri}`); - } + const parsed = parseAtUri(postUri); + if (!parsed) { + throw new Error(`Invalid post URI: ${postUri}`); + } - return `https://blacksky.community/profile/${parsed.did}/post/${parsed.rkey}`; + return `https://blacksky.community/profile/${parsed.did}/post/${parsed.rkey}`; } /** @@ -614,7 +614,7 @@ function buildBlackskyAppUrl(postUri) { * @returns {boolean} True if post is a ThreadViewPost */ function isThreadViewPost(post) { - return post?.$type === "app.bsky.feed.defs#threadViewPost"; + return post?.$type === "app.bsky.feed.defs#threadViewPost"; } /** @@ -634,53 +634,53 @@ function isThreadViewPost(post) { * @returns {Promise} AT-URI */ async function resolvePostUri(uriOrUrl) { - if (uriOrUrl.startsWith("at://")) return uriOrUrl; - - const match = uriOrUrl.match( - /bsky\.app\/profile\/([^/?#]+)\/post\/([^/?#]+)/, - ); - if (!match) throw new Error(`Cannot parse Bluesky URL: ${uriOrUrl}`); - - const [, handleOrDid, rkey] = match; - - let did = handleOrDid; - if (!handleOrDid.startsWith("did:")) { - const url = new URL( - "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle", - ); - url.searchParams.set("handle", handleOrDid); - const response = await fetch(url.toString()); - if (!response.ok) - throw new Error(`Failed to resolve handle: ${response.status}`); - did = (await response.json()).did; - } - - return `at://${did}/app.bsky.feed.post/${rkey}`; + if (uriOrUrl.startsWith("at://")) return uriOrUrl; + + const match = uriOrUrl.match( + /bsky\.app\/profile\/([^/?#]+)\/post\/([^/?#]+)/, + ); + if (!match) throw new Error(`Cannot parse Bluesky URL: ${uriOrUrl}`); + + const [, handleOrDid, rkey] = match; + + let did = handleOrDid; + if (!handleOrDid.startsWith("did:")) { + const url = new URL( + "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle", + ); + url.searchParams.set("handle", handleOrDid); + const response = await fetch(url.toString()); + if (!response.ok) + throw new Error(`Failed to resolve handle: ${response.status}`); + did = (await response.json()).did; + } + + return `at://${did}/app.bsky.feed.post/${rkey}`; } async function getQuotes(postUri) { - const quotes = []; - let cursor; - - do { - const url = new URL( - "https://public.api.bsky.app/xrpc/app.bsky.feed.getQuotes", - ); - url.searchParams.set("uri", postUri); - url.searchParams.set("limit", "100"); - if (cursor) url.searchParams.set("cursor", cursor); - - const response = await fetch(url.toString()); - if (!response.ok) { - throw new Error(`Failed to fetch quotes: ${response.status}`); - } - - const data = await response.json(); - quotes.push(...(data.posts ?? [])); - cursor = data.cursor; - } while (cursor); - - return quotes; + const quotes = []; + let cursor; + + do { + const url = new URL( + "https://public.api.bsky.app/xrpc/app.bsky.feed.getQuotes", + ); + url.searchParams.set("uri", postUri); + url.searchParams.set("limit", "100"); + if (cursor) url.searchParams.set("cursor", cursor); + + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Failed to fetch quotes: ${response.status}`); + } + + const data = await response.json(); + quotes.push(...(data.posts ?? [])); + cursor = data.cursor; + } while (cursor); + + return quotes; } // ============================================================================ @@ -691,7 +691,7 @@ const BLUESKY_ICON = ` `; const BLACKSKY_ICON = - ''; + ''; // ============================================================================ // Web Component @@ -701,164 +701,168 @@ const BLACKSKY_ICON = const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; class SequoiaComments extends BaseElement { - constructor() { - super(); - const shadow = this.attachShadow({ mode: "open" }); - - const styleTag = document.createElement("style"); - shadow.appendChild(styleTag); - styleTag.innerText = styles; - - const container = document.createElement("div"); - shadow.appendChild(container); - container.className = "sequoia-comments-container"; - container.part = "container"; - - this.commentsContainer = container; - this.state = { type: "loading" }; - this.abortController = null; - } - - static get observedAttributes() { - return ["post-uri", "document-uri", "depth", "hide"]; - } - - connectedCallback() { - this.render(); - this.loadComments(); - } - - disconnectedCallback() { - this.abortController?.abort(); - } - - attributeChangedCallback() { - if (this.isConnected) { - this.loadComments(); - } - } - - get documentUri() { - // First check attribute - const attrUri = this.getAttribute("document-uri"); - if (attrUri) { - return attrUri; - } - - // Then scan for link tag in document head - const linkTag = document.querySelector( - 'link[rel="site.standard.document"]', - ); - return linkTag?.href ?? null; - } - - get depth() { - const depthAttr = this.getAttribute("depth"); - return depthAttr ? parseInt(depthAttr, 10) : 6; - } - - get hide() { - const hideAttr = this.getAttribute("hide"); - return hideAttr === "auto"; - } - - async loadComments() { - // Cancel any in-flight request - this.abortController?.abort(); - this.abortController = new AbortController(); - - this.state = { type: "loading" }; - this.render(); - - try { - // Resolve the post URI — either directly from the attribute or via the - // document record (which requires a PDS roundtrip) - const rawPostUri = this.getAttribute("post-uri"); - let postUri = rawPostUri ? await resolvePostUri(rawPostUri) : null; - if (!postUri) { - const docUri = this.documentUri; - if (!docUri) { - this.state = { type: "no-document" }; - this.render(); - return; - } - - const document = await getDocument(docUri); - if (!document.bskyPostRef) { - this.state = { type: "no-comments-enabled" }; - this.render(); - return; - } - - postUri = document.bskyPostRef.uri; - } - - const postUrl = buildBskyAppUrl(postUri); - const blackskyPostUrl = buildBlackskyAppUrl(postUri); - - // Fetch thread and quotes in parallel; quote failures degrade gracefully - const [threadResult, quotesResult] = await Promise.allSettled([ - getPostThread(postUri, this.depth), - getQuotes(postUri), - ]); - - if (threadResult.status === "rejected") { - throw threadResult.reason; - } - - const thread = threadResult.value; - const quotes = - quotesResult.status === "fulfilled" ? quotesResult.value : []; - - const replies = thread.replies?.filter(isThreadViewPost) ?? []; - if (replies.length === 0 && quotes.length === 0) { - this.state = { type: "empty", postUrl, blackskyPostUrl }; - this.render(); - return; - } - - this.state = { type: "loaded", thread, quotes, postUrl, blackskyPostUrl }; - this.render(); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to load comments"; - this.state = { type: "error", message }; - this.render(); - } - } - - render() { - switch (this.state.type) { - case "loading": - this.commentsContainer.innerHTML = ` + constructor() { + super(); + const shadow = this.attachShadow({ mode: "open" }); + + const styleTag = document.createElement("style"); + shadow.appendChild(styleTag); + styleTag.innerText = styles; + + const container = document.createElement("div"); + shadow.appendChild(container); + container.className = "sequoia-comments-container"; + container.part = "container"; + + this.commentsContainer = container; + this.state = { type: "loading" }; + this.abortController = null; + } + + static get observedAttributes() { + return ["post-uri", "document-uri", "depth", "hide"]; + } + + connectedCallback() { + this.initialized = true; + this.render(); + this.loadComments(); + } + + disconnectedCallback() { + this.abortController?.abort(); + } + + attributeChangedCallback() { + // attributeChangedCallback fires for pre-existing attributes during + // element upgrade, *before* connectedCallback — skip until we've done + // the initial load, otherwise every attribute triggers a duplicate fetch. + if (this.initialized) { + this.loadComments(); + } + } + + get documentUri() { + // First check attribute + const attrUri = this.getAttribute("document-uri"); + if (attrUri) { + return attrUri; + } + + // Then scan for link tag in document head + const linkTag = document.querySelector( + 'link[rel="site.standard.document"]', + ); + return linkTag?.href ?? null; + } + + get depth() { + const depthAttr = this.getAttribute("depth"); + return depthAttr ? parseInt(depthAttr, 10) : 6; + } + + get hide() { + const hideAttr = this.getAttribute("hide"); + return hideAttr === "auto"; + } + + async loadComments() { + // Cancel any in-flight request + this.abortController?.abort(); + this.abortController = new AbortController(); + + this.state = { type: "loading" }; + this.render(); + + try { + // Resolve the post URI — either directly from the attribute or via the + // document record (which requires a PDS roundtrip) + const rawPostUri = this.getAttribute("post-uri"); + let postUri = rawPostUri ? await resolvePostUri(rawPostUri) : null; + if (!postUri) { + const docUri = this.documentUri; + if (!docUri) { + this.state = { type: "no-document" }; + this.render(); + return; + } + + const document = await getDocument(docUri); + if (!document.bskyPostRef) { + this.state = { type: "no-comments-enabled" }; + this.render(); + return; + } + + postUri = document.bskyPostRef.uri; + } + + const postUrl = buildBskyAppUrl(postUri); + const blackskyPostUrl = buildBlackskyAppUrl(postUri); + + // Fetch thread and quotes in parallel; quote failures degrade gracefully + const [threadResult, quotesResult] = await Promise.allSettled([ + getPostThread(postUri, this.depth), + getQuotes(postUri), + ]); + + if (threadResult.status === "rejected") { + throw threadResult.reason; + } + + const thread = threadResult.value; + const quotes = + quotesResult.status === "fulfilled" ? quotesResult.value : []; + + const replies = thread.replies?.filter(isThreadViewPost) ?? []; + if (replies.length === 0 && quotes.length === 0) { + this.state = { type: "empty", postUrl, blackskyPostUrl }; + this.render(); + return; + } + + this.state = { type: "loaded", thread, quotes, postUrl, blackskyPostUrl }; + this.render(); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to load comments"; + this.state = { type: "error", message }; + this.render(); + } + } + + render() { + switch (this.state.type) { + case "loading": + this.commentsContainer.innerHTML = `
Loading comments...
`; - break; + break; - case "no-document": - this.commentsContainer.innerHTML = ` + case "no-document": + this.commentsContainer.innerHTML = `
No document found. Add a <link rel="site.standard.document" href="at://..."> tag to your page.
`; - if (this.hide) { - this.commentsContainer.style.display = "none"; - } - break; + if (this.hide) { + this.commentsContainer.style.display = "none"; + } + break; - case "no-comments-enabled": - this.commentsContainer.innerHTML = ` + case "no-comments-enabled": + this.commentsContainer.innerHTML = `
Comments are not enabled for this post.
`; - break; + break; - case "empty": - this.commentsContainer.innerHTML = ` + case "empty": + this.commentsContainer.innerHTML = `

Comments

${this.renderReplyButtons(this.state.postUrl, this.state.blackskyPostUrl)}
@@ -867,31 +871,31 @@ class SequoiaComments extends BaseElement { No comments yet. Be the first to reply on Bluesky!
`; - break; + break; - case "error": - this.commentsContainer.innerHTML = ` + case "error": + this.commentsContainer.innerHTML = `
Failed to load comments: ${escapeHtml(this.state.message)}
`; - break; - - case "loaded": { - const replies = - this.state.thread.replies?.filter(isThreadViewPost) ?? []; - const quotes = this.state.quotes ?? []; - const threadsHtml = replies - .map((reply) => this.renderThread(reply)) - .join(""); - const commentCount = this.countComments(replies); - const titleText = - commentCount > 0 - ? `${commentCount} Comment${commentCount !== 1 ? "s" : ""}` - : "Comments"; - const quotesHtml = this.renderQuotesSection(quotes); - - this.commentsContainer.innerHTML = ` + break; + + case "loaded": { + const replies = + this.state.thread.replies?.filter(isThreadViewPost) ?? []; + const quotes = this.state.quotes ?? []; + const threadsHtml = replies + .map((reply) => this.renderThread(reply)) + .join(""); + const commentCount = this.countComments(replies); + const titleText = + commentCount > 0 + ? `${commentCount} Comment${commentCount !== 1 ? "s" : ""}` + : "Comments"; + const quotesHtml = this.renderQuotesSection(quotes); + + this.commentsContainer.innerHTML = `

${titleText}

${this.renderReplyButtons(this.state.postUrl, this.state.blackskyPostUrl)}
@@ -901,40 +905,40 @@ class SequoiaComments extends BaseElement {
${quotesHtml} `; - break; - } - } - } - - /** - * Flatten a thread into a linear list of comments - * @param {ThreadViewPost} thread - Thread to flatten - * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments - */ - flattenThread(thread) { - const result = []; - const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; - - result.push({ - post: thread.post, - hasMoreReplies: nestedReplies.length > 0, - }); - - // Recursively flatten nested replies - for (const reply of nestedReplies) { - result.push(...this.flattenThread(reply)); - } - - return result; - } - - /** - * Render the reply-button slot. Any element with slot="reply-button" in the - * light DOM is projected here and remains styleable by external CSS. - * The default Bluesky/Blacksky buttons are used as fallback content. - */ - renderReplyButtons(postUrl, blackskyPostUrl) { - return ` + break; + } + } + } + + /** + * Flatten a thread into a linear list of comments + * @param {ThreadViewPost} thread - Thread to flatten + * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments + */ + flattenThread(thread) { + const result = []; + const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; + + result.push({ + post: thread.post, + hasMoreReplies: nestedReplies.length > 0, + }); + + // Recursively flatten nested replies + for (const reply of nestedReplies) { + result.push(...this.flattenThread(reply)); + } + + return result; + } + + /** + * Render the reply-button slot. Any element with slot="reply-button" in the + * light DOM is projected here and remains styleable by external CSS. + * The default Bluesky/Blacksky buttons are used as fallback content. + */ + renderReplyButtons(postUrl, blackskyPostUrl) { + return ` ${BLUESKY_ICON} @@ -944,37 +948,37 @@ class SequoiaComments extends BaseElement { `; - } - - /** - * Render a complete thread (top-level comment + all nested replies) - */ - renderThread(thread) { - const flatComments = this.flattenThread(thread); - const commentsHtml = flatComments - .map((item, index) => - this.renderComment(item.post, item.hasMoreReplies, index), - ) - .join(""); - - return `
${commentsHtml}
`; - } - - /** - * Render a section of quote posts below the replies - * @param {Array} quotes - Array of PostView objects from getQuotes - */ - renderQuotesSection(quotes) { - if (quotes.length === 0) return ""; - - const quotesHtml = quotes - .map((post) => { - const quotePostUrl = buildBskyAppUrl(post.uri); - return `
${this.renderComment(post, false, 0, quotePostUrl)}
`; - }) - .join(""); - - return ` + } + + /** + * Render a complete thread (top-level comment + all nested replies) + */ + renderThread(thread) { + const flatComments = this.flattenThread(thread); + const commentsHtml = flatComments + .map((item, index) => + this.renderComment(item.post, item.hasMoreReplies, index), + ) + .join(""); + + return `
${commentsHtml}
`; + } + + /** + * Render a section of quote posts below the replies + * @param {Array} quotes - Array of PostView objects from getQuotes + */ + renderQuotesSection(quotes) { + if (quotes.length === 0) return ""; + + const quotesHtml = quotes + .map((post) => { + const quotePostUrl = buildBskyAppUrl(post.uri); + return `
${this.renderComment(post, false, 0, quotePostUrl)}
`; + }) + .join(""); + + return `

Quotes (${quotes.length})

@@ -982,33 +986,33 @@ class SequoiaComments extends BaseElement {
`; - } - - /** - * Render a single comment - * @param {any} post - Post data - * @param {boolean} showThreadLine - Whether to show the connecting thread line - * @param {number} _index - Index in the flattened thread (0 = top-level) - * @param {string|null} postUrl - Optional URL to link the timestamp to (used for quote posts) - */ - renderComment(post, showThreadLine = false, _index = 0, postUrl = null) { - const author = post.author; - const displayName = author.displayName || author.handle; - const avatarHtml = author.avatar - ? `${escapeHtml(displayName)}` - : `
${getInitials(displayName)}
`; - - const profileUrl = `https://bsky.app/profile/${author.did}`; - const textHtml = renderTextWithFacets(post.record.text, post.record.facets); - const timeAgo = formatRelativeTime(post.record.createdAt); - const timeHtml = postUrl - ? `${timeAgo}` - : `${timeAgo}`; - const threadLineHtml = showThreadLine - ? '
' - : ""; - - return ` + } + + /** + * Render a single comment + * @param {any} post - Post data + * @param {boolean} showThreadLine - Whether to show the connecting thread line + * @param {number} _index - Index in the flattened thread (0 = top-level) + * @param {string|null} postUrl - Optional URL to link the timestamp to (used for quote posts) + */ + renderComment(post, showThreadLine = false, _index = 0, postUrl = null) { + const author = post.author; + const displayName = author.displayName || author.handle; + const avatarHtml = author.avatar + ? `${escapeHtml(displayName)}` + : `
${getInitials(displayName)}
`; + + const profileUrl = `https://bsky.app/profile/${author.did}`; + const textHtml = renderTextWithFacets(post.record.text, post.record.facets); + const timeAgo = formatRelativeTime(post.record.createdAt); + const timeHtml = postUrl + ? `${timeAgo}` + : `${timeAgo}`; + const threadLineHtml = showThreadLine + ? '
' + : ""; + + return `
${avatarHtml} @@ -1026,22 +1030,22 @@ class SequoiaComments extends BaseElement {
`; - } - - countComments(replies) { - let count = 0; - for (const reply of replies) { - count += 1; - const nested = reply.replies?.filter(isThreadViewPost) ?? []; - count += this.countComments(nested); - } - return count; - } + } + + countComments(replies) { + let count = 0; + for (const reply of replies) { + count += 1; + const nested = reply.replies?.filter(isThreadViewPost) ?? []; + count += this.countComments(nested); + } + return count; + } } // Register the custom element if (typeof customElements !== "undefined") { - customElements.define("sequoia-comments", SequoiaComments); + customElements.define("sequoia-comments", SequoiaComments); } // Export for module usage