feat: initial ui components 9ceb04a7
Steve · 2026-02-05 19:25 13 file(s) · +1057 −1
.gitignore +0 −1
35 35
36 36
# Bun lockfile - keep but binary cache
37 37
bun.lockb
38 -
packages/ui
packages/ui/.gitignore (added) +3 −0
1 +
dist/
2 +
node_modules/
3 +
test-site/
packages/ui/biome.json (added) +37 −0
1 +
{
2 +
	"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
3 +
	"vcs": {
4 +
		"enabled": true,
5 +
		"clientKind": "git",
6 +
		"useIgnoreFile": true
7 +
	},
8 +
	"files": {
9 +
		"includes": ["**", "!!**/dist"]
10 +
	},
11 +
	"formatter": {
12 +
		"enabled": true,
13 +
		"indentStyle": "tab"
14 +
	},
15 +
	"linter": {
16 +
		"enabled": true,
17 +
		"rules": {
18 +
			"recommended": true,
19 +
			"style": {
20 +
				"noNonNullAssertion": "off"
21 +
			}
22 +
		}
23 +
	},
24 +
	"javascript": {
25 +
		"formatter": {
26 +
			"quoteStyle": "double"
27 +
		}
28 +
	},
29 +
	"assist": {
30 +
		"enabled": true,
31 +
		"actions": {
32 +
			"source": {
33 +
				"organizeImports": "on"
34 +
			}
35 +
		}
36 +
	}
37 +
}
packages/ui/package.json (added) +28 −0
1 +
{
2 +
	"name": "sequoia-ui",
3 +
	"version": "0.1.0",
4 +
	"type": "module",
5 +
	"files": [
6 +
		"dist",
7 +
		"README.md"
8 +
	],
9 +
	"main": "./dist/index.js",
10 +
	"exports": {
11 +
		".": "./dist/index.js",
12 +
		"./comments": "./dist/index.js"
13 +
	},
14 +
	"scripts": {
15 +
		"lint": "biome lint --write",
16 +
		"format": "biome format --write",
17 +
		"build": "bun build src/index.ts --outdir dist --target browser && bun build src/index.ts --outfile dist/sequoia-comments.iife.js --target browser --format iife --minify",
18 +
		"dev": "bun run build",
19 +
		"deploy": "bun run build && bun publish --access public"
20 +
	},
21 +
	"devDependencies": {
22 +
		"@biomejs/biome": "^2.3.13",
23 +
		"@types/node": "^20"
24 +
	},
25 +
	"peerDependencies": {
26 +
		"typescript": "^5"
27 +
	}
28 +
}
packages/ui/src/components/sequoia-comments/index.ts (added) +11 −0
1 +
import { SequoiaComments } from "./sequoia-comments";
2 +
3 +
// Register the custom element if not already registered
4 +
if (
5 +
	typeof customElements !== "undefined" &&
6 +
	!customElements.get("sequoia-comments")
7 +
) {
8 +
	customElements.define("sequoia-comments", SequoiaComments);
9 +
}
10 +
11 +
export { SequoiaComments };
packages/ui/src/components/sequoia-comments/sequoia-comments.ts (added) +270 −0
1 +
import {
2 +
	buildBskyAppUrl,
3 +
	getDocument,
4 +
	getPostThread,
5 +
} from "../../lib/atproto-client";
6 +
import type { ThreadViewPost } from "../../types/bluesky";
7 +
import { isThreadViewPost } from "../../types/bluesky";
8 +
import { styles } from "./styles";
9 +
import { formatRelativeTime, getInitials, renderTextWithFacets } from "./utils";
10 +
11 +
/**
12 +
 * Component state
13 +
 */
14 +
type State =
15 +
	| { type: "loading" }
16 +
	| { type: "loaded"; thread: ThreadViewPost; postUrl: string }
17 +
	| { type: "no-document" }
18 +
	| { type: "no-comments-enabled" }
19 +
	| { type: "empty"; postUrl: string }
20 +
	| { type: "error"; message: string };
21 +
22 +
/**
23 +
 * Bluesky butterfly SVG icon
24 +
 */
25 +
const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
26 +
  <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"/>
27 +
</svg>`;
28 +
29 +
export class SequoiaComments extends HTMLElement {
30 +
	private shadow: ShadowRoot;
31 +
	private state: State = { type: "loading" };
32 +
	private abortController: AbortController | null = null;
33 +
34 +
	static get observedAttributes(): string[] {
35 +
		return ["document-uri", "depth"];
36 +
	}
37 +
38 +
	constructor() {
39 +
		super();
40 +
		this.shadow = this.attachShadow({ mode: "open" });
41 +
	}
42 +
43 +
	connectedCallback(): void {
44 +
		this.render();
45 +
		this.loadComments();
46 +
	}
47 +
48 +
	disconnectedCallback(): void {
49 +
		this.abortController?.abort();
50 +
	}
51 +
52 +
	attributeChangedCallback(): void {
53 +
		if (this.isConnected) {
54 +
			this.loadComments();
55 +
		}
56 +
	}
57 +
58 +
	private get documentUri(): string | null {
59 +
		// First check attribute
60 +
		const attrUri = this.getAttribute("document-uri");
61 +
		if (attrUri) {
62 +
			return attrUri;
63 +
		}
64 +
65 +
		// Then scan for link tag in document head
66 +
		const linkTag = document.querySelector<HTMLLinkElement>(
67 +
			'link[rel="site.standard.document"]',
68 +
		);
69 +
		return linkTag?.href ?? null;
70 +
	}
71 +
72 +
	private get depth(): number {
73 +
		const depthAttr = this.getAttribute("depth");
74 +
		return depthAttr ? Number.parseInt(depthAttr, 10) : 6;
75 +
	}
76 +
77 +
	private async loadComments(): Promise<void> {
78 +
		// Cancel any in-flight request
79 +
		this.abortController?.abort();
80 +
		this.abortController = new AbortController();
81 +
82 +
		this.state = { type: "loading" };
83 +
		this.render();
84 +
85 +
		const docUri = this.documentUri;
86 +
		if (!docUri) {
87 +
			this.state = { type: "no-document" };
88 +
			this.render();
89 +
			return;
90 +
		}
91 +
92 +
		try {
93 +
			// Fetch the document record
94 +
			const document = await getDocument(docUri);
95 +
96 +
			// Check if document has a Bluesky post reference
97 +
			if (!document.bskyPostRef) {
98 +
				this.state = { type: "no-comments-enabled" };
99 +
				this.render();
100 +
				return;
101 +
			}
102 +
103 +
			const postUrl = buildBskyAppUrl(document.bskyPostRef.uri);
104 +
105 +
			// Fetch the post thread
106 +
			const thread = await getPostThread(document.bskyPostRef.uri, this.depth);
107 +
108 +
			// Check if there are any replies
109 +
			const replies = thread.replies?.filter(isThreadViewPost) ?? [];
110 +
			if (replies.length === 0) {
111 +
				this.state = { type: "empty", postUrl };
112 +
				this.render();
113 +
				return;
114 +
			}
115 +
116 +
			this.state = { type: "loaded", thread, postUrl };
117 +
			this.render();
118 +
		} catch (error) {
119 +
			const message =
120 +
				error instanceof Error ? error.message : "Failed to load comments";
121 +
			this.state = { type: "error", message };
122 +
			this.render();
123 +
		}
124 +
	}
125 +
126 +
	private render(): void {
127 +
		const styleTag = `<style>${styles}</style>`;
128 +
129 +
		switch (this.state.type) {
130 +
			case "loading":
131 +
				this.shadow.innerHTML = `
132 +
					${styleTag}
133 +
					<div class="sequoia-comments-container">
134 +
						<div class="sequoia-loading">
135 +
							<span class="sequoia-loading-spinner"></span>
136 +
							Loading comments...
137 +
						</div>
138 +
					</div>
139 +
				`;
140 +
				break;
141 +
142 +
			case "no-document":
143 +
				this.shadow.innerHTML = `
144 +
					${styleTag}
145 +
					<div class="sequoia-comments-container">
146 +
						<div class="sequoia-warning">
147 +
							No document found. Add a <code>&lt;link rel="site.standard.document" href="at://..."&gt;</code> tag to your page.
148 +
						</div>
149 +
					</div>
150 +
				`;
151 +
				break;
152 +
153 +
			case "no-comments-enabled":
154 +
				this.shadow.innerHTML = `
155 +
					${styleTag}
156 +
					<div class="sequoia-comments-container">
157 +
						<div class="sequoia-empty">
158 +
							Comments are not enabled for this post.
159 +
						</div>
160 +
					</div>
161 +
				`;
162 +
				break;
163 +
164 +
			case "empty":
165 +
				this.shadow.innerHTML = `
166 +
					${styleTag}
167 +
					<div class="sequoia-comments-container">
168 +
						<div class="sequoia-comments-header">
169 +
							<h3 class="sequoia-comments-title">Comments</h3>
170 +
							<a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
171 +
								${BLUESKY_ICON}
172 +
								Reply on Bluesky
173 +
							</a>
174 +
						</div>
175 +
						<div class="sequoia-empty">
176 +
							No comments yet. Be the first to reply on Bluesky!
177 +
						</div>
178 +
					</div>
179 +
				`;
180 +
				break;
181 +
182 +
			case "error":
183 +
				this.shadow.innerHTML = `
184 +
					${styleTag}
185 +
					<div class="sequoia-comments-container">
186 +
						<div class="sequoia-error">
187 +
							Failed to load comments: ${this.escapeHtml(this.state.message)}
188 +
						</div>
189 +
					</div>
190 +
				`;
191 +
				break;
192 +
193 +
			case "loaded": {
194 +
				const replies = this.state.thread.replies?.filter(isThreadViewPost) ?? [];
195 +
				const commentsHtml = replies.map((reply) => this.renderComment(reply)).join("");
196 +
				const commentCount = this.countComments(replies);
197 +
198 +
				this.shadow.innerHTML = `
199 +
					${styleTag}
200 +
					<div class="sequoia-comments-container">
201 +
						<div class="sequoia-comments-header">
202 +
							<h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3>
203 +
							<a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
204 +
								${BLUESKY_ICON}
205 +
								Reply on Bluesky
206 +
							</a>
207 +
						</div>
208 +
						<div class="sequoia-comments-list">
209 +
							${commentsHtml}
210 +
						</div>
211 +
					</div>
212 +
				`;
213 +
				break;
214 +
			}
215 +
		}
216 +
	}
217 +
218 +
	private renderComment(thread: ThreadViewPost): string {
219 +
		const { post } = thread;
220 +
		const author = post.author;
221 +
		const displayName = author.displayName || author.handle;
222 +
		const avatarHtml = author.avatar
223 +
			? `<img class="sequoia-comment-avatar" src="${this.escapeHtml(author.avatar)}" alt="${this.escapeHtml(displayName)}" loading="lazy" />`
224 +
			: `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`;
225 +
226 +
		const profileUrl = `https://bsky.app/profile/${author.did}`;
227 +
		const textHtml = renderTextWithFacets(post.record.text, post.record.facets);
228 +
		const timeAgo = formatRelativeTime(post.record.createdAt);
229 +
230 +
		// Render nested replies
231 +
		const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? [];
232 +
		const repliesHtml =
233 +
			nestedReplies.length > 0
234 +
				? `<div class="sequoia-comment-replies">${nestedReplies.map((r) => this.renderComment(r)).join("")}</div>`
235 +
				: "";
236 +
237 +
		return `
238 +
			<div class="sequoia-comment">
239 +
				<div class="sequoia-comment-header">
240 +
					${avatarHtml}
241 +
					<div class="sequoia-comment-meta">
242 +
						<a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author">
243 +
							${this.escapeHtml(displayName)}
244 +
						</a>
245 +
						<span class="sequoia-comment-handle">@${this.escapeHtml(author.handle)}</span>
246 +
					</div>
247 +
					<span class="sequoia-comment-time">${timeAgo}</span>
248 +
				</div>
249 +
				<p class="sequoia-comment-text">${textHtml}</p>
250 +
				${repliesHtml}
251 +
			</div>
252 +
		`;
253 +
	}
254 +
255 +
	private countComments(replies: ThreadViewPost[]): number {
256 +
		let count = 0;
257 +
		for (const reply of replies) {
258 +
			count += 1;
259 +
			const nested = reply.replies?.filter(isThreadViewPost) ?? [];
260 +
			count += this.countComments(nested);
261 +
		}
262 +
		return count;
263 +
	}
264 +
265 +
	private escapeHtml(text: string): string {
266 +
		const div = document.createElement("div");
267 +
		div.textContent = text;
268 +
		return div.innerHTML;
269 +
	}
270 +
}
packages/ui/src/components/sequoia-comments/styles.ts (added) +218 −0
1 +
export const styles = `
2 +
:host {
3 +
	display: block;
4 +
	font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
5 +
	color: var(--sequoia-fg-color, #1f2937);
6 +
	line-height: 1.5;
7 +
}
8 +
9 +
* {
10 +
	box-sizing: border-box;
11 +
}
12 +
13 +
.sequoia-comments-container {
14 +
	max-width: 100%;
15 +
}
16 +
17 +
.sequoia-loading,
18 +
.sequoia-error,
19 +
.sequoia-empty,
20 +
.sequoia-warning {
21 +
	padding: 1rem;
22 +
	border-radius: var(--sequoia-border-radius, 8px);
23 +
	text-align: center;
24 +
}
25 +
26 +
.sequoia-loading {
27 +
	background: var(--sequoia-bg-color, #ffffff);
28 +
	border: 1px solid var(--sequoia-border-color, #e5e7eb);
29 +
	color: var(--sequoia-secondary-color, #6b7280);
30 +
}
31 +
32 +
.sequoia-loading-spinner {
33 +
	display: inline-block;
34 +
	width: 1.25rem;
35 +
	height: 1.25rem;
36 +
	border: 2px solid var(--sequoia-border-color, #e5e7eb);
37 +
	border-top-color: var(--sequoia-accent-color, #2563eb);
38 +
	border-radius: 50%;
39 +
	animation: sequoia-spin 0.8s linear infinite;
40 +
	margin-right: 0.5rem;
41 +
	vertical-align: middle;
42 +
}
43 +
44 +
@keyframes sequoia-spin {
45 +
	to { transform: rotate(360deg); }
46 +
}
47 +
48 +
.sequoia-error {
49 +
	background: #fef2f2;
50 +
	border: 1px solid #fecaca;
51 +
	color: #dc2626;
52 +
}
53 +
54 +
.sequoia-warning {
55 +
	background: #fffbeb;
56 +
	border: 1px solid #fde68a;
57 +
	color: #d97706;
58 +
}
59 +
60 +
.sequoia-empty {
61 +
	background: var(--sequoia-bg-color, #ffffff);
62 +
	border: 1px solid var(--sequoia-border-color, #e5e7eb);
63 +
	color: var(--sequoia-secondary-color, #6b7280);
64 +
}
65 +
66 +
.sequoia-comments-header {
67 +
	display: flex;
68 +
	justify-content: space-between;
69 +
	align-items: center;
70 +
	margin-bottom: 1rem;
71 +
	padding-bottom: 0.75rem;
72 +
	border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
73 +
}
74 +
75 +
.sequoia-comments-title {
76 +
	font-size: 1.125rem;
77 +
	font-weight: 600;
78 +
	margin: 0;
79 +
}
80 +
81 +
.sequoia-reply-button {
82 +
	display: inline-flex;
83 +
	align-items: center;
84 +
	gap: 0.375rem;
85 +
	padding: 0.5rem 1rem;
86 +
	background: var(--sequoia-accent-color, #2563eb);
87 +
	color: #ffffff;
88 +
	border: none;
89 +
	border-radius: var(--sequoia-border-radius, 8px);
90 +
	font-size: 0.875rem;
91 +
	font-weight: 500;
92 +
	cursor: pointer;
93 +
	text-decoration: none;
94 +
	transition: background-color 0.15s ease;
95 +
}
96 +
97 +
.sequoia-reply-button:hover {
98 +
	background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
99 +
}
100 +
101 +
.sequoia-reply-button svg {
102 +
	width: 1rem;
103 +
	height: 1rem;
104 +
}
105 +
106 +
.sequoia-comments-list {
107 +
	display: flex;
108 +
	flex-direction: column;
109 +
	gap: 0;
110 +
}
111 +
112 +
.sequoia-comment {
113 +
	padding: 1rem;
114 +
	background: var(--sequoia-bg-color, #ffffff);
115 +
	border: 1px solid var(--sequoia-border-color, #e5e7eb);
116 +
	border-radius: var(--sequoia-border-radius, 8px);
117 +
	margin-bottom: 0.75rem;
118 +
}
119 +
120 +
.sequoia-comment-header {
121 +
	display: flex;
122 +
	align-items: center;
123 +
	gap: 0.75rem;
124 +
	margin-bottom: 0.5rem;
125 +
}
126 +
127 +
.sequoia-comment-avatar {
128 +
	width: 2.5rem;
129 +
	height: 2.5rem;
130 +
	border-radius: 50%;
131 +
	background: var(--sequoia-border-color, #e5e7eb);
132 +
	object-fit: cover;
133 +
	flex-shrink: 0;
134 +
}
135 +
136 +
.sequoia-comment-avatar-placeholder {
137 +
	width: 2.5rem;
138 +
	height: 2.5rem;
139 +
	border-radius: 50%;
140 +
	background: var(--sequoia-border-color, #e5e7eb);
141 +
	display: flex;
142 +
	align-items: center;
143 +
	justify-content: center;
144 +
	flex-shrink: 0;
145 +
	color: var(--sequoia-secondary-color, #6b7280);
146 +
	font-weight: 600;
147 +
	font-size: 1rem;
148 +
}
149 +
150 +
.sequoia-comment-meta {
151 +
	display: flex;
152 +
	flex-direction: column;
153 +
	min-width: 0;
154 +
}
155 +
156 +
.sequoia-comment-author {
157 +
	font-weight: 600;
158 +
	color: var(--sequoia-fg-color, #1f2937);
159 +
	text-decoration: none;
160 +
	overflow: hidden;
161 +
	text-overflow: ellipsis;
162 +
	white-space: nowrap;
163 +
}
164 +
165 +
.sequoia-comment-author:hover {
166 +
	color: var(--sequoia-accent-color, #2563eb);
167 +
}
168 +
169 +
.sequoia-comment-handle {
170 +
	font-size: 0.875rem;
171 +
	color: var(--sequoia-secondary-color, #6b7280);
172 +
	overflow: hidden;
173 +
	text-overflow: ellipsis;
174 +
	white-space: nowrap;
175 +
}
176 +
177 +
.sequoia-comment-time {
178 +
	font-size: 0.75rem;
179 +
	color: var(--sequoia-secondary-color, #6b7280);
180 +
	margin-left: auto;
181 +
	flex-shrink: 0;
182 +
}
183 +
184 +
.sequoia-comment-text {
185 +
	margin: 0;
186 +
	white-space: pre-wrap;
187 +
	word-wrap: break-word;
188 +
}
189 +
190 +
.sequoia-comment-text a {
191 +
	color: var(--sequoia-accent-color, #2563eb);
192 +
	text-decoration: none;
193 +
}
194 +
195 +
.sequoia-comment-text a:hover {
196 +
	text-decoration: underline;
197 +
}
198 +
199 +
.sequoia-comment-replies {
200 +
	margin-top: 0.75rem;
201 +
	margin-left: 1.5rem;
202 +
	padding-left: 1rem;
203 +
	border-left: 2px solid var(--sequoia-border-color, #e5e7eb);
204 +
}
205 +
206 +
.sequoia-comment-replies .sequoia-comment {
207 +
	margin-bottom: 0.5rem;
208 +
}
209 +
210 +
.sequoia-comment-replies .sequoia-comment:last-child {
211 +
	margin-bottom: 0;
212 +
}
213 +
214 +
.sequoia-bsky-logo {
215 +
	width: 1rem;
216 +
	height: 1rem;
217 +
}
218 +
`;
packages/ui/src/components/sequoia-comments/utils.ts (added) +127 −0
1 +
/**
2 +
 * Format a relative time string (e.g., "2 hours ago")
3 +
 */
4 +
export function formatRelativeTime(dateString: string): string {
5 +
	const date = new Date(dateString);
6 +
	const now = new Date();
7 +
	const diffMs = now.getTime() - date.getTime();
8 +
	const diffSeconds = Math.floor(diffMs / 1000);
9 +
	const diffMinutes = Math.floor(diffSeconds / 60);
10 +
	const diffHours = Math.floor(diffMinutes / 60);
11 +
	const diffDays = Math.floor(diffHours / 24);
12 +
	const diffWeeks = Math.floor(diffDays / 7);
13 +
	const diffMonths = Math.floor(diffDays / 30);
14 +
	const diffYears = Math.floor(diffDays / 365);
15 +
16 +
	if (diffSeconds < 60) {
17 +
		return "just now";
18 +
	}
19 +
	if (diffMinutes < 60) {
20 +
		return `${diffMinutes}m ago`;
21 +
	}
22 +
	if (diffHours < 24) {
23 +
		return `${diffHours}h ago`;
24 +
	}
25 +
	if (diffDays < 7) {
26 +
		return `${diffDays}d ago`;
27 +
	}
28 +
	if (diffWeeks < 4) {
29 +
		return `${diffWeeks}w ago`;
30 +
	}
31 +
	if (diffMonths < 12) {
32 +
		return `${diffMonths}mo ago`;
33 +
	}
34 +
	return `${diffYears}y ago`;
35 +
}
36 +
37 +
/**
38 +
 * Escape HTML special characters
39 +
 */
40 +
export function escapeHtml(text: string): string {
41 +
	const div = document.createElement("div");
42 +
	div.textContent = text;
43 +
	return div.innerHTML;
44 +
}
45 +
46 +
/**
47 +
 * Convert post text with facets to HTML
48 +
 */
49 +
export function renderTextWithFacets(
50 +
	text: string,
51 +
	facets?: Array<{
52 +
		index: { byteStart: number; byteEnd: number };
53 +
		features: Array<
54 +
			| { $type: "app.bsky.richtext.facet#link"; uri: string }
55 +
			| { $type: "app.bsky.richtext.facet#mention"; did: string }
56 +
			| { $type: "app.bsky.richtext.facet#tag"; tag: string }
57 +
		>;
58 +
	}>,
59 +
): string {
60 +
	if (!facets || facets.length === 0) {
61 +
		return escapeHtml(text);
62 +
	}
63 +
64 +
	// Convert text to bytes for proper indexing
65 +
	const encoder = new TextEncoder();
66 +
	const decoder = new TextDecoder();
67 +
	const textBytes = encoder.encode(text);
68 +
69 +
	// Sort facets by start index
70 +
	const sortedFacets = [...facets].sort(
71 +
		(a, b) => a.index.byteStart - b.index.byteStart,
72 +
	);
73 +
74 +
	let result = "";
75 +
	let lastEnd = 0;
76 +
77 +
	for (const facet of sortedFacets) {
78 +
		const { byteStart, byteEnd } = facet.index;
79 +
80 +
		// Add text before this facet
81 +
		if (byteStart > lastEnd) {
82 +
			const beforeBytes = textBytes.slice(lastEnd, byteStart);
83 +
			result += escapeHtml(decoder.decode(beforeBytes));
84 +
		}
85 +
86 +
		// Get the facet text
87 +
		const facetBytes = textBytes.slice(byteStart, byteEnd);
88 +
		const facetText = decoder.decode(facetBytes);
89 +
90 +
		// Find the first renderable feature
91 +
		const feature = facet.features[0];
92 +
		if (feature) {
93 +
			if (feature.$type === "app.bsky.richtext.facet#link") {
94 +
				result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
95 +
			} else if (feature.$type === "app.bsky.richtext.facet#mention") {
96 +
				result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
97 +
			} else if (feature.$type === "app.bsky.richtext.facet#tag") {
98 +
				result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
99 +
			} else {
100 +
				result += escapeHtml(facetText);
101 +
			}
102 +
		} else {
103 +
			result += escapeHtml(facetText);
104 +
		}
105 +
106 +
		lastEnd = byteEnd;
107 +
	}
108 +
109 +
	// Add remaining text
110 +
	if (lastEnd < textBytes.length) {
111 +
		const remainingBytes = textBytes.slice(lastEnd);
112 +
		result += escapeHtml(decoder.decode(remainingBytes));
113 +
	}
114 +
115 +
	return result;
116 +
}
117 +
118 +
/**
119 +
 * Get initials from a name for avatar placeholder
120 +
 */
121 +
export function getInitials(name: string): string {
122 +
	const parts = name.trim().split(/\s+/);
123 +
	if (parts.length >= 2) {
124 +
		return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
125 +
	}
126 +
	return name.substring(0, 2).toUpperCase();
127 +
}
packages/ui/src/index.ts (added) +26 −0
1 +
// Components
2 +
export { SequoiaComments } from "./components/sequoia-comments";
3 +
4 +
// AT Protocol client utilities
5 +
export {
6 +
	parseAtUri,
7 +
	resolvePDS,
8 +
	getRecord,
9 +
	getDocument,
10 +
	getPostThread,
11 +
	buildBskyAppUrl,
12 +
} from "./lib/atproto-client";
13 +
14 +
// Types
15 +
export type {
16 +
	StrongRef,
17 +
	ProfileViewBasic,
18 +
	PostRecord,
19 +
	PostView,
20 +
	ThreadViewPost,
21 +
	BlockedPost,
22 +
	NotFoundPost,
23 +
	DocumentRecord,
24 +
} from "./types/bluesky";
25 +
26 +
export { isThreadViewPost } from "./types/bluesky";
packages/ui/src/lib/atproto-client.ts (added) +144 −0
1 +
import type {
2 +
	DIDDocument,
3 +
	DocumentRecord,
4 +
	GetPostThreadResponse,
5 +
	GetRecordResponse,
6 +
	ThreadViewPost,
7 +
} from "../types/bluesky";
8 +
9 +
/**
10 +
 * Parse an AT URI into its components
11 +
 * Format: at://did/collection/rkey
12 +
 */
13 +
export function parseAtUri(
14 +
	atUri: string,
15 +
): { did: string; collection: string; rkey: string } | null {
16 +
	const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
17 +
	if (!match) return null;
18 +
	return {
19 +
		did: match[1]!,
20 +
		collection: match[2]!,
21 +
		rkey: match[3]!,
22 +
	};
23 +
}
24 +
25 +
/**
26 +
 * Resolve a DID to its PDS URL
27 +
 * Supports did:plc and did:web methods
28 +
 */
29 +
export async function resolvePDS(did: string): Promise<string> {
30 +
	let pdsUrl: string | undefined;
31 +
32 +
	if (did.startsWith("did:plc:")) {
33 +
		// Fetch DID document from plc.directory
34 +
		const didDocUrl = `https://plc.directory/${did}`;
35 +
		const didDocResponse = await fetch(didDocUrl);
36 +
		if (!didDocResponse.ok) {
37 +
			throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
38 +
		}
39 +
		const didDoc: DIDDocument = await didDocResponse.json();
40 +
41 +
		// Find the PDS service endpoint
42 +
		const pdsService = didDoc.service?.find(
43 +
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
44 +
		);
45 +
		pdsUrl = pdsService?.serviceEndpoint;
46 +
	} else if (did.startsWith("did:web:")) {
47 +
		// For did:web, fetch the DID document from the domain
48 +
		const domain = did.replace("did:web:", "");
49 +
		const didDocUrl = `https://${domain}/.well-known/did.json`;
50 +
		const didDocResponse = await fetch(didDocUrl);
51 +
		if (!didDocResponse.ok) {
52 +
			throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
53 +
		}
54 +
		const didDoc: DIDDocument = await didDocResponse.json();
55 +
56 +
		const pdsService = didDoc.service?.find(
57 +
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
58 +
		);
59 +
		pdsUrl = pdsService?.serviceEndpoint;
60 +
	} else {
61 +
		throw new Error(`Unsupported DID method: ${did}`);
62 +
	}
63 +
64 +
	if (!pdsUrl) {
65 +
		throw new Error("Could not find PDS URL for user");
66 +
	}
67 +
68 +
	return pdsUrl;
69 +
}
70 +
71 +
/**
72 +
 * Fetch a record from a PDS using the public API
73 +
 */
74 +
export async function getRecord<T>(
75 +
	did: string,
76 +
	collection: string,
77 +
	rkey: string,
78 +
): Promise<T> {
79 +
	const pdsUrl = await resolvePDS(did);
80 +
81 +
	const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`);
82 +
	url.searchParams.set("repo", did);
83 +
	url.searchParams.set("collection", collection);
84 +
	url.searchParams.set("rkey", rkey);
85 +
86 +
	const response = await fetch(url.toString());
87 +
	if (!response.ok) {
88 +
		throw new Error(`Failed to fetch record: ${response.status}`);
89 +
	}
90 +
91 +
	const data: GetRecordResponse<T> = await response.json();
92 +
	return data.value;
93 +
}
94 +
95 +
/**
96 +
 * Fetch a document record from its AT URI
97 +
 */
98 +
export async function getDocument(atUri: string): Promise<DocumentRecord> {
99 +
	const parsed = parseAtUri(atUri);
100 +
	if (!parsed) {
101 +
		throw new Error(`Invalid AT URI: ${atUri}`);
102 +
	}
103 +
104 +
	return getRecord<DocumentRecord>(parsed.did, parsed.collection, parsed.rkey);
105 +
}
106 +
107 +
/**
108 +
 * Fetch a post thread from the public Bluesky API
109 +
 */
110 +
export async function getPostThread(
111 +
	postUri: string,
112 +
	depth = 6,
113 +
): Promise<ThreadViewPost> {
114 +
	const url = new URL(
115 +
		"https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread",
116 +
	);
117 +
	url.searchParams.set("uri", postUri);
118 +
	url.searchParams.set("depth", depth.toString());
119 +
120 +
	const response = await fetch(url.toString());
121 +
	if (!response.ok) {
122 +
		throw new Error(`Failed to fetch post thread: ${response.status}`);
123 +
	}
124 +
125 +
	const data: GetPostThreadResponse = await response.json();
126 +
127 +
	if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") {
128 +
		throw new Error("Post not found or blocked");
129 +
	}
130 +
131 +
	return data.thread as ThreadViewPost;
132 +
}
133 +
134 +
/**
135 +
 * Build a Bluesky app URL for a post
136 +
 */
137 +
export function buildBskyAppUrl(postUri: string): string {
138 +
	const parsed = parseAtUri(postUri);
139 +
	if (!parsed) {
140 +
		throw new Error(`Invalid post URI: ${postUri}`);
141 +
	}
142 +
143 +
	return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`;
144 +
}
packages/ui/src/types/bluesky.ts (added) +133 −0
1 +
/**
2 +
 * Strong reference for AT Protocol records (com.atproto.repo.strongRef)
3 +
 */
4 +
export interface StrongRef {
5 +
	uri: string; // at:// URI format
6 +
	cid: string; // Content ID
7 +
}
8 +
9 +
/**
10 +
 * Basic profile view from Bluesky API
11 +
 */
12 +
export interface ProfileViewBasic {
13 +
	did: string;
14 +
	handle: string;
15 +
	displayName?: string;
16 +
	avatar?: string;
17 +
}
18 +
19 +
/**
20 +
 * Post record content from app.bsky.feed.post
21 +
 */
22 +
export interface PostRecord {
23 +
	$type: "app.bsky.feed.post";
24 +
	text: string;
25 +
	createdAt: string;
26 +
	reply?: {
27 +
		root: StrongRef;
28 +
		parent: StrongRef;
29 +
	};
30 +
	facets?: Array<{
31 +
		index: { byteStart: number; byteEnd: number };
32 +
		features: Array<
33 +
			| { $type: "app.bsky.richtext.facet#link"; uri: string }
34 +
			| { $type: "app.bsky.richtext.facet#mention"; did: string }
35 +
			| { $type: "app.bsky.richtext.facet#tag"; tag: string }
36 +
		>;
37 +
	}>;
38 +
}
39 +
40 +
/**
41 +
 * Post view from Bluesky API
42 +
 */
43 +
export interface PostView {
44 +
	uri: string;
45 +
	cid: string;
46 +
	author: ProfileViewBasic;
47 +
	record: PostRecord;
48 +
	replyCount?: number;
49 +
	repostCount?: number;
50 +
	likeCount?: number;
51 +
	indexedAt: string;
52 +
}
53 +
54 +
/**
55 +
 * Thread view post from app.bsky.feed.getPostThread
56 +
 */
57 +
export interface ThreadViewPost {
58 +
	$type: "app.bsky.feed.defs#threadViewPost";
59 +
	post: PostView;
60 +
	parent?: ThreadViewPost | BlockedPost | NotFoundPost;
61 +
	replies?: Array<ThreadViewPost | BlockedPost | NotFoundPost>;
62 +
}
63 +
64 +
/**
65 +
 * Blocked post placeholder
66 +
 */
67 +
export interface BlockedPost {
68 +
	$type: "app.bsky.feed.defs#blockedPost";
69 +
	uri: string;
70 +
	blocked: true;
71 +
}
72 +
73 +
/**
74 +
 * Not found post placeholder
75 +
 */
76 +
export interface NotFoundPost {
77 +
	$type: "app.bsky.feed.defs#notFoundPost";
78 +
	uri: string;
79 +
	notFound: true;
80 +
}
81 +
82 +
/**
83 +
 * Type guard for ThreadViewPost
84 +
 */
85 +
export function isThreadViewPost(
86 +
	post: ThreadViewPost | BlockedPost | NotFoundPost | undefined,
87 +
): post is ThreadViewPost {
88 +
	return post?.$type === "app.bsky.feed.defs#threadViewPost";
89 +
}
90 +
91 +
/**
92 +
 * Document record from site.standard.document
93 +
 */
94 +
export interface DocumentRecord {
95 +
	$type: "site.standard.document";
96 +
	title: string;
97 +
	site: string;
98 +
	path: string;
99 +
	textContent: string;
100 +
	publishedAt: string;
101 +
	canonicalUrl?: string;
102 +
	description?: string;
103 +
	tags?: string[];
104 +
	bskyPostRef?: StrongRef;
105 +
}
106 +
107 +
/**
108 +
 * DID document structure
109 +
 */
110 +
export interface DIDDocument {
111 +
	id: string;
112 +
	service?: Array<{
113 +
		id: string;
114 +
		type: string;
115 +
		serviceEndpoint: string;
116 +
	}>;
117 +
}
118 +
119 +
/**
120 +
 * Response from com.atproto.repo.getRecord
121 +
 */
122 +
export interface GetRecordResponse<T> {
123 +
	uri: string;
124 +
	cid: string;
125 +
	value: T;
126 +
}
127 +
128 +
/**
129 +
 * Response from app.bsky.feed.getPostThread
130 +
 */
131 +
export interface GetPostThreadResponse {
132 +
	thread: ThreadViewPost | BlockedPost | NotFoundPost;
133 +
}
packages/ui/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:ia2zdnhjaokf5lazhxrmj6eu/site.standard.document/3me3hbjtw2v2v">
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 +
    }
17 +
    h1 {
18 +
      margin-bottom: 2rem;
19 +
    }
20 +
    /* Custom styling example */
21 +
    sequoia-comments {
22 +
      --sequoia-accent-color: #0070f3;
23 +
      --sequoia-border-radius: 12px;
24 +
    }
25 +
    .dark-theme sequoia-comments {
26 +
      --sequoia-bg-color: #1a1a1a;
27 +
      --sequoia-fg-color: #ffffff;
28 +
      --sequoia-border-color: #333;
29 +
      --sequoia-secondary-color: #888;
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 src="./dist/sequoia-comments.iife.js"></script>
42 +
</body>
43 +
</html>
packages/ui/tsconfig.json (added) +17 −0
1 +
{
2 +
	"compilerOptions": {
3 +
		"target": "ES2022",
4 +
		"module": "ESNext",
5 +
		"moduleResolution": "bundler",
6 +
		"lib": ["ES2022", "DOM", "DOM.Iterable"],
7 +
		"strict": true,
8 +
		"esModuleInterop": true,
9 +
		"skipLibCheck": true,
10 +
		"declaration": true,
11 +
		"declarationMap": true,
12 +
		"outDir": "./dist",
13 +
		"rootDir": "./src"
14 +
	},
15 +
	"include": ["src/**/*"],
16 +
	"exclude": ["node_modules", "dist"]
17 +
}