Add Bluesky replies, quotes, and reposts to any web page. Handy for adding a comments section anywhere.
9
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add bsky-conversation web component

Zero-dependency ES module that displays a Bluesky conversation thread.
Converted from IIFE to ES module — exports BskyConversation class,
auto-registers the custom element on import.

Jim Ray abaf334c 747e2d3f

+654
+654
bsky-conversation.js
··· 1 + const API = 'https://public.api.bsky.app/xrpc' 2 + 3 + // Humanize filters inlined from human-eyes (https://tangled.org/jimray.net/human-eyes) 4 + 5 + /** 6 + * Django-style pluralize: returns the appropriate suffix based on count. 7 + * pluralize(1, "y,ies") → "y" 8 + * pluralize(2, "y,ies") → "ies" 9 + * pluralize(1) → "" 10 + * pluralize(2) → "s" 11 + */ 12 + function pluralize(value, arg = 's') { 13 + let singularSuffix, pluralSuffix 14 + if (arg.includes(',')) { 15 + ;[singularSuffix, pluralSuffix] = arg.split(',', 2) 16 + } else { 17 + singularSuffix = '' 18 + pluralSuffix = arg 19 + } 20 + const n = typeof value !== 'string' && typeof value !== 'number' && typeof value?.length === 'number' 21 + ? value.length 22 + : Number(value) 23 + return n === 1 ? singularSuffix : pluralSuffix 24 + } 25 + 26 + /** 27 + * Converts a large integer to friendly text. Numbers under 1 million 28 + * are returned with comma formatting. 29 + * intword(1200000) → "1.2 million" 30 + * intword(500) → "500" 31 + */ 32 + const MAGNITUDES = [ 33 + { threshold: 1e18, label: 'quintillion' }, 34 + { threshold: 1e15, label: 'quadrillion' }, 35 + { threshold: 1e12, label: 'trillion' }, 36 + { threshold: 1e9, label: 'billion' }, 37 + { threshold: 1e6, label: 'million' }, 38 + ] 39 + 40 + function intword(value) { 41 + const num = Number(value) 42 + if (!Number.isFinite(num)) return String(value) 43 + const abs = Math.abs(num) 44 + for (const { threshold, label } of MAGNITUDES) { 45 + if (abs >= threshold) { 46 + const formatted = parseFloat((num / threshold).toFixed(1)) 47 + return `${formatted} ${label}` 48 + } 49 + } 50 + if (Number.isInteger(num)) return num.toLocaleString('en-US') 51 + return String(num) 52 + } 53 + 54 + /** 55 + * Joins a list into a human-readable string with an Oxford comma. 56 + * oxfordComma(["a", "b", "c"]) → "a, b, and c" 57 + * oxfordComma(["a", "b", "c"], 2) → "a, b, and 1 other" 58 + */ 59 + function oxfordComma(items, limit, suffix) { 60 + if (!Array.isArray(items) || items.length === 0) return '' 61 + const list = items.map(String) 62 + if (list.length === 1) return list[0] 63 + if (list.length === 2 && (limit == null || limit >= 2)) { 64 + return `${list[0]} and ${list[1]}` 65 + } 66 + if (limit != null && limit <= 0) return '' 67 + if (limit != null && limit < list.length) { 68 + const shown = list.slice(0, limit) 69 + const remaining = list.length - limit 70 + const tail = suffix ?? `and ${remaining} ${remaining === 1 ? 'other' : 'others'}` 71 + return `${shown.join(', ')}, ${tail}` 72 + } 73 + return `${list.slice(0, -1).join(', ')}, and ${list[list.length - 1]}` 74 + } 75 + 76 + /** 77 + * Convert a bsky.app URL to an AT URI. 78 + * e.g. https://bsky.app/profile/atproto.com/post/3mg6cliy3lc26 79 + * → at://atproto.com/app.bsky.feed.post/3mg6cliy3lc26 80 + */ 81 + function toAtUri(url) { 82 + const m = url.match( 83 + /bsky\.app\/profile\/([^/]+)\/post\/([^/?#]+)/ 84 + ) 85 + if (!m) return null 86 + return `at://${m[1]}/app.bsky.feed.post/${m[2]}` 87 + } 88 + 89 + /** 90 + * Build a bsky.app profile URL from a DID or handle. 91 + */ 92 + function profileUrl(handleOrDid) { 93 + return `https://bsky.app/profile/${handleOrDid}` 94 + } 95 + 96 + /** 97 + * Build a bsky.app post URL from an AT URI. 98 + */ 99 + function postUrl(atUri) { 100 + const parts = atUri.replace('at://', '').split('/') 101 + return `https://bsky.app/profile/${parts[0]}/post/${parts[2]}` 102 + } 103 + 104 + /** 105 + * Format a date string for display. 106 + */ 107 + function formatDate(iso) { 108 + const d = new Date(iso) 109 + return d.toLocaleDateString('en-US', { 110 + month: 'short', 111 + day: 'numeric', 112 + year: 'numeric', 113 + }) 114 + } 115 + 116 + /** 117 + * Render an author link fragment. 118 + */ 119 + function renderAuthor(author) { 120 + const avatar = author.avatar 121 + ? `<img class="avatar" src="${escapeHtml(author.avatar)}" alt="" loading="lazy" />` 122 + : '' 123 + const name = author.displayName || author.handle 124 + return ` 125 + <a class="author" href="${escapeHtml(profileUrl(author.did))}" target="_blank" rel="noopener noreferrer"> 126 + ${avatar} 127 + <strong class="displayname">${escapeHtml(name)}</strong> 128 + <span class="handle">@${escapeHtml(author.handle)}</span> 129 + </a>` 130 + } 131 + 132 + /** 133 + * Render a footer with timestamp and link. 134 + */ 135 + function renderFooter(uri, timestamp) { 136 + return ` 137 + <footer> 138 + <a href="${escapeHtml(postUrl(uri))}" title="View on Bluesky" target="_blank" rel="noopener noreferrer"><time datetime="${timestamp}">${formatDate(timestamp)}</time></a> 139 + </footer>` 140 + } 141 + 142 + /** 143 + * Render a reply and its nested replies recursively. 144 + * Stops at maxDepth and shows a link to continue on Bluesky. 145 + */ 146 + function renderReply(threadView, depth, maxDepth, hiddenReplies) { 147 + const post = threadView.post 148 + const record = post.record 149 + const nested = (threadView.replies || []) 150 + .filter((r) => r.$type === 'app.bsky.feed.defs#threadViewPost') 151 + .filter((r) => !hiddenReplies.has(r.post.uri)) 152 + .sort( 153 + (a, b) => 154 + new Date(a.post.record.createdAt) - new Date(b.post.record.createdAt) 155 + ) 156 + 157 + let nestedHtml = '' 158 + if (nested.length > 0) { 159 + if (depth >= maxDepth) { 160 + nestedHtml = ` 161 + <p class="depth-cutoff"><a href="${escapeHtml(postUrl(post.uri))}" target="_blank" rel="noopener noreferrer">More of the conversation on Bluesky &rarr;</a></p>` 162 + } else { 163 + nestedHtml = ` 164 + <ol class="thread"> 165 + ${nested.map((r) => renderReply(r, depth + 1, maxDepth, hiddenReplies)).join('')} 166 + </ol>` 167 + } 168 + } 169 + 170 + return ` 171 + <li class="reply"> 172 + ${renderAuthor(post.author)} 173 + <p>${renderText(record.text, record.facets)}</p> 174 + ${renderFooter(post.uri, record.createdAt)} 175 + ${nestedHtml} 176 + </li>` 177 + } 178 + 179 + /** 180 + * Render a quote post. 181 + */ 182 + function renderQuote(post) { 183 + const record = post.record 184 + return ` 185 + <li class="quote"> 186 + ${renderAuthor(post.author)} 187 + <p>${renderText(record.text, record.facets)}</p> 188 + ${renderFooter(post.uri, record.createdAt)} 189 + </li>` 190 + } 191 + 192 + /** 193 + * Interpolate a header template string. 194 + * 195 + * Tokens: 196 + * {replies} → raw reply count 197 + * {quotes} → raw quote count 198 + * {reposts} → raw repost count 199 + * {repostedBy} → linked names: "@a, @b, and 3 others" 200 + * {postUrl} → bsky.app post URL 201 + * {replies|one|many} → hidden when 0, "1 one" when 1, "N many" when 2+ 202 + * {quotes|one|many} → same 203 + * {reposts|one|many} → same 204 + * {name?...content...} → conditional block, renders content only if name is truthy 205 + */ 206 + function renderTemplate(template, stats, repostedBy, url) { 207 + const vars = { 208 + replies: stats.replyCount, 209 + quotes: stats.quoteCount, 210 + reposts: stats.repostCount, 211 + postUrl: url, 212 + repostedBy: formatRepostedBy(repostedBy, stats.repostCount, url), 213 + } 214 + 215 + let result = template 216 + 217 + // Replace {name|singular|plural} first — output nothing when count is 0 218 + result = result.replace( 219 + /\{(replies|quotes|reposts)\|([^|}]+)\|([^}]+)\}/g, 220 + (_, key, singular, plural) => { 221 + const n = vars[key] 222 + if (n === 0) return '' 223 + return `${intword(n)} ${pluralize(n, `${singular},${plural}`)}` 224 + } 225 + ) 226 + 227 + // Replace {name} simple tokens 228 + result = result.replace(/\{(\w+)\}/g, (_, key) => 229 + vars[key] !== undefined ? vars[key] : '' 230 + ) 231 + 232 + // Replace {name?...content...} conditional blocks — now inner tokens are resolved 233 + let prev 234 + do { 235 + prev = result 236 + result = result.replace( 237 + /\{(\w+)\?([^{}]*)\}/g, 238 + (_, key, content) => (vars[key] ? content : '') 239 + ) 240 + } while (result !== prev) 241 + 242 + // Collapse multiple spaces / leading-trailing whitespace from removed tokens 243 + result = result.replace(/\s{2,}/g, ' ').trim() 244 + 245 + return result 246 + } 247 + 248 + /** 249 + * Format repostedBy names as linked HTML. 250 + */ 251 + function shuffle(arr) { 252 + const a = [...arr] 253 + for (let i = a.length - 1; i > 0; i--) { 254 + const j = Math.floor(Math.random() * (i + 1)) 255 + ;[a[i], a[j]] = [a[j], a[i]] 256 + } 257 + return a 258 + } 259 + 260 + function formatRepostedBy(repostedBy, repostCount, url) { 261 + if (repostCount === 0) return '' 262 + const showCount = repostCount <= 4 ? 1 : 3 263 + const names = shuffle(repostedBy).slice(0, showCount).map((a) => { 264 + return `<a href="${escapeHtml(profileUrl(a.did))}" target="_blank" rel="noopener noreferrer">@${escapeHtml(a.handle)}</a>` 265 + }) 266 + const remaining = repostCount - names.length 267 + if (remaining > 0) { 268 + const suffix = `and <a href="${url}/reposted-by" target="_blank" rel="noopener noreferrer">${intword(remaining)} other people</a>` 269 + names.push(suffix) 270 + return oxfordComma(names) 271 + } 272 + return oxfordComma(names) 273 + } 274 + 275 + /** 276 + * Render the header summary. 277 + * Uses header-template if provided, otherwise falls back to default format. 278 + */ 279 + function renderHeader(stats, repostedBy, { engageText, postUrl: url, headerTemplate }) { 280 + if (headerTemplate) { 281 + const html = renderTemplate(headerTemplate, stats, repostedBy, url) 282 + const items = [] 283 + if (html) items.push(`<li class="stats">${html}</li>`) 284 + if (engageText && url) { 285 + items.push(`<li class="engage"><a href="${url}" target="_blank" rel="noopener noreferrer">${escapeHtml(engageText)}</a></li>`) 286 + } 287 + if (items.length === 0) return '' 288 + return `<header><ul>${items.join('')}</ul></header>` 289 + } 290 + 291 + const items = [] 292 + 293 + if (stats.replyCount > 0) { 294 + items.push( 295 + `<li class="replies">${intword(stats.replyCount)} ${pluralize(stats.replyCount, 'reply,replies')}</li>` 296 + ) 297 + } 298 + 299 + if (stats.quoteCount > 0) { 300 + items.push( 301 + `<li class="quotes"><a href="${url}/quotes" target="_blank" rel="noopener noreferrer">${intword(stats.quoteCount)} ${pluralize(stats.quoteCount, 'quote,quotes')}</a></li>` 302 + ) 303 + } 304 + 305 + if (stats.repostCount > 0) { 306 + items.push(`<li class="reposts">reposted by ${formatRepostedBy(repostedBy, stats.repostCount, url)}</li>`) 307 + } 308 + 309 + if (engageText && url) { 310 + items.push(`<li class="engage"><a href="${url}" target="_blank" rel="noopener noreferrer">${escapeHtml(engageText)}</a></li>`) 311 + } 312 + 313 + if (items.length === 0) return '' 314 + return `<header><ul>${items.join('')}</ul></header>` 315 + } 316 + 317 + /** 318 + * Minimal HTML escaping. 319 + */ 320 + function escapeHtml(str) { 321 + return str 322 + .replace(/&/g, '&amp;') 323 + .replace(/</g, '&lt;') 324 + .replace(/>/g, '&gt;') 325 + .replace(/"/g, '&quot;') 326 + } 327 + 328 + /** 329 + * Render post text with facets (links, mentions, tags) and newline handling. 330 + * Facets use byte offsets into UTF-8, so we encode to bytes for slicing. 331 + */ 332 + function renderText(text, facets) { 333 + if (!text) return '' 334 + 335 + const encoder = new TextEncoder() 336 + const decoder = new TextDecoder() 337 + const bytes = encoder.encode(text) 338 + 339 + // Sort facets by start index 340 + const sorted = (facets || []) 341 + .filter((f) => f.index && f.features?.length) 342 + .sort((a, b) => a.index.byteStart - b.index.byteStart) 343 + 344 + let result = '' 345 + let cursor = 0 346 + 347 + for (const facet of sorted) { 348 + const start = facet.index.byteStart 349 + const end = facet.index.byteEnd 350 + 351 + // Add plain text before this facet 352 + if (start > cursor) { 353 + result += escapeHtml(decoder.decode(bytes.slice(cursor, start))) 354 + } 355 + 356 + const facetText = escapeHtml(decoder.decode(bytes.slice(start, end))) 357 + const feature = facet.features[0] 358 + 359 + if (feature.$type === 'app.bsky.richtext.facet#link') { 360 + result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener noreferrer">${facetText}</a>` 361 + } else if (feature.$type === 'app.bsky.richtext.facet#mention') { 362 + result += `<a href="${escapeHtml(profileUrl(feature.did))}" target="_blank" rel="noopener noreferrer">${facetText}</a>` 363 + } else if (feature.$type === 'app.bsky.richtext.facet#tag') { 364 + result += `<a href="https://bsky.app/hashtag/${encodeURIComponent(feature.tag)}" target="_blank" rel="noopener noreferrer">${facetText}</a>` 365 + } else { 366 + result += facetText 367 + } 368 + 369 + cursor = end 370 + } 371 + 372 + // Add remaining text 373 + if (cursor < bytes.length) { 374 + result += escapeHtml(decoder.decode(bytes.slice(cursor))) 375 + } 376 + 377 + // Convert newlines to <br> 378 + return result.replace(/\n/g, '<br>') 379 + } 380 + 381 + export class BskyConversation extends HTMLElement { 382 + async connectedCallback() { 383 + const url = this.getAttribute('uri') 384 + if (!url) { 385 + this.innerHTML = '' 386 + return 387 + } 388 + 389 + const atUri = toAtUri(url) 390 + if (!atUri) { 391 + this.innerHTML = '' 392 + return 393 + } 394 + 395 + const maxDepth = parseInt(this.getAttribute('max-depth'), 10) || 3 396 + 397 + this.innerHTML = '<div class="bsky-conversation"><p class="loading">Loading conversation…</p></div>' 398 + 399 + try { 400 + const fetchJson = (url) => 401 + fetch(url, { cache: 'no-store' }) 402 + .then((r) => r.ok ? r.json() : {}) 403 + .catch(() => ({})) 404 + 405 + const encodedUri = encodeURIComponent(atUri) 406 + const [thread, quotesRes, repostsRes] = await Promise.all([ 407 + fetchJson(`${API}/app.bsky.feed.getPostThread?uri=${encodedUri}&depth=${maxDepth}&_t=${Date.now()}`), 408 + fetchJson(`${API}/app.bsky.feed.getQuotes?uri=${encodedUri}&limit=25&_t=${Date.now()}`), 409 + fetchJson(`${API}/app.bsky.feed.getRepostedBy?uri=${encodedUri}&limit=25&_t=${Date.now()}`), 410 + ]) 411 + this.render(url, thread, quotesRes, repostsRes, maxDepth) 412 + } catch (err) { 413 + console.error('bsky-conversation: failed to load', err) 414 + this.innerHTML = '' 415 + } 416 + } 417 + 418 + render(originalUrl, threadRes, quotesRes, repostsRes, maxDepth) { 419 + const threadView = threadRes.thread 420 + if (!threadView || threadView.$type !== 'app.bsky.feed.defs#threadViewPost') { 421 + this.innerHTML = '' 422 + return 423 + } 424 + 425 + // Read configurable attributes 426 + const showOriginalPost = this.getAttribute('show-original-post') === 'true' 427 + const headerTemplate = this.getAttribute('header-template') 428 + const engageAttr = this.getAttribute('engage-text') 429 + // Default to showing engage link; only hide if explicitly set to empty string 430 + const engageText = engageAttr === '' ? null : (engageAttr || 'Add your thoughts on Bluesky') 431 + 432 + // Build a set of reply URIs the post author has hidden 433 + const hiddenReplies = new Set(threadRes.threadgate?.record?.hiddenReplies || []) 434 + 435 + // Direct replies (top-level threads) 436 + // Filter out the original author's direct replies to their own post — 437 + // those are extensions of the original post, not conversation. 438 + // The author's replies deeper in the tree (replying to others) are kept. 439 + // Also filter out replies hidden by the post author. 440 + const rootAuthorDid = threadView.post.author.did 441 + const directReplies = (threadView.replies || []) 442 + .filter((r) => r.$type === 'app.bsky.feed.defs#threadViewPost') 443 + .filter((r) => r.post.author.did !== rootAuthorDid) 444 + .filter((r) => !hiddenReplies.has(r.post.uri)) 445 + 446 + // Filter out detached quote posts (quotes the original author has disassociated from). 447 + // The detached indicator can be on embed.record (plain quote) or embed.record.record (quote with media). 448 + const isDetached = (q) => { 449 + const rec = q.embed?.record 450 + if (!rec) return false 451 + if (rec.$type === 'app.bsky.embed.record#viewDetached') return true 452 + if (rec.record?.$type === 'app.bsky.embed.record#viewDetached') return true 453 + return false 454 + } 455 + const quotes = (quotesRes.posts || []).filter((q) => !isDetached(q)) 456 + const repostedBy = repostsRes.repostedBy || [] 457 + 458 + // Nothing to show (unless we have engage text to display) 459 + const rootPost = threadView.post 460 + if ((rootPost.replyCount || 0) === 0 && (rootPost.quoteCount || 0) === 0 && (rootPost.repostCount || 0) === 0 && !engageText) { 461 + this.innerHTML = '' 462 + return 463 + } 464 + 465 + // Build timeline: interleave top-level reply threads and quote posts 466 + const timelineItems = [] 467 + 468 + // Optionally include the original post 469 + if (showOriginalPost) { 470 + const origPost = threadView.post 471 + timelineItems.push({ 472 + timestamp: new Date(origPost.record.createdAt), 473 + html: ` 474 + <li class="original"> 475 + ${renderAuthor(origPost.author)} 476 + <p>${renderText(origPost.record.text, origPost.record.facets)}</p> 477 + ${renderFooter(origPost.uri, origPost.record.createdAt)} 478 + </li>`, 479 + }) 480 + } 481 + 482 + for (const reply of directReplies) { 483 + timelineItems.push({ 484 + timestamp: new Date(reply.post.record.createdAt), 485 + html: renderReply(reply, 0, maxDepth, hiddenReplies), 486 + }) 487 + } 488 + 489 + for (const quote of quotes) { 490 + timelineItems.push({ 491 + timestamp: new Date(quote.record.createdAt), 492 + html: renderQuote(quote), 493 + }) 494 + } 495 + 496 + // Sort chronologically (oldest first) 497 + timelineItems.sort((a, b) => a.timestamp - b.timestamp) 498 + 499 + const stats = { 500 + replyCount: rootPost.replyCount || 0, 501 + quoteCount: rootPost.quoteCount || 0, 502 + repostCount: rootPost.repostCount || 0, 503 + } 504 + 505 + const header = renderHeader(stats, repostedBy, { 506 + engageText, 507 + postUrl: originalUrl, 508 + headerTemplate, 509 + }) 510 + const timeline = 511 + timelineItems.length > 0 512 + ? `<ol class="timeline">${timelineItems.map((i) => i.html).join('')}</ol>` 513 + : '' 514 + 515 + this.innerHTML = ` 516 + <style> 517 + .bsky-conversation { 518 + --bsky-border-color: #e5e7eb; 519 + --bsky-muted-color: #6b7280; 520 + --bsky-link-color: black; 521 + --bsky-link-underline: rgba(82, 82, 91, 0.5); 522 + --bsky-link-hover: #2563eb; 523 + --bsky-link-underline-hover: rgba(59, 130, 246, 0.3); 524 + } 525 + .dark .bsky-conversation { 526 + --bsky-border-color: #374151; 527 + --bsky-muted-color: #9ca3af; 528 + --bsky-link-color: #60a5fa; 529 + --bsky-link-underline: rgba(59, 130, 246, 0.3); 530 + --bsky-link-hover: #3b82f6; 531 + --bsky-link-underline-hover: rgba(59, 130, 246, 0.3); 532 + } 533 + 534 + .bsky-conversation a { 535 + color: var(--bsky-link-color); 536 + text-decoration: underline; 537 + text-decoration-color: var(--bsky-link-underline); 538 + font-weight: 400; 539 + transition: color 150ms, text-decoration-color 150ms; 540 + } 541 + .bsky-conversation a:hover { 542 + color: var(--bsky-link-hover); 543 + text-decoration-color: var(--bsky-link-underline-hover); 544 + } 545 + 546 + .bsky-conversation header { 547 + margin-bottom: 1em; 548 + } 549 + 550 + .bsky-conversation header ul { 551 + display: flex; 552 + flex-wrap: wrap; 553 + gap: 0.25em 1em; 554 + list-style: none; 555 + padding: 0; 556 + margin: 0; 557 + font-size: smaller; 558 + } 559 + 560 + .bsky-conversation header .engage { 561 + flex-basis: 100%; 562 + margin-top: 0.25em; 563 + } 564 + 565 + .bsky-conversation .timeline { 566 + list-style: none; 567 + padding: 0; 568 + margin: 0; 569 + } 570 + 571 + .bsky-conversation .reply, 572 + .bsky-conversation .quote, 573 + .bsky-conversation .original { 574 + padding: 1.25em 0; 575 + } 576 + 577 + .bsky-conversation .author { 578 + display: inline-flex; 579 + align-items: center; 580 + gap: 0.5em; 581 + color: inherit; 582 + text-decoration-line: none; 583 + margin-bottom: 0.25em; 584 + } 585 + .bsky-conversation .author:hover { 586 + text-decoration-line: none; 587 + } 588 + .bsky-conversation .author:hover .displayname { 589 + text-decoration: underline; 590 + } 591 + 592 + .bsky-conversation .avatar { 593 + width: 1.5em; 594 + height: 1.5em; 595 + border-radius: 50%; 596 + flex-shrink: 0; 597 + } 598 + 599 + .bsky-conversation .displayname { 600 + font-weight: 600; 601 + } 602 + 603 + .bsky-conversation .handle { 604 + color: var(--bsky-muted-color); 605 + } 606 + 607 + .bsky-conversation .reply > p, 608 + .bsky-conversation .quote > p, 609 + .bsky-conversation .original > p { 610 + margin: 0.25em 0 0; 611 + } 612 + 613 + .bsky-conversation footer { 614 + display: flex; 615 + gap: 0.75em; 616 + margin-top: 0.25em; 617 + font-size: smaller; 618 + color: var(--bsky-muted-color); 619 + } 620 + 621 + .bsky-conversation .thread { 622 + list-style: none; 623 + padding: 0 0 0 1.5em; 624 + margin: 0.5em 0 0; 625 + border-left: 2px solid var(--bsky-border-color); 626 + } 627 + 628 + .bsky-conversation .thread .reply { 629 + border-top: none; 630 + padding: 0.5em 0; 631 + } 632 + 633 + .bsky-conversation .depth-cutoff { 634 + margin: 0.5em 0 0; 635 + font-size: smaller; 636 + } 637 + 638 + .bsky-conversation .continue { 639 + text-align: center; 640 + padding: 1em 0 0.5em; 641 + font-size: smaller; 642 + } 643 + </style> 644 + <div class="bsky-conversation"> 645 + ${header} 646 + ${timeline} 647 + ${timeline && engageText ? `<footer class="continue"><a href="${originalUrl}" target="_blank" rel="noopener noreferrer">${escapeHtml(engageText)}</a></footer>` : ''} 648 + </div>` 649 + } 650 + } 651 + 652 + if (typeof customElements !== 'undefined' && !customElements.get('bsky-conversation')) { 653 + customElements.define('bsky-conversation', BskyConversation) 654 + }