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