| 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 }; |