this repo has no description
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 background: var(--sequoia-accent-color, #2563eb);
117 color: #ffffff;
118 border: none;
119 border-radius: var(--sequoia-border-radius, 8px);
120 font-size: 0.875rem;
121 font-weight: 500;
122 cursor: pointer;
123 text-decoration: none;
124 transition: background-color 0.15s ease;
125}
126
127.sequoia-reply-button:hover {
128 background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
129}
130
131.sequoia-reply-button svg {
132 width: 1rem;
133 height: 1rem;
134}
135
136.sequoia-comments-list {
137 display: flex;
138 flex-direction: column;
139}
140
141.sequoia-thread {
142 border-top: 1px solid var(--sequoia-border-color, #e5e7eb);
143 padding-bottom: 1rem;
144}
145
146.sequoia-thread + .sequoia-thread {
147 margin-top: 0.5rem;
148}
149
150.sequoia-thread:last-child {
151 border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
152}
153
154.sequoia-comment {
155 display: flex;
156 gap: 0.75rem;
157 padding-top: 1rem;
158}
159
160.sequoia-comment-avatar-column {
161 display: flex;
162 flex-direction: column;
163 align-items: center;
164 flex-shrink: 0;
165 width: 2.5rem;
166 position: relative;
167}
168
169.sequoia-comment-avatar {
170 width: 2.5rem;
171 height: 2.5rem;
172 border-radius: 50%;
173 background: var(--sequoia-border-color, #e5e7eb);
174 object-fit: cover;
175 flex-shrink: 0;
176 position: relative;
177 z-index: 1;
178}
179
180.sequoia-comment-avatar-placeholder {
181 width: 2.5rem;
182 height: 2.5rem;
183 border-radius: 50%;
184 background: var(--sequoia-border-color, #e5e7eb);
185 display: flex;
186 align-items: center;
187 justify-content: center;
188 flex-shrink: 0;
189 color: var(--sequoia-secondary-color, #6b7280);
190 font-weight: 600;
191 font-size: 1rem;
192 position: relative;
193 z-index: 1;
194}
195
196.sequoia-thread-line {
197 position: absolute;
198 top: 2.5rem;
199 bottom: calc(-1rem - 0.5rem);
200 left: 50%;
201 transform: translateX(-50%);
202 width: 2px;
203 background: var(--sequoia-border-color, #e5e7eb);
204}
205
206.sequoia-comment-content {
207 flex: 1;
208 min-width: 0;
209}
210
211.sequoia-comment-header {
212 display: flex;
213 align-items: baseline;
214 gap: 0.5rem;
215 margin-bottom: 0.25rem;
216 flex-wrap: wrap;
217}
218
219.sequoia-comment-author {
220 font-weight: 600;
221 color: var(--sequoia-fg-color, #1f2937);
222 text-decoration: none;
223 overflow: hidden;
224 text-overflow: ellipsis;
225 white-space: nowrap;
226}
227
228.sequoia-comment-author:hover {
229 color: var(--sequoia-accent-color, #2563eb);
230}
231
232.sequoia-comment-handle {
233 font-size: 0.875rem;
234 color: var(--sequoia-secondary-color, #6b7280);
235 overflow: hidden;
236 text-overflow: ellipsis;
237 white-space: nowrap;
238}
239
240.sequoia-comment-time {
241 font-size: 0.875rem;
242 color: var(--sequoia-secondary-color, #6b7280);
243 flex-shrink: 0;
244}
245
246.sequoia-comment-time::before {
247 content: "·";
248 margin-right: 0.5rem;
249}
250
251.sequoia-comment-text {
252 margin: 0;
253 white-space: pre-wrap;
254 word-wrap: break-word;
255}
256
257.sequoia-comment-text a {
258 color: var(--sequoia-accent-color, #2563eb);
259 text-decoration: none;
260}
261
262.sequoia-comment-text a:hover {
263 text-decoration: underline;
264}
265
266.sequoia-bsky-logo {
267 width: 1rem;
268 height: 1rem;
269}
270`;
271
272// ============================================================================
273// Utility Functions
274// ============================================================================
275
276/**
277 * Format a relative time string (e.g., "2 hours ago")
278 * @param {string} dateString - ISO date string
279 * @returns {string} Formatted relative time
280 */
281function formatRelativeTime(dateString) {
282 const date = new Date(dateString);
283 const now = new Date();
284 const diffMs = now.getTime() - date.getTime();
285 const diffSeconds = Math.floor(diffMs / 1000);
286 const diffMinutes = Math.floor(diffSeconds / 60);
287 const diffHours = Math.floor(diffMinutes / 60);
288 const diffDays = Math.floor(diffHours / 24);
289 const diffWeeks = Math.floor(diffDays / 7);
290 const diffMonths = Math.floor(diffDays / 30);
291 const diffYears = Math.floor(diffDays / 365);
292
293 if (diffSeconds < 60) {
294 return "just now";
295 }
296 if (diffMinutes < 60) {
297 return `${diffMinutes}m ago`;
298 }
299 if (diffHours < 24) {
300 return `${diffHours}h ago`;
301 }
302 if (diffDays < 7) {
303 return `${diffDays}d ago`;
304 }
305 if (diffWeeks < 4) {
306 return `${diffWeeks}w ago`;
307 }
308 if (diffMonths < 12) {
309 return `${diffMonths}mo ago`;
310 }
311 return `${diffYears}y ago`;
312}
313
314/**
315 * Escape HTML special characters
316 * @param {string} text - Text to escape
317 * @returns {string} Escaped HTML
318 */
319function escapeHtml(text) {
320 const div = document.createElement("div");
321 div.textContent = text;
322 return div.innerHTML;
323}
324
325/**
326 * Convert post text with facets to HTML
327 * @param {string} text - Post text
328 * @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets
329 * @returns {string} HTML string with links
330 */
331function renderTextWithFacets(text, facets) {
332 if (!facets || facets.length === 0) {
333 return escapeHtml(text);
334 }
335
336 // Convert text to bytes for proper indexing
337 const encoder = new TextEncoder();
338 const decoder = new TextDecoder();
339 const textBytes = encoder.encode(text);
340
341 // Sort facets by start index
342 const sortedFacets = [...facets].sort(
343 (a, b) => a.index.byteStart - b.index.byteStart,
344 );
345
346 let result = "";
347 let lastEnd = 0;
348
349 for (const facet of sortedFacets) {
350 const { byteStart, byteEnd } = facet.index;
351
352 // Add text before this facet
353 if (byteStart > lastEnd) {
354 const beforeBytes = textBytes.slice(lastEnd, byteStart);
355 result += escapeHtml(decoder.decode(beforeBytes));
356 }
357
358 // Get the facet text
359 const facetBytes = textBytes.slice(byteStart, byteEnd);
360 const facetText = decoder.decode(facetBytes);
361
362 // Find the first renderable feature
363 const feature = facet.features[0];
364 if (feature) {
365 if (feature.$type === "app.bsky.richtext.facet#link") {
366 result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
367 } else if (feature.$type === "app.bsky.richtext.facet#mention") {
368 result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
369 } else if (feature.$type === "app.bsky.richtext.facet#tag") {
370 result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
371 } else {
372 result += escapeHtml(facetText);
373 }
374 } else {
375 result += escapeHtml(facetText);
376 }
377
378 lastEnd = byteEnd;
379 }
380
381 // Add remaining text
382 if (lastEnd < textBytes.length) {
383 const remainingBytes = textBytes.slice(lastEnd);
384 result += escapeHtml(decoder.decode(remainingBytes));
385 }
386
387 return result;
388}
389
390/**
391 * Get initials from a name for avatar placeholder
392 * @param {string} name - Display name
393 * @returns {string} Initials (1-2 characters)
394 */
395function getInitials(name) {
396 const parts = name.trim().split(/\s+/);
397 if (parts.length >= 2) {
398 return (parts[0][0] + parts[1][0]).toUpperCase();
399 }
400 return name.substring(0, 2).toUpperCase();
401}
402
403// ============================================================================
404// AT Protocol Client Functions
405// ============================================================================
406
407/**
408 * Parse an AT URI into its components
409 * Format: at://did/collection/rkey
410 * @param {string} atUri - AT Protocol URI
411 * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null
412 */
413function parseAtUri(atUri) {
414 const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
415 if (!match) return null;
416 return {
417 did: match[1],
418 collection: match[2],
419 rkey: match[3],
420 };
421}
422
423/**
424 * Resolve a DID to its PDS URL
425 * Supports did:plc and did:web methods
426 * @param {string} did - Decentralized Identifier
427 * @returns {Promise<string>} PDS URL
428 */
429async function resolvePDS(did) {
430 let pdsUrl;
431
432 if (did.startsWith("did:plc:")) {
433 // Fetch DID document from plc.directory
434 const didDocUrl = `https://plc.directory/${did}`;
435 const didDocResponse = await fetch(didDocUrl);
436 if (!didDocResponse.ok) {
437 throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
438 }
439 const didDoc = await didDocResponse.json();
440
441 // Find the PDS service endpoint
442 const pdsService = didDoc.service?.find(
443 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
444 );
445 pdsUrl = pdsService?.serviceEndpoint;
446 } else if (did.startsWith("did:web:")) {
447 // For did:web, fetch the DID document from the domain
448 const domain = did.replace("did:web:", "");
449 const didDocUrl = `https://${domain}/.well-known/did.json`;
450 const didDocResponse = await fetch(didDocUrl);
451 if (!didDocResponse.ok) {
452 throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
453 }
454 const didDoc = await didDocResponse.json();
455
456 const pdsService = didDoc.service?.find(
457 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
458 );
459 pdsUrl = pdsService?.serviceEndpoint;
460 } else {
461 throw new Error(`Unsupported DID method: ${did}`);
462 }
463
464 if (!pdsUrl) {
465 throw new Error("Could not find PDS URL for user");
466 }
467
468 return pdsUrl;
469}
470
471/**
472 * Fetch a record from a PDS using the public API
473 * @param {string} did - DID of the repository owner
474 * @param {string} collection - Collection name
475 * @param {string} rkey - Record key
476 * @returns {Promise<any>} Record value
477 */
478async function getRecord(did, collection, rkey) {
479 const pdsUrl = await resolvePDS(did);
480
481 const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`);
482 url.searchParams.set("repo", did);
483 url.searchParams.set("collection", collection);
484 url.searchParams.set("rkey", rkey);
485
486 const response = await fetch(url.toString());
487 if (!response.ok) {
488 throw new Error(`Failed to fetch record: ${response.status}`);
489 }
490
491 const data = await response.json();
492 return data.value;
493}
494
495/**
496 * Fetch a document record from its AT URI
497 * @param {string} atUri - AT Protocol URI for the document
498 * @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
499 */
500async function getDocument(atUri) {
501 const parsed = parseAtUri(atUri);
502 if (!parsed) {
503 throw new Error(`Invalid AT URI: ${atUri}`);
504 }
505
506 return getRecord(parsed.did, parsed.collection, parsed.rkey);
507}
508
509/**
510 * Fetch a post thread from the public Bluesky API
511 * @param {string} postUri - AT Protocol URI for the post
512 * @param {number} [depth=6] - Maximum depth of replies to fetch
513 * @returns {Promise<ThreadViewPost>} Thread view post
514 */
515async function getPostThread(postUri, depth = 6) {
516 const url = new URL(
517 "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread",
518 );
519 url.searchParams.set("uri", postUri);
520 url.searchParams.set("depth", depth.toString());
521
522 const response = await fetch(url.toString());
523 if (!response.ok) {
524 throw new Error(`Failed to fetch post thread: ${response.status}`);
525 }
526
527 const data = await response.json();
528
529 if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") {
530 throw new Error("Post not found or blocked");
531 }
532
533 return data.thread;
534}
535
536/**
537 * Build a Bluesky app URL for a post
538 * @param {string} postUri - AT Protocol URI for the post
539 * @returns {string} Bluesky app URL
540 */
541function buildBskyAppUrl(postUri) {
542 const parsed = parseAtUri(postUri);
543 if (!parsed) {
544 throw new Error(`Invalid post URI: ${postUri}`);
545 }
546
547 return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`;
548}
549
550/**
551 * Type guard for ThreadViewPost
552 * @param {any} post - Post to check
553 * @returns {boolean} True if post is a ThreadViewPost
554 */
555function isThreadViewPost(post) {
556 return post?.$type === "app.bsky.feed.defs#threadViewPost";
557}
558
559// ============================================================================
560// Bluesky Icon
561// ============================================================================
562
563const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
564 <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"/>
565</svg>`;
566
567// ============================================================================
568// Web Component
569// ============================================================================
570
571// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
572const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
573
574class SequoiaComments extends BaseElement {
575 constructor() {
576 super();
577 const shadow = this.attachShadow({ mode: "open" });
578
579 const styleTag = document.createElement("style");
580 shadow.appendChild(styleTag);
581 styleTag.innerText = styles;
582
583 const container = document.createElement("div");
584 shadow.appendChild(container);
585 container.className = "sequoia-comments-container";
586 container.part = "container";
587
588 this.commentsContainer = container;
589 this.state = { type: "loading" };
590 this.abortController = null;
591 }
592
593 static get observedAttributes() {
594 return ["document-uri", "depth", "hide"];
595 }
596
597 connectedCallback() {
598 this.render();
599 this.loadComments();
600 }
601
602 disconnectedCallback() {
603 this.abortController?.abort();
604 }
605
606 attributeChangedCallback() {
607 if (this.isConnected) {
608 this.loadComments();
609 }
610 }
611
612 get documentUri() {
613 // First check attribute
614 const attrUri = this.getAttribute("document-uri");
615 if (attrUri) {
616 return attrUri;
617 }
618
619 // Then scan for link tag in document head
620 const linkTag = document.querySelector(
621 'link[rel="site.standard.document"]',
622 );
623 return linkTag?.href ?? null;
624 }
625
626 get depth() {
627 const depthAttr = this.getAttribute("depth");
628 return depthAttr ? parseInt(depthAttr, 10) : 6;
629 }
630
631 get hide() {
632 const hideAttr = this.getAttribute("hide");
633 return hideAttr === "auto";
634 }
635
636 async loadComments() {
637 // Cancel any in-flight request
638 this.abortController?.abort();
639 this.abortController = new AbortController();
640
641 this.state = { type: "loading" };
642 this.render();
643
644 const docUri = this.documentUri;
645 if (!docUri) {
646 this.state = { type: "no-document" };
647 this.render();
648 return;
649 }
650
651 try {
652 // Fetch the document record
653 const document = await getDocument(docUri);
654
655 // Check if document has a Bluesky post reference
656 if (!document.bskyPostRef) {
657 this.state = { type: "no-comments-enabled" };
658 this.render();
659 return;
660 }
661
662 const postUrl = buildBskyAppUrl(document.bskyPostRef.uri);
663
664 // Fetch the post thread
665 const thread = await getPostThread(document.bskyPostRef.uri, this.depth);
666
667 // Check if there are any replies
668 const replies = thread.replies?.filter(isThreadViewPost) ?? [];
669 if (replies.length === 0) {
670 this.state = { type: "empty", postUrl };
671 this.render();
672 return;
673 }
674
675 this.state = { type: "loaded", thread, postUrl };
676 this.render();
677 } catch (error) {
678 const message =
679 error instanceof Error ? error.message : "Failed to load comments";
680 this.state = { type: "error", message };
681 this.render();
682 }
683 }
684
685 render() {
686 switch (this.state.type) {
687 case "loading":
688 this.commentsContainer.innerHTML = `
689 <div class="sequoia-loading">
690 <span class="sequoia-loading-spinner"></span>
691 Loading comments...
692 </div>
693 `;
694 break;
695
696 case "no-document":
697 this.commentsContainer.innerHTML = `
698 <div class="sequoia-warning">
699 No document found. Add a <code><link rel="site.standard.document" href="at://..."></code> tag to your page.
700 </div>
701 `;
702 if (this.hide) {
703 this.commentsContainer.style.display = "none";
704 }
705 break;
706
707 case "no-comments-enabled":
708 this.commentsContainer.innerHTML = `
709 <div class="sequoia-empty">
710 Comments are not enabled for this post.
711 </div>
712 `;
713 break;
714
715 case "empty":
716 this.commentsContainer.innerHTML = `
717 <div class="sequoia-comments-header">
718 <h3 class="sequoia-comments-title">Comments</h3>
719 <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
720 ${BLUESKY_ICON}
721 Reply on Bluesky
722 </a>
723 </div>
724 <div class="sequoia-empty">
725 No comments yet. Be the first to reply on Bluesky!
726 </div>
727 `;
728 break;
729
730 case "error":
731 this.commentsContainer.innerHTML = `
732 <div class="sequoia-error">
733 Failed to load comments: ${escapeHtml(this.state.message)}
734 </div>
735 `;
736 break;
737
738 case "loaded": {
739 const replies =
740 this.state.thread.replies?.filter(isThreadViewPost) ?? [];
741 const threadsHtml = replies
742 .map((reply) => this.renderThread(reply))
743 .join("");
744 const commentCount = this.countComments(replies);
745
746 this.commentsContainer.innerHTML = `
747 <div class="sequoia-comments-header">
748 <h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3>
749 <a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
750 ${BLUESKY_ICON}
751 Reply on Bluesky
752 </a>
753 </div>
754 <div class="sequoia-comments-list">
755 ${threadsHtml}
756 </div>
757 `;
758 break;
759 }
760 }
761 }
762
763 /**
764 * Flatten a thread into a linear list of comments
765 * @param {ThreadViewPost} thread - Thread to flatten
766 * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments
767 */
768 flattenThread(thread) {
769 const result = [];
770 const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? [];
771
772 result.push({
773 post: thread.post,
774 hasMoreReplies: nestedReplies.length > 0,
775 });
776
777 // Recursively flatten nested replies
778 for (const reply of nestedReplies) {
779 result.push(...this.flattenThread(reply));
780 }
781
782 return result;
783 }
784
785 /**
786 * Render a complete thread (top-level comment + all nested replies)
787 */
788 renderThread(thread) {
789 const flatComments = this.flattenThread(thread);
790 const commentsHtml = flatComments
791 .map((item, index) =>
792 this.renderComment(item.post, item.hasMoreReplies, index),
793 )
794 .join("");
795
796 return `<div class="sequoia-thread">${commentsHtml}</div>`;
797 }
798
799 /**
800 * Render a single comment
801 * @param {any} post - Post data
802 * @param {boolean} showThreadLine - Whether to show the connecting thread line
803 * @param {number} _index - Index in the flattened thread (0 = top-level)
804 */
805 renderComment(post, showThreadLine = false, _index = 0) {
806 const author = post.author;
807 const displayName = author.displayName || author.handle;
808 const avatarHtml = author.avatar
809 ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />`
810 : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`;
811
812 const profileUrl = `https://bsky.app/profile/${author.did}`;
813 const textHtml = renderTextWithFacets(post.record.text, post.record.facets);
814 const timeAgo = formatRelativeTime(post.record.createdAt);
815 const threadLineHtml = showThreadLine
816 ? '<div class="sequoia-thread-line"></div>'
817 : "";
818
819 return `
820 <div class="sequoia-comment">
821 <div class="sequoia-comment-avatar-column">
822 ${avatarHtml}
823 ${threadLineHtml}
824 </div>
825 <div class="sequoia-comment-content">
826 <div class="sequoia-comment-header">
827 <a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author">
828 ${escapeHtml(displayName)}
829 </a>
830 <span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span>
831 <span class="sequoia-comment-time">${timeAgo}</span>
832 </div>
833 <p class="sequoia-comment-text">${textHtml}</p>
834 </div>
835 </div>
836 `;
837 }
838
839 countComments(replies) {
840 let count = 0;
841 for (const reply of replies) {
842 count += 1;
843 const nested = reply.replies?.filter(isThreadViewPost) ?? [];
844 count += this.countComments(nested);
845 }
846 return count;
847 }
848}
849
850// Register the custom element
851if (typeof customElements !== "undefined") {
852 customElements.define("sequoia-comments", SequoiaComments);
853}
854
855// Export for module usage
856export { SequoiaComments };