Only listen for changes after init
41f1f90e
Was loading the script + component in an IntersectionObserver and it triggered twice.
1 file(s) · +433 −429
Was loading the script + component in an IntersectionObserver and it triggered twice.
| 326 | 326 | * @returns {string} Formatted relative time |
|
| 327 | 327 | */ |
|
| 328 | 328 | function formatRelativeTime(dateString) { |
|
| 329 | - | const date = new Date(dateString); |
|
| 330 | - | const now = new Date(); |
|
| 331 | - | const diffMs = now.getTime() - date.getTime(); |
|
| 332 | - | const diffSeconds = Math.floor(diffMs / 1000); |
|
| 333 | - | const diffMinutes = Math.floor(diffSeconds / 60); |
|
| 334 | - | const diffHours = Math.floor(diffMinutes / 60); |
|
| 335 | - | const diffDays = Math.floor(diffHours / 24); |
|
| 336 | - | const diffWeeks = Math.floor(diffDays / 7); |
|
| 337 | - | const diffMonths = Math.floor(diffDays / 30); |
|
| 338 | - | const diffYears = Math.floor(diffDays / 365); |
|
| 329 | + | const date = new Date(dateString); |
|
| 330 | + | const now = new Date(); |
|
| 331 | + | const diffMs = now.getTime() - date.getTime(); |
|
| 332 | + | const diffSeconds = Math.floor(diffMs / 1000); |
|
| 333 | + | const diffMinutes = Math.floor(diffSeconds / 60); |
|
| 334 | + | const diffHours = Math.floor(diffMinutes / 60); |
|
| 335 | + | const diffDays = Math.floor(diffHours / 24); |
|
| 336 | + | const diffWeeks = Math.floor(diffDays / 7); |
|
| 337 | + | const diffMonths = Math.floor(diffDays / 30); |
|
| 338 | + | const diffYears = Math.floor(diffDays / 365); |
|
| 339 | 339 | ||
| 340 | - | if (diffSeconds < 60) { |
|
| 341 | - | return "just now"; |
|
| 342 | - | } |
|
| 343 | - | if (diffMinutes < 60) { |
|
| 344 | - | return `${diffMinutes}m ago`; |
|
| 345 | - | } |
|
| 346 | - | if (diffHours < 24) { |
|
| 347 | - | return `${diffHours}h ago`; |
|
| 348 | - | } |
|
| 349 | - | if (diffDays < 7) { |
|
| 350 | - | return `${diffDays}d ago`; |
|
| 351 | - | } |
|
| 352 | - | if (diffWeeks < 4) { |
|
| 353 | - | return `${diffWeeks}w ago`; |
|
| 354 | - | } |
|
| 355 | - | if (diffMonths < 12) { |
|
| 356 | - | return `${diffMonths}mo ago`; |
|
| 357 | - | } |
|
| 358 | - | return `${diffYears}y ago`; |
|
| 340 | + | if (diffSeconds < 60) { |
|
| 341 | + | return "just now"; |
|
| 342 | + | } |
|
| 343 | + | if (diffMinutes < 60) { |
|
| 344 | + | return `${diffMinutes}m ago`; |
|
| 345 | + | } |
|
| 346 | + | if (diffHours < 24) { |
|
| 347 | + | return `${diffHours}h ago`; |
|
| 348 | + | } |
|
| 349 | + | if (diffDays < 7) { |
|
| 350 | + | return `${diffDays}d ago`; |
|
| 351 | + | } |
|
| 352 | + | if (diffWeeks < 4) { |
|
| 353 | + | return `${diffWeeks}w ago`; |
|
| 354 | + | } |
|
| 355 | + | if (diffMonths < 12) { |
|
| 356 | + | return `${diffMonths}mo ago`; |
|
| 357 | + | } |
|
| 358 | + | return `${diffYears}y ago`; |
|
| 359 | 359 | } |
|
| 360 | 360 | ||
| 361 | 361 | /** |
|
| 364 | 364 | * @returns {string} Escaped HTML |
|
| 365 | 365 | */ |
|
| 366 | 366 | function escapeHtml(text) { |
|
| 367 | - | const div = document.createElement("div"); |
|
| 368 | - | div.textContent = text; |
|
| 369 | - | return div.innerHTML; |
|
| 367 | + | const div = document.createElement("div"); |
|
| 368 | + | div.textContent = text; |
|
| 369 | + | return div.innerHTML; |
|
| 370 | 370 | } |
|
| 371 | 371 | ||
| 372 | 372 | /** |
|
| 376 | 376 | * @returns {string} HTML string with links |
|
| 377 | 377 | */ |
|
| 378 | 378 | function renderTextWithFacets(text, facets) { |
|
| 379 | - | if (!facets || facets.length === 0) { |
|
| 380 | - | return escapeHtml(text); |
|
| 381 | - | } |
|
| 379 | + | if (!facets || facets.length === 0) { |
|
| 380 | + | return escapeHtml(text); |
|
| 381 | + | } |
|
| 382 | 382 | ||
| 383 | - | // Convert text to bytes for proper indexing |
|
| 384 | - | const encoder = new TextEncoder(); |
|
| 385 | - | const decoder = new TextDecoder(); |
|
| 386 | - | const textBytes = encoder.encode(text); |
|
| 383 | + | // Convert text to bytes for proper indexing |
|
| 384 | + | const encoder = new TextEncoder(); |
|
| 385 | + | const decoder = new TextDecoder(); |
|
| 386 | + | const textBytes = encoder.encode(text); |
|
| 387 | 387 | ||
| 388 | - | // Sort facets by start index |
|
| 389 | - | const sortedFacets = [...facets].sort( |
|
| 390 | - | (a, b) => a.index.byteStart - b.index.byteStart, |
|
| 391 | - | ); |
|
| 388 | + | // Sort facets by start index |
|
| 389 | + | const sortedFacets = [...facets].sort( |
|
| 390 | + | (a, b) => a.index.byteStart - b.index.byteStart, |
|
| 391 | + | ); |
|
| 392 | 392 | ||
| 393 | - | let result = ""; |
|
| 394 | - | let lastEnd = 0; |
|
| 393 | + | let result = ""; |
|
| 394 | + | let lastEnd = 0; |
|
| 395 | 395 | ||
| 396 | - | for (const facet of sortedFacets) { |
|
| 397 | - | const { byteStart, byteEnd } = facet.index; |
|
| 396 | + | for (const facet of sortedFacets) { |
|
| 397 | + | const { byteStart, byteEnd } = facet.index; |
|
| 398 | 398 | ||
| 399 | - | // Add text before this facet |
|
| 400 | - | if (byteStart > lastEnd) { |
|
| 401 | - | const beforeBytes = textBytes.slice(lastEnd, byteStart); |
|
| 402 | - | result += escapeHtml(decoder.decode(beforeBytes)); |
|
| 403 | - | } |
|
| 399 | + | // Add text before this facet |
|
| 400 | + | if (byteStart > lastEnd) { |
|
| 401 | + | const beforeBytes = textBytes.slice(lastEnd, byteStart); |
|
| 402 | + | result += escapeHtml(decoder.decode(beforeBytes)); |
|
| 403 | + | } |
|
| 404 | 404 | ||
| 405 | - | // Get the facet text |
|
| 406 | - | const facetBytes = textBytes.slice(byteStart, byteEnd); |
|
| 407 | - | const facetText = decoder.decode(facetBytes); |
|
| 405 | + | // Get the facet text |
|
| 406 | + | const facetBytes = textBytes.slice(byteStart, byteEnd); |
|
| 407 | + | const facetText = decoder.decode(facetBytes); |
|
| 408 | 408 | ||
| 409 | - | // Find the first renderable feature |
|
| 410 | - | const feature = facet.features[0]; |
|
| 411 | - | if (feature) { |
|
| 412 | - | if (feature.$type === "app.bsky.richtext.facet#link") { |
|
| 413 | - | result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; |
|
| 414 | - | } else if (feature.$type === "app.bsky.richtext.facet#mention") { |
|
| 415 | - | result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; |
|
| 416 | - | } else if (feature.$type === "app.bsky.richtext.facet#tag") { |
|
| 417 | - | result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; |
|
| 418 | - | } else { |
|
| 419 | - | result += escapeHtml(facetText); |
|
| 420 | - | } |
|
| 421 | - | } else { |
|
| 422 | - | result += escapeHtml(facetText); |
|
| 423 | - | } |
|
| 409 | + | // Find the first renderable feature |
|
| 410 | + | const feature = facet.features[0]; |
|
| 411 | + | if (feature) { |
|
| 412 | + | if (feature.$type === "app.bsky.richtext.facet#link") { |
|
| 413 | + | result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; |
|
| 414 | + | } else if (feature.$type === "app.bsky.richtext.facet#mention") { |
|
| 415 | + | result += `<a href="https://bsky.app/profile/${escapeHtml(feature.did)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; |
|
| 416 | + | } else if (feature.$type === "app.bsky.richtext.facet#tag") { |
|
| 417 | + | result += `<a href="https://bsky.app/hashtag/${escapeHtml(feature.tag)}" target="_blank" rel="noopener noreferrer">${escapeHtml(facetText)}</a>`; |
|
| 418 | + | } else { |
|
| 419 | + | result += escapeHtml(facetText); |
|
| 420 | + | } |
|
| 421 | + | } else { |
|
| 422 | + | result += escapeHtml(facetText); |
|
| 423 | + | } |
|
| 424 | 424 | ||
| 425 | - | lastEnd = byteEnd; |
|
| 426 | - | } |
|
| 425 | + | lastEnd = byteEnd; |
|
| 426 | + | } |
|
| 427 | 427 | ||
| 428 | - | // Add remaining text |
|
| 429 | - | if (lastEnd < textBytes.length) { |
|
| 430 | - | const remainingBytes = textBytes.slice(lastEnd); |
|
| 431 | - | result += escapeHtml(decoder.decode(remainingBytes)); |
|
| 432 | - | } |
|
| 428 | + | // Add remaining text |
|
| 429 | + | if (lastEnd < textBytes.length) { |
|
| 430 | + | const remainingBytes = textBytes.slice(lastEnd); |
|
| 431 | + | result += escapeHtml(decoder.decode(remainingBytes)); |
|
| 432 | + | } |
|
| 433 | 433 | ||
| 434 | - | return result; |
|
| 434 | + | return result; |
|
| 435 | 435 | } |
|
| 436 | 436 | ||
| 437 | 437 | /** |
|
| 440 | 440 | * @returns {string} Initials (1-2 characters) |
|
| 441 | 441 | */ |
|
| 442 | 442 | function getInitials(name) { |
|
| 443 | - | const parts = name.trim().split(/\s+/); |
|
| 444 | - | if (parts.length >= 2) { |
|
| 445 | - | return (parts[0][0] + parts[1][0]).toUpperCase(); |
|
| 446 | - | } |
|
| 447 | - | return name.substring(0, 2).toUpperCase(); |
|
| 443 | + | const parts = name.trim().split(/\s+/); |
|
| 444 | + | if (parts.length >= 2) { |
|
| 445 | + | return (parts[0][0] + parts[1][0]).toUpperCase(); |
|
| 446 | + | } |
|
| 447 | + | return name.substring(0, 2).toUpperCase(); |
|
| 448 | 448 | } |
|
| 449 | 449 | ||
| 450 | 450 | // ============================================================================ |
|
| 458 | 458 | * @returns {{did: string, collection: string, rkey: string} | null} Parsed components or null |
|
| 459 | 459 | */ |
|
| 460 | 460 | function parseAtUri(atUri) { |
|
| 461 | - | const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); |
|
| 462 | - | if (!match) return null; |
|
| 463 | - | return { |
|
| 464 | - | did: match[1], |
|
| 465 | - | collection: match[2], |
|
| 466 | - | rkey: match[3], |
|
| 467 | - | }; |
|
| 461 | + | const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); |
|
| 462 | + | if (!match) return null; |
|
| 463 | + | return { |
|
| 464 | + | did: match[1], |
|
| 465 | + | collection: match[2], |
|
| 466 | + | rkey: match[3], |
|
| 467 | + | }; |
|
| 468 | 468 | } |
|
| 469 | 469 | ||
| 470 | 470 | /** |
|
| 474 | 474 | * @returns {Promise<string>} PDS URL |
|
| 475 | 475 | */ |
|
| 476 | 476 | async function resolvePDS(did) { |
|
| 477 | - | let pdsUrl; |
|
| 477 | + | let pdsUrl; |
|
| 478 | 478 | ||
| 479 | - | if (did.startsWith("did:plc:")) { |
|
| 480 | - | // Fetch DID document from plc.directory |
|
| 481 | - | const didDocUrl = `https://plc.directory/${did}`; |
|
| 482 | - | const didDocResponse = await fetch(didDocUrl); |
|
| 483 | - | if (!didDocResponse.ok) { |
|
| 484 | - | throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); |
|
| 485 | - | } |
|
| 486 | - | const didDoc = await didDocResponse.json(); |
|
| 479 | + | if (did.startsWith("did:plc:")) { |
|
| 480 | + | // Fetch DID document from plc.directory |
|
| 481 | + | const didDocUrl = `https://plc.directory/${did}`; |
|
| 482 | + | const didDocResponse = await fetch(didDocUrl); |
|
| 483 | + | if (!didDocResponse.ok) { |
|
| 484 | + | throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); |
|
| 485 | + | } |
|
| 486 | + | const didDoc = await didDocResponse.json(); |
|
| 487 | 487 | ||
| 488 | - | // Find the PDS service endpoint |
|
| 489 | - | const pdsService = didDoc.service?.find( |
|
| 490 | - | (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", |
|
| 491 | - | ); |
|
| 492 | - | pdsUrl = pdsService?.serviceEndpoint; |
|
| 493 | - | } else if (did.startsWith("did:web:")) { |
|
| 494 | - | // For did:web, fetch the DID document from the domain |
|
| 495 | - | const domain = did.replace("did:web:", ""); |
|
| 496 | - | const didDocUrl = `https://${domain}/.well-known/did.json`; |
|
| 497 | - | const didDocResponse = await fetch(didDocUrl); |
|
| 498 | - | if (!didDocResponse.ok) { |
|
| 499 | - | throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); |
|
| 500 | - | } |
|
| 501 | - | const didDoc = await didDocResponse.json(); |
|
| 488 | + | // Find the PDS service endpoint |
|
| 489 | + | const pdsService = didDoc.service?.find( |
|
| 490 | + | (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", |
|
| 491 | + | ); |
|
| 492 | + | pdsUrl = pdsService?.serviceEndpoint; |
|
| 493 | + | } else if (did.startsWith("did:web:")) { |
|
| 494 | + | // For did:web, fetch the DID document from the domain |
|
| 495 | + | const domain = did.replace("did:web:", ""); |
|
| 496 | + | const didDocUrl = `https://${domain}/.well-known/did.json`; |
|
| 497 | + | const didDocResponse = await fetch(didDocUrl); |
|
| 498 | + | if (!didDocResponse.ok) { |
|
| 499 | + | throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); |
|
| 500 | + | } |
|
| 501 | + | const didDoc = await didDocResponse.json(); |
|
| 502 | 502 | ||
| 503 | - | const pdsService = didDoc.service?.find( |
|
| 504 | - | (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", |
|
| 505 | - | ); |
|
| 506 | - | pdsUrl = pdsService?.serviceEndpoint; |
|
| 507 | - | } else { |
|
| 508 | - | throw new Error(`Unsupported DID method: ${did}`); |
|
| 509 | - | } |
|
| 503 | + | const pdsService = didDoc.service?.find( |
|
| 504 | + | (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", |
|
| 505 | + | ); |
|
| 506 | + | pdsUrl = pdsService?.serviceEndpoint; |
|
| 507 | + | } else { |
|
| 508 | + | throw new Error(`Unsupported DID method: ${did}`); |
|
| 509 | + | } |
|
| 510 | 510 | ||
| 511 | - | if (!pdsUrl) { |
|
| 512 | - | throw new Error("Could not find PDS URL for user"); |
|
| 513 | - | } |
|
| 511 | + | if (!pdsUrl) { |
|
| 512 | + | throw new Error("Could not find PDS URL for user"); |
|
| 513 | + | } |
|
| 514 | 514 | ||
| 515 | - | return pdsUrl; |
|
| 515 | + | return pdsUrl; |
|
| 516 | 516 | } |
|
| 517 | 517 | ||
| 518 | 518 | /** |
|
| 523 | 523 | * @returns {Promise<any>} Record value |
|
| 524 | 524 | */ |
|
| 525 | 525 | async function getRecord(did, collection, rkey) { |
|
| 526 | - | const pdsUrl = await resolvePDS(did); |
|
| 526 | + | const pdsUrl = await resolvePDS(did); |
|
| 527 | 527 | ||
| 528 | - | const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`); |
|
| 529 | - | url.searchParams.set("repo", did); |
|
| 530 | - | url.searchParams.set("collection", collection); |
|
| 531 | - | url.searchParams.set("rkey", rkey); |
|
| 528 | + | const url = new URL(`${pdsUrl}/xrpc/com.atproto.repo.getRecord`); |
|
| 529 | + | url.searchParams.set("repo", did); |
|
| 530 | + | url.searchParams.set("collection", collection); |
|
| 531 | + | url.searchParams.set("rkey", rkey); |
|
| 532 | 532 | ||
| 533 | - | const response = await fetch(url.toString()); |
|
| 534 | - | if (!response.ok) { |
|
| 535 | - | throw new Error(`Failed to fetch record: ${response.status}`); |
|
| 536 | - | } |
|
| 533 | + | const response = await fetch(url.toString()); |
|
| 534 | + | if (!response.ok) { |
|
| 535 | + | throw new Error(`Failed to fetch record: ${response.status}`); |
|
| 536 | + | } |
|
| 537 | 537 | ||
| 538 | - | const data = await response.json(); |
|
| 539 | - | return data.value; |
|
| 538 | + | const data = await response.json(); |
|
| 539 | + | return data.value; |
|
| 540 | 540 | } |
|
| 541 | 541 | ||
| 542 | 542 | /** |
|
| 545 | 545 | * @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 |
|
| 546 | 546 | */ |
|
| 547 | 547 | async function getDocument(atUri) { |
|
| 548 | - | const parsed = parseAtUri(atUri); |
|
| 549 | - | if (!parsed) { |
|
| 550 | - | throw new Error(`Invalid AT URI: ${atUri}`); |
|
| 551 | - | } |
|
| 548 | + | const parsed = parseAtUri(atUri); |
|
| 549 | + | if (!parsed) { |
|
| 550 | + | throw new Error(`Invalid AT URI: ${atUri}`); |
|
| 551 | + | } |
|
| 552 | 552 | ||
| 553 | - | return getRecord(parsed.did, parsed.collection, parsed.rkey); |
|
| 553 | + | return getRecord(parsed.did, parsed.collection, parsed.rkey); |
|
| 554 | 554 | } |
|
| 555 | 555 | ||
| 556 | 556 | /** |
|
| 560 | 560 | * @returns {Promise<ThreadViewPost>} Thread view post |
|
| 561 | 561 | */ |
|
| 562 | 562 | async function getPostThread(postUri, depth = 6) { |
|
| 563 | - | const url = new URL( |
|
| 564 | - | "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread", |
|
| 565 | - | ); |
|
| 566 | - | url.searchParams.set("uri", postUri); |
|
| 567 | - | url.searchParams.set("depth", depth.toString()); |
|
| 563 | + | const url = new URL( |
|
| 564 | + | "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread", |
|
| 565 | + | ); |
|
| 566 | + | url.searchParams.set("uri", postUri); |
|
| 567 | + | url.searchParams.set("depth", depth.toString()); |
|
| 568 | 568 | ||
| 569 | - | const response = await fetch(url.toString()); |
|
| 570 | - | if (!response.ok) { |
|
| 571 | - | throw new Error(`Failed to fetch post thread: ${response.status}`); |
|
| 572 | - | } |
|
| 569 | + | const response = await fetch(url.toString()); |
|
| 570 | + | if (!response.ok) { |
|
| 571 | + | throw new Error(`Failed to fetch post thread: ${response.status}`); |
|
| 572 | + | } |
|
| 573 | 573 | ||
| 574 | - | const data = await response.json(); |
|
| 574 | + | const data = await response.json(); |
|
| 575 | 575 | ||
| 576 | - | if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { |
|
| 577 | - | throw new Error("Post not found or blocked"); |
|
| 578 | - | } |
|
| 576 | + | if (data.thread.$type !== "app.bsky.feed.defs#threadViewPost") { |
|
| 577 | + | throw new Error("Post not found or blocked"); |
|
| 578 | + | } |
|
| 579 | 579 | ||
| 580 | - | return data.thread; |
|
| 580 | + | return data.thread; |
|
| 581 | 581 | } |
|
| 582 | 582 | ||
| 583 | 583 | /** |
|
| 586 | 586 | * @returns {string} Bluesky app URL |
|
| 587 | 587 | */ |
|
| 588 | 588 | function buildBskyAppUrl(postUri) { |
|
| 589 | - | const parsed = parseAtUri(postUri); |
|
| 590 | - | if (!parsed) { |
|
| 591 | - | throw new Error(`Invalid post URI: ${postUri}`); |
|
| 592 | - | } |
|
| 589 | + | const parsed = parseAtUri(postUri); |
|
| 590 | + | if (!parsed) { |
|
| 591 | + | throw new Error(`Invalid post URI: ${postUri}`); |
|
| 592 | + | } |
|
| 593 | 593 | ||
| 594 | - | return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; |
|
| 594 | + | return `https://bsky.app/profile/${parsed.did}/post/${parsed.rkey}`; |
|
| 595 | 595 | } |
|
| 596 | 596 | ||
| 597 | 597 | /** |
|
| 600 | 600 | * @returns {string} Blacksky app URL |
|
| 601 | 601 | */ |
|
| 602 | 602 | function buildBlackskyAppUrl(postUri) { |
|
| 603 | - | const parsed = parseAtUri(postUri); |
|
| 604 | - | if (!parsed) { |
|
| 605 | - | throw new Error(`Invalid post URI: ${postUri}`); |
|
| 606 | - | } |
|
| 603 | + | const parsed = parseAtUri(postUri); |
|
| 604 | + | if (!parsed) { |
|
| 605 | + | throw new Error(`Invalid post URI: ${postUri}`); |
|
| 606 | + | } |
|
| 607 | 607 | ||
| 608 | - | return `https://blacksky.community/profile/${parsed.did}/post/${parsed.rkey}`; |
|
| 608 | + | return `https://blacksky.community/profile/${parsed.did}/post/${parsed.rkey}`; |
|
| 609 | 609 | } |
|
| 610 | 610 | ||
| 611 | 611 | /** |
|
| 614 | 614 | * @returns {boolean} True if post is a ThreadViewPost |
|
| 615 | 615 | */ |
|
| 616 | 616 | function isThreadViewPost(post) { |
|
| 617 | - | return post?.$type === "app.bsky.feed.defs#threadViewPost"; |
|
| 617 | + | return post?.$type === "app.bsky.feed.defs#threadViewPost"; |
|
| 618 | 618 | } |
|
| 619 | 619 | ||
| 620 | 620 | /** |
|
| 634 | 634 | * @returns {Promise<string>} AT-URI |
|
| 635 | 635 | */ |
|
| 636 | 636 | async function resolvePostUri(uriOrUrl) { |
|
| 637 | - | if (uriOrUrl.startsWith("at://")) return uriOrUrl; |
|
| 637 | + | if (uriOrUrl.startsWith("at://")) return uriOrUrl; |
|
| 638 | 638 | ||
| 639 | - | const match = uriOrUrl.match( |
|
| 640 | - | /bsky\.app\/profile\/([^/?#]+)\/post\/([^/?#]+)/, |
|
| 641 | - | ); |
|
| 642 | - | if (!match) throw new Error(`Cannot parse Bluesky URL: ${uriOrUrl}`); |
|
| 639 | + | const match = uriOrUrl.match( |
|
| 640 | + | /bsky\.app\/profile\/([^/?#]+)\/post\/([^/?#]+)/, |
|
| 641 | + | ); |
|
| 642 | + | if (!match) throw new Error(`Cannot parse Bluesky URL: ${uriOrUrl}`); |
|
| 643 | 643 | ||
| 644 | - | const [, handleOrDid, rkey] = match; |
|
| 644 | + | const [, handleOrDid, rkey] = match; |
|
| 645 | 645 | ||
| 646 | - | let did = handleOrDid; |
|
| 647 | - | if (!handleOrDid.startsWith("did:")) { |
|
| 648 | - | const url = new URL( |
|
| 649 | - | "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle", |
|
| 650 | - | ); |
|
| 651 | - | url.searchParams.set("handle", handleOrDid); |
|
| 652 | - | const response = await fetch(url.toString()); |
|
| 653 | - | if (!response.ok) |
|
| 654 | - | throw new Error(`Failed to resolve handle: ${response.status}`); |
|
| 655 | - | did = (await response.json()).did; |
|
| 656 | - | } |
|
| 646 | + | let did = handleOrDid; |
|
| 647 | + | if (!handleOrDid.startsWith("did:")) { |
|
| 648 | + | const url = new URL( |
|
| 649 | + | "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle", |
|
| 650 | + | ); |
|
| 651 | + | url.searchParams.set("handle", handleOrDid); |
|
| 652 | + | const response = await fetch(url.toString()); |
|
| 653 | + | if (!response.ok) |
|
| 654 | + | throw new Error(`Failed to resolve handle: ${response.status}`); |
|
| 655 | + | did = (await response.json()).did; |
|
| 656 | + | } |
|
| 657 | 657 | ||
| 658 | - | return `at://${did}/app.bsky.feed.post/${rkey}`; |
|
| 658 | + | return `at://${did}/app.bsky.feed.post/${rkey}`; |
|
| 659 | 659 | } |
|
| 660 | 660 | ||
| 661 | 661 | async function getQuotes(postUri) { |
|
| 662 | - | const quotes = []; |
|
| 663 | - | let cursor; |
|
| 662 | + | const quotes = []; |
|
| 663 | + | let cursor; |
|
| 664 | 664 | ||
| 665 | - | do { |
|
| 666 | - | const url = new URL( |
|
| 667 | - | "https://public.api.bsky.app/xrpc/app.bsky.feed.getQuotes", |
|
| 668 | - | ); |
|
| 669 | - | url.searchParams.set("uri", postUri); |
|
| 670 | - | url.searchParams.set("limit", "100"); |
|
| 671 | - | if (cursor) url.searchParams.set("cursor", cursor); |
|
| 665 | + | do { |
|
| 666 | + | const url = new URL( |
|
| 667 | + | "https://public.api.bsky.app/xrpc/app.bsky.feed.getQuotes", |
|
| 668 | + | ); |
|
| 669 | + | url.searchParams.set("uri", postUri); |
|
| 670 | + | url.searchParams.set("limit", "100"); |
|
| 671 | + | if (cursor) url.searchParams.set("cursor", cursor); |
|
| 672 | 672 | ||
| 673 | - | const response = await fetch(url.toString()); |
|
| 674 | - | if (!response.ok) { |
|
| 675 | - | throw new Error(`Failed to fetch quotes: ${response.status}`); |
|
| 676 | - | } |
|
| 673 | + | const response = await fetch(url.toString()); |
|
| 674 | + | if (!response.ok) { |
|
| 675 | + | throw new Error(`Failed to fetch quotes: ${response.status}`); |
|
| 676 | + | } |
|
| 677 | 677 | ||
| 678 | - | const data = await response.json(); |
|
| 679 | - | quotes.push(...(data.posts ?? [])); |
|
| 680 | - | cursor = data.cursor; |
|
| 681 | - | } while (cursor); |
|
| 678 | + | const data = await response.json(); |
|
| 679 | + | quotes.push(...(data.posts ?? [])); |
|
| 680 | + | cursor = data.cursor; |
|
| 681 | + | } while (cursor); |
|
| 682 | 682 | ||
| 683 | - | return quotes; |
|
| 683 | + | return quotes; |
|
| 684 | 684 | } |
|
| 685 | 685 | ||
| 686 | 686 | // ============================================================================ |
|
| 691 | 691 | <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"/> |
|
| 692 | 692 | </svg>`; |
|
| 693 | 693 | const BLACKSKY_ICON = |
|
| 694 | - | '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.0620117 0.348442 87.9941 74.9653"><path d="M41.9565 74.9643L24.0161 74.9653L41.9565 74.9643ZM63.8511 74.9653H45.9097L63.8501 74.9643V57.3286H63.8511V74.9653ZM45.9097 44.5893C45.9099 49.2737 49.7077 53.0707 54.3921 53.0707H63.8501V57.3286H54.3921C49.7077 57.3286 45.9099 61.1257 45.9097 65.81V74.9643H41.9565V65.81C41.9563 61.1258 38.1593 57.3287 33.4751 57.3286H24.0161V53.0707H33.4741C38.1587 53.0707 41.9565 49.2729 41.9565 44.5883V35.1303H45.9097V44.5893ZM63.8511 53.0707H63.8501V35.1303H63.8511V53.0707Z" fill="white"></path><path d="M52.7272 9.83198C49.4148 13.1445 49.4148 18.5151 52.7272 21.8275L59.4155 28.5158L56.4051 31.5262L49.7169 24.8379C46.4044 21.5254 41.0338 21.5254 37.7213 24.8379L31.2482 31.3111L28.4527 28.5156L34.9259 22.0424C38.2383 18.7299 38.2383 13.3594 34.9259 10.0469L28.2378 3.35883L31.2482 0.348442L37.9365 7.03672C41.2489 10.3492 46.6195 10.3492 49.932 7.03672L56.6203 0.348442L59.4155 3.14371L52.7272 9.83198Z" fill="white"/><path d="M24.3831 23.2335C23.1706 27.7584 25.8559 32.4095 30.3808 33.6219L39.5172 36.07L38.4154 40.182L29.2793 37.734C24.7544 36.5215 20.1033 39.2068 18.8909 43.7317L16.5215 52.5745L12.7028 51.5513L15.0721 42.7088C16.2846 38.1839 13.5993 33.5328 9.07434 32.3204L-0.0620117 29.8723L1.03987 25.76L10.1762 28.2081C14.7011 29.4206 19.3522 26.7352 20.5647 22.2103L23.0127 13.074L26.8311 14.0971L24.3831 23.2335Z" fill="white"/><path d="M67.3676 22.0297C68.5801 26.5546 73.2311 29.2399 77.756 28.0275L86.8923 25.5794L87.9941 29.6914L78.8578 32.1394C74.3329 33.3519 71.6476 38.003 72.86 42.5279L75.2294 51.3707L71.411 52.3938L69.0417 43.5513C67.8293 39.0264 63.1782 36.3411 58.6533 37.5535L49.5169 40.0016L48.415 35.8894L57.5514 33.4413C62.0763 32.2288 64.7616 27.5778 63.5492 23.0528L61.1011 13.9165L64.9195 12.8934L67.3676 22.0297Z" fill="white"/></svg>'; |
|
| 694 | + | '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.0620117 0.348442 87.9941 74.9653"><path d="M41.9565 74.9643L24.0161 74.9653L41.9565 74.9643ZM63.8511 74.9653H45.9097L63.8501 74.9643V57.3286H63.8511V74.9653ZM45.9097 44.5893C45.9099 49.2737 49.7077 53.0707 54.3921 53.0707H63.8501V57.3286H54.3921C49.7077 57.3286 45.9099 61.1257 45.9097 65.81V74.9643H41.9565V65.81C41.9563 61.1258 38.1593 57.3287 33.4751 57.3286H24.0161V53.0707H33.4741C38.1587 53.0707 41.9565 49.2729 41.9565 44.5883V35.1303H45.9097V44.5893ZM63.8511 53.0707H63.8501V35.1303H63.8511V53.0707Z" fill="white"></path><path d="M52.7272 9.83198C49.4148 13.1445 49.4148 18.5151 52.7272 21.8275L59.4155 28.5158L56.4051 31.5262L49.7169 24.8379C46.4044 21.5254 41.0338 21.5254 37.7213 24.8379L31.2482 31.3111L28.4527 28.5156L34.9259 22.0424C38.2383 18.7299 38.2383 13.3594 34.9259 10.0469L28.2378 3.35883L31.2482 0.348442L37.9365 7.03672C41.2489 10.3492 46.6195 10.3492 49.932 7.03672L56.6203 0.348442L59.4155 3.14371L52.7272 9.83198Z" fill="white"/><path d="M24.3831 23.2335C23.1706 27.7584 25.8559 32.4095 30.3808 33.6219L39.5172 36.07L38.4154 40.182L29.2793 37.734C24.7544 36.5215 20.1033 39.2068 18.8909 43.7317L16.5215 52.5745L12.7028 51.5513L15.0721 42.7088C16.2846 38.1839 13.5993 33.5328 9.07434 32.3204L-0.0620117 29.8723L1.03987 25.76L10.1762 28.2081C14.7011 29.4206 19.3522 26.7352 20.5647 22.2103L23.0127 13.074L26.8311 14.0971L24.3831 23.2335Z" fill="white"/><path d="M67.3676 22.0297C68.5801 26.5546 73.2311 29.2399 77.756 28.0275L86.8923 25.5794L87.9941 29.6914L78.8578 32.1394C74.3329 33.3519 71.6476 38.003 72.86 42.5279L75.2294 51.3707L71.411 52.3938L69.0417 43.5513C67.8293 39.0264 63.1782 36.3411 58.6533 37.5535L49.5169 40.0016L48.415 35.8894L57.5514 33.4413C62.0763 32.2288 64.7616 27.5778 63.5492 23.0528L61.1011 13.9165L64.9195 12.8934L67.3676 22.0297Z" fill="white"/></svg>'; |
|
| 695 | 695 | ||
| 696 | 696 | // ============================================================================ |
|
| 697 | 697 | // Web Component |
|
| 701 | 701 | const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; |
|
| 702 | 702 | ||
| 703 | 703 | class SequoiaComments extends BaseElement { |
|
| 704 | - | constructor() { |
|
| 705 | - | super(); |
|
| 706 | - | const shadow = this.attachShadow({ mode: "open" }); |
|
| 704 | + | constructor() { |
|
| 705 | + | super(); |
|
| 706 | + | const shadow = this.attachShadow({ mode: "open" }); |
|
| 707 | 707 | ||
| 708 | - | const styleTag = document.createElement("style"); |
|
| 709 | - | shadow.appendChild(styleTag); |
|
| 710 | - | styleTag.innerText = styles; |
|
| 708 | + | const styleTag = document.createElement("style"); |
|
| 709 | + | shadow.appendChild(styleTag); |
|
| 710 | + | styleTag.innerText = styles; |
|
| 711 | 711 | ||
| 712 | - | const container = document.createElement("div"); |
|
| 713 | - | shadow.appendChild(container); |
|
| 714 | - | container.className = "sequoia-comments-container"; |
|
| 715 | - | container.part = "container"; |
|
| 712 | + | const container = document.createElement("div"); |
|
| 713 | + | shadow.appendChild(container); |
|
| 714 | + | container.className = "sequoia-comments-container"; |
|
| 715 | + | container.part = "container"; |
|
| 716 | 716 | ||
| 717 | - | this.commentsContainer = container; |
|
| 718 | - | this.state = { type: "loading" }; |
|
| 719 | - | this.abortController = null; |
|
| 720 | - | } |
|
| 717 | + | this.commentsContainer = container; |
|
| 718 | + | this.state = { type: "loading" }; |
|
| 719 | + | this.abortController = null; |
|
| 720 | + | } |
|
| 721 | 721 | ||
| 722 | - | static get observedAttributes() { |
|
| 723 | - | return ["post-uri", "document-uri", "depth", "hide"]; |
|
| 724 | - | } |
|
| 722 | + | static get observedAttributes() { |
|
| 723 | + | return ["post-uri", "document-uri", "depth", "hide"]; |
|
| 724 | + | } |
|
| 725 | 725 | ||
| 726 | - | connectedCallback() { |
|
| 727 | - | this.render(); |
|
| 728 | - | this.loadComments(); |
|
| 729 | - | } |
|
| 726 | + | connectedCallback() { |
|
| 727 | + | this.initialized = true; |
|
| 728 | + | this.render(); |
|
| 729 | + | this.loadComments(); |
|
| 730 | + | } |
|
| 730 | 731 | ||
| 731 | - | disconnectedCallback() { |
|
| 732 | - | this.abortController?.abort(); |
|
| 733 | - | } |
|
| 732 | + | disconnectedCallback() { |
|
| 733 | + | this.abortController?.abort(); |
|
| 734 | + | } |
|
| 734 | 735 | ||
| 735 | - | attributeChangedCallback() { |
|
| 736 | - | if (this.isConnected) { |
|
| 737 | - | this.loadComments(); |
|
| 738 | - | } |
|
| 739 | - | } |
|
| 736 | + | attributeChangedCallback() { |
|
| 737 | + | // attributeChangedCallback fires for pre-existing attributes during |
|
| 738 | + | // element upgrade, *before* connectedCallback — skip until we've done |
|
| 739 | + | // the initial load, otherwise every attribute triggers a duplicate fetch. |
|
| 740 | + | if (this.initialized) { |
|
| 741 | + | this.loadComments(); |
|
| 742 | + | } |
|
| 743 | + | } |
|
| 740 | 744 | ||
| 741 | - | get documentUri() { |
|
| 742 | - | // First check attribute |
|
| 743 | - | const attrUri = this.getAttribute("document-uri"); |
|
| 744 | - | if (attrUri) { |
|
| 745 | - | return attrUri; |
|
| 746 | - | } |
|
| 745 | + | get documentUri() { |
|
| 746 | + | // First check attribute |
|
| 747 | + | const attrUri = this.getAttribute("document-uri"); |
|
| 748 | + | if (attrUri) { |
|
| 749 | + | return attrUri; |
|
| 750 | + | } |
|
| 747 | 751 | ||
| 748 | - | // Then scan for link tag in document head |
|
| 749 | - | const linkTag = document.querySelector( |
|
| 750 | - | 'link[rel="site.standard.document"]', |
|
| 751 | - | ); |
|
| 752 | - | return linkTag?.href ?? null; |
|
| 753 | - | } |
|
| 752 | + | // Then scan for link tag in document head |
|
| 753 | + | const linkTag = document.querySelector( |
|
| 754 | + | 'link[rel="site.standard.document"]', |
|
| 755 | + | ); |
|
| 756 | + | return linkTag?.href ?? null; |
|
| 757 | + | } |
|
| 754 | 758 | ||
| 755 | - | get depth() { |
|
| 756 | - | const depthAttr = this.getAttribute("depth"); |
|
| 757 | - | return depthAttr ? parseInt(depthAttr, 10) : 6; |
|
| 758 | - | } |
|
| 759 | + | get depth() { |
|
| 760 | + | const depthAttr = this.getAttribute("depth"); |
|
| 761 | + | return depthAttr ? parseInt(depthAttr, 10) : 6; |
|
| 762 | + | } |
|
| 759 | 763 | ||
| 760 | - | get hide() { |
|
| 761 | - | const hideAttr = this.getAttribute("hide"); |
|
| 762 | - | return hideAttr === "auto"; |
|
| 763 | - | } |
|
| 764 | + | get hide() { |
|
| 765 | + | const hideAttr = this.getAttribute("hide"); |
|
| 766 | + | return hideAttr === "auto"; |
|
| 767 | + | } |
|
| 764 | 768 | ||
| 765 | - | async loadComments() { |
|
| 766 | - | // Cancel any in-flight request |
|
| 767 | - | this.abortController?.abort(); |
|
| 768 | - | this.abortController = new AbortController(); |
|
| 769 | + | async loadComments() { |
|
| 770 | + | // Cancel any in-flight request |
|
| 771 | + | this.abortController?.abort(); |
|
| 772 | + | this.abortController = new AbortController(); |
|
| 769 | 773 | ||
| 770 | - | this.state = { type: "loading" }; |
|
| 771 | - | this.render(); |
|
| 774 | + | this.state = { type: "loading" }; |
|
| 775 | + | this.render(); |
|
| 772 | 776 | ||
| 773 | - | try { |
|
| 774 | - | // Resolve the post URI — either directly from the attribute or via the |
|
| 775 | - | // document record (which requires a PDS roundtrip) |
|
| 776 | - | const rawPostUri = this.getAttribute("post-uri"); |
|
| 777 | - | let postUri = rawPostUri ? await resolvePostUri(rawPostUri) : null; |
|
| 778 | - | if (!postUri) { |
|
| 779 | - | const docUri = this.documentUri; |
|
| 780 | - | if (!docUri) { |
|
| 781 | - | this.state = { type: "no-document" }; |
|
| 782 | - | this.render(); |
|
| 783 | - | return; |
|
| 784 | - | } |
|
| 777 | + | try { |
|
| 778 | + | // Resolve the post URI — either directly from the attribute or via the |
|
| 779 | + | // document record (which requires a PDS roundtrip) |
|
| 780 | + | const rawPostUri = this.getAttribute("post-uri"); |
|
| 781 | + | let postUri = rawPostUri ? await resolvePostUri(rawPostUri) : null; |
|
| 782 | + | if (!postUri) { |
|
| 783 | + | const docUri = this.documentUri; |
|
| 784 | + | if (!docUri) { |
|
| 785 | + | this.state = { type: "no-document" }; |
|
| 786 | + | this.render(); |
|
| 787 | + | return; |
|
| 788 | + | } |
|
| 785 | 789 | ||
| 786 | - | const document = await getDocument(docUri); |
|
| 787 | - | if (!document.bskyPostRef) { |
|
| 788 | - | this.state = { type: "no-comments-enabled" }; |
|
| 789 | - | this.render(); |
|
| 790 | - | return; |
|
| 791 | - | } |
|
| 790 | + | const document = await getDocument(docUri); |
|
| 791 | + | if (!document.bskyPostRef) { |
|
| 792 | + | this.state = { type: "no-comments-enabled" }; |
|
| 793 | + | this.render(); |
|
| 794 | + | return; |
|
| 795 | + | } |
|
| 792 | 796 | ||
| 793 | - | postUri = document.bskyPostRef.uri; |
|
| 794 | - | } |
|
| 797 | + | postUri = document.bskyPostRef.uri; |
|
| 798 | + | } |
|
| 795 | 799 | ||
| 796 | - | const postUrl = buildBskyAppUrl(postUri); |
|
| 797 | - | const blackskyPostUrl = buildBlackskyAppUrl(postUri); |
|
| 800 | + | const postUrl = buildBskyAppUrl(postUri); |
|
| 801 | + | const blackskyPostUrl = buildBlackskyAppUrl(postUri); |
|
| 798 | 802 | ||
| 799 | - | // Fetch thread and quotes in parallel; quote failures degrade gracefully |
|
| 800 | - | const [threadResult, quotesResult] = await Promise.allSettled([ |
|
| 801 | - | getPostThread(postUri, this.depth), |
|
| 802 | - | getQuotes(postUri), |
|
| 803 | - | ]); |
|
| 803 | + | // Fetch thread and quotes in parallel; quote failures degrade gracefully |
|
| 804 | + | const [threadResult, quotesResult] = await Promise.allSettled([ |
|
| 805 | + | getPostThread(postUri, this.depth), |
|
| 806 | + | getQuotes(postUri), |
|
| 807 | + | ]); |
|
| 804 | 808 | ||
| 805 | - | if (threadResult.status === "rejected") { |
|
| 806 | - | throw threadResult.reason; |
|
| 807 | - | } |
|
| 809 | + | if (threadResult.status === "rejected") { |
|
| 810 | + | throw threadResult.reason; |
|
| 811 | + | } |
|
| 808 | 812 | ||
| 809 | - | const thread = threadResult.value; |
|
| 810 | - | const quotes = |
|
| 811 | - | quotesResult.status === "fulfilled" ? quotesResult.value : []; |
|
| 813 | + | const thread = threadResult.value; |
|
| 814 | + | const quotes = |
|
| 815 | + | quotesResult.status === "fulfilled" ? quotesResult.value : []; |
|
| 812 | 816 | ||
| 813 | - | const replies = thread.replies?.filter(isThreadViewPost) ?? []; |
|
| 814 | - | if (replies.length === 0 && quotes.length === 0) { |
|
| 815 | - | this.state = { type: "empty", postUrl, blackskyPostUrl }; |
|
| 816 | - | this.render(); |
|
| 817 | - | return; |
|
| 818 | - | } |
|
| 817 | + | const replies = thread.replies?.filter(isThreadViewPost) ?? []; |
|
| 818 | + | if (replies.length === 0 && quotes.length === 0) { |
|
| 819 | + | this.state = { type: "empty", postUrl, blackskyPostUrl }; |
|
| 820 | + | this.render(); |
|
| 821 | + | return; |
|
| 822 | + | } |
|
| 819 | 823 | ||
| 820 | - | this.state = { type: "loaded", thread, quotes, postUrl, blackskyPostUrl }; |
|
| 821 | - | this.render(); |
|
| 822 | - | } catch (error) { |
|
| 823 | - | const message = |
|
| 824 | - | error instanceof Error ? error.message : "Failed to load comments"; |
|
| 825 | - | this.state = { type: "error", message }; |
|
| 826 | - | this.render(); |
|
| 827 | - | } |
|
| 828 | - | } |
|
| 824 | + | this.state = { type: "loaded", thread, quotes, postUrl, blackskyPostUrl }; |
|
| 825 | + | this.render(); |
|
| 826 | + | } catch (error) { |
|
| 827 | + | const message = |
|
| 828 | + | error instanceof Error ? error.message : "Failed to load comments"; |
|
| 829 | + | this.state = { type: "error", message }; |
|
| 830 | + | this.render(); |
|
| 831 | + | } |
|
| 832 | + | } |
|
| 829 | 833 | ||
| 830 | - | render() { |
|
| 831 | - | switch (this.state.type) { |
|
| 832 | - | case "loading": |
|
| 833 | - | this.commentsContainer.innerHTML = ` |
|
| 834 | + | render() { |
|
| 835 | + | switch (this.state.type) { |
|
| 836 | + | case "loading": |
|
| 837 | + | this.commentsContainer.innerHTML = ` |
|
| 834 | 838 | <div class="sequoia-loading"> |
|
| 835 | 839 | <span class="sequoia-loading-spinner"></span> |
|
| 836 | 840 | Loading comments... |
|
| 837 | 841 | </div> |
|
| 838 | 842 | `; |
|
| 839 | - | break; |
|
| 843 | + | break; |
|
| 840 | 844 | ||
| 841 | - | case "no-document": |
|
| 842 | - | this.commentsContainer.innerHTML = ` |
|
| 845 | + | case "no-document": |
|
| 846 | + | this.commentsContainer.innerHTML = ` |
|
| 843 | 847 | <div class="sequoia-warning"> |
|
| 844 | 848 | No document found. Add a <code><link rel="site.standard.document" href="at://..."></code> tag to your page. |
|
| 845 | 849 | </div> |
|
| 846 | 850 | `; |
|
| 847 | - | if (this.hide) { |
|
| 848 | - | this.commentsContainer.style.display = "none"; |
|
| 849 | - | } |
|
| 850 | - | break; |
|
| 851 | + | if (this.hide) { |
|
| 852 | + | this.commentsContainer.style.display = "none"; |
|
| 853 | + | } |
|
| 854 | + | break; |
|
| 851 | 855 | ||
| 852 | - | case "no-comments-enabled": |
|
| 853 | - | this.commentsContainer.innerHTML = ` |
|
| 856 | + | case "no-comments-enabled": |
|
| 857 | + | this.commentsContainer.innerHTML = ` |
|
| 854 | 858 | <div class="sequoia-empty"> |
|
| 855 | 859 | Comments are not enabled for this post. |
|
| 856 | 860 | </div> |
|
| 857 | 861 | `; |
|
| 858 | - | break; |
|
| 862 | + | break; |
|
| 859 | 863 | ||
| 860 | - | case "empty": |
|
| 861 | - | this.commentsContainer.innerHTML = ` |
|
| 864 | + | case "empty": |
|
| 865 | + | this.commentsContainer.innerHTML = ` |
|
| 862 | 866 | <div class="sequoia-comments-header"> |
|
| 863 | 867 | <h3 class="sequoia-comments-title">Comments</h3> |
|
| 864 | 868 | <div>${this.renderReplyButtons(this.state.postUrl, this.state.blackskyPostUrl)}</div> |
|
| 867 | 871 | No comments yet. Be the first to reply on Bluesky! |
|
| 868 | 872 | </div> |
|
| 869 | 873 | `; |
|
| 870 | - | break; |
|
| 874 | + | break; |
|
| 871 | 875 | ||
| 872 | - | case "error": |
|
| 873 | - | this.commentsContainer.innerHTML = ` |
|
| 876 | + | case "error": |
|
| 877 | + | this.commentsContainer.innerHTML = ` |
|
| 874 | 878 | <div class="sequoia-error"> |
|
| 875 | 879 | Failed to load comments: ${escapeHtml(this.state.message)} |
|
| 876 | 880 | </div> |
|
| 877 | 881 | `; |
|
| 878 | - | break; |
|
| 882 | + | break; |
|
| 879 | 883 | ||
| 880 | - | case "loaded": { |
|
| 881 | - | const replies = |
|
| 882 | - | this.state.thread.replies?.filter(isThreadViewPost) ?? []; |
|
| 883 | - | const quotes = this.state.quotes ?? []; |
|
| 884 | - | const threadsHtml = replies |
|
| 885 | - | .map((reply) => this.renderThread(reply)) |
|
| 886 | - | .join(""); |
|
| 887 | - | const commentCount = this.countComments(replies); |
|
| 888 | - | const titleText = |
|
| 889 | - | commentCount > 0 |
|
| 890 | - | ? `${commentCount} Comment${commentCount !== 1 ? "s" : ""}` |
|
| 891 | - | : "Comments"; |
|
| 892 | - | const quotesHtml = this.renderQuotesSection(quotes); |
|
| 884 | + | case "loaded": { |
|
| 885 | + | const replies = |
|
| 886 | + | this.state.thread.replies?.filter(isThreadViewPost) ?? []; |
|
| 887 | + | const quotes = this.state.quotes ?? []; |
|
| 888 | + | const threadsHtml = replies |
|
| 889 | + | .map((reply) => this.renderThread(reply)) |
|
| 890 | + | .join(""); |
|
| 891 | + | const commentCount = this.countComments(replies); |
|
| 892 | + | const titleText = |
|
| 893 | + | commentCount > 0 |
|
| 894 | + | ? `${commentCount} Comment${commentCount !== 1 ? "s" : ""}` |
|
| 895 | + | : "Comments"; |
|
| 896 | + | const quotesHtml = this.renderQuotesSection(quotes); |
|
| 893 | 897 | ||
| 894 | - | this.commentsContainer.innerHTML = ` |
|
| 898 | + | this.commentsContainer.innerHTML = ` |
|
| 895 | 899 | <div class="sequoia-comments-header"> |
|
| 896 | 900 | <h3 class="sequoia-comments-title">${titleText}</h3> |
|
| 897 | 901 | <div>${this.renderReplyButtons(this.state.postUrl, this.state.blackskyPostUrl)}</div> |
|
| 901 | 905 | </div> |
|
| 902 | 906 | ${quotesHtml} |
|
| 903 | 907 | `; |
|
| 904 | - | break; |
|
| 905 | - | } |
|
| 906 | - | } |
|
| 907 | - | } |
|
| 908 | + | break; |
|
| 909 | + | } |
|
| 910 | + | } |
|
| 911 | + | } |
|
| 908 | 912 | ||
| 909 | - | /** |
|
| 910 | - | * Flatten a thread into a linear list of comments |
|
| 911 | - | * @param {ThreadViewPost} thread - Thread to flatten |
|
| 912 | - | * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments |
|
| 913 | - | */ |
|
| 914 | - | flattenThread(thread) { |
|
| 915 | - | const result = []; |
|
| 916 | - | const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; |
|
| 913 | + | /** |
|
| 914 | + | * Flatten a thread into a linear list of comments |
|
| 915 | + | * @param {ThreadViewPost} thread - Thread to flatten |
|
| 916 | + | * @returns {Array<{post: any, hasMoreReplies: boolean}>} Flattened comments |
|
| 917 | + | */ |
|
| 918 | + | flattenThread(thread) { |
|
| 919 | + | const result = []; |
|
| 920 | + | const nestedReplies = thread.replies?.filter(isThreadViewPost) ?? []; |
|
| 917 | 921 | ||
| 918 | - | result.push({ |
|
| 919 | - | post: thread.post, |
|
| 920 | - | hasMoreReplies: nestedReplies.length > 0, |
|
| 921 | - | }); |
|
| 922 | + | result.push({ |
|
| 923 | + | post: thread.post, |
|
| 924 | + | hasMoreReplies: nestedReplies.length > 0, |
|
| 925 | + | }); |
|
| 922 | 926 | ||
| 923 | - | // Recursively flatten nested replies |
|
| 924 | - | for (const reply of nestedReplies) { |
|
| 925 | - | result.push(...this.flattenThread(reply)); |
|
| 926 | - | } |
|
| 927 | + | // Recursively flatten nested replies |
|
| 928 | + | for (const reply of nestedReplies) { |
|
| 929 | + | result.push(...this.flattenThread(reply)); |
|
| 930 | + | } |
|
| 927 | 931 | ||
| 928 | - | return result; |
|
| 929 | - | } |
|
| 932 | + | return result; |
|
| 933 | + | } |
|
| 930 | 934 | ||
| 931 | - | /** |
|
| 932 | - | * Render the reply-button slot. Any element with slot="reply-button" in the |
|
| 933 | - | * light DOM is projected here and remains styleable by external CSS. |
|
| 934 | - | * The default Bluesky/Blacksky buttons are used as fallback content. |
|
| 935 | - | */ |
|
| 936 | - | renderReplyButtons(postUrl, blackskyPostUrl) { |
|
| 937 | - | return ` |
|
| 935 | + | /** |
|
| 936 | + | * Render the reply-button slot. Any element with slot="reply-button" in the |
|
| 937 | + | * light DOM is projected here and remains styleable by external CSS. |
|
| 938 | + | * The default Bluesky/Blacksky buttons are used as fallback content. |
|
| 939 | + | */ |
|
| 940 | + | renderReplyButtons(postUrl, blackskyPostUrl) { |
|
| 941 | + | return ` |
|
| 938 | 942 | <slot name="reply-button"> |
|
| 939 | 943 | <a href="${escapeHtml(postUrl)}" target="_blank" rel="noopener noreferrer" class="sequoia-reply-button sequoia-reply-bluesky"> |
|
| 940 | 944 | ${BLUESKY_ICON} |
|
| 944 | 948 | </a> |
|
| 945 | 949 | </slot> |
|
| 946 | 950 | `; |
|
| 947 | - | } |
|
| 951 | + | } |
|
| 948 | 952 | ||
| 949 | - | /** |
|
| 950 | - | * Render a complete thread (top-level comment + all nested replies) |
|
| 951 | - | */ |
|
| 952 | - | renderThread(thread) { |
|
| 953 | - | const flatComments = this.flattenThread(thread); |
|
| 954 | - | const commentsHtml = flatComments |
|
| 955 | - | .map((item, index) => |
|
| 956 | - | this.renderComment(item.post, item.hasMoreReplies, index), |
|
| 957 | - | ) |
|
| 958 | - | .join(""); |
|
| 953 | + | /** |
|
| 954 | + | * Render a complete thread (top-level comment + all nested replies) |
|
| 955 | + | */ |
|
| 956 | + | renderThread(thread) { |
|
| 957 | + | const flatComments = this.flattenThread(thread); |
|
| 958 | + | const commentsHtml = flatComments |
|
| 959 | + | .map((item, index) => |
|
| 960 | + | this.renderComment(item.post, item.hasMoreReplies, index), |
|
| 961 | + | ) |
|
| 962 | + | .join(""); |
|
| 959 | 963 | ||
| 960 | - | return `<div class="sequoia-thread">${commentsHtml}</div>`; |
|
| 961 | - | } |
|
| 964 | + | return `<div class="sequoia-thread">${commentsHtml}</div>`; |
|
| 965 | + | } |
|
| 962 | 966 | ||
| 963 | - | /** |
|
| 964 | - | * Render a section of quote posts below the replies |
|
| 965 | - | * @param {Array} quotes - Array of PostView objects from getQuotes |
|
| 966 | - | */ |
|
| 967 | - | renderQuotesSection(quotes) { |
|
| 968 | - | if (quotes.length === 0) return ""; |
|
| 967 | + | /** |
|
| 968 | + | * Render a section of quote posts below the replies |
|
| 969 | + | * @param {Array} quotes - Array of PostView objects from getQuotes |
|
| 970 | + | */ |
|
| 971 | + | renderQuotesSection(quotes) { |
|
| 972 | + | if (quotes.length === 0) return ""; |
|
| 969 | 973 | ||
| 970 | - | const quotesHtml = quotes |
|
| 971 | - | .map((post) => { |
|
| 972 | - | const quotePostUrl = buildBskyAppUrl(post.uri); |
|
| 973 | - | return `<div class="sequoia-thread">${this.renderComment(post, false, 0, quotePostUrl)}</div>`; |
|
| 974 | - | }) |
|
| 975 | - | .join(""); |
|
| 974 | + | const quotesHtml = quotes |
|
| 975 | + | .map((post) => { |
|
| 976 | + | const quotePostUrl = buildBskyAppUrl(post.uri); |
|
| 977 | + | return `<div class="sequoia-thread">${this.renderComment(post, false, 0, quotePostUrl)}</div>`; |
|
| 978 | + | }) |
|
| 979 | + | .join(""); |
|
| 976 | 980 | ||
| 977 | - | return ` |
|
| 981 | + | return ` |
|
| 978 | 982 | <div class="sequoia-quotes-section"> |
|
| 979 | 983 | <h4 class="sequoia-quotes-header">Quotes (${quotes.length})</h4> |
|
| 980 | 984 | <div class="sequoia-comments-list"> |
|
| 982 | 986 | </div> |
|
| 983 | 987 | </div> |
|
| 984 | 988 | `; |
|
| 985 | - | } |
|
| 989 | + | } |
|
| 986 | 990 | ||
| 987 | - | /** |
|
| 988 | - | * Render a single comment |
|
| 989 | - | * @param {any} post - Post data |
|
| 990 | - | * @param {boolean} showThreadLine - Whether to show the connecting thread line |
|
| 991 | - | * @param {number} _index - Index in the flattened thread (0 = top-level) |
|
| 992 | - | * @param {string|null} postUrl - Optional URL to link the timestamp to (used for quote posts) |
|
| 993 | - | */ |
|
| 994 | - | renderComment(post, showThreadLine = false, _index = 0, postUrl = null) { |
|
| 995 | - | const author = post.author; |
|
| 996 | - | const displayName = author.displayName || author.handle; |
|
| 997 | - | const avatarHtml = author.avatar |
|
| 998 | - | ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />` |
|
| 999 | - | : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`; |
|
| 991 | + | /** |
|
| 992 | + | * Render a single comment |
|
| 993 | + | * @param {any} post - Post data |
|
| 994 | + | * @param {boolean} showThreadLine - Whether to show the connecting thread line |
|
| 995 | + | * @param {number} _index - Index in the flattened thread (0 = top-level) |
|
| 996 | + | * @param {string|null} postUrl - Optional URL to link the timestamp to (used for quote posts) |
|
| 997 | + | */ |
|
| 998 | + | renderComment(post, showThreadLine = false, _index = 0, postUrl = null) { |
|
| 999 | + | const author = post.author; |
|
| 1000 | + | const displayName = author.displayName || author.handle; |
|
| 1001 | + | const avatarHtml = author.avatar |
|
| 1002 | + | ? `<img class="sequoia-comment-avatar" src="${escapeHtml(author.avatar)}" alt="${escapeHtml(displayName)}" loading="lazy" />` |
|
| 1003 | + | : `<div class="sequoia-comment-avatar-placeholder">${getInitials(displayName)}</div>`; |
|
| 1000 | 1004 | ||
| 1001 | - | const profileUrl = `https://bsky.app/profile/${author.did}`; |
|
| 1002 | - | const textHtml = renderTextWithFacets(post.record.text, post.record.facets); |
|
| 1003 | - | const timeAgo = formatRelativeTime(post.record.createdAt); |
|
| 1004 | - | const timeHtml = postUrl |
|
| 1005 | - | ? `<a href="${escapeHtml(postUrl)}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-time">${timeAgo}</a>` |
|
| 1006 | - | : `<span class="sequoia-comment-time">${timeAgo}</span>`; |
|
| 1007 | - | const threadLineHtml = showThreadLine |
|
| 1008 | - | ? '<div class="sequoia-thread-line"></div>' |
|
| 1009 | - | : ""; |
|
| 1005 | + | const profileUrl = `https://bsky.app/profile/${author.did}`; |
|
| 1006 | + | const textHtml = renderTextWithFacets(post.record.text, post.record.facets); |
|
| 1007 | + | const timeAgo = formatRelativeTime(post.record.createdAt); |
|
| 1008 | + | const timeHtml = postUrl |
|
| 1009 | + | ? `<a href="${escapeHtml(postUrl)}" target="_blank" rel="noopener noreferrer" class="sequoia-comment-time">${timeAgo}</a>` |
|
| 1010 | + | : `<span class="sequoia-comment-time">${timeAgo}</span>`; |
|
| 1011 | + | const threadLineHtml = showThreadLine |
|
| 1012 | + | ? '<div class="sequoia-thread-line"></div>' |
|
| 1013 | + | : ""; |
|
| 1010 | 1014 | ||
| 1011 | - | return ` |
|
| 1015 | + | return ` |
|
| 1012 | 1016 | <div class="sequoia-comment"> |
|
| 1013 | 1017 | <div class="sequoia-comment-avatar-column"> |
|
| 1014 | 1018 | ${avatarHtml} |
|
| 1026 | 1030 | </div> |
|
| 1027 | 1031 | </div> |
|
| 1028 | 1032 | `; |
|
| 1029 | - | } |
|
| 1033 | + | } |
|
| 1030 | 1034 | ||
| 1031 | - | countComments(replies) { |
|
| 1032 | - | let count = 0; |
|
| 1033 | - | for (const reply of replies) { |
|
| 1034 | - | count += 1; |
|
| 1035 | - | const nested = reply.replies?.filter(isThreadViewPost) ?? []; |
|
| 1036 | - | count += this.countComments(nested); |
|
| 1037 | - | } |
|
| 1038 | - | return count; |
|
| 1039 | - | } |
|
| 1035 | + | countComments(replies) { |
|
| 1036 | + | let count = 0; |
|
| 1037 | + | for (const reply of replies) { |
|
| 1038 | + | count += 1; |
|
| 1039 | + | const nested = reply.replies?.filter(isThreadViewPost) ?? []; |
|
| 1040 | + | count += this.countComments(nested); |
|
| 1041 | + | } |
|
| 1042 | + | return count; |
|
| 1043 | + | } |
|
| 1040 | 1044 | } |
|
| 1041 | 1045 | ||
| 1042 | 1046 | // Register the custom element |
|
| 1043 | 1047 | if (typeof customElements !== "undefined") { |
|
| 1044 | - | customElements.define("sequoia-comments", SequoiaComments); |
|
| 1048 | + | customElements.define("sequoia-comments", SequoiaComments); |
|
| 1045 | 1049 | } |
|
| 1046 | 1050 | ||
| 1047 | 1051 | // Export for module usage |
|