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