} 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 };
diff --git a/src/layouts/PostLayout.astro b/src/layouts/PostLayout.astro
index 65aeb2c..1f3f0ca 100644
--- a/src/layouts/PostLayout.astro
+++ b/src/layouts/PostLayout.astro
@@ -55,6 +55,9 @@ const backupImage = `/open-graph/${postSlug}.png`
+ Comments
+
+
Comments
+ +