packages/cli/src/components/sequoia-comments.js 22.7 K raw
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
 *
18
 * CSS Custom Properties:
19
 *   - --sequoia-fg-color: Text color (default: #1f2937)
20
 *   - --sequoia-bg-color: Background color (default: #ffffff)
21
 *   - --sequoia-border-color: Border color (default: #e5e7eb)
22
 *   - --sequoia-accent-color: Accent/link color (default: #2563eb)
23
 *   - --sequoia-secondary-color: Secondary text color (default: #6b7280)
24
 *   - --sequoia-border-radius: Border radius (default: 8px)
25
 */
26
27
// ============================================================================
28
// Styles
29
// ============================================================================
30
31
const styles = `
32
:host {
33
	display: block;
34
	font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
35
	color: var(--sequoia-fg-color, #1f2937);
36
	line-height: 1.5;
37
}
38
39
* {
40
	box-sizing: border-box;
41
}
42
43
.sequoia-comments-container {
44
	max-width: 100%;
45
}
46
47
.sequoia-loading,
48
.sequoia-error,
49
.sequoia-empty,
50
.sequoia-warning {
51
	padding: 1rem;
52
	border-radius: var(--sequoia-border-radius, 8px);
53
	text-align: center;
54
}
55
56
.sequoia-loading {
57
	background: var(--sequoia-bg-color, #ffffff);
58
	border: 1px solid var(--sequoia-border-color, #e5e7eb);
59
	color: var(--sequoia-secondary-color, #6b7280);
60
}
61
62
.sequoia-loading-spinner {
63
	display: inline-block;
64
	width: 1.25rem;
65
	height: 1.25rem;
66
	border: 2px solid var(--sequoia-border-color, #e5e7eb);
67
	border-top-color: var(--sequoia-accent-color, #2563eb);
68
	border-radius: 50%;
69
	animation: sequoia-spin 0.8s linear infinite;
70
	margin-right: 0.5rem;
71
	vertical-align: middle;
72
}
73
74
@keyframes sequoia-spin {
75
	to { transform: rotate(360deg); }
76
}
77
78
.sequoia-error {
79
	background: #fef2f2;
80
	border: 1px solid #fecaca;
81
	color: #dc2626;
82
}
83
84
.sequoia-warning {
85
	background: #fffbeb;
86
	border: 1px solid #fde68a;
87
	color: #d97706;
88
}
89
90
.sequoia-empty {
91
	background: var(--sequoia-bg-color, #ffffff);
92
	border: 1px solid var(--sequoia-border-color, #e5e7eb);
93
	color: var(--sequoia-secondary-color, #6b7280);
94
}
95
96
.sequoia-comments-header {
97
	display: flex;
98
	justify-content: space-between;
99
	align-items: center;
100
	margin-bottom: 1rem;
101
	padding-bottom: 0.75rem;
102
}
103
104
.sequoia-comments-title {
105
	font-size: 1.125rem;
106
	font-weight: 600;
107
	margin: 0;
108
}
109
110
.sequoia-reply-button {
111
	display: inline-flex;
112
	align-items: center;
113
	gap: 0.375rem;
114
	padding: 0.5rem 1rem;
115
	background: var(--sequoia-accent-color, #2563eb);
116
	color: #ffffff;
117
	border: none;
118
	border-radius: var(--sequoia-border-radius, 8px);
119
	font-size: 0.875rem;
120
	font-weight: 500;
121
	cursor: pointer;
122
	text-decoration: none;
123
	transition: background-color 0.15s ease;
124
}
125
126
.sequoia-reply-button:hover {
127
	background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
128
}
129
130
.sequoia-reply-button svg {
131
	width: 1rem;
132
	height: 1rem;
133
}
134
135
.sequoia-comments-list {
136
	display: flex;
137
	flex-direction: column;
138
}
139
140
.sequoia-thread {
141
	border-top: 1px solid var(--sequoia-border-color, #e5e7eb);
142
	padding-bottom: 1rem;
143
}
144
145
.sequoia-thread + .sequoia-thread {
146
	margin-top: 0.5rem;
147
}
148
149
.sequoia-thread:last-child {
150
	border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
151
}
152
153
.sequoia-comment {
154
	display: flex;
155
	gap: 0.75rem;
156
	padding-top: 1rem;
157
}
158
159
.sequoia-comment-avatar-column {
160
	display: flex;
161
	flex-direction: column;
162
	align-items: center;
163
	flex-shrink: 0;
164
	width: 2.5rem;
165
	position: relative;
166
}
167
168
.sequoia-comment-avatar {
169
	width: 2.5rem;
170
	height: 2.5rem;
171
	border-radius: 50%;
172
	background: var(--sequoia-border-color, #e5e7eb);
173
	object-fit: cover;
174
	flex-shrink: 0;
175
	position: relative;
176
	z-index: 1;
177
}
178
179
.sequoia-comment-avatar-placeholder {
180
	width: 2.5rem;
181
	height: 2.5rem;
182
	border-radius: 50%;
183
	background: var(--sequoia-border-color, #e5e7eb);
184
	display: flex;
185
	align-items: center;
186
	justify-content: center;
187
	flex-shrink: 0;
188
	color: var(--sequoia-secondary-color, #6b7280);
189
	font-weight: 600;
190
	font-size: 1rem;
191
	position: relative;
192
	z-index: 1;
193
}
194
195
.sequoia-thread-line {
196
	position: absolute;
197
	top: 2.5rem;
198
	bottom: calc(-1rem - 0.5rem);
199
	left: 50%;
200
	transform: translateX(-50%);
201
	width: 2px;
202
	background: var(--sequoia-border-color, #e5e7eb);
203
}
204
205
.sequoia-comment-content {
206
	flex: 1;
207
	min-width: 0;
208
}
209
210
.sequoia-comment-header {
211
	display: flex;
212
	align-items: baseline;
213
	gap: 0.5rem;
214
	margin-bottom: 0.25rem;
215
	flex-wrap: wrap;
216
}
217
218
.sequoia-comment-author {
219
	font-weight: 600;
220
	color: var(--sequoia-fg-color, #1f2937);
221
	text-decoration: none;
222
	overflow: hidden;
223
	text-overflow: ellipsis;
224
	white-space: nowrap;
225
}
226
227
.sequoia-comment-author:hover {
228
	color: var(--sequoia-accent-color, #2563eb);
229
}
230
231
.sequoia-comment-handle {
232
	font-size: 0.875rem;
233
	color: var(--sequoia-secondary-color, #6b7280);
234
	overflow: hidden;
235
	text-overflow: ellipsis;
236
	white-space: nowrap;
237
}
238
239
.sequoia-comment-time {
240
	font-size: 0.875rem;
241
	color: var(--sequoia-secondary-color, #6b7280);
242
	flex-shrink: 0;
243
}
244
245
.sequoia-comment-time::before {
246
	content: "·";
247
	margin-right: 0.5rem;
248
}
249
250
.sequoia-comment-text {
251
	margin: 0;
252
	white-space: pre-wrap;
253
	word-wrap: break-word;
254
}
255
256
.sequoia-comment-text a {
257
	color: var(--sequoia-accent-color, #2563eb);
258
	text-decoration: none;
259
}
260
261
.sequoia-comment-text a:hover {
262
	text-decoration: underline;
263
}
264
265
.sequoia-bsky-logo {
266
	width: 1rem;
267
	height: 1rem;
268
}
269
`;
270
271
// ============================================================================
272
// Utility Functions
273
// ============================================================================
274
275
/**
276
 * Format a relative time string (e.g., "2 hours ago")
277
 * @param {string} dateString - ISO date string
278
 * @returns {string} Formatted relative time
279
 */
280
function formatRelativeTime(dateString) {
281
	const date = new Date(dateString);
282
	const now = new Date();
283
	const diffMs = now.getTime() - date.getTime();
284
	const diffSeconds = Math.floor(diffMs / 1000);
285
	const diffMinutes = Math.floor(diffSeconds / 60);
286
	const diffHours = Math.floor(diffMinutes / 60);
287
	const diffDays = Math.floor(diffHours / 24);
288
	const diffWeeks = Math.floor(diffDays / 7);
289
	const diffMonths = Math.floor(diffDays / 30);
290
	const diffYears = Math.floor(diffDays / 365);
291
292
	if (diffSeconds < 60) {
293
		return "just now";
294
	}
295
	if (diffMinutes < 60) {
296
		return `${diffMinutes}m ago`;
297
	}
298
	if (diffHours < 24) {
299
		return `${diffHours}h ago`;
300
	}
301
	if (diffDays < 7) {
302
		return `${diffDays}d ago`;
303
	}
304
	if (diffWeeks < 4) {
305
		return `${diffWeeks}w ago`;
306
	}
307
	if (diffMonths < 12) {
308
		return `${diffMonths}mo ago`;
309
	}
310
	return `${diffYears}y ago`;
311
}
312
313
/**
314
 * Escape HTML special characters
315
 * @param {string} text - Text to escape
316
 * @returns {string} Escaped HTML
317
 */
318
function escapeHtml(text) {
319
	const div = document.createElement("div");
320
	div.textContent = text;
321
	return div.innerHTML;
322
}
323
324
/**
325
 * Convert post text with facets to HTML
326
 * @param {string} text - Post text
327
 * @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets
328
 * @returns {string} HTML string with links
329
 */
330
function renderTextWithFacets(text, facets) {
331
	if (!facets || facets.length === 0) {
332
		return escapeHtml(text);
333
	}
334
335
	// Convert text to bytes for proper indexing
336
	const encoder = new TextEncoder();
337
	const decoder = new TextDecoder();
338
	const textBytes = encoder.encode(text);
339
340
	// Sort facets by start index
341
	const sortedFacets = [...facets].sort(
342
		(a, b) => a.index.byteStart - b.index.byteStart,
343
	);
344
345
	let result = "";
346
	let lastEnd = 0;
347
348
	for (const facet of sortedFacets) {
349
		const { byteStart, byteEnd } = facet.index;
350
351
		// Add text before this facet
352
		if (byteStart > lastEnd) {
353
			const beforeBytes = textBytes.slice(lastEnd, byteStart);
354
			result += escapeHtml(decoder.decode(beforeBytes));
355
		}
356
357
		// Get the facet text
358
		const facetBytes = textBytes.slice(byteStart, byteEnd);
359
		const facetText = decoder.decode(facetBytes);
360
361
		// Find the first renderable feature
362
		const feature = facet.features[0];
363
		if (feature) {
364
			if (feature.$type === "app.bsky.richtext.facet#link") {
365
				result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
366
			} else if (feature.$type === "app.bsky.richtext.facet#mention") {
367
				result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
368
			} else if (feature.$type === "app.bsky.richtext.facet#tag") {
369
				result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
370
			} else {
371
				result += escapeHtml(facetText);
372
			}
373
		} else {
374
			result += escapeHtml(facetText);
375
		}
376
377
		lastEnd = byteEnd;
378
	}
379
380
	// Add remaining text
381
	if (lastEnd < textBytes.length) {
382
		const remainingBytes = textBytes.slice(lastEnd);
383
		result += escapeHtml(decoder.decode(remainingBytes));
384
	}
385
386
	return result;
387
}
388
389
/**
390
 * Get initials from a name for avatar placeholder
391
 * @param {string} name - Display name
392
 * @returns {string} Initials (1-2 characters)
393
 */
394
function getInitials(name) {
395
	const parts = name.trim().split(/\s+/);
396
	if (parts.length >= 2) {
397
		return (parts[0][0] + parts[1][0]).toUpperCase();
398
	}
399
	return name.substring(0, 2).toUpperCase();
400
}
401
402
// ============================================================================
403
// AT Protocol Client Functions
404
// ============================================================================
405
406
/**
407
 * Parse an AT URI into its components
408
 * Format: at://did/collection/rkey
409
 * @param {string} atUri - AT Protocol URI
410
 * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null
411
 */
412
function parseAtUri(atUri) {
413
	const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
414
	if (!match) return null;
415
	return {
416
		did: match[1],
417
		collection: match[2],
418
		rkey: match[3],
419
	};
420
}
421
422
/**
423
 * Resolve a DID to its PDS URL
424
 * Supports did:plc and did:web methods
425
 * @param {string} did - Decentralized Identifier
426
 * @returns {Promise<string>} PDS URL
427
 */
428
async function resolvePDS(did) {
429
	let pdsUrl;
430
431
	if (did.startsWith("did:plc:")) {
432
		// Fetch DID document from plc.directory
433
		const didDocUrl = `https://plc.directory/${did}`;
434
		const didDocResponse = await fetch(didDocUrl);
435
		if (!didDocResponse.ok) {
436
			throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
437
		}
438
		const didDoc = await didDocResponse.json();
439
440
		// Find the PDS service endpoint
441
		const pdsService = didDoc.service?.find(
442
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
443
		);
444
		pdsUrl = pdsService?.serviceEndpoint;
445
	} else if (did.startsWith("did:web:")) {
446
		// For did:web, fetch the DID document from the domain
447
		const domain = did.replace("did:web:", "");
448
		const didDocUrl = `https://${domain}/.well-known/did.json`;
449
		const didDocResponse = await fetch(didDocUrl);
450
		if (!didDocResponse.ok) {
451
			throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
452
		}
453
		const didDoc = await didDocResponse.json();
454
455
		const pdsService = didDoc.service?.find(
456
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
457
		);
458
		pdsUrl = pdsService?.serviceEndpoint;
459
	} else {
460
		throw new Error(`Unsupported DID method: ${did}`);
461
	}
462
463
	if (!pdsUrl) {
464
		throw new Error("Could not find PDS URL for user");
465
	}
466
467
	return pdsUrl;
468
}
469
470
/**
471
 * Fetch a record from a PDS using the public API
472
 * @param {string} did - DID of the repository owner
473
 * @param {string} collection - Collection name
474
 * @param {string} rkey - Record key
475
 * @returns {Promise<any>} Record value
476
 */
477
async function getRecord(did, collection, rkey) {
478
	const pdsUrl = await resolvePDS(did);
479
480
	const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`);
481
	url.searchParams.set("repo", did);
482
	url.searchParams.set("collection", collection);
483
	url.searchParams.set("rkey", rkey);
484
485
	const response = await fetch(url.toString());
486
	if (!response.ok) {
487
		throw new Error(`Failed to fetch record: ${response.status}`);
488
	}
489
490
	const data = await response.json();
491
	return data.value;
492
}
493
494
/**
495
 * Fetch a document record from its AT URI
496
 * @param {string} atUri - AT Protocol URI for the document
497
 * @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
498
 */
499
async function getDocument(atUri) {
500
	const parsed = parseAtUri(atUri);
501
	if (!parsed) {
502
		throw new Error(`Invalid AT URI: ${atUri}`);
503
	}
504
505
	return getRecord(parsed.did, parsed.collection, parsed.rkey);
506
}
507
508
/**
509
 * Fetch a post thread from the public Bluesky API
510
 * @param {string} postUri - AT Protocol URI for the post
511
 * @param {number} [depth=6] - Maximum depth of replies to fetch
512
 * @returns {Promise<ThreadViewPost>} Thread view post
513
 */
514
async function getPostThread(postUri, depth = 6) {
515
	const url = new URL(
516
		"https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread",
517
	);
518
	url.searchParams.set("uri", postUri);
519
	url.searchParams.set("depth", depth.toString());
520
521
	const response = await fetch(url.toString());
522
	if (!response.ok) {
523
		throw new Error(`Failed to fetch post thread: ${response.status}`);
524
	}
525
526
	const data = await response.json();
527
528
	if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") {
529
		throw new Error("Post not found or blocked");
530
	}
531
532
	return data.thread;
533
}
534
535
/**
536
 * Build a Bluesky app URL for a post
537
 * @param {string} postUri - AT Protocol URI for the post
538
 * @returns {string} Bluesky app URL
539
 */
540
function buildBskyAppUrl(postUri) {
541
	const parsed = parseAtUri(postUri);
542
	if (!parsed) {
543
		throw new Error(`Invalid post URI: ${postUri}`);
544
	}
545
546
	return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`;
547
}
548
549
/**
550
 * Type guard for ThreadViewPost
551
 * @param {any} post - Post to check
552
 * @returns {boolean} True if post is a ThreadViewPost
553
 */
554
function isThreadViewPost(post) {
555
	return post?.$type === "app.bsky.feed.defs#threadViewPost";
556
}
557
558
// ============================================================================
559
// Bluesky Icon
560
// ============================================================================
561
562
const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
563
  <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"/>
564
</svg>`;
565
566
// ============================================================================
567
// Web Component
568
// ============================================================================
569
570
// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
571
const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
572
573
class SequoiaComments extends BaseElement {
574
	constructor() {
575
		super();
576
		this.shadow = this.attachShadow({ mode: "open" });
577
		this.state = { type: "loading" };
578
		this.abortController = null;
579
	}
580
581
	static get observedAttributes() {
582
		return ["document-uri", "depth"];
583
	}
584
585
	connectedCallback() {
586
		this.render();
587
		this.loadComments();
588
	}
589
590
	disconnectedCallback() {
591
		this.abortController?.abort();
592
	}
593
594
	attributeChangedCallback() {
595
		if (this.isConnected) {
596
			this.loadComments();
597
		}
598
	}
599
600
	get documentUri() {
601
		// First check attribute
602
		const attrUri = this.getAttribute("document-uri");
603
		if (attrUri) {
604
			return attrUri;
605
		}
606
607
		// Then scan for link tag in document head
608
		const linkTag = document.querySelector(
609
			'link[rel="site.standard.document"]',
610
		);
611
		return linkTag?.href ?? null;
612
	}
613
614
	get depth() {
615
		const depthAttr = this.getAttribute("depth");
616
		return depthAttr ? parseInt(depthAttr, 10) : 6;
617
	}
618
619
	async loadComments() {
620
		// Cancel any in-flight request
621
		this.abortController?.abort();
622
		this.abortController = new AbortController();
623
624
		this.state = { type: "loading" };
625
		this.render();
626
627
		const docUri = this.documentUri;
628
		if (!docUri) {
629
			this.state = { type: "no-document" };
630
			this.render();
631
			return;
632
		}
633
634
		try {
635
			// Fetch the document record
636
			const document = await getDocument(docUri);
637
638
			// Check if document has a Bluesky post reference
639
			if (!document.bskyPostRef) {
640
				this.state = { type: "no-comments-enabled" };
641
				this.render();
642
				return;
643
			}
644
645
			const postUrl = buildBskyAppUrl(document.bskyPostRef.uri);
646
647
			// Fetch the post thread
648
			const thread = await getPostThread(document.bskyPostRef.uri, this.depth);
649
650
			// Check if there are any replies
651
			const replies = thread.replies?.filter(isThreadViewPost) ?? [];
652
			if (replies.length === 0) {
653
				this.state = { type: "empty", postUrl };
654
				this.render();
655
				return;
656
			}
657
658
			this.state = { type: "loaded", thread, postUrl };
659
			this.render();
660
		} catch (error) {
661
			const message =
662
				error instanceof Error ? error.message : "Failed to load comments";
663
			this.state = { type: "error", message };
664
			this.render();
665
		}
666
	}
667
668
	render() {
669
		const styleTag = `<style>${styles}</style>`;
670
671
		switch (this.state.type) {
672
			case "loading":
673
				this.shadow.innerHTML = `
674
					${styleTag}
675
					<div class="sequoia-comments-container">
676
						<div class="sequoia-loading">
677
							<span class="sequoia-loading-spinner"></span>
678
							Loading comments...
679
						</div>
680
					</div>
681
				`;
682
				break;
683
684
			case "no-document":
685
				this.shadow.innerHTML = `
686
					${styleTag}
687
					<div class="sequoia-comments-container">
688
						<div class="sequoia-warning">
689
							No document found. Add a <code>&lt;link rel="site.standard.document" href="at://..."&gt;</code> tag to your page.
690
						</div>
691
					</div>
692
				`;
693
				break;
694
695
			case "no-comments-enabled":
696
				this.shadow.innerHTML = `
697
					${styleTag}
698
					<div class="sequoia-comments-container">
699
						<div class="sequoia-empty">
700
							Comments are not enabled for this post.
701
						</div>
702
					</div>
703
				`;
704
				break;
705
706
			case "empty":
707
				this.shadow.innerHTML = `
708
					${styleTag}
709
					<div class="sequoia-comments-container">
710
						<div class="sequoia-comments-header">
711
							<h3 class="sequoia-comments-title">Comments</h3>
712
							<a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
713
								${BLUESKY_ICON}
714
								Reply on Bluesky
715
							</a>
716
						</div>
717
						<div class="sequoia-empty">
718
							No comments yet. Be the first to reply on Bluesky!
719
						</div>
720
					</div>
721
				`;
722
				break;
723
724
			case "error":
725
				this.shadow.innerHTML = `
726
					${styleTag}
727
					<div class="sequoia-comments-container">
728
						<div class="sequoia-error">
729
							Failed to load comments: ${escapeHtml(this.state.message)}
730
						</div>
731
					</div>
732
				`;
733
				break;
734
735
			case "loaded": {
736
				const replies =
737
					this.state.thread.replies?.filter(isThreadViewPost) ?? [];
738
				const threadsHtml = replies
739
					.map((reply) => this.renderThread(reply))
740
					.join("");
741
				const commentCount = this.countComments(replies);
742
743
				this.shadow.innerHTML = `
744
					${styleTag}
745
					<div class="sequoia-comments-container">
746
						<div class="sequoia-comments-header">
747
							<h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3>
748
							<a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
749
								${BLUESKY_ICON}
750
								Reply on Bluesky
751
							</a>
752
						</div>
753
						<div class="sequoia-comments-list">
754
							${threadsHtml}
755
						</div>
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
851
if (typeof customElements !== "undefined") {
852
	customElements.define("sequoia-comments", SequoiaComments);
853
}
854
855
// Export for module usage
856
export { SequoiaComments };