chore: updated thread style and added test.html 2ea975e7
Steve · 2026-02-06 19:33 2 file(s) · +159 −56
packages/cli/src/components/sequoia-comments.js +116 −56
99 99
	align-items: center;
100 100
	margin-bottom: 1rem;
101 101
	padding-bottom: 0.75rem;
102 -
	border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
103 102
}
104 103
105 104
.sequoia-comments-title {
136 135
.sequoia-comments-list {
137 136
	display: flex;
138 137
	flex-direction: column;
139 -
	gap: 0;
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);
140 151
}
141 152
142 153
.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;
154 +
	display: flex;
155 +
	gap: 0.75rem;
156 +
	padding-top: 1rem;
148 157
}
149 158
150 -
.sequoia-comment-header {
159 +
.sequoia-comment-avatar-column {
151 160
	display: flex;
161 +
	flex-direction: column;
152 162
	align-items: center;
153 -
	gap: 0.75rem;
154 -
	margin-bottom: 0.5rem;
163 +
	flex-shrink: 0;
164 +
	width: 2.5rem;
165 +
	position: relative;
155 166
}
156 167
157 168
.sequoia-comment-avatar {
161 172
	background: var(--sequoia-border-color, #e5e7eb);
162 173
	object-fit: cover;
163 174
	flex-shrink: 0;
175 +
	position: relative;
176 +
	z-index: 1;
164 177
}
165 178
166 179
.sequoia-comment-avatar-placeholder {
175 188
	color: var(--sequoia-secondary-color, #6b7280);
176 189
	font-weight: 600;
177 190
	font-size: 1rem;
191 +
	position: relative;
192 +
	z-index: 1;
178 193
}
179 194
180 -
.sequoia-comment-meta {
181 -
	display: flex;
182 -
	flex-direction: column;
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;
183 207
	min-width: 0;
184 208
}
185 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 +
186 218
.sequoia-comment-author {
187 219
	font-weight: 600;
188 220
	color: var(--sequoia-fg-color, #1f2937);
205 237
}
206 238
207 239
.sequoia-comment-time {
208 -
	font-size: 0.75rem;
240 +
	font-size: 0.875rem;
209 241
	color: var(--sequoia-secondary-color, #6b7280);
210 -
	margin-left: auto;
211 242
	flex-shrink: 0;
212 243
}
213 244
245 +
.sequoia-comment-time::before {
246 +
	content: "·";
247 +
	margin-right: 0.5rem;
248 +
}
249 +
214 250
.sequoia-comment-text {
215 251
	margin: 0;
216 252
	white-space: pre-wrap;
226 262
	text-decoration: underline;
227 263
}
228 264
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 265
.sequoia-bsky-logo {
245 266
	width: 1rem;
246 267
	height: 1rem;
318 339
319 340
	// Sort facets by start index
320 341
	const sortedFacets = [...facets].sort(
321 -
		(a, b) => a.index.byteStart - b.index.byteStart
342 +
		(a, b) => a.index.byteStart - b.index.byteStart,
322 343
	);
323 344
324 345
	let result = "";
418 439
419 440
		// Find the PDS service endpoint
420 441
		const pdsService = didDoc.service?.find(
421 -
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer"
442 +
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
422 443
		);
423 444
		pdsUrl = pdsService?.serviceEndpoint;
424 445
	} else if (did.startsWith("did:web:")) {
432 453
		const didDoc = await didDocResponse.json();
433 454
434 455
		const pdsService = didDoc.service?.find(
435 -
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer"
456 +
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
436 457
		);
437 458
		pdsUrl = pdsService?.serviceEndpoint;
438 459
	} else {
492 513
 */
493 514
async function getPostThread(postUri, depth = 6) {
494 515
	const url = new URL(
495 -
		"https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread"
516 +
		"https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread",
496 517
	);
497 518
	url.searchParams.set("uri", postUri);
498 519
	url.searchParams.set("depth", depth.toString());
547 568
// ============================================================================
548 569
549 570
// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
550 -
const BaseElement =
551 -
	typeof HTMLElement !== "undefined"
552 -
		? HTMLElement
553 -
		: class {};
571 +
const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
554 572
555 573
class SequoiaComments extends BaseElement {
556 574
	constructor() {
588 606
589 607
		// Then scan for link tag in document head
590 608
		const linkTag = document.querySelector(
591 -
			'link[rel="site.standard.document"]'
609 +
			'link[rel="site.standard.document"]',
592 610
		);
593 611
		return linkTag?.href ?? null;
594 612
	}
715 733
				break;
716 734
717 735
			case "loaded": {
718 -
				const replies = this.state.thread.replies?.filter(isThreadViewPost) ?? [];
719 -
				const commentsHtml = replies.map((reply) => this.renderComment(reply)).join("");
736 +
				const replies =
737 +
					this.state.thread.replies?.filter(isThreadViewPost) ?? [];
738 +
				const threadsHtml = replies
739 +
					.map((reply) => this.renderThread(reply))
740 +
					.join("");
720 741
				const commentCount = this.countComments(replies);
721 742
722 743
				this.shadow.innerHTML = `
730 751
							</a>
731 752
						</div>
732 753
						<div class="sequoia-comments-list">
733 -
							${commentsHtml}
754 +
							${threadsHtml}
734 755
						</div>
735 756
					</div>
736 757
				`;
739 760
		}
740 761
	}
741 762
742 -
	renderComment(thread) {
743 -
		const { post } = thread;
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) {
744 806
		const author = post.author;
745 807
		const displayName = author.displayName || author.handle;
746 808
		const avatarHtml = author.avatar
750 812
		const profileUrl = `https://bsky.app/profile/${author.did}`;
751 813
		const textHtml = renderTextWithFacets(post.record.text, post.record.facets);
752 814
		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 -
				: "";
815 +
		const threadLineHtml = showThreadLine
816 +
			? '<div class="sequoia-thread-line"></div>'
817 +
			: "";
760 818
761 819
		return `
762 820
			<div class="sequoia-comment">
763 -
				<div class="sequoia-comment-header">
821 +
				<div class="sequoia-comment-avatar-column">
764 822
					${avatarHtml}
765 -
					<div class="sequoia-comment-meta">
823 +
					${threadLineHtml}
824 +
				</div>
825 +
				<div class="sequoia-comment-content">
826 +
					<div class="sequoia-comment-header">
766 827
						<a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author">
767 828
							${escapeHtml(displayName)}
768 829
						</a>
769 830
						<span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span>
831 +
						<span class="sequoia-comment-time">${timeAgo}</span>
770 832
					</div>
771 -
					<span class="sequoia-comment-time">${timeAgo}</span>
833 +
					<p class="sequoia-comment-text">${textHtml}</p>
772 834
				</div>
773 -
				<p class="sequoia-comment-text">${textHtml}</p>
774 -
				${repliesHtml}
775 835
			</div>
776 836
		`;
777 837
	}
packages/cli/test.html (added) +43 −0
1 +
<!DOCTYPE html>
2 +
<html lang="en">
3 +
<head>
4 +
  <meta charset="UTF-8">
5 +
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 +
  <title>Sequoia Comments Test</title>
7 +
  <!-- Link to a published document - replace with your own AT URI -->
8 +
  <link rel="site.standard.document" href="at://did:plc:kq6bvkw4sxof3vdinuitehn5/site.standard.document/3mdnztyhoem2v">
9 +
  <style>
10 +
    body {
11 +
      font-family: system-ui, -apple-system, sans-serif;
12 +
      max-width: 800px;
13 +
      margin: 2rem auto;
14 +
      padding: 0 1rem;
15 +
      line-height: 1.6;
16 +
      background-color: #1A1A1A;
17 +
      color: #F5F3EF;
18 +
    }
19 +
    h1 {
20 +
      margin-bottom: 2rem;
21 +
    }
22 +
    /* Custom styling example */
23 +
     :root {
24 +
      --sequoia-accent-color: #3A5A40;
25 +
      --sequoia-border-radius: 12px;
26 +
      --sequoia-bg-color: #1a1a1a;
27 +
      --sequoia-fg-color: #F5F3EF;
28 +
      --sequoia-border-color: #333;
29 +
      --sequoia-secondary-color: #8B7355;
30 +
    }
31 +
  </style>
32 +
</head>
33 +
<body>
34 +
  <h1>Blog Post Title</h1>
35 +
  <p>This is a test page for the sequoia-comments web component.</p>
36 +
  <p>The component will look for a <code>&lt;link rel="site.standard.document"&gt;</code> tag in the document head to find the AT Protocol document, then fetch and display Bluesky replies as comments.</p>
37 +
38 +
  <h2>Comments</h2>
39 +
  <sequoia-comments></sequoia-comments>
40 +
41 +
  <script type="module" src="./src/components/sequoia-comments.js"></script>
42 +
</body>
43 +
</html>