} 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 = `
`;
- 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 = `
${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 `
`;
- }
-
- /**
- * 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
- ? ``
- : ``;
-
- 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
- ? ``
- : ``;
- 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
+ ? ``
+ : ``;
+
+ 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
+ ? ``
+ : ``;
+ const threadLineHtml = showThreadLine
+ ? ''
+ : "";
+
+ return `
`;
- }
-
- 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
Comments