sequoia-cli 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,856 @@
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 };