} 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 response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`Failed to fetch post thread: ${response.status}`);
}
const data = await response.json();
if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") {
throw new Error("Post not found or blocked");
}
return data.thread;
}
/**
* Build a Bluesky app URL for a post
* @param {string} postUri - AT Protocol URI for the post
* @returns {string} Bluesky app URL
*/
function buildBskyAppUrl(postUri) {
const parsed = parseAtUri(postUri);
if (!parsed) {
throw new Error(`Invalid post URI: ${postUri}`);
}
return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`;
}
/**
* Type guard for ThreadViewPost
* @param {any} post - Post to check
* @returns {boolean} True if post is a ThreadViewPost
*/
function isThreadViewPost(post) {
return post?.$type === "app.bsky.feed.defs#threadViewPost";
}
// ============================================================================
// Bluesky Icon
// ============================================================================
const BLUESKY_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 ["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();
const docUri = this.documentUri;
if (!docUri) {
this.state = { type: "no-document" };
this.render();
return;
}
try {
// Fetch the document record
const document = await getDocument(docUri);
// Check if document has a Bluesky post reference
if (!document.bskyPostRef) {
this.state = { type: "no-comments-enabled" };
this.render();
return;
}
const postUrl = buildBskyAppUrl(document.bskyPostRef.uri);
// Fetch the post thread
const thread = await getPostThread(document.bskyPostRef.uri, this.depth);
// Check if there are any replies
const replies = thread.replies?.filter(isThreadViewPost) ?? [];
if (replies.length === 0) {
this.state = { type: "empty", postUrl };
this.render();
return;
}
this.state = { type: "loaded", thread, postUrl };
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 threadsHtml = replies
.map((reply) => this.renderThread(reply))
.join("");
const commentCount = this.countComments(replies);
this.commentsContainer.innerHTML = `
`;
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 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 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 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