sequoia-cli 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/sequoia-comments.js +856 -0
- package/dist/index.js +1114 -997
- package/package.json +2 -2
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sequoia Comments - A Bluesky-powered comments component
|
|
3
|
+
*
|
|
4
|
+
* A self-contained Web Component that displays comments from Bluesky posts
|
|
5
|
+
* linked to documents via the AT Protocol.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* <sequoia-comments></sequoia-comments>
|
|
9
|
+
*
|
|
10
|
+
* The component looks for a document URI in two places:
|
|
11
|
+
* 1. The `document-uri` attribute on the element
|
|
12
|
+
* 2. A <link rel="site.standard.document" href="at://..."> tag in the document head
|
|
13
|
+
*
|
|
14
|
+
* Attributes:
|
|
15
|
+
* - document-uri: AT Protocol URI for the document (optional if link tag exists)
|
|
16
|
+
* - depth: Maximum depth of nested replies to fetch (default: 6)
|
|
17
|
+
*
|
|
18
|
+
* CSS Custom Properties:
|
|
19
|
+
* - --sequoia-fg-color: Text color (default: #1f2937)
|
|
20
|
+
* - --sequoia-bg-color: Background color (default: #ffffff)
|
|
21
|
+
* - --sequoia-border-color: Border color (default: #e5e7eb)
|
|
22
|
+
* - --sequoia-accent-color: Accent/link color (default: #2563eb)
|
|
23
|
+
* - --sequoia-secondary-color: Secondary text color (default: #6b7280)
|
|
24
|
+
* - --sequoia-border-radius: Border radius (default: 8px)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// Styles
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
const styles = `
|
|
32
|
+
:host {
|
|
33
|
+
display: block;
|
|
34
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
35
|
+
color: var(--sequoia-fg-color, #1f2937);
|
|
36
|
+
line-height: 1.5;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
* {
|
|
40
|
+
box-sizing: border-box;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.sequoia-comments-container {
|
|
44
|
+
max-width: 100%;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.sequoia-loading,
|
|
48
|
+
.sequoia-error,
|
|
49
|
+
.sequoia-empty,
|
|
50
|
+
.sequoia-warning {
|
|
51
|
+
padding: 1rem;
|
|
52
|
+
border-radius: var(--sequoia-border-radius, 8px);
|
|
53
|
+
text-align: center;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.sequoia-loading {
|
|
57
|
+
background: var(--sequoia-bg-color, #ffffff);
|
|
58
|
+
border: 1px solid var(--sequoia-border-color, #e5e7eb);
|
|
59
|
+
color: var(--sequoia-secondary-color, #6b7280);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.sequoia-loading-spinner {
|
|
63
|
+
display: inline-block;
|
|
64
|
+
width: 1.25rem;
|
|
65
|
+
height: 1.25rem;
|
|
66
|
+
border: 2px solid var(--sequoia-border-color, #e5e7eb);
|
|
67
|
+
border-top-color: var(--sequoia-accent-color, #2563eb);
|
|
68
|
+
border-radius: 50%;
|
|
69
|
+
animation: sequoia-spin 0.8s linear infinite;
|
|
70
|
+
margin-right: 0.5rem;
|
|
71
|
+
vertical-align: middle;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@keyframes sequoia-spin {
|
|
75
|
+
to { transform: rotate(360deg); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.sequoia-error {
|
|
79
|
+
background: #fef2f2;
|
|
80
|
+
border: 1px solid #fecaca;
|
|
81
|
+
color: #dc2626;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.sequoia-warning {
|
|
85
|
+
background: #fffbeb;
|
|
86
|
+
border: 1px solid #fde68a;
|
|
87
|
+
color: #d97706;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.sequoia-empty {
|
|
91
|
+
background: var(--sequoia-bg-color, #ffffff);
|
|
92
|
+
border: 1px solid var(--sequoia-border-color, #e5e7eb);
|
|
93
|
+
color: var(--sequoia-secondary-color, #6b7280);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.sequoia-comments-header {
|
|
97
|
+
display: flex;
|
|
98
|
+
justify-content: space-between;
|
|
99
|
+
align-items: center;
|
|
100
|
+
margin-bottom: 1rem;
|
|
101
|
+
padding-bottom: 0.75rem;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.sequoia-comments-title {
|
|
105
|
+
font-size: 1.125rem;
|
|
106
|
+
font-weight: 600;
|
|
107
|
+
margin: 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.sequoia-reply-button {
|
|
111
|
+
display: inline-flex;
|
|
112
|
+
align-items: center;
|
|
113
|
+
gap: 0.375rem;
|
|
114
|
+
padding: 0.5rem 1rem;
|
|
115
|
+
background: var(--sequoia-accent-color, #2563eb);
|
|
116
|
+
color: #ffffff;
|
|
117
|
+
border: none;
|
|
118
|
+
border-radius: var(--sequoia-border-radius, 8px);
|
|
119
|
+
font-size: 0.875rem;
|
|
120
|
+
font-weight: 500;
|
|
121
|
+
cursor: pointer;
|
|
122
|
+
text-decoration: none;
|
|
123
|
+
transition: background-color 0.15s ease;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.sequoia-reply-button:hover {
|
|
127
|
+
background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.sequoia-reply-button svg {
|
|
131
|
+
width: 1rem;
|
|
132
|
+
height: 1rem;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.sequoia-comments-list {
|
|
136
|
+
display: flex;
|
|
137
|
+
flex-direction: column;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.sequoia-thread {
|
|
141
|
+
border-top: 1px solid var(--sequoia-border-color, #e5e7eb);
|
|
142
|
+
padding-bottom: 1rem;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.sequoia-thread + .sequoia-thread {
|
|
146
|
+
margin-top: 0.5rem;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.sequoia-thread:last-child {
|
|
150
|
+
border-bottom: 1px solid var(--sequoia-border-color, #e5e7eb);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.sequoia-comment {
|
|
154
|
+
display: flex;
|
|
155
|
+
gap: 0.75rem;
|
|
156
|
+
padding-top: 1rem;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.sequoia-comment-avatar-column {
|
|
160
|
+
display: flex;
|
|
161
|
+
flex-direction: column;
|
|
162
|
+
align-items: center;
|
|
163
|
+
flex-shrink: 0;
|
|
164
|
+
width: 2.5rem;
|
|
165
|
+
position: relative;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.sequoia-comment-avatar {
|
|
169
|
+
width: 2.5rem;
|
|
170
|
+
height: 2.5rem;
|
|
171
|
+
border-radius: 50%;
|
|
172
|
+
background: var(--sequoia-border-color, #e5e7eb);
|
|
173
|
+
object-fit: cover;
|
|
174
|
+
flex-shrink: 0;
|
|
175
|
+
position: relative;
|
|
176
|
+
z-index: 1;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.sequoia-comment-avatar-placeholder {
|
|
180
|
+
width: 2.5rem;
|
|
181
|
+
height: 2.5rem;
|
|
182
|
+
border-radius: 50%;
|
|
183
|
+
background: var(--sequoia-border-color, #e5e7eb);
|
|
184
|
+
display: flex;
|
|
185
|
+
align-items: center;
|
|
186
|
+
justify-content: center;
|
|
187
|
+
flex-shrink: 0;
|
|
188
|
+
color: var(--sequoia-secondary-color, #6b7280);
|
|
189
|
+
font-weight: 600;
|
|
190
|
+
font-size: 1rem;
|
|
191
|
+
position: relative;
|
|
192
|
+
z-index: 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.sequoia-thread-line {
|
|
196
|
+
position: absolute;
|
|
197
|
+
top: 2.5rem;
|
|
198
|
+
bottom: calc(-1rem - 0.5rem);
|
|
199
|
+
left: 50%;
|
|
200
|
+
transform: translateX(-50%);
|
|
201
|
+
width: 2px;
|
|
202
|
+
background: var(--sequoia-border-color, #e5e7eb);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.sequoia-comment-content {
|
|
206
|
+
flex: 1;
|
|
207
|
+
min-width: 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.sequoia-comment-header {
|
|
211
|
+
display: flex;
|
|
212
|
+
align-items: baseline;
|
|
213
|
+
gap: 0.5rem;
|
|
214
|
+
margin-bottom: 0.25rem;
|
|
215
|
+
flex-wrap: wrap;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.sequoia-comment-author {
|
|
219
|
+
font-weight: 600;
|
|
220
|
+
color: var(--sequoia-fg-color, #1f2937);
|
|
221
|
+
text-decoration: none;
|
|
222
|
+
overflow: hidden;
|
|
223
|
+
text-overflow: ellipsis;
|
|
224
|
+
white-space: nowrap;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.sequoia-comment-author:hover {
|
|
228
|
+
color: var(--sequoia-accent-color, #2563eb);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.sequoia-comment-handle {
|
|
232
|
+
font-size: 0.875rem;
|
|
233
|
+
color: var(--sequoia-secondary-color, #6b7280);
|
|
234
|
+
overflow: hidden;
|
|
235
|
+
text-overflow: ellipsis;
|
|
236
|
+
white-space: nowrap;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.sequoia-comment-time {
|
|
240
|
+
font-size: 0.875rem;
|
|
241
|
+
color: var(--sequoia-secondary-color, #6b7280);
|
|
242
|
+
flex-shrink: 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.sequoia-comment-time::before {
|
|
246
|
+
content: "·";
|
|
247
|
+
margin-right: 0.5rem;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.sequoia-comment-text {
|
|
251
|
+
margin: 0;
|
|
252
|
+
white-space: pre-wrap;
|
|
253
|
+
word-wrap: break-word;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.sequoia-comment-text a {
|
|
257
|
+
color: var(--sequoia-accent-color, #2563eb);
|
|
258
|
+
text-decoration: none;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.sequoia-comment-text a:hover {
|
|
262
|
+
text-decoration: underline;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.sequoia-bsky-logo {
|
|
266
|
+
width: 1rem;
|
|
267
|
+
height: 1rem;
|
|
268
|
+
}
|
|
269
|
+
`;
|
|
270
|
+
|
|
271
|
+
// ============================================================================
|
|
272
|
+
// Utility Functions
|
|
273
|
+
// ============================================================================
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Format a relative time string (e.g., "2 hours ago")
|
|
277
|
+
* @param {string} dateString - ISO date string
|
|
278
|
+
* @returns {string} Formatted relative time
|
|
279
|
+
*/
|
|
280
|
+
function formatRelativeTime(dateString) {
|
|
281
|
+
const date = new Date(dateString);
|
|
282
|
+
const now = new Date();
|
|
283
|
+
const diffMs = now.getTime() - date.getTime();
|
|
284
|
+
const diffSeconds = Math.floor(diffMs / 1000);
|
|
285
|
+
const diffMinutes = Math.floor(diffSeconds / 60);
|
|
286
|
+
const diffHours = Math.floor(diffMinutes / 60);
|
|
287
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
288
|
+
const diffWeeks = Math.floor(diffDays / 7);
|
|
289
|
+
const diffMonths = Math.floor(diffDays / 30);
|
|
290
|
+
const diffYears = Math.floor(diffDays / 365);
|
|
291
|
+
|
|
292
|
+
if (diffSeconds < 60) {
|
|
293
|
+
return "just now";
|
|
294
|
+
}
|
|
295
|
+
if (diffMinutes < 60) {
|
|
296
|
+
return `${diffMinutes}m ago`;
|
|
297
|
+
}
|
|
298
|
+
if (diffHours < 24) {
|
|
299
|
+
return `${diffHours}h ago`;
|
|
300
|
+
}
|
|
301
|
+
if (diffDays < 7) {
|
|
302
|
+
return `${diffDays}d ago`;
|
|
303
|
+
}
|
|
304
|
+
if (diffWeeks < 4) {
|
|
305
|
+
return `${diffWeeks}w ago`;
|
|
306
|
+
}
|
|
307
|
+
if (diffMonths < 12) {
|
|
308
|
+
return `${diffMonths}mo ago`;
|
|
309
|
+
}
|
|
310
|
+
return `${diffYears}y ago`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Escape HTML special characters
|
|
315
|
+
* @param {string} text - Text to escape
|
|
316
|
+
* @returns {string} Escaped HTML
|
|
317
|
+
*/
|
|
318
|
+
function escapeHtml(text) {
|
|
319
|
+
const div = document.createElement("div");
|
|
320
|
+
div.textContent = text;
|
|
321
|
+
return div.innerHTML;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Convert post text with facets to HTML
|
|
326
|
+
* @param {string} text - Post text
|
|
327
|
+
* @param {Array<{index: {byteStart: number, byteEnd: number}, features: Array<{$type: string, uri?: string, did?: string, tag?: string}>}>} [facets] - Rich text facets
|
|
328
|
+
* @returns {string} HTML string with links
|
|
329
|
+
*/
|
|
330
|
+
function renderTextWithFacets(text, facets) {
|
|
331
|
+
if (!facets || facets.length === 0) {
|
|
332
|
+
return escapeHtml(text);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Convert text to bytes for proper indexing
|
|
336
|
+
const encoder = new TextEncoder();
|
|
337
|
+
const decoder = new TextDecoder();
|
|
338
|
+
const textBytes = encoder.encode(text);
|
|
339
|
+
|
|
340
|
+
// Sort facets by start index
|
|
341
|
+
const sortedFacets = [...facets].sort(
|
|
342
|
+
(a, b) => a.index.byteStart - b.index.byteStart,
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
let result = "";
|
|
346
|
+
let lastEnd = 0;
|
|
347
|
+
|
|
348
|
+
for (const facet of sortedFacets) {
|
|
349
|
+
const { byteStart, byteEnd } = facet.index;
|
|
350
|
+
|
|
351
|
+
// Add text before this facet
|
|
352
|
+
if (byteStart > lastEnd) {
|
|
353
|
+
const beforeBytes = textBytes.slice(lastEnd, byteStart);
|
|
354
|
+
result += escapeHtml(decoder.decode(beforeBytes));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Get the facet text
|
|
358
|
+
const facetBytes = textBytes.slice(byteStart, byteEnd);
|
|
359
|
+
const facetText = decoder.decode(facetBytes);
|
|
360
|
+
|
|
361
|
+
// Find the first renderable feature
|
|
362
|
+
const feature = facet.features[0];
|
|
363
|
+
if (feature) {
|
|
364
|
+
if (feature.$type === "app.bsky.richtext.facet#link") {
|
|
365
|
+
result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
|
|
366
|
+
} else if (feature.$type === "app.bsky.richtext.facet#mention") {
|
|
367
|
+
result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
|
|
368
|
+
} else if (feature.$type === "app.bsky.richtext.facet#tag") {
|
|
369
|
+
result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`;
|
|
370
|
+
} else {
|
|
371
|
+
result += escapeHtml(facetText);
|
|
372
|
+
}
|
|
373
|
+
} else {
|
|
374
|
+
result += escapeHtml(facetText);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
lastEnd = byteEnd;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Add remaining text
|
|
381
|
+
if (lastEnd < textBytes.length) {
|
|
382
|
+
const remainingBytes = textBytes.slice(lastEnd);
|
|
383
|
+
result += escapeHtml(decoder.decode(remainingBytes));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return result;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Get initials from a name for avatar placeholder
|
|
391
|
+
* @param {string} name - Display name
|
|
392
|
+
* @returns {string} Initials (1-2 characters)
|
|
393
|
+
*/
|
|
394
|
+
function getInitials(name) {
|
|
395
|
+
const parts = name.trim().split(/\s+/);
|
|
396
|
+
if (parts.length >= 2) {
|
|
397
|
+
return (parts[0][0] + parts[1][0]).toUpperCase();
|
|
398
|
+
}
|
|
399
|
+
return name.substring(0, 2).toUpperCase();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ============================================================================
|
|
403
|
+
// AT Protocol Client Functions
|
|
404
|
+
// ============================================================================
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Parse an AT URI into its components
|
|
408
|
+
* Format: at://did/collection/rkey
|
|
409
|
+
* @param {string} atUri - AT Protocol URI
|
|
410
|
+
* @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null
|
|
411
|
+
*/
|
|
412
|
+
function parseAtUri(atUri) {
|
|
413
|
+
const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/);
|
|
414
|
+
if (!match) return null;
|
|
415
|
+
return {
|
|
416
|
+
did: match[1],
|
|
417
|
+
collection: match[2],
|
|
418
|
+
rkey: match[3],
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Resolve a DID to its PDS URL
|
|
424
|
+
* Supports did:plc and did:web methods
|
|
425
|
+
* @param {string} did - Decentralized Identifier
|
|
426
|
+
* @returns {Promise<string>} PDS URL
|
|
427
|
+
*/
|
|
428
|
+
async function resolvePDS(did) {
|
|
429
|
+
let pdsUrl;
|
|
430
|
+
|
|
431
|
+
if (did.startsWith("did:plc:")) {
|
|
432
|
+
// Fetch DID document from plc.directory
|
|
433
|
+
const didDocUrl = `https://plc.directory/${did}`;
|
|
434
|
+
const didDocResponse = await fetch(didDocUrl);
|
|
435
|
+
if (!didDocResponse.ok) {
|
|
436
|
+
throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
|
|
437
|
+
}
|
|
438
|
+
const didDoc = await didDocResponse.json();
|
|
439
|
+
|
|
440
|
+
// Find the PDS service endpoint
|
|
441
|
+
const pdsService = didDoc.service?.find(
|
|
442
|
+
(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
|
|
443
|
+
);
|
|
444
|
+
pdsUrl = pdsService?.serviceEndpoint;
|
|
445
|
+
} else if (did.startsWith("did:web:")) {
|
|
446
|
+
// For did:web, fetch the DID document from the domain
|
|
447
|
+
const domain = did.replace("did:web:", "");
|
|
448
|
+
const didDocUrl = `https://${domain}/.well-known/did.json`;
|
|
449
|
+
const didDocResponse = await fetch(didDocUrl);
|
|
450
|
+
if (!didDocResponse.ok) {
|
|
451
|
+
throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
|
|
452
|
+
}
|
|
453
|
+
const didDoc = await didDocResponse.json();
|
|
454
|
+
|
|
455
|
+
const pdsService = didDoc.service?.find(
|
|
456
|
+
(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
|
|
457
|
+
);
|
|
458
|
+
pdsUrl = pdsService?.serviceEndpoint;
|
|
459
|
+
} else {
|
|
460
|
+
throw new Error(`Unsupported DID method: ${did}`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (!pdsUrl) {
|
|
464
|
+
throw new Error("Could not find PDS URL for user");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return pdsUrl;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Fetch a record from a PDS using the public API
|
|
472
|
+
* @param {string} did - DID of the repository owner
|
|
473
|
+
* @param {string} collection - Collection name
|
|
474
|
+
* @param {string} rkey - Record key
|
|
475
|
+
* @returns {Promise<any>} Record value
|
|
476
|
+
*/
|
|
477
|
+
async function getRecord(did, collection, rkey) {
|
|
478
|
+
const pdsUrl = await resolvePDS(did);
|
|
479
|
+
|
|
480
|
+
const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`);
|
|
481
|
+
url.searchParams.set("repo", did);
|
|
482
|
+
url.searchParams.set("collection", collection);
|
|
483
|
+
url.searchParams.set("rkey", rkey);
|
|
484
|
+
|
|
485
|
+
const response = await fetch(url.toString());
|
|
486
|
+
if (!response.ok) {
|
|
487
|
+
throw new Error(`Failed to fetch record: ${response.status}`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const data = await response.json();
|
|
491
|
+
return data.value;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Fetch a document record from its AT URI
|
|
496
|
+
* @param {string} atUri - AT Protocol URI for the document
|
|
497
|
+
* @returns {Promise<{$type: string, title: string, site: string, path: string, textContent: string, publishedAt: string, canonicalUrl?: string, description?: string, tags?: string[], bskyPostRef?: {uri: string, cid: string}}>} Document record
|
|
498
|
+
*/
|
|
499
|
+
async function getDocument(atUri) {
|
|
500
|
+
const parsed = parseAtUri(atUri);
|
|
501
|
+
if (!parsed) {
|
|
502
|
+
throw new Error(`Invalid AT URI: ${atUri}`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return getRecord(parsed.did, parsed.collection, parsed.rkey);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Fetch a post thread from the public Bluesky API
|
|
510
|
+
* @param {string} postUri - AT Protocol URI for the post
|
|
511
|
+
* @param {number} [depth=6] - Maximum depth of replies to fetch
|
|
512
|
+
* @returns {Promise<ThreadViewPost>} Thread view post
|
|
513
|
+
*/
|
|
514
|
+
async function getPostThread(postUri, depth = 6) {
|
|
515
|
+
const url = new URL(
|
|
516
|
+
"https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread",
|
|
517
|
+
);
|
|
518
|
+
url.searchParams.set("uri", postUri);
|
|
519
|
+
url.searchParams.set("depth", depth.toString());
|
|
520
|
+
|
|
521
|
+
const response = await fetch(url.toString());
|
|
522
|
+
if (!response.ok) {
|
|
523
|
+
throw new Error(`Failed to fetch post thread: ${response.status}`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const data = await response.json();
|
|
527
|
+
|
|
528
|
+
if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") {
|
|
529
|
+
throw new Error("Post not found or blocked");
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return data.thread;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Build a Bluesky app URL for a post
|
|
537
|
+
* @param {string} postUri - AT Protocol URI for the post
|
|
538
|
+
* @returns {string} Bluesky app URL
|
|
539
|
+
*/
|
|
540
|
+
function buildBskyAppUrl(postUri) {
|
|
541
|
+
const parsed = parseAtUri(postUri);
|
|
542
|
+
if (!parsed) {
|
|
543
|
+
throw new Error(`Invalid post URI: ${postUri}`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Type guard for ThreadViewPost
|
|
551
|
+
* @param {any} post - Post to check
|
|
552
|
+
* @returns {boolean} True if post is a ThreadViewPost
|
|
553
|
+
*/
|
|
554
|
+
function isThreadViewPost(post) {
|
|
555
|
+
return post?.$type === "app.bsky.feed.defs#threadViewPost";
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ============================================================================
|
|
559
|
+
// Bluesky Icon
|
|
560
|
+
// ============================================================================
|
|
561
|
+
|
|
562
|
+
const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
|
563
|
+
<path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/>
|
|
564
|
+
</svg>`;
|
|
565
|
+
|
|
566
|
+
// ============================================================================
|
|
567
|
+
// Web Component
|
|
568
|
+
// ============================================================================
|
|
569
|
+
|
|
570
|
+
// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
|
|
571
|
+
const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
|
|
572
|
+
|
|
573
|
+
class SequoiaComments extends BaseElement {
|
|
574
|
+
constructor() {
|
|
575
|
+
super();
|
|
576
|
+
this.shadow = this.attachShadow({ mode: "open" });
|
|
577
|
+
this.state = { type: "loading" };
|
|
578
|
+
this.abortController = null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
static get observedAttributes() {
|
|
582
|
+
return ["document-uri", "depth"];
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
connectedCallback() {
|
|
586
|
+
this.render();
|
|
587
|
+
this.loadComments();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
disconnectedCallback() {
|
|
591
|
+
this.abortController?.abort();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
attributeChangedCallback() {
|
|
595
|
+
if (this.isConnected) {
|
|
596
|
+
this.loadComments();
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
get documentUri() {
|
|
601
|
+
// First check attribute
|
|
602
|
+
const attrUri = this.getAttribute("document-uri");
|
|
603
|
+
if (attrUri) {
|
|
604
|
+
return attrUri;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Then scan for link tag in document head
|
|
608
|
+
const linkTag = document.querySelector(
|
|
609
|
+
'link[rel="site.standard.document"]',
|
|
610
|
+
);
|
|
611
|
+
return linkTag?.href ?? null;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
get depth() {
|
|
615
|
+
const depthAttr = this.getAttribute("depth");
|
|
616
|
+
return depthAttr ? parseInt(depthAttr, 10) : 6;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async loadComments() {
|
|
620
|
+
// Cancel any in-flight request
|
|
621
|
+
this.abortController?.abort();
|
|
622
|
+
this.abortController = new AbortController();
|
|
623
|
+
|
|
624
|
+
this.state = { type: "loading" };
|
|
625
|
+
this.render();
|
|
626
|
+
|
|
627
|
+
const docUri = this.documentUri;
|
|
628
|
+
if (!docUri) {
|
|
629
|
+
this.state = { type: "no-document" };
|
|
630
|
+
this.render();
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
try {
|
|
635
|
+
// Fetch the document record
|
|
636
|
+
const document = await getDocument(docUri);
|
|
637
|
+
|
|
638
|
+
// Check if document has a Bluesky post reference
|
|
639
|
+
if (!document.bskyPostRef) {
|
|
640
|
+
this.state = { type: "no-comments-enabled" };
|
|
641
|
+
this.render();
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const postUrl = buildBskyAppUrl(document.bskyPostRef.uri);
|
|
646
|
+
|
|
647
|
+
// Fetch the post thread
|
|
648
|
+
const thread = await getPostThread(document.bskyPostRef.uri, this.depth);
|
|
649
|
+
|
|
650
|
+
// Check if there are any replies
|
|
651
|
+
const replies = thread.replies?.filter(isThreadViewPost) ?? [];
|
|
652
|
+
if (replies.length === 0) {
|
|
653
|
+
this.state = { type: "empty", postUrl };
|
|
654
|
+
this.render();
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
this.state = { type: "loaded", thread, postUrl };
|
|
659
|
+
this.render();
|
|
660
|
+
} catch (error) {
|
|
661
|
+
const message =
|
|
662
|
+
error instanceof Error ? error.message : "Failed to load comments";
|
|
663
|
+
this.state = { type: "error", message };
|
|
664
|
+
this.render();
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
render() {
|
|
669
|
+
const styleTag = `<style>${styles}</style>`;
|
|
670
|
+
|
|
671
|
+
switch (this.state.type) {
|
|
672
|
+
case "loading":
|
|
673
|
+
this.shadow.innerHTML = `
|
|
674
|
+
${styleTag}
|
|
675
|
+
<div class="sequoia-comments-container">
|
|
676
|
+
<div class="sequoia-loading">
|
|
677
|
+
<span class="sequoia-loading-spinner"></span>
|
|
678
|
+
Loading comments...
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
`;
|
|
682
|
+
break;
|
|
683
|
+
|
|
684
|
+
case "no-document":
|
|
685
|
+
this.shadow.innerHTML = `
|
|
686
|
+
${styleTag}
|
|
687
|
+
<div class="sequoia-comments-container">
|
|
688
|
+
<div class="sequoia-warning">
|
|
689
|
+
No document found. Add a <code><link rel="site.standard.document" href="at://..."></code> tag to your page.
|
|
690
|
+
</div>
|
|
691
|
+
</div>
|
|
692
|
+
`;
|
|
693
|
+
break;
|
|
694
|
+
|
|
695
|
+
case "no-comments-enabled":
|
|
696
|
+
this.shadow.innerHTML = `
|
|
697
|
+
${styleTag}
|
|
698
|
+
<div class="sequoia-comments-container">
|
|
699
|
+
<div class="sequoia-empty">
|
|
700
|
+
Comments are not enabled for this post.
|
|
701
|
+
</div>
|
|
702
|
+
</div>
|
|
703
|
+
`;
|
|
704
|
+
break;
|
|
705
|
+
|
|
706
|
+
case "empty":
|
|
707
|
+
this.shadow.innerHTML = `
|
|
708
|
+
${styleTag}
|
|
709
|
+
<div class="sequoia-comments-container">
|
|
710
|
+
<div class="sequoia-comments-header">
|
|
711
|
+
<h3 class="sequoia-comments-title">Comments</h3>
|
|
712
|
+
<a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
|
|
713
|
+
${BLUESKY_ICON}
|
|
714
|
+
Reply on Bluesky
|
|
715
|
+
</a>
|
|
716
|
+
</div>
|
|
717
|
+
<div class="sequoia-empty">
|
|
718
|
+
No comments yet. Be the first to reply on Bluesky!
|
|
719
|
+
</div>
|
|
720
|
+
</div>
|
|
721
|
+
`;
|
|
722
|
+
break;
|
|
723
|
+
|
|
724
|
+
case "error":
|
|
725
|
+
this.shadow.innerHTML = `
|
|
726
|
+
${styleTag}
|
|
727
|
+
<div class="sequoia-comments-container">
|
|
728
|
+
<div class="sequoia-error">
|
|
729
|
+
Failed to load comments: ${escapeHtml(this.state.message)}
|
|
730
|
+
</div>
|
|
731
|
+
</div>
|
|
732
|
+
`;
|
|
733
|
+
break;
|
|
734
|
+
|
|
735
|
+
case "loaded": {
|
|
736
|
+
const replies =
|
|
737
|
+
this.state.thread.replies?.filter(isThreadViewPost) ?? [];
|
|
738
|
+
const threadsHtml = replies
|
|
739
|
+
.map((reply) => this.renderThread(reply))
|
|
740
|
+
.join("");
|
|
741
|
+
const commentCount = this.countComments(replies);
|
|
742
|
+
|
|
743
|
+
this.shadow.innerHTML = `
|
|
744
|
+
${styleTag}
|
|
745
|
+
<div class="sequoia-comments-container">
|
|
746
|
+
<div class="sequoia-comments-header">
|
|
747
|
+
<h3 class="sequoia-comments-title">${commentCount} Comment${commentCount !== 1 ? "s" : ""}</h3>
|
|
748
|
+
<a href="${this.state.postUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button">
|
|
749
|
+
${BLUESKY_ICON}
|
|
750
|
+
Reply on Bluesky
|
|
751
|
+
</a>
|
|
752
|
+
</div>
|
|
753
|
+
<div class="sequoia-comments-list">
|
|
754
|
+
${threadsHtml}
|
|
755
|
+
</div>
|
|
756
|
+
</div>
|
|
757
|
+
`;
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Flatten a thread into a linear list of comments
|
|
765
|
+
* @param {ThreadViewPost} thread - Thread to flatten
|
|
766
|
+
* @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments
|
|
767
|
+
*/
|
|
768
|
+
flattenThread(thread) {
|
|
769
|
+
const result = [];
|
|
770
|
+
const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? [];
|
|
771
|
+
|
|
772
|
+
result.push({
|
|
773
|
+
post: thread.post,
|
|
774
|
+
hasMoreReplies: nestedReplies.length > 0,
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
// Recursively flatten nested replies
|
|
778
|
+
for (const reply of nestedReplies) {
|
|
779
|
+
result.push(...this.flattenThread(reply));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
return result;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Render a complete thread (top-level comment + all nested replies)
|
|
787
|
+
*/
|
|
788
|
+
renderThread(thread) {
|
|
789
|
+
const flatComments = this.flattenThread(thread);
|
|
790
|
+
const commentsHtml = flatComments
|
|
791
|
+
.map((item, index) =>
|
|
792
|
+
this.renderComment(item.post, item.hasMoreReplies, index),
|
|
793
|
+
)
|
|
794
|
+
.join("");
|
|
795
|
+
|
|
796
|
+
return `<div class="sequoia-thread">${commentsHtml}</div>`;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Render a single comment
|
|
801
|
+
* @param {any} post - Post data
|
|
802
|
+
* @param {boolean} showThreadLine - Whether to show the connecting thread line
|
|
803
|
+
* @param {number} _index - Index in the flattened thread (0 = top-level)
|
|
804
|
+
*/
|
|
805
|
+
renderComment(post, showThreadLine = false, _index = 0) {
|
|
806
|
+
const author = post.author;
|
|
807
|
+
const displayName = author.displayName || author.handle;
|
|
808
|
+
const avatarHtml = author.avatar
|
|
809
|
+
? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />`
|
|
810
|
+
: `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`;
|
|
811
|
+
|
|
812
|
+
const profileUrl = `https://bsky.app/profile/${author.did}`;
|
|
813
|
+
const textHtml = renderTextWithFacets(post.record.text, post.record.facets);
|
|
814
|
+
const timeAgo = formatRelativeTime(post.record.createdAt);
|
|
815
|
+
const threadLineHtml = showThreadLine
|
|
816
|
+
? '<div class="sequoia-thread-line"></div>'
|
|
817
|
+
: "";
|
|
818
|
+
|
|
819
|
+
return `
|
|
820
|
+
<div class="sequoia-comment">
|
|
821
|
+
<div class="sequoia-comment-avatar-column">
|
|
822
|
+
${avatarHtml}
|
|
823
|
+
${threadLineHtml}
|
|
824
|
+
</div>
|
|
825
|
+
<div class="sequoia-comment-content">
|
|
826
|
+
<div class="sequoia-comment-header">
|
|
827
|
+
<a href="${profileUrl}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-author">
|
|
828
|
+
${escapeHtml(displayName)}
|
|
829
|
+
</a>
|
|
830
|
+
<span class="sequoia-comment-handle">@${escapeHtml(author.handle)}</span>
|
|
831
|
+
<span class="sequoia-comment-time">${timeAgo}</span>
|
|
832
|
+
</div>
|
|
833
|
+
<p class="sequoia-comment-text">${textHtml}</p>
|
|
834
|
+
</div>
|
|
835
|
+
</div>
|
|
836
|
+
`;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
countComments(replies) {
|
|
840
|
+
let count = 0;
|
|
841
|
+
for (const reply of replies) {
|
|
842
|
+
count += 1;
|
|
843
|
+
const nested = reply.replies?.filter(isThreadViewPost) ?? [];
|
|
844
|
+
count += this.countComments(nested);
|
|
845
|
+
}
|
|
846
|
+
return count;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Register the custom element
|
|
851
|
+
if (typeof customElements !== "undefined") {
|
|
852
|
+
customElements.define("sequoia-comments", SequoiaComments);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Export for module usage
|
|
856
|
+
export { SequoiaComments };
|