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.

Harden for public release: XSS fixes, error logging, style dedup

Security:
- escapeHtml: add single-quote escaping, type guard for non-string input
- Escape originalUrl in all href attributes (footer, header engage links,
repostedBy "other people" link, quotes link)

Developer experience:
- Add console.warn for missing/invalid URI attribute
- Add console.warn in fetchJson for API errors and network failures
(was completely silent before)

Quality:
- Deduplicate <style> injection — styles now go into document.head once
via an id-guarded style element instead of being inlined per-instance
- Fix misplaced JSDoc: "Format repostedBy" was above shuffle()
- Add .DS_Store to .gitignore
- Add author field to package.json

hide-reply.mjs:
- Differentiate "record not found" from network/auth errors in catch
blocks to avoid silently overwriting existing threadgate/postgate data

README:
- Fix "detach your post from a quoted post" (was backwards)
- Clarify graceful degradation: thread fetch failure = empty render

Jim Ray 9ec8da35 37b86601

+62 -17
+1
.gitignore
··· 1 1 .env 2 2 node_modules/ 3 + .DS_Store
+9 -1
README.md
··· 133 133 - Reply threads stay grouped — nested replies are not flattened into the timeline 134 134 - Quote posts are interleaved chronologically with top-level reply threads 135 135 - Reposts appear only in the header summary, not as timeline items 136 - - API failures degrade gracefully — the rest of the conversation still renders 136 + - If quote or repost API calls fail, the thread still renders without that data. If the thread itself cannot be fetched, the component renders nothing. 137 137 - All user content is XSS-hardened through `escapeHtml()` 138 138 139 139 ## Moderation 140 + 141 + Sometimes people will reply or repost in a manner you don't want to appear on your site. You can use Bluesky's built in tools to help you manage this. 142 + 143 + To hide a reply, go to the post on bsky.app, select "Hide reply from everyone" from the ellipsis menu, and it will no longer appear on the page. 144 + 145 + If someone quotes your post and you detach that quote, it will no longer appear in the conversation. 146 + 147 + Additionally, this package includes an optional script that takes the full URL of a post that you wish to hide. In order for this to work, you will need to create a `.env` file and include your bsky handle and an [app password](https://bsky.app/settings/app-passwords). 140 148 141 149 The `hide-reply` script lets you hide replies or detach quote posts from conversations via the command line. It auto-detects the post type. 142 150
+37 -14
bsky-conversation.js
··· 246 246 } 247 247 248 248 /** 249 - * Format repostedBy names as linked HTML. 249 + * Fisher-Yates shuffle: return a new array with elements in random order. 250 250 */ 251 251 function shuffle(arr) { 252 252 const a = [...arr] ··· 257 257 return a 258 258 } 259 259 260 + /** 261 + * Format repostedBy names as linked HTML. 262 + */ 260 263 function formatRepostedBy(repostedBy, repostCount, url) { 261 264 if (repostCount === 0) return '' 262 265 const showCount = repostCount <= 4 ? 1 : 3 ··· 265 268 }) 266 269 const remaining = repostCount - names.length 267 270 if (remaining > 0) { 268 - const othersLink = `<a href="${url}/reposted-by" target="_blank" rel="noopener noreferrer">${intword(remaining)} other people</a>` 271 + const othersLink = `<a href="${escapeHtml(url)}/reposted-by" target="_blank" rel="noopener noreferrer">${intword(remaining)} other people</a>` 269 272 if (names.length === 0) return othersLink 270 273 if (names.length === 1) return `${names[0]} and ${othersLink}` 271 274 return `${names.join(', ')}, and ${othersLink}` ··· 283 286 const items = [] 284 287 if (html) items.push(`<li class="stats">${html}</li>`) 285 288 if (engageText && url) { 286 - items.push(`<li class="engage"><a href="${url}" target="_blank" rel="noopener noreferrer">${escapeHtml(engageText)}</a></li>`) 289 + items.push(`<li class="engage"><a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(engageText)}</a></li>`) 287 290 } 288 291 if (items.length === 0) return '' 289 292 return `<header><ul>${items.join('')}</ul></header>` ··· 299 302 300 303 if (stats.quoteCount > 0) { 301 304 items.push( 302 - `<li class="quotes"><a href="${url}/quotes" target="_blank" rel="noopener noreferrer">${intword(stats.quoteCount)} ${pluralize(stats.quoteCount, 'quote,quotes')}</a></li>` 305 + `<li class="quotes"><a href="${escapeHtml(url)}/quotes" target="_blank" rel="noopener noreferrer">${intword(stats.quoteCount)} ${pluralize(stats.quoteCount, 'quote,quotes')}</a></li>` 303 306 ) 304 307 } 305 308 ··· 308 311 } 309 312 310 313 if (engageText && url) { 311 - items.push(`<li class="engage"><a href="${url}" target="_blank" rel="noopener noreferrer">${escapeHtml(engageText)}</a></li>`) 314 + items.push(`<li class="engage"><a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(engageText)}</a></li>`) 312 315 } 313 316 314 317 if (items.length === 0) return '' ··· 316 319 } 317 320 318 321 /** 319 - * Minimal HTML escaping. 322 + * Escape HTML special characters for safe interpolation into 323 + * double-quoted attributes and element content. 320 324 */ 321 325 function escapeHtml(str) { 326 + if (typeof str !== 'string') return '' 322 327 return str 323 328 .replace(/&/g, '&amp;') 324 329 .replace(/</g, '&lt;') 325 330 .replace(/>/g, '&gt;') 326 331 .replace(/"/g, '&quot;') 332 + .replace(/'/g, '&#x27;') 327 333 } 328 334 329 335 /** ··· 383 389 async connectedCallback() { 384 390 const url = this.getAttribute('uri') 385 391 if (!url) { 392 + console.warn('bsky-conversation: missing required "uri" attribute') 386 393 this.innerHTML = '' 387 394 return 388 395 } 389 396 390 397 const atUri = toAtUri(url) 391 398 if (!atUri) { 399 + console.warn(`bsky-conversation: could not parse URI "${url}". Expected a bsky.app post URL.`) 392 400 this.innerHTML = '' 393 401 return 394 402 } ··· 398 406 this.innerHTML = '<div class="bsky-conversation"><p class="loading">Loading conversation…</p></div>' 399 407 400 408 try { 401 - const fetchJson = (url) => 402 - fetch(url, { cache: 'no-store' }) 403 - .then((r) => r.ok ? r.json() : {}) 404 - .catch(() => ({})) 409 + const fetchJson = async (url) => { 410 + try { 411 + const r = await fetch(url, { cache: 'no-store' }) 412 + if (!r.ok) { 413 + console.warn(`bsky-conversation: API returned ${r.status} for ${url}`) 414 + return {} 415 + } 416 + return await r.json() 417 + } catch (err) { 418 + console.warn('bsky-conversation: fetch failed', err.message) 419 + return {} 420 + } 421 + } 405 422 406 423 const encodedUri = encodeURIComponent(atUri) 407 424 const [thread, quotesRes, repostsRes] = await Promise.all([ ··· 513 530 ? `<ol class="timeline">${timelineItems.map((i) => i.html).join('')}</ol>` 514 531 : '' 515 532 516 - this.innerHTML = ` 517 - <style> 533 + if (!document.getElementById('bsky-conversation-styles')) { 534 + const style = document.createElement('style') 535 + style.id = 'bsky-conversation-styles' 536 + style.textContent = ` 518 537 .dark .bsky-conversation { 519 538 --bsky-border-color: #374151; 520 539 --bsky-muted-color: #9ca3af; ··· 633 652 padding: 1em 0 0.5em; 634 653 font-size: smaller; 635 654 } 636 - </style> 655 + ` 656 + document.head.appendChild(style) 657 + } 658 + 659 + this.innerHTML = ` 637 660 <div class="bsky-conversation"> 638 661 ${header} 639 662 ${timeline} 640 - ${timeline && engageText ? `<footer class="continue"><a href="${originalUrl}" target="_blank" rel="noopener noreferrer">${escapeHtml(engageText)}</a></footer>` : ''} 663 + ${timeline && engageText ? `<footer class="continue"><a href="${escapeHtml(originalUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(engageText)}</a></footer>` : ''} 641 664 </div>` 642 665 } 643 666 }
+1
package.json
··· 28 28 "@atproto/lex": "^0.0.11", 29 29 "@atproto/lex-password-session": "^0.0.2" 30 30 }, 31 + "author": "Bluesky Social PBC", 31 32 "license": "MIT", 32 33 "repository": { 33 34 "type": "git",
+14 -2
scripts/hide-reply.mjs
··· 178 178 console.log('\n⚠️ This reply is already hidden. Nothing to do.') 179 179 process.exit(0) 180 180 } 181 - } catch { 181 + } catch (err) { 182 + // If the record genuinely doesn't exist, create a new one. 183 + // But if it's a network/auth error, bail out to avoid overwriting existing data. 184 + const status = err?.status ?? err?.response?.status 185 + if (status && status !== 400 && status !== 404) { 186 + console.error(`\nError: Failed to read existing threadgate (${status}). Aborting to avoid data loss.`) 187 + process.exit(1) 188 + } 182 189 console.log(' No existing threadgate — will create one') 183 190 } 184 191 ··· 225 232 console.log('\n⚠️ This quote is already detached. Nothing to do.') 226 233 process.exit(0) 227 234 } 228 - } catch { 235 + } catch (err) { 236 + const status = err?.status ?? err?.response?.status 237 + if (status && status !== 400 && status !== 404) { 238 + console.error(`\nError: Failed to read existing postgate (${status}). Aborting to avoid data loss.`) 239 + process.exit(1) 240 + } 229 241 console.log(' No existing postgate — will create one') 230 242 } 231 243