packages/cli/src/components/sequoia-comments.js 25.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
 *   - 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
	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
 */
294
function 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
 */
332
function 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
 */
344
function 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
 */
408
function 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
 */
426
function 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
 */
442
async 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
 */
491
async 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
 */
513
async 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
 */
528
async 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
 */
554
function 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
 */
568
function 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
 */
582
function isThreadViewPost(post) {
583
	return post?.$type === "app.bsky.feed.defs#threadViewPost";
584
}
585
586
// ============================================================================
587
// Bluesky Icon
588
// ============================================================================
589
590
const 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>`;
593
const 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
601
const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
602
603
class 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>&lt;link rel="site.standard.document" href="at://..."&gt;</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
889
if (typeof customElements !== "undefined") {
890
	customElements.define("sequoia-comments", SequoiaComments);
891
}
892
893
// Export for module usage
894
export { SequoiaComments };