forked from
stevedylan.dev/sequoia
A CLI for publishing standard.site documents to ATProto
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 =
594 '<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>';
595
596// ============================================================================
597// Web Component
598// ============================================================================
599
600// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
601const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
602
603class SequoiaComments extends BaseElement {
604 constructor() {
605 super();
606 const shadow = this.attachShadow({ mode: "open" });
607
608 const styleTag = document.createElement("style");
609 shadow.appendChild(styleTag);
610 styleTag.innerText = styles;
611
612 const container = document.createElement("div");
613 shadow.appendChild(container);
614 container.className = "sequoia-comments-container";
615 container.part = "container";
616
617 this.commentsContainer = container;
618 this.state = { type: "loading" };
619 this.abortController = null;
620 }
621
622 static get observedAttributes() {
623 return ["document-uri", "depth", "hide"];
624 }
625
626 connectedCallback() {
627 this.render();
628 this.loadComments();
629 }
630
631 disconnectedCallback() {
632 this.abortController?.abort();
633 }
634
635 attributeChangedCallback() {
636 if (this.isConnected) {
637 this.loadComments();
638 }
639 }
640
641 get documentUri() {
642 // First check attribute
643 const attrUri = this.getAttribute("document-uri");
644 if (attrUri) {
645 return attrUri;
646 }
647
648 // Then scan for link tag in document head
649 const linkTag = document.querySelector(
650 'link[rel="site.standard.document"]',
651 );
652 return linkTag?.href ?? null;
653 }
654
655 get depth() {
656 const depthAttr = this.getAttribute("depth");
657 return depthAttr ? parseInt(depthAttr, 10) : 6;
658 }
659
660 get hide() {
661 const hideAttr = this.getAttribute("hide");
662 return hideAttr === "auto";
663 }
664
665 async loadComments() {
666 // Cancel any in-flight request
667 this.abortController?.abort();
668 this.abortController = new AbortController();
669
670 this.state = { type: "loading" };
671 this.render();
672
673 const docUri = this.documentUri;
674 if (!docUri) {
675 this.state = { type: "no-document" };
676 this.render();
677 return;
678 }
679
680 try {
681 // Fetch the document record
682 const document = await getDocument(docUri);
683
684 // Check if document has a Bluesky post reference
685 if (!document.bskyPostRef) {
686 this.state = { type: "no-comments-enabled" };
687 this.render();
688 return;
689 }
690
691 const postUrl = buildBskyAppUrl(document.bskyPostRef.uri);
692 const blackskyPostUrl = buildBlackskyAppUrl(document.bskyPostRef.uri);
693
694 // Fetch the post thread
695 const thread = await getPostThread(document.bskyPostRef.uri, this.depth);
696
697 // Check if there are any replies
698 const replies = thread.replies?.filter(isThreadViewPost) ?? [];
699 if (replies.length === 0) {
700 this.state = { type: "empty", postUrl, blackskyPostUrl };
701 this.render();
702 return;
703 }
704
705 this.state = { type: "loaded", thread, postUrl, blackskyPostUrl };
706 this.render();
707 } catch (error) {
708 const message =
709 error instanceof Error ? error.message : "Failed to load comments";
710 this.state = { type: "error", message };
711 this.render();
712 }
713 }
714
715 render() {
716 switch (this.state.type) {
717 case "loading":
718 this.commentsContainer.innerHTML = `
719 <div class="sequoia-loading">
720 <span class="sequoia-loading-spinner"></span>
721 Loading comments...
722 </div>
723 `;
724 break;
725
726 case "no-document":
727 this.commentsContainer.innerHTML = `
728 <div class="sequoia-warning">
729 No document found. Add a <code><link rel="site.standard.document" href="at://..."></code> tag to your page.
730 </div>
731 `;
732 if (this.hide) {
733 this.commentsContainer.style.display = "none";
734 }
735 break;
736
737 case "no-comments-enabled":
738 this.commentsContainer.innerHTML = `
739 <div class="sequoia-empty">
740 Comments are not enabled for this post.
741 </div>
742 `;
743 break;
744
745 case "empty":
746 this.commentsContainer.innerHTML = `
747 <div class="sequoia-comments-header">
748 <h3 class="sequoia-comments-title">Comments</h3>
749 <div>
750 <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-bluesky">
751 ${BLUESKY_ICON}
752 </a>
753 <a href="${this.state.blackskyPostUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-blacksky">
754 ${BLACKSKY_ICON}
755 </a>
756 </div>
757 </div>
758 <div class="sequoia-empty">
759 No comments yet. Be the first to reply on Bluesky!
760 </div>
761 `;
762 break;
763
764 case "error":
765 this.commentsContainer.innerHTML = `
766 <div class="sequoia-error">
767 Failed to load comments: ${escapeHtml(this.state.message)}
768 </div>
769 `;
770 break;
771
772 case "loaded": {
773 const replies =
774 this.state.thread.replies?.filter(isThreadViewPost) ?? [];
775 const threadsHtml = replies
776 .map((reply) => this.renderThread(reply))
777 .join("");
778 const commentCount = this.countComments(replies);
779
780 this.commentsContainer.innerHTML = `
781 <div class="sequoia-comments-header">
782 <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3>
783 <div>
784 <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-bluesky">
785 ${BLUESKY_ICON}
786 </a>
787 <a href="${this.state.blackskyPostUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-blacksky">
788 ${BLACKSKY_ICON}
789 </a>
790 </div>
791 </div>
792 <div class="sequoia-comments-list">
793 ${threadsHtml}
794 </div>
795 `;
796 break;
797 }
798 }
799 }
800
801 /**
802 * Flatten a thread into a linear list of comments
803 * @param {ThreadViewPost} thread - Thread to flatten
804 * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments
805 */
806 flattenThread(thread) {
807 const result = [];
808 const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? [];
809
810 result.push({
811 post: thread.post,
812 hasMoreReplies: nestedReplies.length > 0,
813 });
814
815 // Recursively flatten nested replies
816 for (const reply of nestedReplies) {
817 result.push(...this.flattenThread(reply));
818 }
819
820 return result;
821 }
822
823 /**
824 * Render a complete thread (top-level comment + all nested replies)
825 */
826 renderThread(thread) {
827 const flatComments = this.flattenThread(thread);
828 const commentsHtml = flatComments
829 .map((item, index) =>
830 this.renderComment(item.post, item.hasMoreReplies, index),
831 )
832 .join("");
833
834 return `<div class="sequoia-thread">${commentsHtml}</div>`;
835 }
836
837 /**
838 * Render a single comment
839 * @param {any} post - Post data
840 * @param {boolean} showThreadLine - Whether to show the connecting thread line
841 * @param {number} _index - Index in the flattened thread (0 = top-level)
842 */
843 renderComment(post, showThreadLine = false, _index = 0) {
844 const author = post.author;
845 const displayName = author.displayName || author.handle;
846 const avatarHtml = author.avatar
847 ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />`
848 : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`;
849
850 const profileUrl = `https://bsky.app/profile/${author.did}`;
851 const textHtml = renderTextWithFacets(post.record.text, post.record.facets);
852 const timeAgo = formatRelativeTime(post.record.createdAt);
853 const threadLineHtml = showThreadLine
854 ? '<div class="sequoia-thread-line"></div>'
855 : "";
856
857 return `
858 <div class="sequoia-comment">
859 <div class="sequoia-comment-avatar-column">
860 ${avatarHtml}
861 ${threadLineHtml}
862 </div>
863 <div class="sequoia-comment-content">
864 <div class="sequoia-comment-header">
865 <a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author">
866 ${escapeHtml(displayName)}
867 </a>
868 <span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span>
869 <span class="sequoia-comment-time">${timeAgo}</span>
870 </div>
871 <p class="sequoia-comment-text">${textHtml}</p>
872 </div>
873 </div>
874 `;
875 }
876
877 countComments(replies) {
878 let count = 0;
879 for (const reply of replies) {
880 count += 1;
881 const nested = reply.replies?.filter(isThreadViewPost) ?? [];
882 count += this.countComments(nested);
883 }
884 return count;
885 }
886}
887
888// Register the custom element
889if (typeof customElements !== "undefined") {
890 customElements.define("sequoia-comments", SequoiaComments);
891}
892
893// Export for module usage
894export { SequoiaComments };