packages/cli/src/components/sequoia-comments.js 22.8 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
 *   - 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
32
const 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
 */
281
function 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
 */
319
function 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
 */
331
function 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
 */
395
function 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
 */
413
function 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
 */
429
async 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
 */
478
async 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
 */
500
async 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
 */
515
async 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
 */
541
function 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
 */
555
function isThreadViewPost(post) {
556
	return post?.$type === "app.bsky.feed.defs#threadViewPost";
557
}
558
559
// ============================================================================
560
// Bluesky Icon
561
// ============================================================================
562
563
const 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
572
const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
573
574
class 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>&lt;link rel="site.standard.document" href="at://..."&gt;</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
851
if (typeof customElements !== "undefined") {
852
	customElements.define("sequoia-comments", SequoiaComments);
853
}
854
855
// Export for module usage
856
export { SequoiaComments };