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