} 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}`;
}
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;
}
// ============================================================================
// Bluesky Icon
// ============================================================================
const BLUESKY_ICON = ``;
const BLACKSKY_ICON =
'';
// ============================================================================
// Web Component
// ============================================================================
// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
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.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;
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;
case "no-comments-enabled":
this.commentsContainer.innerHTML = `
Comments are not enabled for this post.
`;
break;
case "empty":
this.commentsContainer.innerHTML = `
No comments yet. Be the first to reply on Bluesky!
`;
break;
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 = `
${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 `
${BLUESKY_ICON}
${BLACKSKY_ICON}
`;
}
/**
* 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) => {
return `${this.renderComment(post, false, 0)}
`;
})
.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)
*/
renderComment(post, showThreadLine = false, _index = 0) {
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 = ``;
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;
}
}
// Register the custom element
if (typeof customElements !== "undefined") {
customElements.define("sequoia-comments", SequoiaComments);
}
// Export for module usage
export { SequoiaComments };
Comments