packages/cli/src/components/sequoia-comments.js 30.6 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
 * Custom reply button:
15
 *   Place any element with slot="reply-button" to replace the default Bluesky/Blacksky buttons.
16
 *   It stays in the light DOM, so your page CSS applies to it normally.
17
 *   Only practical with post-uri, since that's the only time the URL is known at authoring time:
18
 *     <sequoia-comments post-uri="https://bsky.app/profile/.../post/...">
19
 *       <a slot="reply-button" href="https://bsky.app/profile/.../post/...">Reply</a>
20
 *     </sequoia-comments>
21
 *
22
 * Attributes:
23
 *   - post-uri: Bluesky post as AT-URI (at://...) or bsky.app URL — skips PDS document lookup
24
 *   - document-uri: AT Protocol URI for the document (optional if link tag exists)
25
 *   - depth: Maximum depth of nested replies to fetch (default: 6)
26
 *   - hide: Set to "auto" to hide if no document link is detected
27
 *
28
 * CSS Custom Properties:
29
 *   - --sequoia-fg-color: Text color (default: #1f2937)
30
 *   - --sequoia-bg-color: Background color (default: #ffffff)
31
 *   - --sequoia-border-color: Border color (default: #e5e7eb)
32
 *   - --sequoia-accent-color: Accent/link color (default: #2563eb)
33
 *   - --sequoia-secondary-color: Secondary text color (default: #6b7280)
34
 *   - --sequoia-font-family: Font family (default: system-ui stack)
35
 *   - --sequoia-border-radius: Border radius (default: 8px)
36
 */
37
38
// ============================================================================
39
// Styles
40
// ============================================================================
41
42
const styles = `
43
:host {
44
	display: block;
45
	font-family: var(--sequoia-font-family, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
46
	color: var(--sequoia-fg-color, #1f2937);
47
	line-height: 1.5;
48
}
49
50
* {
51
	box-sizing: border-box;
52
}
53
54
.sequoia-comments-container {
55
	max-width: 100%;
56
}
57
58
.sequoia-loading,
59
.sequoia-error,
60
.sequoia-empty,
61
.sequoia-warning {
62
	padding: 1rem;
63
	border-radius: var(--sequoia-border-radius, 8px);
64
	text-align: center;
65
}
66
67
.sequoia-loading {
68
	background: var(--sequoia-bg-color, #ffffff);
69
	border: 1px solid var(--sequoia-border-color, #e5e7eb);
70
	color: var(--sequoia-secondary-color, #6b7280);
71
}
72
73
.sequoia-loading-spinner {
74
	display: inline-block;
75
	width: 1.25rem;
76
	height: 1.25rem;
77
	border: 2px solid var(--sequoia-border-color, #e5e7eb);
78
	border-top-color: var(--sequoia-accent-color, #2563eb);
79
	border-radius: 50%;
80
	animation: sequoia-spin 0.8s linear infinite;
81
	margin-right: 0.5rem;
82
	vertical-align: middle;
83
}
84
85
@keyframes sequoia-spin {
86
	to { transform: rotate(360deg); }
87
}
88
89
.sequoia-error {
90
	background: #fef2f2;
91
	border: 1px solid #fecaca;
92
	color: #dc2626;
93
}
94
95
.sequoia-warning {
96
	background: #fffbeb;
97
	border: 1px solid #fde68a;
98
	color: #d97706;
99
}
100
101
.sequoia-empty {
102
	background: var(--sequoia-bg-color, #ffffff);
103
	border: 1px solid var(--sequoia-border-color, #e5e7eb);
104
	color: var(--sequoia-secondary-color, #6b7280);
105
}
106
107
.sequoia-comments-header {
108
	display: flex;
109
	justify-content: space-between;
110
	align-items: center;
111
	margin-bottom: 1rem;
112
	padding-bottom: 0.75rem;
113
}
114
115
.sequoia-comments-title {
116
	font-size: 1.125rem;
117
	font-weight: 600;
118
	margin: 0;
119
}
120
121
.sequoia-reply-button {
122
	display: inline-flex;
123
	align-items: center;
124
	gap: 0.375rem;
125
	padding: 0.5rem 1rem;
126
	border: none;
127
	border-radius: var(--sequoia-border-radius, 15px);
128
	font-size: 0.875rem;
129
	font-weight: 500;
130
	cursor: pointer;
131
	text-decoration: none;
132
	transition: background-color 0.15s ease;
133
	margin-left:10px;
134
}
135
136
.sequoia-reply-bluesky {
137
	background: var(--sequoia-accent-color, #2563eb);
138
	color: #ffffff;
139
}
140
141
.sequoia-reply-blacksky {
142
	background: var(--sequoia-accent-color, #6060E9);
143
	color: #ffffff;
144
}
145
146
.sequoia-reply-bluesky:hover {
147
	background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
148
}
149
150
.sequoia-reply-blacksky:hover {
151
	background: color-mix(in srgb, var(--sequoia-accent-color, #5252c3) 85%, black);
152
}
153
154
.sequoia-reply-button svg {
155
	width: 1rem;
156
	height: 1rem;
157
}
158
159
.sequoia-comments-list {
160
	display: flex;
161
	flex-direction: column;
162
}
163
164
.sequoia-thread {
165
	border-top: 1px solid var(--sequoia-border-color, #e5e7eb);
166
	padding-bottom: 1rem;
167
}
168
169
.sequoia-thread + .sequoia-thread {
170
	margin-top: 0.5rem;
171
}
172
173
.sequoia-thread:last-child {
174
	border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
175
}
176
177
.sequoia-comment {
178
	display: flex;
179
	gap: 0.75rem;
180
	padding-top: 1rem;
181
}
182
183
.sequoia-comment-avatar-column {
184
	display: flex;
185
	flex-direction: column;
186
	align-items: center;
187
	flex-shrink: 0;
188
	width: 2.5rem;
189
	position: relative;
190
}
191
192
.sequoia-comment-avatar {
193
	width: 2.5rem;
194
	height: 2.5rem;
195
	border-radius: 50%;
196
	background: var(--sequoia-border-color, #e5e7eb);
197
	object-fit: cover;
198
	flex-shrink: 0;
199
	position: relative;
200
	z-index: 1;
201
}
202
203
.sequoia-comment-avatar-placeholder {
204
	width: 2.5rem;
205
	height: 2.5rem;
206
	border-radius: 50%;
207
	background: var(--sequoia-border-color, #e5e7eb);
208
	display: flex;
209
	align-items: center;
210
	justify-content: center;
211
	flex-shrink: 0;
212
	color: var(--sequoia-secondary-color, #6b7280);
213
	font-weight: 600;
214
	font-size: 1rem;
215
	position: relative;
216
	z-index: 1;
217
}
218
219
.sequoia-thread-line {
220
	position: absolute;
221
	top: 2.5rem;
222
	bottom: calc(-1rem - 0.5rem);
223
	left: 50%;
224
	transform: translateX(-50%);
225
	width: 2px;
226
	background: var(--sequoia-border-color, #e5e7eb);
227
}
228
229
.sequoia-comment-content {
230
	flex: 1;
231
	min-width: 0;
232
}
233
234
.sequoia-comment-header {
235
	display: flex;
236
	align-items: baseline;
237
	gap: 0.5rem;
238
	margin-bottom: 0.25rem;
239
	flex-wrap: wrap;
240
}
241
242
.sequoia-comment-author {
243
	font-weight: 600;
244
	color: var(--sequoia-fg-color, #1f2937);
245
	text-decoration: none;
246
	overflow: hidden;
247
	text-overflow: ellipsis;
248
	white-space: nowrap;
249
}
250
251
.sequoia-comment-author:hover {
252
	color: var(--sequoia-accent-color, #2563eb);
253
}
254
255
.sequoia-comment-handle {
256
	font-size: 0.875rem;
257
	color: var(--sequoia-secondary-color, #6b7280);
258
	overflow: hidden;
259
	text-overflow: ellipsis;
260
	white-space: nowrap;
261
}
262
263
.sequoia-comment-handle::after {
264
  content: "·";
265
	margin-left: 0.5rem;
266
}
267
268
.sequoia-comment-time {
269
	font-size: 0.875rem;
270
	color: var(--sequoia-secondary-color, #6b7280);
271
	flex-shrink: 0;
272
}
273
274
.sequoia-comment-text {
275
	margin: 0;
276
	white-space: pre-wrap;
277
	word-wrap: break-word;
278
}
279
280
.sequoia-comment-text a {
281
	color: var(--sequoia-accent-color, #2563eb);
282
	text-decoration: none;
283
}
284
285
.sequoia-comment-text a:hover {
286
	text-decoration: underline;
287
}
288
289
.sequoia-bsky-logo {
290
	width: 1rem;
291
	height: 1rem;
292
}
293
294
.sequoia-quotes-section {
295
	margin-top: 1.75rem;
296
}
297
298
.sequoia-quotes-header {
299
	font-size: 0.75rem;
300
	font-weight: 600;
301
	color: var(--sequoia-secondary-color, #6b7280);
302
	letter-spacing: 0.05em;
303
	text-transform: uppercase;
304
	margin: 0;
305
	padding-bottom: 0.75rem;
306
	border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
307
}
308
309
a.sequoia-comment-time {
310
	text-decoration: none;
311
	color: var(--sequoia-secondary-color, #6b7280);
312
}
313
314
a.sequoia-comment-time:hover {
315
	text-decoration: underline;
316
}
317
`;
318
319
// ============================================================================
320
// Utility Functions
321
// ============================================================================
322
323
/**
324
 * Format a relative time string (e.g., "2 hours ago")
325
 * @param {string} dateString - ISO date string
326
 * @returns {string} Formatted relative time
327
 */
328
function formatRelativeTime(dateString) {
329
	const date = new Date(dateString);
330
	const now = new Date();
331
	const diffMs = now.getTime() - date.getTime();
332
	const diffSeconds = Math.floor(diffMs / 1000);
333
	const diffMinutes = Math.floor(diffSeconds / 60);
334
	const diffHours = Math.floor(diffMinutes / 60);
335
	const diffDays = Math.floor(diffHours / 24);
336
	const diffWeeks = Math.floor(diffDays / 7);
337
	const diffMonths = Math.floor(diffDays / 30);
338
	const diffYears = Math.floor(diffDays / 365);
339
340
	if (diffSeconds < 60) {
341
		return "just now";
342
	}
343
	if (diffMinutes < 60) {
344
		return `${diffMinutes}m ago`;
345
	}
346
	if (diffHours < 24) {
347
		return `${diffHours}h ago`;
348
	}
349
	if (diffDays < 7) {
350
		return `${diffDays}d ago`;
351
	}
352
	if (diffWeeks < 4) {
353
		return `${diffWeeks}w ago`;
354
	}
355
	if (diffMonths < 12) {
356
		return `${diffMonths}mo ago`;
357
	}
358
	return `${diffYears}y ago`;
359
}
360
361
/**
362
 * Escape HTML special characters
363
 * @param {string} text - Text to escape
364
 * @returns {string} Escaped HTML
365
 */
366
function escapeHtml(text) {
367
	const div = document.createElement("div");
368
	div.textContent = text;
369
	return div.innerHTML;
370
}
371
372
/**
373
 * Convert post text with facets to HTML
374
 * @param {string} text - Post text
375
 * @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets
376
 * @returns {string} HTML string with links
377
 */
378
function renderTextWithFacets(text, facets) {
379
	if (!facets || facets.length === 0) {
380
		return escapeHtml(text);
381
	}
382
383
	// Convert text to bytes for proper indexing
384
	const encoder = new TextEncoder();
385
	const decoder = new TextDecoder();
386
	const textBytes = encoder.encode(text);
387
388
	// Sort facets by start index
389
	const sortedFacets = [...facets].sort(
390
		(a, b) => a.index.byteStart - b.index.byteStart,
391
	);
392
393
	let result = "";
394
	let lastEnd = 0;
395
396
	for (const facet of sortedFacets) {
397
		const { byteStart, byteEnd } = facet.index;
398
399
		// Add text before this facet
400
		if (byteStart > lastEnd) {
401
			const beforeBytes = textBytes.slice(lastEnd, byteStart);
402
			result += escapeHtml(decoder.decode(beforeBytes));
403
		}
404
405
		// Get the facet text
406
		const facetBytes = textBytes.slice(byteStart, byteEnd);
407
		const facetText = decoder.decode(facetBytes);
408
409
		// Find the first renderable feature
410
		const feature = facet.features[0];
411
		if (feature) {
412
			if (feature.$type === "app.bsky.richtext.facet#link") {
413
				result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
414
			} else if (feature.$type === "app.bsky.richtext.facet#mention") {
415
				result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
416
			} else if (feature.$type === "app.bsky.richtext.facet#tag") {
417
				result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
418
			} else {
419
				result += escapeHtml(facetText);
420
			}
421
		} else {
422
			result += escapeHtml(facetText);
423
		}
424
425
		lastEnd = byteEnd;
426
	}
427
428
	// Add remaining text
429
	if (lastEnd < textBytes.length) {
430
		const remainingBytes = textBytes.slice(lastEnd);
431
		result += escapeHtml(decoder.decode(remainingBytes));
432
	}
433
434
	return result;
435
}
436
437
/**
438
 * Get initials from a name for avatar placeholder
439
 * @param {string} name - Display name
440
 * @returns {string} Initials (1-2 characters)
441
 */
442
function getInitials(name) {
443
	const parts = name.trim().split(/\s+/);
444
	if (parts.length >= 2) {
445
		return (parts[0][0] + parts[1][0]).toUpperCase();
446
	}
447
	return name.substring(0, 2).toUpperCase();
448
}
449
450
// ============================================================================
451
// AT Protocol Client Functions
452
// ============================================================================
453
454
/**
455
 * Parse an AT URI into its components
456
 * Format: at://did/collection/rkey
457
 * @param {string} atUri - AT Protocol URI
458
 * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null
459
 */
460
function parseAtUri(atUri) {
461
	const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
462
	if (!match) return null;
463
	return {
464
		did: match[1],
465
		collection: match[2],
466
		rkey: match[3],
467
	};
468
}
469
470
/**
471
 * Resolve a DID to its PDS URL
472
 * Supports did:plc and did:web methods
473
 * @param {string} did - Decentralized Identifier
474
 * @returns {Promise<string>} PDS URL
475
 */
476
async function resolvePDS(did) {
477
	let pdsUrl;
478
479
	if (did.startsWith("did:plc:")) {
480
		// Fetch DID document from plc.directory
481
		const didDocUrl = `https://plc.directory/${did}`;
482
		const didDocResponse = await fetch(didDocUrl);
483
		if (!didDocResponse.ok) {
484
			throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
485
		}
486
		const didDoc = await didDocResponse.json();
487
488
		// Find the PDS service endpoint
489
		const pdsService = didDoc.service?.find(
490
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
491
		);
492
		pdsUrl = pdsService?.serviceEndpoint;
493
	} else if (did.startsWith("did:web:")) {
494
		// For did:web, fetch the DID document from the domain
495
		const domain = did.replace("did:web:", "");
496
		const didDocUrl = `https://${domain}/.well-known/did.json`;
497
		const didDocResponse = await fetch(didDocUrl);
498
		if (!didDocResponse.ok) {
499
			throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
500
		}
501
		const didDoc = await didDocResponse.json();
502
503
		const pdsService = didDoc.service?.find(
504
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
505
		);
506
		pdsUrl = pdsService?.serviceEndpoint;
507
	} else {
508
		throw new Error(`Unsupported DID method: ${did}`);
509
	}
510
511
	if (!pdsUrl) {
512
		throw new Error("Could not find PDS URL for user");
513
	}
514
515
	return pdsUrl;
516
}
517
518
/**
519
 * Fetch a record from a PDS using the public API
520
 * @param {string} did - DID of the repository owner
521
 * @param {string} collection - Collection name
522
 * @param {string} rkey - Record key
523
 * @returns {Promise<any>} Record value
524
 */
525
async function getRecord(did, collection, rkey) {
526
	const pdsUrl = await resolvePDS(did);
527
528
	const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`);
529
	url.searchParams.set("repo", did);
530
	url.searchParams.set("collection", collection);
531
	url.searchParams.set("rkey", rkey);
532
533
	const response = await fetch(url.toString());
534
	if (!response.ok) {
535
		throw new Error(`Failed to fetch record: ${response.status}`);
536
	}
537
538
	const data = await response.json();
539
	return data.value;
540
}
541
542
/**
543
 * Fetch a document record from its AT URI
544
 * @param {string} atUri - AT Protocol URI for the document
545
 * @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
546
 */
547
async function getDocument(atUri) {
548
	const parsed = parseAtUri(atUri);
549
	if (!parsed) {
550
		throw new Error(`Invalid AT URI: ${atUri}`);
551
	}
552
553
	return getRecord(parsed.did, parsed.collection, parsed.rkey);
554
}
555
556
/**
557
 * Fetch a post thread from the public Bluesky API
558
 * @param {string} postUri - AT Protocol URI for the post
559
 * @param {number} [depth=6] - Maximum depth of replies to fetch
560
 * @returns {Promise<ThreadViewPost>} Thread view post
561
 */
562
async function getPostThread(postUri, depth = 6) {
563
	const url = new URL(
564
		"https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread",
565
	);
566
	url.searchParams.set("uri", postUri);
567
	url.searchParams.set("depth", depth.toString());
568
569
	const response = await fetch(url.toString());
570
	if (!response.ok) {
571
		throw new Error(`Failed to fetch post thread: ${response.status}`);
572
	}
573
574
	const data = await response.json();
575
576
	if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") {
577
		throw new Error("Post not found or blocked");
578
	}
579
580
	return data.thread;
581
}
582
583
/**
584
 * Build a Bluesky app URL for a post
585
 * @param {string} postUri - AT Protocol URI for the post
586
 * @returns {string} Bluesky app URL
587
 */
588
function buildBskyAppUrl(postUri) {
589
	const parsed = parseAtUri(postUri);
590
	if (!parsed) {
591
		throw new Error(`Invalid post URI: ${postUri}`);
592
	}
593
594
	return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`;
595
}
596
597
/**
598
 * Build a Blacksky app URL for a post
599
 * @param {string} postUri - AT Protocol URI for the post
600
 * @returns {string} Blacksky app URL
601
 */
602
function buildBlackskyAppUrl(postUri) {
603
	const parsed = parseAtUri(postUri);
604
	if (!parsed) {
605
		throw new Error(`Invalid post URI: ${postUri}`);
606
	}
607
608
	return `https://blacksky.community/profile/${parsed.did}/post/${parsed.rkey}`;
609
}
610
611
/**
612
 * Type guard for ThreadViewPost
613
 * @param {any} post - Post to check
614
 * @returns {boolean} True if post is a ThreadViewPost
615
 */
616
function isThreadViewPost(post) {
617
	return post?.$type === "app.bsky.feed.defs#threadViewPost";
618
}
619
620
/**
621
 * Fetch all quote posts for a given post URI, paginating through all results.
622
 * Uses the public Bluesky AppView — gaps are expected for posts from
623
 * less-connected PDS instances.
624
 * @param {string} postUri - AT Protocol URI for the post
625
 * @returns {Promise<Array>} Array of PostView objects
626
 */
627
/**
628
 * Normalise a user-supplied post reference to an AT-URI.
629
 * Accepts:
630
 *   - AT-URIs as-is:          at://did:plc:.../app.bsky.feed.post/rkey
631
 *   - bsky.app post URLs:     https://bsky.app/profile/<handle-or-did>/post/<rkey>
632
 * When the profile segment is already a DID no network request is made.
633
 * @param {string} uriOrUrl
634
 * @returns {Promise<string>} AT-URI
635
 */
636
async function resolvePostUri(uriOrUrl) {
637
	if (uriOrUrl.startsWith("at://")) return uriOrUrl;
638
639
	const match = uriOrUrl.match(
640
		/bsky\.app\/profile\/([^/?#]+)\/post\/([^/?#]+)/,
641
	);
642
	if (!match) throw new Error(`Cannot parse Bluesky URL: ${uriOrUrl}`);
643
644
	const [, handleOrDid, rkey] = match;
645
646
	let did = handleOrDid;
647
	if (!handleOrDid.startsWith("did:")) {
648
		const url = new URL(
649
			"https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle",
650
		);
651
		url.searchParams.set("handle", handleOrDid);
652
		const response = await fetch(url.toString());
653
		if (!response.ok)
654
			throw new Error(`Failed to resolve handle: ${response.status}`);
655
		did = (await response.json()).did;
656
	}
657
658
	return `at://${did}/app.bsky.feed.post/${rkey}`;
659
}
660
661
async function getQuotes(postUri) {
662
	const quotes = [];
663
	let cursor;
664
665
	do {
666
		const url = new URL(
667
			"https://public.api.bsky.app/xrpc/app.bsky.feed.getQuotes",
668
		);
669
		url.searchParams.set("uri", postUri);
670
		url.searchParams.set("limit", "100");
671
		if (cursor) url.searchParams.set("cursor", cursor);
672
673
		const response = await fetch(url.toString());
674
		if (!response.ok) {
675
			throw new Error(`Failed to fetch quotes: ${response.status}`);
676
		}
677
678
		const data = await response.json();
679
		quotes.push(...(data.posts ?? []));
680
		cursor = data.cursor;
681
	} while (cursor);
682
683
	return quotes;
684
}
685
686
// ============================================================================
687
// Bluesky Icon
688
// ============================================================================
689
690
const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
691
  <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"/>
692
</svg>`;
693
const BLACKSKY_ICON =
694
	'<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>';
695
696
// ============================================================================
697
// Web Component
698
// ============================================================================
699
700
// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
701
const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
702
703
class SequoiaComments extends BaseElement {
704
	constructor() {
705
		super();
706
		const shadow = this.attachShadow({ mode: "open" });
707
708
		const styleTag = document.createElement("style");
709
		shadow.appendChild(styleTag);
710
		styleTag.innerText = styles;
711
712
		const container = document.createElement("div");
713
		shadow.appendChild(container);
714
		container.className = "sequoia-comments-container";
715
		container.part = "container";
716
717
		this.commentsContainer = container;
718
		this.state = { type: "loading" };
719
		this.abortController = null;
720
	}
721
722
	static get observedAttributes() {
723
		return ["post-uri", "document-uri", "depth", "hide"];
724
	}
725
726
	connectedCallback() {
727
		this.initialized = true;
728
		this.render();
729
		this.loadComments();
730
	}
731
732
	disconnectedCallback() {
733
		this.abortController?.abort();
734
	}
735
736
	attributeChangedCallback() {
737
		// attributeChangedCallback fires for pre-existing attributes during
738
		// element upgrade, *before* connectedCallback — skip until we've done
739
		// the initial load, otherwise every attribute triggers a duplicate fetch.
740
		if (this.initialized) {
741
			this.loadComments();
742
		}
743
	}
744
745
	get documentUri() {
746
		// First check attribute
747
		const attrUri = this.getAttribute("document-uri");
748
		if (attrUri) {
749
			return attrUri;
750
		}
751
752
		// Then scan for link tag in document head
753
		const linkTag = document.querySelector(
754
			'link[rel="site.standard.document"]',
755
		);
756
		return linkTag?.href ?? null;
757
	}
758
759
	get depth() {
760
		const depthAttr = this.getAttribute("depth");
761
		return depthAttr ? parseInt(depthAttr, 10) : 6;
762
	}
763
764
	get hide() {
765
		const hideAttr = this.getAttribute("hide");
766
		return hideAttr === "auto";
767
	}
768
769
	async loadComments() {
770
		// Cancel any in-flight request
771
		this.abortController?.abort();
772
		this.abortController = new AbortController();
773
774
		this.state = { type: "loading" };
775
		this.render();
776
777
		try {
778
			// Resolve the post URI — either directly from the attribute or via the
779
			// document record (which requires a PDS roundtrip)
780
			const rawPostUri = this.getAttribute("post-uri");
781
			let postUri = rawPostUri ? await resolvePostUri(rawPostUri) : null;
782
			if (!postUri) {
783
				const docUri = this.documentUri;
784
				if (!docUri) {
785
					this.state = { type: "no-document" };
786
					this.render();
787
					return;
788
				}
789
790
				const document = await getDocument(docUri);
791
				if (!document.bskyPostRef) {
792
					this.state = { type: "no-comments-enabled" };
793
					this.render();
794
					return;
795
				}
796
797
				postUri = document.bskyPostRef.uri;
798
			}
799
800
			const postUrl = buildBskyAppUrl(postUri);
801
			const blackskyPostUrl = buildBlackskyAppUrl(postUri);
802
803
			// Fetch thread and quotes in parallel; quote failures degrade gracefully
804
			const [threadResult, quotesResult] = await Promise.allSettled([
805
				getPostThread(postUri, this.depth),
806
				getQuotes(postUri),
807
			]);
808
809
			if (threadResult.status === "rejected") {
810
				throw threadResult.reason;
811
			}
812
813
			const thread = threadResult.value;
814
			const quotes =
815
				quotesResult.status === "fulfilled" ? quotesResult.value : [];
816
817
			const replies = thread.replies?.filter(isThreadViewPost) ?? [];
818
			if (replies.length === 0 && quotes.length === 0) {
819
				this.state = { type: "empty", postUrl, blackskyPostUrl };
820
				this.render();
821
				return;
822
			}
823
824
			this.state = { type: "loaded", thread, quotes, postUrl, blackskyPostUrl };
825
			this.render();
826
		} catch (error) {
827
			const message =
828
				error instanceof Error ? error.message : "Failed to load comments";
829
			this.state = { type: "error", message };
830
			this.render();
831
		}
832
	}
833
834
	render() {
835
		switch (this.state.type) {
836
			case "loading":
837
				this.commentsContainer.innerHTML = `
838
					<div class="sequoia-loading">
839
						<span class="sequoia-loading-spinner"></span>
840
						Loading comments...
841
					</div>
842
				`;
843
				break;
844
845
			case "no-document":
846
				this.commentsContainer.innerHTML = `
847
					<div class="sequoia-warning">
848
						No document found. Add a <code>&lt;link rel="site.standard.document" href="at://..."&gt;</code> tag to your page.
849
					</div>
850
				`;
851
				if (this.hide) {
852
					this.commentsContainer.style.display = "none";
853
				}
854
				break;
855
856
			case "no-comments-enabled":
857
				this.commentsContainer.innerHTML = `
858
					<div class="sequoia-empty">
859
						Comments are not enabled for this post.
860
					</div>
861
				`;
862
				break;
863
864
			case "empty":
865
				this.commentsContainer.innerHTML = `
866
					<div class="sequoia-comments-header">
867
						<h3 class="sequoia-comments-title">Comments</h3>
868
						<div>${this.renderReplyButtons(this.state.postUrl, this.state.blackskyPostUrl)}</div>
869
					</div>
870
					<div class="sequoia-empty">
871
						No comments yet. Be the first to reply on Bluesky!
872
					</div>
873
				`;
874
				break;
875
876
			case "error":
877
				this.commentsContainer.innerHTML = `
878
					<div class="sequoia-error">
879
						Failed to load comments: ${escapeHtml(this.state.message)}
880
					</div>
881
				`;
882
				break;
883
884
			case "loaded": {
885
				const replies =
886
					this.state.thread.replies?.filter(isThreadViewPost) ?? [];
887
				const quotes = this.state.quotes ?? [];
888
				const threadsHtml = replies
889
					.map((reply) => this.renderThread(reply))
890
					.join("");
891
				const commentCount = this.countComments(replies);
892
				const titleText =
893
					commentCount > 0
894
						? `${commentCount} Comment${commentCount !== 1 ? "s" : ""}`
895
						: "Comments";
896
				const quotesHtml = this.renderQuotesSection(quotes);
897
898
				this.commentsContainer.innerHTML = `
899
					<div class="sequoia-comments-header">
900
						<h3 class="sequoia-comments-title">${titleText}</h3>
901
						<div>${this.renderReplyButtons(this.state.postUrl, this.state.blackskyPostUrl)}</div>
902
					</div>
903
					<div class="sequoia-comments-list">
904
						${threadsHtml}
905
					</div>
906
					${quotesHtml}
907
				`;
908
				break;
909
			}
910
		}
911
	}
912
913
	/**
914
	 * Flatten a thread into a linear list of comments
915
	 * @param {ThreadViewPost} thread - Thread to flatten
916
	 * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments
917
	 */
918
	flattenThread(thread) {
919
		const result = [];
920
		const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? [];
921
922
		result.push({
923
			post: thread.post,
924
			hasMoreReplies: nestedReplies.length > 0,
925
		});
926
927
		// Recursively flatten nested replies
928
		for (const reply of nestedReplies) {
929
			result.push(...this.flattenThread(reply));
930
		}
931
932
		return result;
933
	}
934
935
	/**
936
	 * Render the reply-button slot. Any element with slot="reply-button" in the
937
	 * light DOM is projected here and remains styleable by external CSS.
938
	 * The default Bluesky/Blacksky buttons are used as fallback content.
939
	 */
940
	renderReplyButtons(postUrl, blackskyPostUrl) {
941
		return `
942
			<slot name="reply-button">
943
				<a href="${escapeHtml(postUrl)}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-bluesky">
944
					${BLUESKY_ICON}
945
				</a>
946
				<a href="${escapeHtml(blackskyPostUrl)}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-blacksky">
947
					${BLACKSKY_ICON}
948
				</a>
949
			</slot>
950
		`;
951
	}
952
953
	/**
954
	 * Render a complete thread (top-level comment + all nested replies)
955
	 */
956
	renderThread(thread) {
957
		const flatComments = this.flattenThread(thread);
958
		const commentsHtml = flatComments
959
			.map((item, index) =>
960
				this.renderComment(item.post, item.hasMoreReplies, index),
961
			)
962
			.join("");
963
964
		return `<div class="sequoia-thread">${commentsHtml}</div>`;
965
	}
966
967
	/**
968
	 * Render a section of quote posts below the replies
969
	 * @param {Array} quotes - Array of PostView objects from getQuotes
970
	 */
971
	renderQuotesSection(quotes) {
972
		if (quotes.length === 0) return "";
973
974
		const quotesHtml = quotes
975
			.map((post) => {
976
				return `<div class="sequoia-thread">${this.renderComment(post, false, 0)}</div>`;
977
			})
978
			.join("");
979
980
		return `
981
			<div class="sequoia-quotes-section">
982
				<h4 class="sequoia-quotes-header">Quotes (${quotes.length})</h4>
983
				<div class="sequoia-comments-list">
984
					${quotesHtml}
985
				</div>
986
			</div>
987
		`;
988
	}
989
990
	/**
991
	 * Render a single comment
992
	 * @param {any} post - Post data
993
	 * @param {boolean} showThreadLine - Whether to show the connecting thread line
994
	 * @param {number} _index - Index in the flattened thread (0 = top-level)
995
	 */
996
	renderComment(post, showThreadLine = false, _index = 0) {
997
		const author = post.author;
998
		const displayName = author.displayName || author.handle;
999
		const avatarHtml = author.avatar
1000
			? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />`
1001
			: `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`;
1002
1003
		const profileUrl = `https://bsky.app/profile/${author.did}`;
1004
		const textHtml = renderTextWithFacets(post.record.text, post.record.facets);
1005
		const timeAgo = formatRelativeTime(post.record.createdAt);
1006
		const timeHtml = `<a href="${escapeHtml(buildBskyAppUrl(post.uri))}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-time">${timeAgo}</a>`;
1007
		const threadLineHtml = showThreadLine
1008
			? '<div class="sequoia-thread-line"></div>'
1009
			: "";
1010
1011
		return `
1012
			<div class="sequoia-comment">
1013
				<div class="sequoia-comment-avatar-column">
1014
					${avatarHtml}
1015
					${threadLineHtml}
1016
				</div>
1017
				<div class="sequoia-comment-content">
1018
					<div class="sequoia-comment-header">
1019
						<a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author">
1020
							${escapeHtml(displayName)}
1021
						</a>
1022
						<span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span>
1023
						${timeHtml}
1024
					</div>
1025
					<p class="sequoia-comment-text">${textHtml}</p>
1026
				</div>
1027
			</div>
1028
		`;
1029
	}
1030
1031
	countComments(replies) {
1032
		let count = 0;
1033
		for (const reply of replies) {
1034
			count += 1;
1035
			const nested = reply.replies?.filter(isThreadViewPost) ?? [];
1036
			count += this.countComments(nested);
1037
		}
1038
		return count;
1039
	}
1040
}
1041
1042
// Register the custom element
1043
if (typeof customElements !== "undefined") {
1044
	customElements.define("sequoia-comments", SequoiaComments);
1045
}
1046
1047
// Export for module usage
1048
export { SequoiaComments };