Fork of Chiri for Astro for my blog
1/**
2 * Sequoia Comments - A Bluesky-powered comments component
3 *
4 * A self-contained Web Component that displays comments from Bluesky posts
5 * linked to documents via the AT Protocol.
6 *
7 * Usage:
8 * <sequoia-comments></sequoia-comments>
9 *
10 * The component looks for a document URI in two places:
11 * 1. The `document-uri` attribute on the element
12 * 2. A <link rel="site.standard.document" href="at://..."> tag in the document head
13 *
14 * Attributes:
15 * - document-uri: AT Protocol URI for the document (optional if link tag exists)
16 * - depth: Maximum depth of nested replies to fetch (default: 6)
17 * - hide: Set to "auto" to hide if no document link is detected
18 *
19 * CSS Custom Properties:
20 * - --sequoia-fg-color: Text color (default: #1f2937)
21 * - --sequoia-bg-color: Background color (default: #ffffff)
22 * - --sequoia-border-color: Border color (default: #e5e7eb)
23 * - --sequoia-accent-color: Accent/link color (default: #2563eb)
24 * - --sequoia-secondary-color: Secondary text color (default: #6b7280)
25 * - --sequoia-border-radius: Border radius (default: 8px)
26 */
27
28// ============================================================================
29// Styles
30// ============================================================================
31
32const styles = `
33:host {
34 display: block;
35 font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
36 color: var(--sequoia-fg-color, #1f2937);
37 line-height: 1.5;
38}
39
40* {
41 box-sizing: border-box;
42}
43
44.sequoia-comments-container {
45 max-width: 100%;
46}
47
48.sequoia-loading,
49.sequoia-error,
50.sequoia-empty,
51.sequoia-warning {
52 padding: 1rem;
53 border-radius: var(--sequoia-border-radius, 8px);
54 text-align: center;
55}
56
57.sequoia-loading {
58 background: var(--sequoia-bg-color, #ffffff);
59 border: 1px solid var(--sequoia-border-color, #e5e7eb);
60 color: var(--sequoia-secondary-color, #6b7280);
61}
62
63.sequoia-loading-spinner {
64 display: inline-block;
65 width: 1.25rem;
66 height: 1.25rem;
67 border: 2px solid var(--sequoia-border-color, #e5e7eb);
68 border-top-color: var(--sequoia-accent-color, #2563eb);
69 border-radius: 50%;
70 animation: sequoia-spin 0.8s linear infinite;
71 margin-right: 0.5rem;
72 vertical-align: middle;
73}
74
75@keyframes sequoia-spin {
76 to { transform: rotate(360deg); }
77}
78
79.sequoia-error {
80 background: #fef2f2;
81 border: 1px solid #fecaca;
82 color: #dc2626;
83}
84
85.sequoia-warning {
86 background: #fffbeb;
87 border: 1px solid #fde68a;
88 color: #d97706;
89}
90
91.sequoia-empty {
92 background: var(--sequoia-bg-color, #ffffff);
93 border: 1px solid var(--sequoia-border-color, #e5e7eb);
94 color: var(--sequoia-secondary-color, #6b7280);
95}
96
97.sequoia-comments-header {
98 display: flex;
99 justify-content: space-between;
100 align-items: center;
101 margin-bottom: 1rem;
102 padding-bottom: 0.75rem;
103}
104
105.sequoia-comments-title {
106 font-size: 1.125rem;
107 font-weight: 600;
108 margin: 0;
109}
110
111.sequoia-reply-button {
112 display: inline-flex;
113 align-items: center;
114 gap: 0.375rem;
115 padding: 0.5rem 1rem;
116 border: none;
117 border-radius: var(--sequoia-border-radius, 15px);
118 font-size: 0.875rem;
119 font-weight: 500;
120 cursor: pointer;
121 text-decoration: none;
122 transition: background-color 0.15s ease;
123 margin-left:10px;
124}
125
126.sequoia-reply-bluesky {
127 background: var(--sequoia-accent-color, #2563eb);
128 color: #ffffff;
129}
130
131.sequoia-reply-blacksky {
132 background: var(--sequoia-accent-color, #6060E9);
133 color: #ffffff;
134}
135
136.sequoia-reply-bluesky:hover {
137 background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
138}
139
140.sequoia-reply-blacksky:hover {
141 background: color-mix(in srgb, var(--sequoia-accent-color, #5252c3) 85%, black);
142}
143
144.sequoia-reply-button svg {
145 width: 1rem;
146 height: 1rem;
147}
148
149.sequoia-comments-list {
150 display: flex;
151 flex-direction: column;
152}
153
154.sequoia-thread {
155 border-top: 1px solid var(--sequoia-border-color, #e5e7eb);
156 padding-bottom: 1rem;
157}
158
159.sequoia-thread + .sequoia-thread {
160 margin-top: 0.5rem;
161}
162
163.sequoia-thread:last-child {
164 border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
165}
166
167.sequoia-comment {
168 display: flex;
169 gap: 0.75rem;
170 padding-top: 1rem;
171}
172
173.sequoia-comment-avatar-column {
174 display: flex;
175 flex-direction: column;
176 align-items: center;
177 flex-shrink: 0;
178 width: 2.5rem;
179 position: relative;
180}
181
182.sequoia-comment-avatar {
183 width: 2.5rem;
184 height: 2.5rem;
185 border-radius: 50%;
186 background: var(--sequoia-border-color, #e5e7eb);
187 object-fit: cover;
188 flex-shrink: 0;
189 position: relative;
190 z-index: 1;
191}
192
193.sequoia-comment-avatar-placeholder {
194 width: 2.5rem;
195 height: 2.5rem;
196 border-radius: 50%;
197 background: var(--sequoia-border-color, #e5e7eb);
198 display: flex;
199 align-items: center;
200 justify-content: center;
201 flex-shrink: 0;
202 color: var(--sequoia-secondary-color, #6b7280);
203 font-weight: 600;
204 font-size: 1rem;
205 position: relative;
206 z-index: 1;
207}
208
209.sequoia-thread-line {
210 position: absolute;
211 top: 2.5rem;
212 bottom: calc(-1rem - 0.5rem);
213 left: 50%;
214 transform: translateX(-50%);
215 width: 2px;
216 background: var(--sequoia-border-color, #e5e7eb);
217}
218
219.sequoia-comment-content {
220 flex: 1;
221 min-width: 0;
222}
223
224.sequoia-comment-header {
225 display: flex;
226 align-items: baseline;
227 gap: 0.5rem;
228 margin-bottom: 0.25rem;
229 flex-wrap: wrap;
230}
231
232.sequoia-comment-author {
233 font-weight: 600;
234 color: var(--sequoia-fg-color, #1f2937);
235 text-decoration: none;
236 overflow: hidden;
237 text-overflow: ellipsis;
238 white-space: nowrap;
239}
240
241.sequoia-comment-author:hover {
242 color: var(--sequoia-accent-color, #2563eb);
243}
244
245.sequoia-comment-handle {
246 font-size: 0.875rem;
247 color: var(--sequoia-secondary-color, #6b7280);
248 overflow: hidden;
249 text-overflow: ellipsis;
250 white-space: nowrap;
251}
252
253.sequoia-comment-time {
254 font-size: 0.875rem;
255 color: var(--sequoia-secondary-color, #6b7280);
256 flex-shrink: 0;
257}
258
259.sequoia-comment-time::before {
260 content: "·";
261 margin-right: 0.5rem;
262}
263
264.sequoia-comment-text {
265 margin: 0;
266 white-space: pre-wrap;
267 word-wrap: break-word;
268}
269
270.sequoia-comment-text a {
271 color: var(--sequoia-accent-color, #2563eb);
272 text-decoration: none;
273}
274
275.sequoia-comment-text a:hover {
276 text-decoration: underline;
277}
278
279.sequoia-bsky-logo {
280 width: 1rem;
281 height: 1rem;
282}
283`;
284
285// ============================================================================
286// Utility Functions
287// ============================================================================
288
289/**
290 * Format a relative time string (e.g., "2 hours ago")
291 * @param {string} dateString - ISO date string
292 * @returns {string} Formatted relative time
293 */
294function formatRelativeTime(dateString) {
295 const date = new Date(dateString);
296 const now = new Date();
297 const diffMs = now.getTime() - date.getTime();
298 const diffSeconds = Math.floor(diffMs / 1000);
299 const diffMinutes = Math.floor(diffSeconds / 60);
300 const diffHours = Math.floor(diffMinutes / 60);
301 const diffDays = Math.floor(diffHours / 24);
302 const diffWeeks = Math.floor(diffDays / 7);
303 const diffMonths = Math.floor(diffDays / 30);
304 const diffYears = Math.floor(diffDays / 365);
305
306 if (diffSeconds < 60) {
307 return "just now";
308 }
309 if (diffMinutes < 60) {
310 return `${diffMinutes}m ago`;
311 }
312 if (diffHours < 24) {
313 return `${diffHours}h ago`;
314 }
315 if (diffDays < 7) {
316 return `${diffDays}d ago`;
317 }
318 if (diffWeeks < 4) {
319 return `${diffWeeks}w ago`;
320 }
321 if (diffMonths < 12) {
322 return `${diffMonths}mo ago`;
323 }
324 return `${diffYears}y ago`;
325}
326
327/**
328 * Escape HTML special characters
329 * @param {string} text - Text to escape
330 * @returns {string} Escaped HTML
331 */
332function escapeHtml(text) {
333 const div = document.createElement("div");
334 div.textContent = text;
335 return div.innerHTML;
336}
337
338/**
339 * Convert post text with facets to HTML
340 * @param {string} text - Post text
341 * @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets
342 * @returns {string} HTML string with links
343 */
344function renderTextWithFacets(text, facets) {
345 if (!facets || facets.length === 0) {
346 return escapeHtml(text);
347 }
348
349 // Convert text to bytes for proper indexing
350 const encoder = new TextEncoder();
351 const decoder = new TextDecoder();
352 const textBytes = encoder.encode(text);
353
354 // Sort facets by start index
355 const sortedFacets = [...facets].sort(
356 (a, b) => a.index.byteStart - b.index.byteStart,
357 );
358
359 let result = "";
360 let lastEnd = 0;
361
362 for (const facet of sortedFacets) {
363 const { byteStart, byteEnd } = facet.index;
364
365 // Add text before this facet
366 if (byteStart > lastEnd) {
367 const beforeBytes = textBytes.slice(lastEnd, byteStart);
368 result += escapeHtml(decoder.decode(beforeBytes));
369 }
370
371 // Get the facet text
372 const facetBytes = textBytes.slice(byteStart, byteEnd);
373 const facetText = decoder.decode(facetBytes);
374
375 // Find the first renderable feature
376 const feature = facet.features[0];
377 if (feature) {
378 if (feature.$type === "app.bsky.richtext.facet#link") {
379 result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
380 } else if (feature.$type === "app.bsky.richtext.facet#mention") {
381 result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
382 } else if (feature.$type === "app.bsky.richtext.facet#tag") {
383 result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
384 } else {
385 result += escapeHtml(facetText);
386 }
387 } else {
388 result += escapeHtml(facetText);
389 }
390
391 lastEnd = byteEnd;
392 }
393
394 // Add remaining text
395 if (lastEnd < textBytes.length) {
396 const remainingBytes = textBytes.slice(lastEnd);
397 result += escapeHtml(decoder.decode(remainingBytes));
398 }
399
400 return result;
401}
402
403/**
404 * Get initials from a name for avatar placeholder
405 * @param {string} name - Display name
406 * @returns {string} Initials (1-2 characters)
407 */
408function getInitials(name) {
409 const parts = name.trim().split(/\s+/);
410 if (parts.length >= 2) {
411 return (parts[0][0] + parts[1][0]).toUpperCase();
412 }
413 return name.substring(0, 2).toUpperCase();
414}
415
416// ============================================================================
417// AT Protocol Client Functions
418// ============================================================================
419
420/**
421 * Parse an AT URI into its components
422 * Format: at://did/collection/rkey
423 * @param {string} atUri - AT Protocol URI
424 * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null
425 */
426function parseAtUri(atUri) {
427 const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
428 if (!match) return null;
429 return {
430 did: match[1],
431 collection: match[2],
432 rkey: match[3],
433 };
434}
435
436/**
437 * Resolve a DID to its PDS URL
438 * Supports did:plc and did:web methods
439 * @param {string} did - Decentralized Identifier
440 * @returns {Promise<string>} PDS URL
441 */
442async function resolvePDS(did) {
443 let pdsUrl;
444
445 if (did.startsWith("did:plc:")) {
446 // Fetch DID document from plc.directory
447 const didDocUrl = `https://plc.directory/${did}`;
448 const didDocResponse = await fetch(didDocUrl);
449 if (!didDocResponse.ok) {
450 throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
451 }
452 const didDoc = await didDocResponse.json();
453
454 // Find the PDS service endpoint
455 const pdsService = didDoc.service?.find(
456 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
457 );
458 pdsUrl = pdsService?.serviceEndpoint;
459 } else if (did.startsWith("did:web:")) {
460 // For did:web, fetch the DID document from the domain
461 const domain = did.replace("did:web:", "");
462 const didDocUrl = `https://${domain}/.well-known/did.json`;
463 const didDocResponse = await fetch(didDocUrl);
464 if (!didDocResponse.ok) {
465 throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
466 }
467 const didDoc = await didDocResponse.json();
468
469 const pdsService = didDoc.service?.find(
470 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
471 );
472 pdsUrl = pdsService?.serviceEndpoint;
473 } else {
474 throw new Error(`Unsupported DID method: ${did}`);
475 }
476
477 if (!pdsUrl) {
478 throw new Error("Could not find PDS URL for user");
479 }
480
481 return pdsUrl;
482}
483
484/**
485 * Fetch a record from a PDS using the public API
486 * @param {string} did - DID of the repository owner
487 * @param {string} collection - Collection name
488 * @param {string} rkey - Record key
489 * @returns {Promise<any>} Record value
490 */
491async function getRecord(did, collection, rkey) {
492 const pdsUrl = await resolvePDS(did);
493
494 const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`);
495 url.searchParams.set("repo", did);
496 url.searchParams.set("collection", collection);
497 url.searchParams.set("rkey", rkey);
498
499 const response = await fetch(url.toString());
500 if (!response.ok) {
501 throw new Error(`Failed to fetch record: ${response.status}`);
502 }
503
504 const data = await response.json();
505 return data.value;
506}
507
508/**
509 * Fetch a document record from its AT URI
510 * @param {string} atUri - AT Protocol URI for the document
511 * @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
512 */
513async function getDocument(atUri) {
514 const parsed = parseAtUri(atUri);
515 if (!parsed) {
516 throw new Error(`Invalid AT URI: ${atUri}`);
517 }
518
519 return getRecord(parsed.did, parsed.collection, parsed.rkey);
520}
521
522/**
523 * Fetch a post thread from the public Bluesky API
524 * @param {string} postUri - AT Protocol URI for the post
525 * @param {number} [depth=6] - Maximum depth of replies to fetch
526 * @returns {Promise<ThreadViewPost>} Thread view post
527 */
528async function getPostThread(postUri, depth = 6) {
529 const url = new URL(
530 "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread",
531 );
532 url.searchParams.set("uri", postUri);
533 url.searchParams.set("depth", depth.toString());
534
535 const response = await fetch(url.toString());
536 if (!response.ok) {
537 throw new Error(`Failed to fetch post thread: ${response.status}`);
538 }
539
540 const data = await response.json();
541
542 if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") {
543 throw new Error("Post not found or blocked");
544 }
545
546 return data.thread;
547}
548
549/**
550 * Build a Bluesky app URL for a post
551 * @param {string} postUri - AT Protocol URI for the post
552 * @returns {string} Bluesky app URL
553 */
554function buildBskyAppUrl(postUri) {
555 const parsed = parseAtUri(postUri);
556 if (!parsed) {
557 throw new Error(`Invalid post URI: ${postUri}`);
558 }
559
560 return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`;
561}
562
563/**
564 * Build a Blacksky app URL for a post
565 * @param {string} postUri - AT Protocol URI for the post
566 * @returns {string} Blacksky app URL
567 */
568function buildBlackskyAppUrl(postUri) {
569 const parsed = parseAtUri(postUri);
570 if (!parsed) {
571 throw new Error(`Invalid post URI: ${postUri}`);
572 }
573
574 return `https://blacksky.community/profile/${parsed.did}/post/${parsed.rkey}`;
575}
576
577/**
578 * Type guard for ThreadViewPost
579 * @param {any} post - Post to check
580 * @returns {boolean} True if post is a ThreadViewPost
581 */
582function isThreadViewPost(post) {
583 return post?.$type === "app.bsky.feed.defs#threadViewPost";
584}
585
586// ============================================================================
587// Bluesky Icon
588// ============================================================================
589
590const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
591 <path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/>
592</svg>`;
593const BLACKSKY_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.0620117 0.348442 87.9941 74.9653"><path d="M41.9565 74.9643L24.0161 74.9653L41.9565 74.9643ZM63.8511 74.9653H45.9097L63.8501 74.9643V57.3286H63.8511V74.9653ZM45.9097 44.5893C45.9099 49.2737 49.7077 53.0707 54.3921 53.0707H63.8501V57.3286H54.3921C49.7077 57.3286 45.9099 61.1257 45.9097 65.81V74.9643H41.9565V65.81C41.9563 61.1258 38.1593 57.3287 33.4751 57.3286H24.0161V53.0707H33.4741C38.1587 53.0707 41.9565 49.2729 41.9565 44.5883V35.1303H45.9097V44.5893ZM63.8511 53.0707H63.8501V35.1303H63.8511V53.0707Z" fill="white"></path><path d="M52.7272 9.83198C49.4148 13.1445 49.4148 18.5151 52.7272 21.8275L59.4155 28.5158L56.4051 31.5262L49.7169 24.8379C46.4044 21.5254 41.0338 21.5254 37.7213 24.8379L31.2482 31.3111L28.4527 28.5156L34.9259 22.0424C38.2383 18.7299 38.2383 13.3594 34.9259 10.0469L28.2378 3.35883L31.2482 0.348442L37.9365 7.03672C41.2489 10.3492 46.6195 10.3492 49.932 7.03672L56.6203 0.348442L59.4155 3.14371L52.7272 9.83198Z" fill="white"/><path d="M24.3831 23.2335C23.1706 27.7584 25.8559 32.4095 30.3808 33.6219L39.5172 36.07L38.4154 40.182L29.2793 37.734C24.7544 36.5215 20.1033 39.2068 18.8909 43.7317L16.5215 52.5745L12.7028 51.5513L15.0721 42.7088C16.2846 38.1839 13.5993 33.5328 9.07434 32.3204L-0.0620117 29.8723L1.03987 25.76L10.1762 28.2081C14.7011 29.4206 19.3522 26.7352 20.5647 22.2103L23.0127 13.074L26.8311 14.0971L24.3831 23.2335Z" fill="white"/><path d="M67.3676 22.0297C68.5801 26.5546 73.2311 29.2399 77.756 28.0275L86.8923 25.5794L87.9941 29.6914L78.8578 32.1394C74.3329 33.3519 71.6476 38.003 72.86 42.5279L75.2294 51.3707L71.411 52.3938L69.0417 43.5513C67.8293 39.0264 63.1782 36.3411 58.6533 37.5535L49.5169 40.0016L48.415 35.8894L57.5514 33.4413C62.0763 32.2288 64.7616 27.5778 63.5492 23.0528L61.1011 13.9165L64.9195 12.8934L67.3676 22.0297Z" fill="white"/></svg>';
594
595// ============================================================================
596// Web Component
597// ============================================================================
598
599// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
600const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
601
602class SequoiaComments extends BaseElement {
603 constructor() {
604 super();
605 const shadow = this.attachShadow({ mode: "open" });
606
607 const styleTag = document.createElement("style");
608 shadow.appendChild(styleTag);
609 styleTag.innerText = styles;
610
611 const container = document.createElement("div");
612 shadow.appendChild(container);
613 container.className = "sequoia-comments-container";
614 container.part = "container";
615
616 this.commentsContainer = container;
617 this.state = { type: "loading" };
618 this.abortController = null;
619 }
620
621 static get observedAttributes() {
622 return ["document-uri", "depth", "hide"];
623 }
624
625 connectedCallback() {
626 this.render();
627 this.loadComments();
628 }
629
630 disconnectedCallback() {
631 this.abortController?.abort();
632 }
633
634 attributeChangedCallback() {
635 if (this.isConnected) {
636 this.loadComments();
637 }
638 }
639
640 get documentUri() {
641 // First check attribute
642 const attrUri = this.getAttribute("document-uri");
643 if (attrUri) {
644 return attrUri;
645 }
646
647 // Then scan for link tag in document head
648 const linkTag = document.querySelector(
649 'link[rel="site.standard.document"]',
650 );
651 return linkTag?.href ?? null;
652 }
653
654 get depth() {
655 const depthAttr = this.getAttribute("depth");
656 return depthAttr ? parseInt(depthAttr, 10) : 6;
657 }
658
659 get hide() {
660 const hideAttr = this.getAttribute("hide");
661 return hideAttr === "auto";
662 }
663
664 async loadComments() {
665 // Cancel any in-flight request
666 this.abortController?.abort();
667 this.abortController = new AbortController();
668
669 this.state = { type: "loading" };
670 this.render();
671
672 const docUri = this.documentUri;
673 if (!docUri) {
674 this.state = { type: "no-document" };
675 this.render();
676 return;
677 }
678
679 try {
680 // Fetch the document record
681 const document = await getDocument(docUri);
682
683 // Check if document has a Bluesky post reference
684 if (!document.bskyPostRef) {
685 this.state = { type: "no-comments-enabled" };
686 this.render();
687 return;
688 }
689
690 const postUrl = buildBskyAppUrl(document.bskyPostRef.uri);
691 const blackskyPostUrl = buildBlackskyAppUrl(document.bskyPostRef.uri);
692
693 // Fetch the post thread
694 const thread = await getPostThread(document.bskyPostRef.uri, this.depth);
695
696 // Check if there are any replies
697 const replies = thread.replies?.filter(isThreadViewPost) ?? [];
698 if (replies.length === 0) {
699 this.state = { type: "empty", postUrl, blackskyPostUrl };
700 this.render();
701 return;
702 }
703
704 this.state = { type: "loaded", thread, postUrl, blackskyPostUrl };
705 this.render();
706 } catch (error) {
707 const message =
708 error instanceof Error ? error.message : "Failed to load comments";
709 this.state = { type: "error", message };
710 this.render();
711 }
712 }
713
714 render() {
715 switch (this.state.type) {
716 case "loading":
717 this.commentsContainer.innerHTML = `
718 <div class="sequoia-loading">
719 <span class="sequoia-loading-spinner"></span>
720 Loading comments...
721 </div>
722 `;
723 break;
724
725 case "no-document":
726 this.commentsContainer.innerHTML = `
727 <div class="sequoia-warning">
728 No document found. Add a <code><link rel="site.standard.document" href="at://..."></code> tag to your page.
729 </div>
730 `;
731 if (this.hide) {
732 this.commentsContainer.style.display = "none";
733 }
734 break;
735
736 case "no-comments-enabled":
737 this.commentsContainer.innerHTML = `
738 <div class="sequoia-empty">
739 Comments are not enabled for this post.
740 </div>
741 `;
742 break;
743
744 case "empty":
745 this.commentsContainer.innerHTML = `
746 <div class="sequoia-comments-header">
747 <h3 class="sequoia-comments-title">Comments</h3>
748 <div>
749 <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-bluesky">
750 ${BLUESKY_ICON}
751 </a>
752 <a href="${this.state.blackskyPostUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-blacksky">
753 ${BLACKSKY_ICON}
754 </a>
755 </div>
756 </div>
757 <div class="sequoia-empty">
758 No comments yet. Be the first to reply on Bluesky!
759 </div>
760 `;
761 break;
762
763 case "error":
764 this.commentsContainer.innerHTML = `
765 <div class="sequoia-error">
766 Failed to load comments: ${escapeHtml(this.state.message)}
767 </div>
768 `;
769 break;
770
771 case "loaded": {
772 const replies =
773 this.state.thread.replies?.filter(isThreadViewPost) ?? [];
774 const threadsHtml = replies
775 .map((reply) => this.renderThread(reply))
776 .join("");
777 const commentCount = this.countComments(replies);
778
779 this.commentsContainer.innerHTML = `
780 <div class="sequoia-comments-header">
781 <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3>
782 <div>
783 <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-bluesky">
784 ${BLUESKY_ICON}
785 </a>
786 <a href="${this.state.blackskyPostUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-blacksky">
787 ${BLACKSKY_ICON}
788 </a>
789 </div>
790 </div>
791 <div class="sequoia-comments-list">
792 ${threadsHtml}
793 </div>
794 `;
795 break;
796 }
797 }
798 }
799
800 /**
801 * Flatten a thread into a linear list of comments
802 * @param {ThreadViewPost} thread - Thread to flatten
803 * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments
804 */
805 flattenThread(thread) {
806 const result = [];
807 const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? [];
808
809 result.push({
810 post: thread.post,
811 hasMoreReplies: nestedReplies.length > 0,
812 });
813
814 // Recursively flatten nested replies
815 for (const reply of nestedReplies) {
816 result.push(...this.flattenThread(reply));
817 }
818
819 return result;
820 }
821
822 /**
823 * Render a complete thread (top-level comment + all nested replies)
824 */
825 renderThread(thread) {
826 const flatComments = this.flattenThread(thread);
827 const commentsHtml = flatComments
828 .map((item, index) =>
829 this.renderComment(item.post, item.hasMoreReplies, index),
830 )
831 .join("");
832
833 return `<div class="sequoia-thread">${commentsHtml}</div>`;
834 }
835
836 /**
837 * Render a single comment
838 * @param {any} post - Post data
839 * @param {boolean} showThreadLine - Whether to show the connecting thread line
840 * @param {number} _index - Index in the flattened thread (0 = top-level)
841 */
842 renderComment(post, showThreadLine = false, _index = 0) {
843 const author = post.author;
844 const displayName = author.displayName || author.handle;
845 const avatarHtml = author.avatar
846 ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />`
847 : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`;
848
849 const profileUrl = `https://bsky.app/profile/${author.did}`;
850 const textHtml = renderTextWithFacets(post.record.text, post.record.facets);
851 const timeAgo = formatRelativeTime(post.record.createdAt);
852 const threadLineHtml = showThreadLine
853 ? '<div class="sequoia-thread-line"></div>'
854 : "";
855
856 return `
857 <div class="sequoia-comment">
858 <div class="sequoia-comment-avatar-column">
859 ${avatarHtml}
860 ${threadLineHtml}
861 </div>
862 <div class="sequoia-comment-content">
863 <div class="sequoia-comment-header">
864 <a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author">
865 ${escapeHtml(displayName)}
866 </a>
867 <span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span>
868 <span class="sequoia-comment-time">${timeAgo}</span>
869 </div>
870 <p class="sequoia-comment-text">${textHtml}</p>
871 </div>
872 </div>
873 `;
874 }
875
876 countComments(replies) {
877 let count = 0;
878 for (const reply of replies) {
879 count += 1;
880 const nested = reply.replies?.filter(isThreadViewPost) ?? [];
881 count += this.countComments(nested);
882 }
883 return count;
884 }
885}
886
887// Register the custom element
888if (typeof customElements !== "undefined") {
889 customElements.define("sequoia-comments", SequoiaComments);
890}
891
892// Export for module usage
893export { SequoiaComments };